From e6d70b523edd5b408b3267be189b48e4c769a0d9 Mon Sep 17 00:00:00 2001 From: Acbox Liu Date: Sun, 22 Feb 2026 02:06:47 +0800 Subject: [PATCH] refactor: divide agent logic and gateway into different package (#90) * feat: add @memoh/agent * chore: use @memoh/agent in @memoh-gateway --- agent/package.json | 5 +- agent/src/index.ts | 5 +- agent/src/models.ts | 2 +- agent/src/modules/chat.ts | 4 +- agent/src/test/attachments.test.ts | 358 ------------------ agent/src/test/sse_chunked.test.ts | 45 --- agent/src/test/unified_mcp_tools.test.ts | 94 ----- agent/src/utils/container.ts | 339 ----------------- agent/src/utils/skill.ts | 0 packages/agent/.gitignore | 34 ++ packages/agent/README.md | 2 + packages/agent/package.json | 26 ++ {agent => packages/agent}/src/agent.ts | 9 +- packages/agent/src/index.ts | 6 + {agent => packages/agent}/src/model.ts | 0 .../agent}/src/prompts/index.ts | 0 .../agent}/src/prompts/schedule.ts | 0 .../agent}/src/prompts/subagent.ts | 0 .../agent}/src/prompts/system.ts | 0 .../agent}/src/prompts/utils.ts | 0 {agent => packages/agent}/src/tools/index.ts | 7 +- {agent => packages/agent}/src/tools/mcp.ts | 4 +- {agent => packages/agent}/src/tools/skill.ts | 0 .../agent}/src/tools/subagent.ts | 2 +- {agent => packages/agent}/src/tools/web.ts | 0 {agent => packages/agent}/src/types/action.ts | 2 +- {agent => packages/agent}/src/types/agent.ts | 0 .../agent}/src/types/attachment.ts | 0 packages/agent/src/types/auth.ts | 4 + {agent => packages/agent}/src/types/index.ts | 4 +- {agent => packages/agent}/src/types/mcp.ts | 0 {agent => packages/agent}/src/types/model.ts | 0 .../agent}/src/types/schedule.ts | 0 .../agent}/src/utils/attachments.ts | 0 .../agent}/src/utils/headers.ts | 0 packages/agent/src/utils/index.ts | 2 + packages/agent/tsconfig.json | 22 ++ pnpm-lock.yaml | 106 ++++-- 38 files changed, 198 insertions(+), 884 deletions(-) delete mode 100644 agent/src/test/attachments.test.ts delete mode 100644 agent/src/test/sse_chunked.test.ts delete mode 100644 agent/src/test/unified_mcp_tools.test.ts delete mode 100644 agent/src/utils/container.ts delete mode 100644 agent/src/utils/skill.ts create mode 100644 packages/agent/.gitignore create mode 100644 packages/agent/README.md create mode 100644 packages/agent/package.json rename {agent => packages/agent}/src/agent.ts (98%) create mode 100644 packages/agent/src/index.ts rename {agent => packages/agent}/src/model.ts (100%) rename {agent => packages/agent}/src/prompts/index.ts (100%) rename {agent => packages/agent}/src/prompts/schedule.ts (100%) rename {agent => packages/agent}/src/prompts/subagent.ts (100%) rename {agent => packages/agent}/src/prompts/system.ts (100%) rename {agent => packages/agent}/src/prompts/utils.ts (100%) rename {agent => packages/agent}/src/tools/index.ts (88%) rename {agent => packages/agent}/src/tools/mcp.ts (96%) rename {agent => packages/agent}/src/tools/skill.ts (100%) rename {agent => packages/agent}/src/tools/subagent.ts (98%) rename {agent => packages/agent}/src/tools/web.ts (100%) rename {agent => packages/agent}/src/types/action.ts (98%) rename {agent => packages/agent}/src/types/agent.ts (100%) rename {agent => packages/agent}/src/types/attachment.ts (100%) create mode 100644 packages/agent/src/types/auth.ts rename {agent => packages/agent}/src/types/index.ts (60%) rename {agent => packages/agent}/src/types/mcp.ts (100%) rename {agent => packages/agent}/src/types/model.ts (100%) rename {agent => packages/agent}/src/types/schedule.ts (100%) rename {agent => packages/agent}/src/utils/attachments.ts (100%) rename {agent => packages/agent}/src/utils/headers.ts (100%) create mode 100644 packages/agent/src/utils/index.ts create mode 100644 packages/agent/tsconfig.json diff --git a/agent/package.json b/agent/package.json index 30e5e9cd..813b9f3a 100644 --- a/agent/package.json +++ b/agent/package.json @@ -8,10 +8,7 @@ }, "dependencies": { "@memoh/config": "workspace:*", - "@ai-sdk/anthropic": "^3.0.9", - "@ai-sdk/google": "^3.0.6", - "@ai-sdk/mcp": "^1.0.6", - "@ai-sdk/openai": "^3.0.7", + "@memoh/agent": "workspace:*", "@elysiajs/bearer": "^1.4.2", "@elysiajs/cors": "^1.4.1", "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/agent/src/index.ts b/agent/src/index.ts index 94126852..4ae5c75d 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -3,6 +3,7 @@ import { chatModule } from './modules/chat' import { corsMiddleware } from './middlewares/cors' import { errorMiddleware } from './middlewares/error' import { loadConfig, getBaseUrl as getBaseUrlByConfig } from '@memoh/config' +import { AuthFetcher } from '@memoh/agent' const config = loadConfig('../config.toml') @@ -10,10 +11,6 @@ export const getBaseUrl = () => { return getBaseUrlByConfig(config) } -export type AuthFetcher = ( - url: string, - options?: RequestInit, -) => Promise; export const createAuthFetcher = (bearer: string | undefined): AuthFetcher => { return async (url: string, options?: RequestInit) => { const requestOptions = options ?? {} diff --git a/agent/src/models.ts b/agent/src/models.ts index fdee0da6..2bf0f0c5 100644 --- a/agent/src/models.ts +++ b/agent/src/models.ts @@ -1,5 +1,5 @@ import z from 'zod' -import { allActions } from './types' +import { allActions } from '@memoh/agent' export const AgentSkillModel = z.object({ name: z.string().min(1, 'Skill name is required'), diff --git a/agent/src/modules/chat.ts b/agent/src/modules/chat.ts index 164a1672..4758ba91 100644 --- a/agent/src/modules/chat.ts +++ b/agent/src/modules/chat.ts @@ -1,11 +1,9 @@ import { Elysia } from 'elysia' import z from 'zod' -import { createAgent } from '../agent' +import { createAgent, ModelConfig, allActions } from '@memoh/agent' import { createAuthFetcher, getBaseUrl } from '../index' -import { ModelConfig } from '../types' import { bearerMiddleware } from '../middlewares/bearer' import { AgentSkillModel, AllowedActionModel, AttachmentModel, IdentityContextModel, InboxItemModel, MCPConnectionModel, ModelConfigModel, ScheduleModel } from '../models' -import { allActions } from '../types' import { sseChunked } from '../utils/sse' const AgentModel = z.object({ diff --git a/agent/src/test/attachments.test.ts b/agent/src/test/attachments.test.ts deleted file mode 100644 index 84998edc..00000000 --- a/agent/src/test/attachments.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { describe, test, expect } from 'bun:test' -import type { ModelMessage } from 'ai' -import { - parseAttachmentPaths, - extractAttachmentsFromText, - stripAttachmentsFromMessages, - dedupeAttachments, - AttachmentsStreamExtractor, -} from '../utils/attachments' -import { buildNativeImageParts } from '../agent' -import type { ContainerFileAttachment, GatewayInputAttachment } from '../types/attachment' - -// --------------------------------------------------------------------------- -// parseAttachmentPaths -// --------------------------------------------------------------------------- - -describe('parseAttachmentPaths', () => { - test('parses standard list', () => { - const input = ` -- /path/to/file.pdf -- /path/to/video.mp4 - ` - expect(parseAttachmentPaths(input)).toEqual([ - '/path/to/file.pdf', - '/path/to/video.mp4', - ]) - }) - - test('ignores lines without leading dash', () => { - const input = ` -some random text -- /valid/path.txt -not a path -- /another/path.png - ` - expect(parseAttachmentPaths(input)).toEqual([ - '/valid/path.txt', - '/another/path.png', - ]) - }) - - test('returns empty array for empty input', () => { - expect(parseAttachmentPaths('')).toEqual([]) - }) - - test('handles extra whitespace around paths', () => { - const input = ' - /spaced/path.txt ' - expect(parseAttachmentPaths(input)).toEqual(['/spaced/path.txt']) - }) -}) - -// --------------------------------------------------------------------------- -// extractAttachmentsFromText -// --------------------------------------------------------------------------- - -describe('extractAttachmentsFromText', () => { - test('extracts a single block', () => { - const text = 'Hello world\n\n- /file.pdf\n\nGoodbye' - const { cleanedText, attachments } = extractAttachmentsFromText(text) - expect(attachments).toEqual([{ type: 'file', path: '/file.pdf' }]) - expect(cleanedText).toBe('Hello world\n\nGoodbye') - }) - - test('extracts multiple blocks', () => { - const text = [ - 'Start', - '', - '- /a.txt', - '', - 'Middle', - '', - '- /b.txt', - '', - 'End', - ].join('\n') - const { cleanedText, attachments } = extractAttachmentsFromText(text) - expect(attachments).toHaveLength(2) - expect(attachments.map(a => a.path)).toEqual(['/a.txt', '/b.txt']) - expect(cleanedText).toContain('Start') - expect(cleanedText).toContain('Middle') - expect(cleanedText).toContain('End') - expect(cleanedText).not.toContain('') - }) - - test('deduplicates paths across blocks', () => { - const text = [ - '', - '- /dup.txt', - '', - '', - '- /dup.txt', - '', - ].join('\n') - const { attachments } = extractAttachmentsFromText(text) - expect(attachments).toHaveLength(1) - expect(attachments[0].path).toBe('/dup.txt') - }) - - test('returns original text when no blocks present', () => { - const text = 'No attachments here' - const { cleanedText, attachments } = extractAttachmentsFromText(text) - expect(cleanedText).toBe('No attachments here') - expect(attachments).toEqual([]) - }) - - test('collapses excessive newlines left by removal', () => { - const text = 'Line1\n\n\n\n- /f.txt\n\n\n\nLine2' - const { cleanedText } = extractAttachmentsFromText(text) - // Should not have more than two consecutive newlines - expect(cleanedText).not.toMatch(/\n{3,}/) - }) -}) - -// --------------------------------------------------------------------------- -// stripAttachmentsFromMessages -// --------------------------------------------------------------------------- - -describe('stripAttachmentsFromMessages', () => { - test('strips from assistant message with string content', () => { - const messages: ModelMessage[] = [ - { role: 'user', content: 'hi' }, - { - role: 'assistant', - content: 'Here you go\n\n- /result.pdf\n', - }, - ] - const { messages: stripped, attachments } = stripAttachmentsFromMessages(messages) - expect(attachments).toEqual([{ type: 'file', path: '/result.pdf' }]) - const assistantMsg = stripped.find(m => m.role === 'assistant')! - expect((assistantMsg as { content: string }).content).not.toContain('') - }) - - test('strips from assistant message with array content containing TextPart', () => { - const messages: ModelMessage[] = [ - { - role: 'assistant', - content: [ - { type: 'text', text: 'Check this\n\n- /img.png\n' }, - ], - }, - ] - const { messages: stripped, attachments } = stripAttachmentsFromMessages(messages) - expect(attachments).toEqual([{ type: 'file', path: '/img.png' }]) - const content = (stripped[0] as { content: Array<{ type: string; text?: string }> }).content - expect(content[0].text).not.toContain('') - }) - - test('does not modify user or tool messages', () => { - const messages: ModelMessage[] = [ - { role: 'user', content: '\n- /should-stay.txt\n' }, - ] - const { messages: stripped, attachments } = stripAttachmentsFromMessages(messages) - expect(attachments).toEqual([]) - expect((stripped[0] as { content: string }).content).toContain('') - }) - - test('deduplicates attachments across messages', () => { - const messages: ModelMessage[] = [ - { role: 'assistant', content: '\n- /same.txt\n' }, - { role: 'assistant', content: '\n- /same.txt\n' }, - ] - const { attachments } = stripAttachmentsFromMessages(messages) - expect(attachments).toHaveLength(1) - }) -}) - -// --------------------------------------------------------------------------- -// dedupeAttachments -// --------------------------------------------------------------------------- - -describe('dedupeAttachments', () => { - test('deduplicates file attachments by path', () => { - const items: ContainerFileAttachment[] = [ - { type: 'file', path: '/a.txt' }, - { type: 'file', path: '/b.txt' }, - { type: 'file', path: '/a.txt' }, - ] - const result = dedupeAttachments(items) - expect(result).toHaveLength(2) - }) - - test('deduplicates image attachments by base64 prefix', () => { - const base64 = 'a'.repeat(100) - const result = dedupeAttachments([ - { type: 'image', base64 }, - { type: 'image', base64 }, - ]) - expect(result).toHaveLength(1) - }) - - test('keeps different types separate', () => { - const result = dedupeAttachments([ - { type: 'file', path: '/a.txt' }, - { type: 'image', base64: 'abc' }, - ]) - expect(result).toHaveLength(2) - }) -}) - -describe('buildNativeImageParts', () => { - test('keeps inline data url and public url images', () => { - const attachments: GatewayInputAttachment[] = [ - { type: 'image', transport: 'inline_data_url', payload: 'data:image/png;base64,AAAA' }, - { type: 'image', transport: 'public_url', payload: 'https://example.com/demo.png' }, - ] - const parts = buildNativeImageParts(attachments) - expect(parts).toHaveLength(2) - expect(parts[0].image).toBe('data:image/png;base64,AAAA') - expect(parts[1].image).toBe('https://example.com/demo.png') - }) - - test('drops tool_file_ref images', () => { - const attachments: GatewayInputAttachment[] = [ - { type: 'image', transport: 'tool_file_ref', payload: '/data/media/image/demo.png' }, - ] - const parts = buildNativeImageParts(attachments) - expect(parts).toEqual([]) - }) -}) - -// --------------------------------------------------------------------------- -// AttachmentsStreamExtractor -// --------------------------------------------------------------------------- - -describe('AttachmentsStreamExtractor', () => { - /** Helper: simulates streaming by feeding one character at a time. */ - const feedCharByChar = (extractor: AttachmentsStreamExtractor, text: string) => { - let visibleText = '' - const attachments: ContainerFileAttachment[] = [] - for (const ch of text) { - const result = extractor.push(ch) - visibleText += result.visibleText - attachments.push(...result.attachments) - } - const remainder = extractor.flushRemainder() - visibleText += remainder.visibleText - attachments.push(...remainder.attachments) - return { visibleText, attachments } - } - - /** Helper: simulates streaming by feeding the entire string at once. */ - const feedAtOnce = (extractor: AttachmentsStreamExtractor, text: string) => { - const result = extractor.push(text) - const remainder = extractor.flushRemainder() - return { - visibleText: result.visibleText + remainder.visibleText, - attachments: [...result.attachments, ...remainder.attachments], - } - } - - test('passes through plain text (char-by-char)', () => { - const ext = new AttachmentsStreamExtractor() - const { visibleText, attachments } = feedCharByChar(ext, 'Hello world') - expect(visibleText).toBe('Hello world') - expect(attachments).toEqual([]) - }) - - test('passes through plain text (all-at-once)', () => { - const ext = new AttachmentsStreamExtractor() - const { visibleText, attachments } = feedAtOnce(ext, 'Hello world') - expect(visibleText).toBe('Hello world') - expect(attachments).toEqual([]) - }) - - test('extracts attachments block (char-by-char)', () => { - const ext = new AttachmentsStreamExtractor() - const input = 'Before\n- /file.pdf\nAfter' - const { visibleText, attachments } = feedCharByChar(ext, input) - expect(visibleText).toBe('BeforeAfter') - expect(attachments).toEqual([{ type: 'file', path: '/file.pdf' }]) - }) - - test('extracts attachments block (all-at-once)', () => { - const ext = new AttachmentsStreamExtractor() - const input = 'Before\n- /file.pdf\nAfter' - const { visibleText, attachments } = feedAtOnce(ext, input) - expect(visibleText).toBe('BeforeAfter') - expect(attachments).toEqual([{ type: 'file', path: '/file.pdf' }]) - }) - - test('extracts multiple paths from one block', () => { - const ext = new AttachmentsStreamExtractor() - const input = '\n- /a.txt\n- /b.txt\n' - const { attachments } = feedCharByChar(ext, input) - expect(attachments.map(a => a.path)).toEqual(['/a.txt', '/b.txt']) - }) - - test('handles multiple blocks in one stream', () => { - const ext = new AttachmentsStreamExtractor() - const input = 'A\n- /x.txt\nB\n- /y.txt\nC' - const { visibleText, attachments } = feedCharByChar(ext, input) - expect(visibleText).toBe('ABC') - expect(attachments.map(a => a.path)).toEqual(['/x.txt', '/y.txt']) - }) - - test('handles chunk boundaries splitting the opening tag', () => { - const ext = new AttachmentsStreamExtractor() - let visible = '' - const attachments: ContainerFileAttachment[] = [] - - // Feed the opening tag across two chunks - let r = ext.push('Hello \n- /split.txt\n Done') - visible += r.visibleText - attachments.push(...r.attachments) - - const remainder = ext.flushRemainder() - visible += remainder.visibleText - attachments.push(...remainder.attachments) - - expect(visible).toBe('Hello Done') - expect(attachments).toEqual([{ type: 'file', path: '/split.txt' }]) - }) - - test('handles chunk boundaries splitting the closing tag', () => { - const ext = new AttachmentsStreamExtractor() - let visible = '' - const attachments: ContainerFileAttachment[] = [] - - let r = ext.push('\n- /f.txt\nTail') - visible += r.visibleText - attachments.push(...r.attachments) - - const remainder = ext.flushRemainder() - visible += remainder.visibleText - attachments.push(...remainder.attachments) - - expect(visible).toBe('Tail') - expect(attachments).toEqual([{ type: 'file', path: '/f.txt' }]) - }) - - test('flushRemainder returns raw text for unclosed block', () => { - const ext = new AttachmentsStreamExtractor() - ext.push('\n- /orphan.txt\n') - const remainder = ext.flushRemainder() - // Unclosed block should be returned as visible text - expect(remainder.visibleText).toContain('') - expect(remainder.visibleText).toContain('/orphan.txt') - expect(remainder.attachments).toEqual([]) - }) - - test('text without any angle brackets passes through immediately', () => { - const ext = new AttachmentsStreamExtractor() - const r = ext.push('simple text without tags') - // Most of the text should be emitted (minus a small buffered tail) - const remainder = ext.flushRemainder() - const full = r.visibleText + remainder.visibleText - expect(full).toBe('simple text without tags') - }) -}) - diff --git a/agent/src/test/sse_chunked.test.ts b/agent/src/test/sse_chunked.test.ts deleted file mode 100644 index 85cfb580..00000000 --- a/agent/src/test/sse_chunked.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { sseChunked } from '../utils/sse' - -function parseChunkedSSE(payload: string): string { - const lines = payload.split('\n') - const dataLines = lines.filter(line => line.startsWith('data:')) - return dataLines.map(line => line.slice('data:'.length)).join('') -} - -describe('sseChunked', () => { - test('reconstructs original payload losslessly', () => { - const input = JSON.stringify({ - type: 'tool_call_end', - toolName: 'big_tool', - toolCallId: 'call-1', - // include whitespace and unicode so trimming/surrogate splitting bugs show up - result: ' leading spaces\tand tabs\nand unicode 😀😃😄 ', - blob: 'x'.repeat(200_000), - }) - - const chunked = sseChunked(input, 1024).toSSE() - const reconstructed = parseChunkedSSE(chunked) - - expect(reconstructed).toBe(input) - }) - - test('chunkSize=1 does not produce invalid UTF-8 (surrogate pairs)', () => { - const input = `😀${'x'.repeat(1000)}😃` - const payload = sseChunked(input, 1).toSSE() - - // Simulate the UTF-8 encode/decode step that happens over the network. - const encoded = new TextEncoder().encode(payload) - const decoded = new TextDecoder().decode(encoded) - expect(decoded).toBe(payload) - - const reconstructed = parseChunkedSSE(decoded) - expect(reconstructed).toBe(input) - }) - - test('does not inject an extra space after data:', () => { - const input = ' abc' - const chunked = sseChunked(input, 2).toSSE() - expect(chunked.split('\n')[0]).toBe('data: a') - }) -}) diff --git a/agent/src/test/unified_mcp_tools.test.ts b/agent/src/test/unified_mcp_tools.test.ts deleted file mode 100644 index c580a365..00000000 --- a/agent/src/test/unified_mcp_tools.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { getMCPTools } from '../tools/mcp' - -describe('getMCPTools (unified endpoint)', () => { - test('loads tools from unified MCP HTTP endpoint', async () => { - const seenMethods: string[] = [] - const seenAuthHeaders: string[] = [] - - const server = Bun.serve({ - port: 0, - async fetch(request) { - seenAuthHeaders.push(request.headers.get('authorization') ?? '') - const body = await request.json().catch(() => ({} as Record)) - const method = typeof body?.method === 'string' ? body.method : '' - seenMethods.push(method) - - if (method === 'initialize') { - return Response.json({ - jsonrpc: '2.0', - id: body.id ?? null, - result: { - protocolVersion: '2025-06-18', - capabilities: { - tools: { - listChanged: false, - }, - }, - serverInfo: { - name: 'test-mcp', - version: '1.0.0', - }, - }, - }) - } - - if (method === 'notifications/initialized') { - return new Response(null, { status: 202 }) - } - - if (method === 'tools/list') { - return Response.json({ - jsonrpc: '2.0', - id: body.id ?? null, - result: { - tools: [ - { - name: 'search_memory', - description: 'Search memory', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string' }, - }, - required: ['query'], - }, - }, - ], - }, - }) - } - - return Response.json({ - jsonrpc: '2.0', - id: body.id ?? null, - error: { - code: -32601, - message: 'method not found', - }, - }) - }, - }) - - try { - const endpoint = `http://127.0.0.1:${server.port}/bots/bot-1/tools` - const { tools, close } = await getMCPTools([{ - type: 'http', - name: 'builtin', - url: endpoint, - headers: { - Authorization: 'Bearer test-token', - }, - }]) - - expect(Object.keys(tools)).toContain('search_memory') - expect(seenMethods).toContain('initialize') - expect(seenMethods).toContain('tools/list') - expect(seenAuthHeaders.some(value => value === 'Bearer test-token')).toBe(true) - - await close() - } finally { - server.stop(true) - } - }) -}) diff --git a/agent/src/utils/container.ts b/agent/src/utils/container.ts deleted file mode 100644 index 547ace96..00000000 --- a/agent/src/utils/container.ts +++ /dev/null @@ -1,339 +0,0 @@ - -interface JSONRPCRequest { - jsonrpc: string - id: string | number - method: string - params?: unknown -} - -interface JSONRPCResponse { - jsonrpc: string - id: string | number - result?: T - error?: { - code: number - message: string - } -} - -interface ToolCallContent { - type: string - text?: string -} - -interface ToolCallResult { - content: ToolCallContent[] - isError?: boolean -} - -export interface FSFileEntry { - path: string - is_dir: boolean - size: number - mode: number - mod_time: string -} - -export interface FSReadResult { - content: string -} - -export interface FSReadBase64Result { - data: string - mime_type: string -} - -export interface FSWriteResult { - ok: boolean -} - -export interface FSListResult { - path: string - entries: FSFileEntry[] -} - -export interface FSStatResult { - entry: FSFileEntry -} - -export interface FSDeleteResult { - ok: boolean -} - -export interface FSApplyPatchResult { - ok: boolean -} - -export interface FSMkdirResult { - ok: boolean -} - -export interface FSRenameResult { - ok: boolean -} - -export interface GrepResult { - stdout: string - stderr: string - exit_code: number -} - -export interface EchoResult { - text: string -} - -export interface ToolInfo { - name: string - description?: string - inputSchema?: Record -} - -export interface ToolsListResult { - tools: ToolInfo[] -} - -export interface UseContainerOptions { - url: string - fetch: (url: string, options?: RequestInit) => Promise -} - -class JSONRPCClient { - private requestId = 0 - - constructor( - private url: string, - private fetch: (url: string, options?: RequestInit) => Promise - ) {} - - async call(method: string, params?: unknown): Promise { - const request: JSONRPCRequest = { - jsonrpc: '2.0', - id: ++this.requestId, - method, - params, - } - - const response = await this.fetch(this.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - - const jsonResponse: JSONRPCResponse = await response.json() - - if (jsonResponse.error) { - throw new Error( - `JSON-RPC Error ${jsonResponse.error.code}: ${jsonResponse.error.message}` - ) - } - - return jsonResponse.result as T - } -} - -class MCPToolCaller { - constructor(private rpcClient: JSONRPCClient) {} - - async callTool(toolName: string, args: Record): Promise { - const result = await this.rpcClient.call('tools/call', { - name: toolName, - arguments: args, - }) - - if (result.isError) { - const errorMessage = result.content?.[0]?.text || 'Tool execution failed' - throw new Error(errorMessage) - } - - const textContent = result.content?.[0]?.text - if (textContent) { - try { - return JSON.parse(textContent) as T - } catch { - return textContent as T - } - } - - throw new Error('No result content returned') - } -} - -class FileSystemOperations { - constructor(private toolCaller: MCPToolCaller) {} - - async read(path: string): Promise { - const result = await this.toolCaller.callTool('fs.read', { path }) - return result.content - } - - async readBase64(path: string): Promise { - return this.toolCaller.callTool('fs.read_base64', { path }) - } - - async write(path: string, content: string): Promise { - const result = await this.toolCaller.callTool('fs.write', { - path, - content, - }) - return result.ok - } - - async list(path: string = '.', recursive: boolean = false): Promise { - return this.toolCaller.callTool('fs.list', { path, recursive }) - } - - async stat(path: string): Promise { - const result = await this.toolCaller.callTool('fs.stat', { path }) - return result.entry - } - - async delete(path: string): Promise { - const result = await this.toolCaller.callTool('fs.delete', { path }) - return result.ok - } - - async mkdir(path: string): Promise { - const result = await this.toolCaller.callTool('fs.mkdir', { path }) - return result.ok - } - - async rename(source: string, destination: string): Promise { - const result = await this.toolCaller.callTool('fs.rename', { - source, - destination, - }) - return result.ok - } - - async applyPatch(path: string, patch: string): Promise { - const result = await this.toolCaller.callTool('fs.apply_patch', { - path, - patch, - }) - return result.ok - } - - async exists(path: string): Promise { - try { - await this.stat(path) - return true - } catch { - return false - } - } - - async readJSON(path: string): Promise { - const content = await this.read(path) - return JSON.parse(content) - } - - async writeJSON(path: string, data: unknown, pretty: boolean = true): Promise { - const content = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data) - return this.write(path, content) - } - - async append(path: string, content: string): Promise { - const exists = await this.exists(path) - if (exists) { - const existing = await this.read(path) - return this.write(path, existing + content) - } - return this.write(path, content) - } - - async copy(source: string, destination: string): Promise { - const stat = await this.stat(source) - if (stat.is_dir) { - throw new Error('Directory copy not implemented. Use list + copy for each file.') - } - const content = await this.read(source) - return this.write(destination, content) - } -} - -class SearchOperations { - constructor(private toolCaller: MCPToolCaller) {} - - async grep(pattern: string, args: string[] = []): Promise { - return this.toolCaller.callTool('grep', { pattern, args }) - } - - async search( - pattern: string, - options: { - caseSensitive?: boolean - lineNumbers?: boolean - filesOnly?: boolean - } = {} - ): Promise { - const args: string[] = ['-r'] - if (!options.caseSensitive) args.push('-i') - if (options.lineNumbers) args.push('-n') - if (options.filesOnly) args.push('-l') - - const result = await this.grep(pattern, args) - return result.stdout - } - - async findFiles(pattern: string): Promise { - const result = await this.grep(pattern, ['-r', '-l']) - return result.stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - } -} - -class UtilityOperations { - constructor(private toolCaller: MCPToolCaller) {} - - async echo(text: string): Promise { - const result = await this.toolCaller.callTool('echo', { text }) - return result.text - } - - async ping(): Promise { - try { - const result = await this.echo('ping') - return result === 'ping' - } catch { - return false - } - } -} - -export class ContainerClient { - private rpcClient: JSONRPCClient - private toolCaller: MCPToolCaller - - public readonly fs: FileSystemOperations - public readonly search: SearchOperations - public readonly utils: UtilityOperations - - constructor(options: UseContainerOptions) { - this.rpcClient = new JSONRPCClient(options.url, options.fetch) - this.toolCaller = new MCPToolCaller(this.rpcClient) - - this.fs = new FileSystemOperations(this.toolCaller) - this.search = new SearchOperations(this.toolCaller) - this.utils = new UtilityOperations(this.toolCaller) - } - - async listTools(): Promise { - return this.rpcClient.call('tools/list') - } - - async callTool(toolName: string, args: Record = {}): Promise { - return this.toolCaller.callTool(toolName, args) - } -} - -export const useContainer = (options: UseContainerOptions): ContainerClient => { - return new ContainerClient(options) -} \ No newline at end of file diff --git a/agent/src/utils/skill.ts b/agent/src/utils/skill.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/agent/.gitignore b/packages/agent/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/packages/agent/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/agent/README.md b/packages/agent/README.md new file mode 100644 index 00000000..dca6aec8 --- /dev/null +++ b/packages/agent/README.md @@ -0,0 +1,2 @@ +# @memoh/config + diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 00000000..894d2a4b --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,26 @@ +{ + "name": "@memoh/agent", + "version": "0.1.0-beta.5", + "exports": { + ".": "./src/index.ts" + }, + "packageManager": "pnpm@10.27.0", + "module": "src/index.ts", + "type": "module", + "private": true, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.9", + "@ai-sdk/google": "^3.0.6", + "@ai-sdk/mcp": "^1.0.6", + "@ai-sdk/openai": "^3.0.7", + "@mozilla/readability": "^0.6.0", + "ai": "^6.0.25", + "jsdom": "^27.4.0", + "toml": "^3.0.0", + "turndown": "^7.2.2", + "zod": "^4.3.6" + } +} diff --git a/agent/src/agent.ts b/packages/agent/src/agent.ts similarity index 98% rename from agent/src/agent.ts rename to packages/agent/src/agent.ts index 16339fca..8ad2973b 100644 --- a/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -12,15 +12,15 @@ import { AgentInput, AgentParams, AgentSkill, + AgentStreamAction, allActions, MCPConnection, Schedule, } from './types' import { ModelInput, hasInputModality } from './types/model' import { system, schedule, subagentSystem } from './prompts' -import { AuthFetcher } from './index' +import { AuthFetcher } from './types' import { createModel } from './model' -import { AgentAction } from './types/action' import { extractAttachmentsFromText, stripAttachmentsFromMessages, @@ -115,7 +115,8 @@ export const createAgent = ( }) const response = await fetch(url, { method: 'POST', headers, body }) if (!response.ok) return '' - const data = await response.json().catch(() => ({})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await response.json().catch(() => ({})) as any const structured = data?.result?.structuredContent ?? data?.result?.content?.[0]?.text if (typeof structured === 'string') { @@ -360,7 +361,7 @@ export const createAgent = ( return 'Model stream failed' } - async function* stream(input: AgentInput): AsyncGenerator { + async function* stream(input: AgentInput): AsyncGenerator { const userPrompt = generateUserPrompt(input) const messages = [...input.messages, userPrompt] input.skills.forEach((skill) => enableSkill(skill)) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 00000000..dc057cf1 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,6 @@ +export * from './agent' +export * from './types' +export * from './model' +export * from './utils' +export * from './tools' +export * from './prompts' \ No newline at end of file diff --git a/agent/src/model.ts b/packages/agent/src/model.ts similarity index 100% rename from agent/src/model.ts rename to packages/agent/src/model.ts diff --git a/agent/src/prompts/index.ts b/packages/agent/src/prompts/index.ts similarity index 100% rename from agent/src/prompts/index.ts rename to packages/agent/src/prompts/index.ts diff --git a/agent/src/prompts/schedule.ts b/packages/agent/src/prompts/schedule.ts similarity index 100% rename from agent/src/prompts/schedule.ts rename to packages/agent/src/prompts/schedule.ts diff --git a/agent/src/prompts/subagent.ts b/packages/agent/src/prompts/subagent.ts similarity index 100% rename from agent/src/prompts/subagent.ts rename to packages/agent/src/prompts/subagent.ts diff --git a/agent/src/prompts/system.ts b/packages/agent/src/prompts/system.ts similarity index 100% rename from agent/src/prompts/system.ts rename to packages/agent/src/prompts/system.ts diff --git a/agent/src/prompts/utils.ts b/packages/agent/src/prompts/utils.ts similarity index 100% rename from agent/src/prompts/utils.ts rename to packages/agent/src/prompts/utils.ts diff --git a/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts similarity index 88% rename from agent/src/tools/index.ts rename to packages/agent/src/tools/index.ts index 1e1b2230..ed1659a2 100644 --- a/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -1,4 +1,4 @@ -import { AuthFetcher } from '..' +import { AuthFetcher } from '../types' import { AgentAction, AgentAuthContext, IdentityContext, ModelConfig } from '../types' import { ToolSet } from 'ai' import { getWebTools } from './web' @@ -32,3 +32,8 @@ export const getTools = ( } return tools } + +export * from './web' +export * from './subagent' +export * from './skill' +export * from './mcp' diff --git a/agent/src/tools/mcp.ts b/packages/agent/src/tools/mcp.ts similarity index 96% rename from agent/src/tools/mcp.ts rename to packages/agent/src/tools/mcp.ts index 90260ea0..062e98a2 100644 --- a/agent/src/tools/mcp.ts +++ b/packages/agent/src/tools/mcp.ts @@ -1,6 +1,6 @@ import { HTTPMCPConnection, MCPConnection, SSEMCPConnection, StdioMCPConnection } from '../types' import { createMCPClient } from '@ai-sdk/mcp' -import { AuthFetcher } from '../index' +import { AuthFetcher } from '../types' import type { AgentAuthContext } from '../types/agent' type MCPToolOptions = { @@ -59,7 +59,7 @@ export const getMCPTools = async (connections: MCPConnection[], options: MCPTool const text = await response.text().catch(() => '') throw new Error(`mcp-stdio failed: ${response.status} ${text}`) } - const data = await response.json().catch(() => ({} as { url?: string })) + const data = await response.json().catch(() => ({})) as { url?: string } const rawUrl = typeof data?.url === 'string' ? data.url : '' if (!rawUrl) { throw new Error('mcp-stdio response missing url') diff --git a/agent/src/tools/skill.ts b/packages/agent/src/tools/skill.ts similarity index 100% rename from agent/src/tools/skill.ts rename to packages/agent/src/tools/skill.ts diff --git a/agent/src/tools/subagent.ts b/packages/agent/src/tools/subagent.ts similarity index 98% rename from agent/src/tools/subagent.ts rename to packages/agent/src/tools/subagent.ts index d73b394f..42c9ba02 100644 --- a/agent/src/tools/subagent.ts +++ b/packages/agent/src/tools/subagent.ts @@ -2,7 +2,7 @@ import { tool } from 'ai' import { z } from 'zod' import { createAgent } from '../agent' import { ModelConfig, AgentAuthContext } from '../types' -import { AuthFetcher } from '..' +import { AuthFetcher } from '../types' import { AgentAction, IdentityContext } from '../types/agent' export interface SubagentToolParams { diff --git a/agent/src/tools/web.ts b/packages/agent/src/tools/web.ts similarity index 100% rename from agent/src/tools/web.ts rename to packages/agent/src/tools/web.ts diff --git a/agent/src/types/action.ts b/packages/agent/src/types/action.ts similarity index 98% rename from agent/src/types/action.ts rename to packages/agent/src/types/action.ts index 94dcbdf3..4487ddee 100644 --- a/agent/src/types/action.ts +++ b/packages/agent/src/types/action.ts @@ -67,7 +67,7 @@ export interface AgentEndAction extends BaseAction { usages: (LanguageModelUsage | null)[] } -export type AgentAction = +export type AgentStreamAction = | AgentStartAction | ReasoningStartAction | ReasoningDeltaAction diff --git a/agent/src/types/agent.ts b/packages/agent/src/types/agent.ts similarity index 100% rename from agent/src/types/agent.ts rename to packages/agent/src/types/agent.ts diff --git a/agent/src/types/attachment.ts b/packages/agent/src/types/attachment.ts similarity index 100% rename from agent/src/types/attachment.ts rename to packages/agent/src/types/attachment.ts diff --git a/packages/agent/src/types/auth.ts b/packages/agent/src/types/auth.ts new file mode 100644 index 00000000..f137970d --- /dev/null +++ b/packages/agent/src/types/auth.ts @@ -0,0 +1,4 @@ +export type AuthFetcher = ( + url: string, + options?: RequestInit, +) => Promise \ No newline at end of file diff --git a/agent/src/types/index.ts b/packages/agent/src/types/index.ts similarity index 60% rename from agent/src/types/index.ts rename to packages/agent/src/types/index.ts index dd5abe62..451baee9 100644 --- a/agent/src/types/index.ts +++ b/packages/agent/src/types/index.ts @@ -2,4 +2,6 @@ export * from './agent' export * from './model' export * from './schedule' export * from './attachment' -export * from './mcp' \ No newline at end of file +export * from './mcp' +export * from './auth' +export * from './action' \ No newline at end of file diff --git a/agent/src/types/mcp.ts b/packages/agent/src/types/mcp.ts similarity index 100% rename from agent/src/types/mcp.ts rename to packages/agent/src/types/mcp.ts diff --git a/agent/src/types/model.ts b/packages/agent/src/types/model.ts similarity index 100% rename from agent/src/types/model.ts rename to packages/agent/src/types/model.ts diff --git a/agent/src/types/schedule.ts b/packages/agent/src/types/schedule.ts similarity index 100% rename from agent/src/types/schedule.ts rename to packages/agent/src/types/schedule.ts diff --git a/agent/src/utils/attachments.ts b/packages/agent/src/utils/attachments.ts similarity index 100% rename from agent/src/utils/attachments.ts rename to packages/agent/src/utils/attachments.ts diff --git a/agent/src/utils/headers.ts b/packages/agent/src/utils/headers.ts similarity index 100% rename from agent/src/utils/headers.ts rename to packages/agent/src/utils/headers.ts diff --git a/packages/agent/src/utils/index.ts b/packages/agent/src/utils/index.ts new file mode 100644 index 00000000..48ff40e2 --- /dev/null +++ b/packages/agent/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './attachments' +export * from './headers' \ No newline at end of file diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 00000000..94111105 --- /dev/null +++ b/packages/agent/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e75b7d8..6d62ca38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,24 +48,15 @@ importers: agent: dependencies: - '@ai-sdk/anthropic': - specifier: ^3.0.9 - version: 3.0.9(zod@4.3.5) - '@ai-sdk/google': - specifier: ^3.0.6 - version: 3.0.6(zod@4.3.5) - '@ai-sdk/mcp': - specifier: ^1.0.6 - version: 1.0.6(zod@4.3.5) - '@ai-sdk/openai': - specifier: ^3.0.7 - version: 3.0.7(zod@4.3.5) '@elysiajs/bearer': specifier: ^1.4.2 version: 1.4.2(elysia@1.4.25(@sinclair/typebox@0.34.47)(@types/bun@1.3.9)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) '@elysiajs/cors': specifier: ^1.4.1 version: 1.4.1(elysia@1.4.25(@sinclair/typebox@0.34.47)(@types/bun@1.3.9)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) + '@memoh/agent': + specifier: workspace:* + version: link:../packages/agent '@memoh/config': specifier: workspace:* version: link:../packages/config @@ -113,6 +104,42 @@ importers: specifier: ^3.5.0 version: 3.5.26(typescript@5.9.3) + packages/agent: + dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.9 + version: 3.0.9(zod@4.3.6) + '@ai-sdk/google': + specifier: ^3.0.6 + version: 3.0.6(zod@4.3.6) + '@ai-sdk/mcp': + specifier: ^1.0.6 + version: 1.0.6(zod@4.3.6) + '@ai-sdk/openai': + specifier: ^3.0.7 + version: 3.0.7(zod@4.3.6) + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 + ai: + specifier: ^6.0.25 + version: 6.0.25(zod@4.3.6) + jsdom: + specifier: ^27.4.0 + version: 27.4.0 + toml: + specifier: ^3.0.0 + version: 3.0.0 + turndown: + specifier: ^7.2.2 + version: 7.2.2 + typescript: + specifier: ^5 + version: 5.9.3 + zod: + specifier: ^4.3.6 + version: 4.3.6 + packages/cli: dependencies: '@memoh/sdk': @@ -5131,6 +5158,9 @@ packages: zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5138,11 +5168,11 @@ snapshots: '@acemir/cssom@0.9.31': {} - '@ai-sdk/anthropic@3.0.9(zod@4.3.5)': + '@ai-sdk/anthropic@3.0.9(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.2 - '@ai-sdk/provider-utils': 4.0.4(zod@4.3.5) - zod: 4.3.5 + '@ai-sdk/provider-utils': 4.0.4(zod@4.3.6) + zod: 4.3.6 '@ai-sdk/gateway@3.0.10(zod@4.3.5)': dependencies: @@ -5151,24 +5181,31 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.3.5 - '@ai-sdk/google@3.0.6(zod@4.3.5)': + '@ai-sdk/gateway@3.0.10(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.2 - '@ai-sdk/provider-utils': 4.0.4(zod@4.3.5) - zod: 4.3.5 + '@ai-sdk/provider-utils': 4.0.4(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 - '@ai-sdk/mcp@1.0.6(zod@4.3.5)': + '@ai-sdk/google@3.0.6(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.2 - '@ai-sdk/provider-utils': 4.0.5(zod@4.3.5) + '@ai-sdk/provider-utils': 4.0.4(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/mcp@1.0.6(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.5(zod@4.3.6) pkce-challenge: 5.0.1 - zod: 4.3.5 + zod: 4.3.6 - '@ai-sdk/openai@3.0.7(zod@4.3.5)': + '@ai-sdk/openai@3.0.7(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.2 - '@ai-sdk/provider-utils': 4.0.4(zod@4.3.5) - zod: 4.3.5 + '@ai-sdk/provider-utils': 4.0.4(zod@4.3.6) + zod: 4.3.6 '@ai-sdk/provider-utils@4.0.4(zod@4.3.5)': dependencies: @@ -5177,12 +5214,19 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.5 - '@ai-sdk/provider-utils@4.0.5(zod@4.3.5)': + '@ai-sdk/provider-utils@4.0.4(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.2 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.3.5 + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.5(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 '@ai-sdk/provider@3.0.2': dependencies: @@ -7230,6 +7274,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.3.5 + ai@6.0.25(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.10(zod@4.3.6) + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -9966,4 +10018,6 @@ snapshots: zod@4.3.5: {} + zod@4.3.6: {} + zwitch@2.0.4: {}