diff --git a/AGENTS.md b/AGENTS.md index e1a7eae7..7e3ffcbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Memoh/ │ ├── agent/ # Main backend server (main.go, FX wiring) │ ├── bridge/ # In-container gRPC bridge (UDS-based, runs inside bot containers) │ ├── mcp/ # MCP stdio transport binary -│ └── memoh/ # Unified binary wrapper (Cobra CLI: serve, migrate, version) +│ └── memoh/ # Unified binary wrapper (Cobra: serve, migrate, version) ├── internal/ # Go backend core code (domain packages) │ ├── accounts/ # User account management (CRUD, password hashing) │ ├── acl/ # Access control list (source-aware chat trigger ACL) @@ -151,7 +151,6 @@ Memoh/ ├── packages/ # Shared TypeScript libraries │ ├── ui/ # Shared UI component library (@memohai/ui) │ ├── sdk/ # TypeScript SDK (@memohai/sdk, auto-generated from OpenAPI) -│ ├── cli/ # CLI tool (@memohai/cli, Commander.js) │ └── config/ # Shared configuration utilities (@memohai/config) ├── spec/ # OpenAPI specifications (swagger.json, swagger.yaml) ├── db/ # Database @@ -202,7 +201,6 @@ Memoh/ | `mise run lint:fix` | Run all linters with auto-fix | | `mise run release` | Release new version (bumpp) | | `mise run release-binaries` | Build release archive for target (requires TARGET_OS TARGET_ARCH) | -| `mise run install-cli` | Install CLI locally | | `mise run install-socktainer` | Install socktainer (macOS container backend) | | `mise run install-workspace-toolkit` | Install workspace toolkit (bridge binary etc.) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5c8a8b3..cc73eaaa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ cmd/ — Go application entry points internal/ — Go backend core code apps/ — Application services (Agent Gateway, etc.) agent/ — Agent Gateway (Bun/Elysia) -packages/ — Frontend monorepo (web, ui, sdk, cli, config) +packages/ — Frontend monorepo (web, ui, sdk, config) db/ — Database migrations and queries scripts/ — Utility scripts ``` diff --git a/README.md b/README.md index 0f42857b..9d60b31f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@
-Memoh is an always-on, containerized AI agent system. Create multiple AI bots, each running in its own isolated container with persistent memory, and interact with them across Telegram, Discord, Lark (Feishu), Email, or the built-in Web/CLI. Bots can execute commands, edit files, browse the web, call external tools via MCP, and remember everything — like giving each bot its own computer and brain. +Memoh is an always-on, containerized AI agent system. Create multiple AI bots, each running in its own isolated container with persistent memory, and interact with them across Telegram, Discord, Lark (Feishu), Email, or the built-in Web UI. Bots can execute commands, edit files, browse the web, call external tools via MCP, and remember everything — like giving each bot its own computer and brain. ## Quick Start @@ -80,7 +80,7 @@ Memoh Bot can distinguish and remember requests from multiple humans and bots, w - 👥 **Multi-User & Identity Recognition**: Bots can distinguish individual users in group chats, remember each person's context separately, and send direct messages to specific users. Cross-platform identity binding unifies the same person across Telegram, Discord, Lark, and Web. - 📦 **Containerized**: Each bot runs in its own isolated containerd container. Bots can freely execute commands, edit files, and access the network within their containers — like having their own computer. Supports container snapshots for save/restore. - 🧠 **Memory Engineering**: Multi-provider memory architecture — Built-in (off / sparse / dense modes), [Mem0](https://mem0.ai), and OpenViking. LLM-driven fact extraction, hybrid retrieval (dense semantic search + BM25 keyword + neural sparse vectors), 24-hour context loading, memory compaction & rebuild, and multi-language auto-detection. -- 💬 **Multi-Platform**: Supports Telegram, Discord, Lark (Feishu), Email, and built-in Web/CLI. Unified message format with rich text, media attachments, reactions, and streaming across all platforms. Cross-platform identity binding. +- 💬 **Multi-Platform**: Supports Telegram, Discord, Lark (Feishu), Email, and the built-in Web UI. Unified message format with rich text, media attachments, reactions, and streaming across all platforms. Cross-platform identity binding. - 📧 **Email**: Multi-adapter email service (Mailgun, generic SMTP) with per-bot binding and outbound audit log. Bots can send and receive emails as a channel. - 🔧 **MCP (Model Context Protocol)**: Full MCP support (HTTP / SSE / Stdio). Built-in tools for container operations, memory search, web search, scheduling, messaging, and more. Connect external MCP servers for extensibility. - 🧩 **Subagents**: Create specialized sub-agents per bot with independent context and skills, enabling multi-agent collaboration. @@ -142,10 +142,9 @@ flowchart TB direction LR CH["Channels
Telegram · Discord · Feishu · QQ · Email"] WEB["Web UI (Vue 3 :8082)"] - CLI["CLI"] end - CH & WEB & CLI --> API + CH & WEB --> API subgraph Server [" Server · Go :8080 "] API["REST API & Channel Adapters"] diff --git a/README_CN.md b/README_CN.md index a60cbb91..65117874 100644 --- a/README_CN.md +++ b/README_CN.md @@ -28,7 +28,7 @@
-Memoh 是一个常驻运行的容器化 AI Agent 系统。你可以创建多个 AI 机器人,每个机器人运行在独立的容器中,拥有持久化记忆,并通过 Telegram、Discord、飞书(Lark)、Email 或内置的 Web/CLI 与之交互。机器人可以执行命令、编辑文件、浏览网页、通过 MCP 调用外部工具,并记住一切 —— 就像给每个 Bot 一台自己的电脑和大脑。 +Memoh 是一个常驻运行的容器化 AI Agent 系统。你可以创建多个 AI 机器人,每个机器人运行在独立的容器中,拥有持久化记忆,并通过 Telegram、Discord、飞书(Lark)、Email 或内置 Web 管理界面与之交互。机器人可以执行命令、编辑文件、浏览网页、通过 MCP 调用外部工具,并记住一切 —— 就像给每个 Bot 一台自己的电脑和大脑。 ## 快速开始 @@ -78,7 +78,7 @@ Memoh Bot 能区分并记忆多人与多 bot 的请求,在任意群聊中无 - 👥 **多用户与身份识别**:Bot 可在群聊中区分不同用户,分别记忆每个人的上下文,并支持向特定用户单独发送消息。跨平台身份绑定将同一用户在 Telegram、Discord、飞书、Web 上的身份统一关联。 - 📦 **容器化**:每个 bot 运行在独立的 containerd 容器中,可在容器内自由执行命令、编辑文件、访问网络,宛如各自拥有一台电脑。支持容器快照保存与恢复。 - 🧠 **记忆工程**:多供应商记忆架构 —— 内置(off / sparse / dense 三种模式)、[Mem0](https://mem0.ai)、OpenViking。LLM 驱动的知识抽取,混合检索(稠密语义搜索 + BM25 关键词 + 神经稀疏向量),24 小时上下文加载,记忆压缩与重建,多语言自动检测。 -- 💬 **多平台**:支持 Telegram、Discord、飞书(Lark)、Email 及内置 Web/CLI。跨平台统一消息格式,支持富文本、媒体附件、表情回应和流式输出。跨平台身份绑定。 +- 💬 **多平台**:支持 Telegram、Discord、飞书(Lark)、Email 及内置 Web 管理界面。跨平台统一消息格式,支持富文本、媒体附件、表情回应和流式输出。跨平台身份绑定。 - 📧 **邮件**:多适配器邮件服务(Mailgun、通用 SMTP),支持按 bot 绑定与发信审计日志。Bot 可将邮件作为渠道收发。 - 🔧 **MCP(模型上下文协议)**:完整 MCP 支持(HTTP / SSE / Stdio)。内置容器操作、记忆搜索、网络搜索、定时任务、消息发送等工具,可连接外部 MCP 服务器扩展。 - 🧩 **子代理**:为每个 bot 创建专用子代理,拥有独立上下文与技能,实现多代理协作。 @@ -141,10 +141,9 @@ flowchart TB direction LR CH["渠道
Telegram · Discord · 飞书 · QQ · Email"] WEB["Web 管理界面 (Vue 3 :8082)"] - CLI["CLI"] end - CH & WEB & CLI --> API + CH & WEB --> API subgraph Server [" 服务端 · Go :8080 "] API["REST API & 渠道适配器"] diff --git a/docs/docs/about.md b/docs/docs/about.md index 5d4d2d40..bf69958c 100644 --- a/docs/docs/about.md +++ b/docs/docs/about.md @@ -2,7 +2,7 @@ ## What is Memoh? -Memoh is a multi-member, structured long-memory, containerized AI agent system platform. You can create your own AI bots and chat with them via Telegram, Discord, Lark (Feishu), Email, Web, or CLI. Every bot has an independent container and memory system, allowing it to edit files, execute commands, and access the network within its own container — like having its own computer and brain. +Memoh is a multi-member, structured long-memory, containerized AI agent system platform. You can create your own AI bots and chat with them via Telegram, Discord, Lark (Feishu), Email, or Web. Every bot has an independent container and memory system, allowing it to edit files, execute commands, and access the network within its own container — like having its own computer and brain. ## Key Features @@ -38,7 +38,6 @@ Unified channel adapter architecture for connecting to multiple messaging platfo - **Lark (Feishu)** — Full support - **Email** — Inbound webhook + outbound providers (Mailgun, generic SMTP) - **Web** — Built-in web chat interface with streaming -- **CLI** — Command-line chat ### Agent Capabilities @@ -78,10 +77,6 @@ Cross-channel inbox — messages from other channels are queued and surfaced in Modern web UI (Vue 3 + Tailwind CSS) with real-time streaming chat, tool call visualization, container filesystem browser, and visual configuration for bots, channels, providers, models, MCP, skills, and all other settings. Dark/light theme, i18n. No coding required to set up your own AI bot. -### CLI Tool - -A command-line tool for bot management, channel configuration, model management, streaming chat, and more — designed for developers who prefer the terminal. See [CLI documentation](/cli/). - ## Installation To get Memoh running: diff --git a/docs/docs/index.md b/docs/docs/index.md index fcedbf68..9f01fa85 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,6 +1,6 @@ # Memoh Documentation -Memoh(/ˈmemoʊ/) is a multi-member, structured long-memory, containerized AI agent system. Create your own AI bots, chat with them via Telegram, Lark (Feishu), Web, or CLI. Each bot runs in an isolated container with its own memory system — able to edit files, run commands, and access the network. +Memoh(/ˈmemoʊ/) is a multi-member, structured long-memory, containerized AI agent system. Create your own AI bots, chat with them via Telegram, Lark (Feishu), or Web. Each bot runs in an isolated container with its own memory system — able to edit files, run commands, and access the network. ## Documentation diff --git a/internal/channel/adapters/local/broadcaster.go b/internal/channel/adapters/local/broadcaster.go index ea1cc31a..555c15ab 100644 --- a/internal/channel/adapters/local/broadcaster.go +++ b/internal/channel/adapters/local/broadcaster.go @@ -8,7 +8,7 @@ import ( // RouteHubBroadcaster implements channel.StreamObserver by forwarding events // to the RouteHub. This enables cross-channel visibility: events from external -// channels (Telegram, Feishu, …) are mirrored to WebUI/CLI subscribers. +// channels (Telegram, Feishu, …) are mirrored to RouteHub subscribers. type RouteHubBroadcaster struct { hub *RouteHub } diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index 0d165819..8a22ec6a 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -484,7 +484,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel }() // For non-local channels, wrap the stream so events are mirrored to the - // RouteHub (and thus to WebUI/CLI subscribers). + // RouteHub (and thus to Web UI and other local subscribers). if p.observer != nil && !isLocalChannelType(msg.Channel) { stream = channel.NewTeeStream(stream, p.observer, strings.TrimSpace(identity.BotID), msg.Channel) // Broadcast the inbound user message so WebUI can display it. @@ -1852,7 +1852,7 @@ func extractStorageKey(accessPath string, _ string) string { } // isLocalChannelType returns true for channels that already publish to RouteHub -// natively (Web, CLI). Wrapping these with a tee would cause duplicate events. +// natively (e.g. web, cli). Wrapping these with a tee would cause duplicate events. func isLocalChannelType(ct channel.ChannelType) bool { s := strings.ToLower(strings.TrimSpace(string(ct))) return s == "web" || s == "cli" diff --git a/mise.toml b/mise.toml index 04929d31..9df72ae6 100644 --- a/mise.toml +++ b/mise.toml @@ -112,11 +112,6 @@ description = "Build release archive for one target (requires TARGET_OS TARGET_A depends = ["//:pnpm-install"] run = "scripts/release.sh" -[tasks.install-cli] -description = "Install CLI" -depends = ["//:pnpm-install"] -run = "cd packages/cli && npm install -g" - [tasks.install-socktainer] description = "Install socktainer" run = "brew tap socktainer/tap && brew install socktainer" @@ -162,7 +157,6 @@ depends = [ "//:sqlc-generate", "//:pnpm-install", "//:go-install", - "//:install-cli", ] run = """ #!/bin/bash diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore deleted file mode 100644 index a14702c4..00000000 --- a/packages/cli/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# 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/cli/README.md b/packages/cli/README.md deleted file mode 100644 index 4d49eb84..00000000 --- a/packages/cli/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# @memohai/cli - diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index 1c60879e..00000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@memohai/cli", - "version": "0.5.0", - "description": "Command line interface and core API for Memoh", - "exports": { - ".": "./src/index.ts", - "./core": "./src/core/index.ts", - "./types": "./src/types/index.ts", - "./utils": "./src/utils/index.ts", - "./cli": "./src/cli/index.ts" - }, - "bin": { - "memoh": "./dist/cli.js" - }, - "scripts": { - "start": "bun run src/cli/index.ts", - "dev": "bun run --watch src/cli/index.ts", - "build": "tsup", - "build:watch": "tsup --watch" - }, - "dependencies": { - "@memohai/sdk": "workspace:*", - "commander": "^12.1.0", - "chalk": "^5.4.1", - "ora": "^8.1.1", - "inquirer": "^12.3.0", - "table": "^6.8.2" - }, - "devDependencies": { - "@types/node": "^22.10.5", - "bun-types": "latest", - "@types/bun": "latest", - "tsup": "^8.4.0" - }, - "packageManager": "pnpm@10.27.0", - "module": "src/index.ts", - "type": "module", - "private": true, - "peerDependencies": { - "typescript": "^5" - } -} diff --git a/packages/cli/src/cli/bot.ts b/packages/cli/src/cli/bot.ts deleted file mode 100644 index 6bf4c73c..00000000 --- a/packages/cli/src/cli/bot.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { Command } from 'commander' -import chalk from 'chalk' -import inquirer from 'inquirer' -import ora from 'ora' -import { table } from 'table' -import readline from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' - -import { - getBots, - postBots, - putBotsById, - deleteBotsById, - getModels, - type BotsBot, - type BotsCreateBotRequest, - type BotsUpdateBotRequest, - type ModelsGetResponse, -} from '@memohai/sdk' -import { client } from '@memohai/sdk/client' -import { ensureAuth, getErrorMessage, resolveBotId } from './shared' -import { streamChat } from './stream' - -const getModelId = (item: ModelsGetResponse) => item.model_id ?? '' -const getModelType = (item: ModelsGetResponse) => item.type ?? 'chat' - -const ensureModelsReady = async () => { - ensureAuth() - const [chatResult, embeddingResult] = await Promise.all([ - getModels({ query: { type: 'chat' }, throwOnError: true }), - getModels({ query: { type: 'embedding' }, throwOnError: true }), - ]) - const chatModels = chatResult.data ?? [] - const embeddingModels = embeddingResult.data ?? [] - if (!Array.isArray(chatModels) || chatModels.length === 0 || !Array.isArray(embeddingModels) || embeddingModels.length === 0) { - console.log(chalk.red('Model configuration incomplete.')) - console.log(chalk.yellow('At least one chat model and one embedding model are required.')) - process.exit(1) - } -} - -const renderBotsTable = (items: BotsBot[]) => { - const rows: string[][] = [['ID', 'Name', 'Type', 'Active', 'Owner']] - for (const bot of items) { - rows.push([ - bot.id ?? '', - bot.display_name || bot.id || '', - bot.type ?? '', - bot.is_active ? 'yes' : 'no', - bot.owner_user_id ?? '', - ]) - } - return table(rows) -} - -export const registerBotCommands = (program: Command) => { - const bot = program.command('bot').description('Bot management') - - bot - .command('list') - .description('List bots') - .option('--owner ', 'Filter by owner user ID (admin only)') - .action(async (opts) => { - ensureAuth() - const { data } = await getBots({ - query: opts.owner ? { owner_id: opts.owner } : undefined, - throwOnError: true, - }) - const items = data.items ?? [] - if (!items.length) { - console.log(chalk.yellow('No bots found.')) - return - } - console.log(renderBotsTable(items)) - }) - - bot - .command('create') - .description('Create a bot') - .option('--type ', 'Bot type (personal, public)') - .option('--name ', 'Bot display name') - .option('--avatar ', 'Bot avatar URL') - .option('--active', 'Set bot active') - .option('--inactive', 'Set bot inactive') - .action(async (opts) => { - if (opts.active && opts.inactive) { - console.log(chalk.red('Use only one of --active or --inactive.')) - process.exit(1) - } - ensureAuth() - let type = opts.type - if (!type) { - const answer = await inquirer.prompt<{ type: string }>([ - { - type: 'list', - name: 'type', - message: 'Bot type:', - choices: ['personal', 'public'], - }, - ]) - type = answer.type - } - if (!['personal', 'public'].includes(String(type))) { - console.log(chalk.red('Bot type must be personal or public.')) - process.exit(1) - } - const name = opts.name ?? (await inquirer.prompt<{ name: string }>([ - { type: 'input', name: 'name', message: 'Bot name (optional):', default: '' }, - ])).name - const body: Record = { - type: String(type), - } - if (String(name).trim()) body.display_name = String(name).trim() - if (opts.avatar) body.avatar_url = String(opts.avatar).trim() - if (opts.active) body.is_active = true - if (opts.inactive) body.is_active = false - const spinner = ora('Creating bot...').start() - try { - const { data } = await postBots({ - body: body as BotsCreateBotRequest, - throwOnError: true, - }) - spinner.succeed(`Bot created: ${data.display_name || data.id}`) - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to create bot') - process.exit(1) - } - }) - - bot - .command('update') - .description('Update bot info') - .argument('[id]') - .option('--name ', 'Bot display name') - .option('--avatar ', 'Bot avatar URL') - .option('--active', 'Set bot active') - .option('--inactive', 'Set bot inactive') - .action(async (id, opts) => { - if (opts.active && opts.inactive) { - console.log(chalk.red('Use only one of --active or --inactive.')) - process.exit(1) - } - ensureAuth() - const botId = await resolveBotId(id) - const body: Record = {} - if (opts.name) body.display_name = String(opts.name).trim() - if (opts.avatar) body.avatar_url = String(opts.avatar).trim() - if (opts.active) body.is_active = true - if (opts.inactive) body.is_active = false - if (Object.keys(body).length === 0) { - const answers = await inquirer.prompt<{ name: string; avatar: string; status: string }>([ - { type: 'input', name: 'name', message: 'Bot name (leave empty to skip):', default: '' }, - { type: 'input', name: 'avatar', message: 'Bot avatar URL (leave empty to skip):', default: '' }, - { - type: 'list', - name: 'status', - message: 'Bot status:', - choices: [ - { name: 'keep', value: 'keep' }, - { name: 'active', value: 'active' }, - { name: 'inactive', value: 'inactive' }, - ], - }, - ]) - if (answers.name.trim()) body.display_name = answers.name.trim() - if (answers.avatar.trim()) body.avatar_url = answers.avatar.trim() - if (answers.status === 'active') body.is_active = true - if (answers.status === 'inactive') body.is_active = false - } - if (Object.keys(body).length === 0) { - console.log(chalk.red('No updates provided.')) - process.exit(1) - } - const spinner = ora('Updating bot...').start() - try { - await putBotsById({ - path: { id: botId }, - body: body as BotsUpdateBotRequest, - throwOnError: true, - }) - spinner.succeed('Bot updated') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to update bot') - process.exit(1) - } - }) - - bot - .command('delete') - .description('Delete a bot') - .argument('[id]') - .action(async (id) => { - ensureAuth() - const botId = await resolveBotId(id) - const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ - { type: 'confirm', name: 'confirmed', message: `Delete bot ${botId}?`, default: false }, - ]) - if (!confirmed) return - const spinner = ora('Deleting bot...').start() - try { - await deleteBotsById({ - path: { id: botId }, - throwOnError: true, - }) - spinner.succeed('Bot deleted') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to delete bot') - process.exit(1) - } - }) - - bot - .command('chat') - .description('Chat with a bot (stream)') - .argument('[id]') - .action(async (id) => { - await ensureModelsReady() - ensureAuth() - const botId = await resolveBotId(id) - const rl = readline.createInterface({ input, output }) - console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`)) - while (true) { - const line = (await rl.question(chalk.cyan('> '))).trim() - if (!line) { - if (!input.isTTY && input.readableEnded) break - continue - } - if (line.toLowerCase() === 'exit') { - break - } - await streamChat(line, botId) - } - rl.close() - }) - - bot - .command('set-model') - .description('Enable model for a bot') - .argument('[id]') - .option('--as ', 'chat | memory | embedding') - .option('--model ', 'Model ID') - .action(async (id, opts) => { - ensureAuth() - const botId = await resolveBotId(id) - let enableAs = opts.as - if (!enableAs) { - const answer = await inquirer.prompt<{ usage: string }>([{ - type: 'list', - name: 'usage', - message: 'Enable as:', - choices: ['chat', 'memory', 'embedding'], - }]) - enableAs = answer.usage - } - enableAs = String(enableAs).trim() - if (!['chat', 'memory', 'embedding'].includes(enableAs)) { - console.log(chalk.red('Enable as must be one of chat, memory, embedding.')) - process.exit(1) - } - const { data: models } = await getModels({ throwOnError: true }) - const modelList = Array.isArray(models) ? models as ModelsGetResponse[] : [] - const requiredType = enableAs === 'embedding' ? 'embedding' : 'chat' - const candidates = modelList.filter(m => getModelType(m) === requiredType) - if (candidates.length === 0) { - console.log(chalk.red(`No ${requiredType} models available.`)) - process.exit(1) - } - let modelId = opts.model - if (!modelId) { - const answer = await inquirer.prompt<{ model: string }>([{ - type: 'list', - name: 'model', - message: 'Select model:', - choices: candidates.map(m => getModelId(m)), - }]) - modelId = answer.model - } - const selected = candidates.find(m => getModelId(m) === modelId) - if (!selected) { - console.log(chalk.red('Selected model not found.')) - process.exit(1) - } - const body: Record = {} - if (enableAs === 'chat') body.chat_model_id = getModelId(selected) - if (enableAs === 'memory') body.memory_model_id = getModelId(selected) - if (enableAs === 'embedding') body.embedding_model_id = getModelId(selected) - const spinner = ora('Updating bot settings...').start() - try { - // Use raw client because bot_id path parameter is not typed in SDK - await client.put({ - url: `/bots/${encodeURIComponent(botId)}/settings`, - body, - headers: { 'Content-Type': 'application/json' }, - throwOnError: true, - }) - spinner.succeed('Model enabled') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to enable model') - process.exit(1) - } - }) -} diff --git a/packages/cli/src/cli/channel.ts b/packages/cli/src/cli/channel.ts deleted file mode 100644 index 386f5767..00000000 --- a/packages/cli/src/cli/channel.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { Command } from 'commander' -import chalk from 'chalk' -import inquirer from 'inquirer' -import ora from 'ora' -import { table } from 'table' - -import { apiRequest } from '../core/api' -import { ensureAuth, getErrorMessage, resolveBotId } from './shared' -import { getBaseURL, readConfig } from '../utils/store' - -type ChannelFieldSchema = { - type: 'string' | 'secret' | 'bool' | 'number' | 'enum' - required: boolean - title?: string - description?: string - enum?: string[] - example?: unknown -} - -type ChannelConfigSchema = { - version: number - fields: Record -} - -type ChannelMeta = { - type: string - display_name: string - configless: boolean - capabilities: Record - config_schema: ChannelConfigSchema - user_config_schema: ChannelConfigSchema -} - -type ChannelUserBinding = { - id: string - channel_type: string - user_id: string - config: Record - created_at: string - updated_at: string -} - -type ChannelConfig = { - id: string - bot_id: string - channel_type: string - credentials: Record - external_identity: string - self_identity: Record - routing: Record - capabilities: Record - disabled: boolean - verified_at: string - created_at: string - updated_at: string -} - -const readInboundMode = (credentials: Record) => { - const raw = credentials.inboundMode ?? credentials.inbound_mode - if (typeof raw !== 'string') return '' - return raw.trim().toLowerCase() -} - -const buildWebhookCallbackUrl = (configId: string) => { - const baseUrl = getBaseURL(readConfig()).replace(/\/+$/, '') - return `${baseUrl}/channels/feishu/webhook/${encodeURIComponent(configId)}` -} - -const printWebhookCallbackIfEnabled = (channelType: string, config: ChannelConfig) => { - if (channelType !== 'feishu') return - if (readInboundMode(config.credentials || {}) !== 'webhook') return - const configId = String(config.id || '').trim() - if (!configId) { - console.log(chalk.yellow('Webhook is enabled, but config id is missing so callback URL cannot be generated yet.')) - return - } - console.log(chalk.cyan(`Webhook callback URL: ${buildWebhookCallbackUrl(configId)}`)) -} - -const renderChannelsTable = (items: ChannelMeta[]) => { - const rows: string[][] = [['Type', 'Name', 'Configless']] - for (const item of items) { - rows.push([item.type, item.display_name, item.configless ? 'yes' : 'no']) - } - return table(rows) -} - -const fetchChannels = async (token: ReturnType) => { - return apiRequest('/channels', {}, token) -} - -const resolveChannelType = async ( - token: ReturnType, - preset?: string, - options?: { allowConfigless?: boolean } -) => { - if (preset && preset.trim()) { - return preset.trim() - } - const channels = await fetchChannels(token) - const allowConfigless = options?.allowConfigless ?? false - const candidates = channels.filter(item => allowConfigless || !item.configless) - if (candidates.length === 0) { - console.log(chalk.yellow('No configurable channels available.')) - process.exit(0) - } - const { channelType } = await inquirer.prompt<{ channelType: string }>([ - { - type: 'list', - name: 'channelType', - message: 'Select channel type:', - choices: candidates.map(item => ({ - name: `${item.display_name} (${item.type})`, - value: item.type, - })), - }, - ]) - return channelType -} - -const collectFeishuCredentials = async (opts: Record) => { - let appId = typeof opts.app_id === 'string' ? opts.app_id : undefined - let appSecret = typeof opts.app_secret === 'string' ? opts.app_secret : undefined - let encryptKey = typeof opts.encrypt_key === 'string' ? opts.encrypt_key : undefined - let verificationToken = typeof opts.verification_token === 'string' ? opts.verification_token : undefined - let region = typeof opts.region === 'string' ? opts.region : undefined - let inboundMode = typeof opts.inbound_mode === 'string' ? opts.inbound_mode : undefined - - const questions = [] - if (!appId) questions.push({ type: 'input', name: 'appId', message: 'Feishu App ID:' }) - if (!appSecret) questions.push({ type: 'password', name: 'appSecret', message: 'Feishu App Secret:' }) - if (!encryptKey) { - questions.push({ type: 'input', name: 'encryptKey', message: 'Encrypt Key (optional):', default: '' }) - } - if (!verificationToken) { - questions.push({ type: 'input', name: 'verificationToken', message: 'Verification Token (optional):', default: '' }) - } - if (!region) { - questions.push({ - type: 'list', - name: 'region', - message: 'Region:', - choices: [ - { name: 'Feishu (open.feishu.cn)', value: 'feishu' }, - { name: 'Lark (open.larksuite.com)', value: 'lark' }, - ], - default: 'feishu', - }) - } - if (!inboundMode) { - questions.push({ - type: 'list', - name: 'inboundMode', - message: 'Inbound mode:', - choices: [ - { name: 'WebSocket', value: 'websocket' }, - { name: 'Webhook', value: 'webhook' }, - ], - default: 'websocket', - }) - } - const answers = questions.length ? await inquirer.prompt>(questions) : {} - - appId = appId ?? answers.appId - appSecret = appSecret ?? answers.appSecret - encryptKey = encryptKey ?? answers.encryptKey - verificationToken = verificationToken ?? answers.verificationToken - region = region ?? answers.region - inboundMode = inboundMode ?? answers.inboundMode - - const payload: Record = { - appId: String(appId).trim(), - appSecret: String(appSecret).trim(), - region: String(region || 'feishu').trim(), - inboundMode: String(inboundMode || 'websocket').trim(), - } - if (String(encryptKey || '').trim()) payload.encryptKey = String(encryptKey).trim() - if (String(verificationToken || '').trim()) payload.verificationToken = String(verificationToken).trim() - return payload -} - -const collectFeishuUserConfig = async (opts: Record) => { - let openId = typeof opts.open_id === 'string' ? opts.open_id : undefined - let userId = typeof opts.user_id === 'string' ? opts.user_id : undefined - - if (!openId && !userId) { - const answers = await inquirer.prompt<{ kind: 'open_id' | 'user_id'; value: string }>([ - { - type: 'list', - name: 'kind', - message: 'Bind using:', - choices: [ - { name: 'Open ID', value: 'open_id' }, - { name: 'User ID', value: 'user_id' }, - ], - }, - { - type: 'input', - name: 'value', - message: 'Value:', - }, - ]) - if (answers.kind === 'open_id') openId = answers.value - if (answers.kind === 'user_id') userId = answers.value - } - if (!openId && !userId) { - console.log(chalk.red('open_id or user_id is required.')) - process.exit(1) - } - const config: Record = {} - if (openId) config.open_id = String(openId).trim() - if (userId) config.user_id = String(userId).trim() - return config -} - -export const registerChannelCommands = (program: Command) => { - const channel = program.command('channel').description('Channel management') - - channel - .command('list') - .description('List available channels') - .action(async () => { - const token = ensureAuth() - const channels = await fetchChannels(token) - if (!channels.length) { - console.log(chalk.yellow('No channels available.')) - return - } - console.log(renderChannelsTable(channels)) - }) - - channel - .command('info') - .description('Show channel meta and schema') - .argument('[type]') - .action(async (type) => { - const token = ensureAuth() - const channelType = await resolveChannelType(token, type, { allowConfigless: true }) - const meta = await apiRequest(`/channels/${encodeURIComponent(channelType)}`, {}, token) - console.log(JSON.stringify(meta, null, 2)) - }) - - const config = channel.command('config').description('Bot channel configuration') - - config - .command('get') - .description('Get bot channel config') - .argument('[bot_id]') - .option('--type ', 'Channel type') - .action(async (botId, opts) => { - const token = ensureAuth() - const resolvedBotId = await resolveBotId(token, botId) - const channelType = await resolveChannelType(token, opts.type) - const resp = await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {}, token) - console.log(JSON.stringify(resp, null, 2)) - printWebhookCallbackIfEnabled(channelType, resp) - }) - - config - .command('set') - .description('Set bot channel config') - .argument('[bot_id]') - .option('--type ', 'Channel type (feishu)') - .option('--app_id ') - .option('--app_secret ') - .option('--encrypt_key ') - .option('--verification_token ') - .option('--region ', 'feishu|lark') - .option('--inbound_mode ', 'websocket|webhook') - .action(async (botId, opts) => { - const token = ensureAuth() - const resolvedBotId = await resolveBotId(token, botId) - const channelType = await resolveChannelType(token, opts.type) - if (channelType !== 'feishu') { - console.log(chalk.red(`Channel type ${channelType} is not supported by this command.`)) - process.exit(1) - } - const credentials = await collectFeishuCredentials(opts) - const spinner = ora('Updating channel config...').start() - try { - const resp = await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, { - method: 'PUT', - body: JSON.stringify({ credentials }), - }, token) - spinner.succeed('Channel config updated') - printWebhookCallbackIfEnabled(channelType, resp) - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to update channel config') - process.exit(1) - } - }) - - const binding = channel.command('bind').description('User channel binding') - - binding - .command('get') - .description('Get current user channel binding') - .option('--type ', 'Channel type') - .action(async (opts) => { - const token = ensureAuth() - const channelType = await resolveChannelType(token, opts.type) - const resp = await apiRequest(`/users/me/channels/${encodeURIComponent(channelType)}`, {}, token) - console.log(JSON.stringify(resp, null, 2)) - }) - - binding - .command('set') - .description('Set current user channel binding') - .option('--type ', 'Channel type (feishu)') - .option('--open_id ') - .option('--user_id ') - .action(async (opts) => { - const token = ensureAuth() - const channelType = await resolveChannelType(token, opts.type) - if (channelType !== 'feishu') { - console.log(chalk.red(`Channel type ${channelType} is not supported by this command.`)) - process.exit(1) - } - const configPayload = await collectFeishuUserConfig(opts) - const spinner = ora('Updating user binding...').start() - try { - await apiRequest(`/users/me/channels/${encodeURIComponent(channelType)}`, { - method: 'PUT', - body: JSON.stringify({ config: configPayload }), - }, token) - spinner.succeed('User binding updated') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to update user binding') - process.exit(1) - } - }) -} diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts deleted file mode 100755 index 19dc4055..00000000 --- a/packages/cli/src/cli/index.ts +++ /dev/null @@ -1,715 +0,0 @@ -import { Command } from 'commander' -import chalk from 'chalk' -import inquirer from 'inquirer' -import ora from 'ora' -import { table } from 'table' -import readline from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' - -import packageJson from '../../package.json' -import { setupClient, client } from '../core/client' -import { registerBotCommands } from './bot' -import { registerChannelCommands } from './channel' -import { streamChat } from './stream' -import { - readConfig, - writeConfig, - writeToken, - clearToken, - type TokenInfo, -} from '../utils/store' -import { ensureAuth, getErrorMessage, resolveBotId } from './shared' - -import { - postAuthLogin, - getUsersMe, - getProviders, - postProviders, - getProvidersNameByName, - deleteProvidersById, - getModels, - postModels, - deleteModelsModelByModelId, - type ProvidersGetResponse, - type ModelsGetResponse, - type ModelsAddRequest, - type ScheduleSchedule, - type ScheduleListResponse, -} from '@memohai/sdk' - -// --------------------------------------------------------------------------- -// Initialize SDK client -// --------------------------------------------------------------------------- - -setupClient() - -// --------------------------------------------------------------------------- -// Program setup -// --------------------------------------------------------------------------- - -const program = new Command() -program - .name('memoh') - .description('Memoh CLI') - .version(packageJson.version) - -registerBotCommands(program) -registerChannelCommands(program) - -// --------------------------------------------------------------------------- -// Model/Provider helpers -// --------------------------------------------------------------------------- - -const getModelId = (item: ModelsGetResponse) => item.model_id ?? '' -const getProviderId = (item: ModelsGetResponse) => item.llm_provider_id ?? '' -const getModelType = (item: ModelsGetResponse) => item.type ?? 'chat' -const getModelInputModalities = (item: ModelsGetResponse) => item.input_modalities ?? ['text'] - -const ensureModelsReady = async () => { - ensureAuth() - const [chatResult, embeddingResult] = await Promise.all([ - getModels({ query: { type: 'chat' }, throwOnError: true }), - getModels({ query: { type: 'embedding' }, throwOnError: true }), - ]) - const chatModels = chatResult.data ?? [] - const embeddingModels = embeddingResult.data ?? [] - if (!Array.isArray(chatModels) || chatModels.length === 0 || - !Array.isArray(embeddingModels) || embeddingModels.length === 0) { - console.log(chalk.red('Model configuration incomplete.')) - console.log(chalk.yellow('At least one chat model and one embedding model are required.')) - process.exit(1) - } -} - -const renderProvidersTable = (providers: ProvidersGetResponse[], models: ModelsGetResponse[]) => { - const rows: string[][] = [['Provider', 'Base URL', 'Models']] - for (const provider of providers) { - const providerModels = models - .filter(m => getProviderId(m) === provider.id) - .map(m => `${getModelId(m)} (${getModelType(m)})`) - rows.push([ - provider.name ?? '', - provider.base_url ?? '', - providerModels.join(', ') || '-', - ]) - } - return table(rows) -} - -const renderModelsTable = (models: ModelsGetResponse[], providers: ProvidersGetResponse[]) => { - const providerMap = new Map(providers.map(p => [p.id, p.name])) - const rows: string[][] = [['Model ID', 'Type', 'Provider', 'Input Modalities']] - for (const item of models) { - rows.push([ - getModelId(item), - getModelType(item), - providerMap.get(getProviderId(item)) ?? getProviderId(item), - getModelInputModalities(item).join(', '), - ]) - } - return table(rows) -} - -const renderSchedulesTable = (items: ScheduleSchedule[]) => { - const rows: string[][] = [['ID', 'Name', 'Pattern', 'Enabled', 'Max Calls', 'Current Calls', 'Command']] - for (const item of items) { - rows.push([ - item.id ?? '', - item.name ?? '', - item.pattern ?? '', - item.enabled ? 'yes' : 'no', - item.max_calls === null || item.max_calls === undefined ? '-' : String(item.max_calls), - item.current_calls === undefined ? '-' : String(item.current_calls), - item.command ?? '', - ]) - } - return table(rows) -} - -// --------------------------------------------------------------------------- -// Auth commands -// --------------------------------------------------------------------------- - -program - .command('login') - .description('Login') - .action(async () => { - const answers = await inquirer.prompt([ - { type: 'input', name: 'username', message: 'Username:' }, - { type: 'password', name: 'password', message: 'Password:' }, - ]) - const spinner = ora('Logging in...').start() - try { - const { data } = await postAuthLogin({ - body: { - username: answers.username, - password: answers.password, - }, - throwOnError: true, - }) - const tokenInfo: TokenInfo = { - access_token: data.access_token ?? '', - token_type: data.token_type ?? 'bearer', - expires_at: data.expires_at ?? '', - user_id: data.user_id ?? '', - username: data.username, - } - writeToken(tokenInfo) - spinner.succeed('Logged in') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Login failed') - process.exit(1) - } - }) - -program - .command('logout') - .description('Logout') - .action(() => { - clearToken() - console.log(chalk.green('Logged out')) - }) - -program - .command('whoami') - .description('Show current user') - .action(async () => { - const token = ensureAuth() - try { - const { data } = await getUsersMe({ throwOnError: true }) - if (data.username) console.log(`username: ${data.username}`) - if (data.display_name) console.log(`display_name: ${data.display_name}`) - if (data.id) console.log(`user_id: ${data.id}`) - if (data.role) console.log(`role: ${data.role}`) - } catch { - // Fallback to token info if API call fails - if (token.username) console.log(`username: ${token.username}`) - if (token.user_id) console.log(`user_id: ${token.user_id}`) - } - }) - -// --------------------------------------------------------------------------- -// Config commands -// --------------------------------------------------------------------------- - -const configCmd = program - .command('config') - .description('Show or update current config') - -configCmd.action(async () => { - const config = readConfig() - console.log(`host = "${config.host}"`) - console.log(`port = ${config.port}`) -}) - -configCmd - .command('set') - .description('Update config') - .option('--host ') - .option('--port ') - .action(async (opts) => { - const current = readConfig() - let host = opts.host - let port = opts.port ? Number.parseInt(opts.port, 10) : undefined - - if (!host && !port) { - const answers = await inquirer.prompt([ - { type: 'input', name: 'host', message: 'Host:', default: current.host }, - { type: 'input', name: 'port', message: 'Port:', default: current.port }, - ]) - host = answers.host - port = Number.parseInt(answers.port, 10) - } - - if (host) current.host = host - if (port && !Number.isNaN(port)) current.port = port - - writeConfig(current) - console.log(chalk.green('Config updated')) - }) - -// --------------------------------------------------------------------------- -// Provider commands -// --------------------------------------------------------------------------- - -const provider = program.command('provider').description('Provider management') - -provider - .command('list') - .description('List providers') - .option('--provider ', 'Filter by provider name') - .action(async (opts) => { - ensureAuth() - let providers: ProvidersGetResponse[] - if (opts.provider) { - const { data } = await getProvidersNameByName({ - path: { name: opts.provider }, - throwOnError: true, - }) - providers = [data] - } else { - const { data } = await getProviders({ throwOnError: true }) - providers = data as ProvidersGetResponse[] - } - const { data: models } = await getModels({ throwOnError: true }) - console.log(renderProvidersTable(providers, models as ModelsGetResponse[])) - }) - -provider - .command('create') - .description('Create provider') - .option('--name ') - .option('--base_url ') - .option('--api_key ') - .action(async (opts) => { - ensureAuth() - const questions = [] - if (!opts.name) questions.push({ type: 'input', name: 'name', message: 'Provider name:' }) - if (!opts.base_url) questions.push({ type: 'input', name: 'base_url', message: 'Base URL:' }) - if (!opts.api_key) questions.push({ type: 'password', name: 'api_key', message: 'API key:' }) - const answers = questions.length ? await inquirer.prompt(questions) : {} - const spinner = ora('Creating provider...').start() - try { - await postProviders({ - body: { - name: opts.name ?? answers.name, - base_url: opts.base_url ?? answers.base_url, - api_key: opts.api_key ?? answers.api_key, - }, - throwOnError: true, - }) - spinner.succeed('Provider created') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to create provider') - process.exit(1) - } - }) - -provider - .command('delete') - .description('Delete provider') - .option('--provider ', 'Provider name') - .action(async (opts) => { - ensureAuth() - if (!opts.provider) { - console.log(chalk.red('Provider name is required.')) - process.exit(1) - } - const { data: providerInfo } = await getProvidersNameByName({ - path: { name: opts.provider }, - throwOnError: true, - }) - const spinner = ora('Deleting provider...').start() - try { - await deleteProvidersById({ - path: { id: providerInfo.id! }, - throwOnError: true, - }) - spinner.succeed('Provider deleted') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to delete provider') - process.exit(1) - } - }) - -// --------------------------------------------------------------------------- -// Model commands -// --------------------------------------------------------------------------- - -const model = program.command('model').description('Model management') - -model - .command('list') - .description('List models') - .action(async () => { - ensureAuth() - const [modelsResult, providersResult] = await Promise.all([ - getModels({ throwOnError: true }), - getProviders({ throwOnError: true }), - ]) - console.log(renderModelsTable( - modelsResult.data as ModelsGetResponse[], - providersResult.data as ProvidersGetResponse[], - )) - }) - -model - .command('create') - .description('Create model') - .option('--model_id ') - .option('--name ') - .option('--provider ') - .option('--client_type ', 'Client type: openai-responses, openai-completions, anthropic-messages, google-generative-ai') - .option('--type ') - .option('--dimensions ') - .option('--multimodal', 'Is multimodal') - .action(async (opts) => { - ensureAuth() - const { data: providerList } = await getProviders({ throwOnError: true }) - const providers = providerList as ProvidersGetResponse[] - let provider = providers.find(p => p.name === opts.provider) - if (!provider) { - const answer = await inquirer.prompt([{ - type: 'list', - name: 'provider', - message: 'Select provider:', - choices: providers.map(p => p.name), - }]) - provider = providers.find(p => p.name === answer.provider) - } - if (!provider) { - console.log(chalk.red('Provider not found.')) - process.exit(1) - } - const questions = [] - if (!opts.model_id) questions.push({ type: 'input', name: 'model_id', message: 'Model ID (e.g. gpt-4):' }) - if (!opts.type) questions.push({ type: 'list', name: 'type', message: 'Model type:', choices: ['chat', 'embedding'] }) - const answers = questions.length ? await inquirer.prompt(questions) : {} - const modelId = opts.model_id ?? answers.model_id - const modelType = opts.type ?? answers.type - let clientType = opts.client_type - if (modelType === 'chat' && !clientType) { - const ctAnswer = await inquirer.prompt([{ - type: 'list', - name: 'client_type', - message: 'Client type:', - choices: ['openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai'], - }]) - clientType = ctAnswer.client_type - } - let dimensions = opts.dimensions ? Number.parseInt(opts.dimensions, 10) : undefined - if (modelType === 'embedding' && (!dimensions || Number.isNaN(dimensions))) { - const dimAnswer = await inquirer.prompt([{ - type: 'input', - name: 'dimensions', - message: 'Embedding dimensions (e.g. 1536):', - }]) - dimensions = Number.parseInt(dimAnswer.dimensions, 10) - } - if (modelType === 'embedding' && (!dimensions || Number.isNaN(dimensions) || dimensions <= 0)) { - console.log(chalk.red('Embedding models require a valid dimensions value.')) - process.exit(1) - } - const inputModalities = opts.multimodal ? ['text', 'image'] : ['text'] - const spinner = ora('Creating model...').start() - try { - const body: Record = { - model_id: modelId, - name: opts.name ?? modelId, - llm_provider_id: provider.id, - input_modalities: inputModalities, - type: modelType, - dimensions, - } - if (modelType === 'chat' && clientType) { - body.client_type = clientType - } - await postModels({ body: body as ModelsAddRequest, throwOnError: true }) - spinner.succeed('Model created') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to create model') - process.exit(1) - } - }) - -model - .command('delete') - .description('Delete model') - .option('--model ') - .action(async (opts) => { - ensureAuth() - if (!opts.model) { - console.log(chalk.red('Model name is required.')) - process.exit(1) - } - const spinner = ora('Deleting model...').start() - try { - await deleteModelsModelByModelId({ - path: { modelId: opts.model }, - throwOnError: true, - }) - spinner.succeed('Model deleted') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to delete model') - process.exit(1) - } - }) - -// --------------------------------------------------------------------------- -// Schedule commands (uses raw client due to untyped bot_id path param) -// --------------------------------------------------------------------------- - -const schedule = program.command('schedule').description('Schedule management') - .option('--bot ', 'Bot ID (required for schedule operations)') - -const resolveScheduleBotId = async (opts: { bot?: string }) => { - return await resolveBotId(opts.bot) -} - -schedule - .command('list') - .description('List schedules') - .action(async () => { - ensureAuth() - const botId = await resolveScheduleBotId(schedule.opts()) - const { data } = await client.get({ - url: `/bots/${encodeURIComponent(botId)}/schedule`, - throwOnError: true, - }) - const resp = data as ScheduleListResponse - if (!resp.items?.length) { - console.log(chalk.yellow('No schedules found.')) - return - } - console.log(renderSchedulesTable(resp.items)) - }) - -schedule - .command('get') - .description('Get schedule') - .argument('') - .action(async (id) => { - ensureAuth() - const botId = await resolveScheduleBotId(schedule.opts()) - const { data } = await client.get({ - url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`, - throwOnError: true, - }) - console.log(JSON.stringify(data, null, 2)) - }) - -schedule - .command('create') - .description('Create schedule') - .option('--name ') - .option('--description ') - .option('--pattern ') - .option('--command ') - .option('--max_calls ') - .option('--enabled') - .option('--disabled') - .action(async (opts) => { - if (opts.enabled && opts.disabled) { - console.log(chalk.red('Use only one of --enabled or --disabled.')) - process.exit(1) - } - const questions = [] - if (!opts.name) questions.push({ type: 'input', name: 'name', message: 'Name:' }) - if (!opts.description) questions.push({ type: 'input', name: 'description', message: 'Description:' }) - if (!opts.pattern) questions.push({ type: 'input', name: 'pattern', message: 'Cron pattern:' }) - if (!opts.command) questions.push({ type: 'input', name: 'command', message: 'Command:' }) - if (opts.max_calls === undefined) { - questions.push({ - type: 'input', - name: 'max_calls', - message: 'Max calls (optional, empty for unlimited):', - default: '', - }) - } - const answers = questions.length ? await inquirer.prompt(questions) : {} - const maxCallsInput = opts.max_calls ?? answers.max_calls - let maxCalls: number | undefined - if (maxCallsInput !== undefined && String(maxCallsInput).trim() !== '') { - const parsed = Number.parseInt(String(maxCallsInput), 10) - if (Number.isNaN(parsed) || parsed <= 0) { - console.log(chalk.red('max_calls must be a positive integer.')) - process.exit(1) - } - maxCalls = parsed - } - const payload = { - name: opts.name ?? answers.name, - description: opts.description ?? answers.description, - pattern: opts.pattern ?? answers.pattern, - command: opts.command ?? answers.command, - max_calls: maxCalls !== undefined ? { set: true, value: maxCalls } : undefined, - enabled: opts.enabled ? true : (opts.disabled ? false : undefined), - } - ensureAuth() - const botId = await resolveScheduleBotId(schedule.opts()) - const spinner = ora('Creating schedule...').start() - try { - await client.post({ - url: `/bots/${encodeURIComponent(botId)}/schedule`, - body: payload, - headers: { 'Content-Type': 'application/json' }, - throwOnError: true, - }) - spinner.succeed('Schedule created') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to create schedule') - process.exit(1) - } - }) - -schedule - .command('update') - .description('Update schedule') - .argument('') - .option('--name ') - .option('--description ') - .option('--pattern ') - .option('--command ') - .option('--max_calls ') - .option('--enabled') - .option('--disabled') - .action(async (id, opts) => { - if (opts.enabled && opts.disabled) { - console.log(chalk.red('Use only one of --enabled or --disabled.')) - process.exit(1) - } - const payload: Record = {} - if (opts.name) payload.name = opts.name - if (opts.description) payload.description = opts.description - if (opts.pattern) payload.pattern = opts.pattern - if (opts.command) payload.command = opts.command - if (opts.max_calls !== undefined) { - const parsed = Number.parseInt(String(opts.max_calls), 10) - if (Number.isNaN(parsed) || parsed <= 0) { - console.log(chalk.red('max_calls must be a positive integer.')) - process.exit(1) - } - payload.max_calls = { set: true, value: parsed } - } - if (opts.enabled) payload.enabled = true - if (opts.disabled) payload.enabled = false - if (Object.keys(payload).length === 0) { - console.log(chalk.red('No updates provided.')) - process.exit(1) - } - ensureAuth() - const botId = await resolveScheduleBotId(schedule.opts()) - const spinner = ora('Updating schedule...').start() - try { - await client.put({ - url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`, - body: payload, - headers: { 'Content-Type': 'application/json' }, - throwOnError: true, - }) - spinner.succeed('Schedule updated') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to update schedule') - process.exit(1) - } - }) - -schedule - .command('toggle') - .description('Enable/disable schedule') - .argument('') - .action(async (id) => { - ensureAuth() - const botId = await resolveScheduleBotId(schedule.opts()) - const { data: current } = await client.get({ - url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`, - throwOnError: true, - }) - const currentSchedule = current as ScheduleSchedule - const spinner = ora('Updating schedule...').start() - try { - await client.put({ - url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`, - body: { enabled: !currentSchedule.enabled }, - headers: { 'Content-Type': 'application/json' }, - throwOnError: true, - }) - spinner.succeed(`Schedule ${currentSchedule.enabled ? 'disabled' : 'enabled'}`) - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to update schedule') - process.exit(1) - } - }) - -schedule - .command('delete') - .description('Delete schedule') - .argument('') - .action(async (id) => { - ensureAuth() - const botId = await resolveScheduleBotId(schedule.opts()) - const spinner = ora('Deleting schedule...').start() - try { - await client.delete({ - url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`, - throwOnError: true, - }) - spinner.succeed('Schedule deleted') - } catch (err: unknown) { - spinner.fail(getErrorMessage(err) || 'Failed to delete schedule') - process.exit(1) - } - }) - -// --------------------------------------------------------------------------- -// Default action: interactive chat -// --------------------------------------------------------------------------- - -program - .option('--bot ', 'Bot id to chat with') - .action(async () => { - await ensureModelsReady() - ensureAuth() - const botId = await resolveBotId(program.opts().bot) - - const rl = readline.createInterface({ input, output }) - console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`)) - - while (true) { - const line = (await rl.question(chalk.cyan('> '))).trim() - if (!line) { - if (input.readableEnded) break - continue - } - if (line.toLowerCase() === 'exit') { - break - } - await streamChat(line, botId) - } - rl.close() - }) - -// --------------------------------------------------------------------------- -// Version command -// --------------------------------------------------------------------------- - -program - .command('version') - .description('Show version information') - .action(() => { - console.log(`Memoh CLI v${packageJson.version}`) - }) - -// --------------------------------------------------------------------------- -// TUI command -// --------------------------------------------------------------------------- - -program - .command('tui') - .description('Terminal UI chat session') - .option('--bot ', 'Bot id to chat with') - .action(async (opts: { bot?: string }) => { - await ensureModelsReady() - ensureAuth() - const botId = await resolveBotId(opts.bot) - await runTui(botId) - }) - -program.parseAsync(process.argv) - -const runTui = async (botId: string) => { - const rl = readline.createInterface({ input, output }) - console.log(chalk.green(`TUI session (line mode) with ${chalk.bold(botId)}. Type \`exit\` to quit.`)) - while (true) { - const line = (await rl.question(chalk.cyan('> '))).trim() - if (!line) { - if (input.readableEnded) break - continue - } - if (line.toLowerCase() === 'exit') { - break - } - await streamChat(line, botId) - } - rl.close() -} diff --git a/packages/cli/src/cli/shared.ts b/packages/cli/src/cli/shared.ts deleted file mode 100644 index fd891f90..00000000 --- a/packages/cli/src/cli/shared.ts +++ /dev/null @@ -1,60 +0,0 @@ -import chalk from 'chalk' -import inquirer from 'inquirer' -import ora from 'ora' - -import { getBots, type BotsBot } from '@memohai/sdk' -import { readToken, type TokenInfo } from '../utils/store' - -export type BotSummary = BotsBot - -export const ensureAuth = (): TokenInfo => { - const token = readToken() - if (!token?.access_token) { - console.log(chalk.red('Not logged in. Run `memoh login` first.')) - process.exit(1) - } - return token -} - -export const getErrorMessage = (err: unknown) => { - if (err && typeof err === 'object' && 'message' in err) { - const value = (err as { message?: unknown }).message - if (typeof value === 'string') return value - } - return 'Unknown error' -} - -export const fetchBots = async () => { - const { data } = await getBots({ throwOnError: true }) - return data.items ?? [] -} - -export const resolveBotId = async (preset?: string) => { - if (preset && preset.trim()) { - return preset.trim() - } - const spinner = ora('Fetching bots...').start() - try { - const bots = await fetchBots() - spinner.stop() - if (bots.length === 0) { - console.log(chalk.yellow('No bots found. Please create a bot first.')) - process.exit(0) - } - const { botId } = await inquirer.prompt<{ botId: string }>([ - { - type: 'list', - name: 'botId', - message: 'Select a bot:', - choices: bots.map(bot => ({ - name: `${bot.display_name || bot.id} ${chalk.gray(bot.type ?? '')}`, - value: bot.id, - })), - }, - ]) - return botId - } catch (err: unknown) { - spinner.fail(`Failed to fetch bots: ${getErrorMessage(err)}`) - process.exit(1) - } -} diff --git a/packages/cli/src/cli/stream.ts b/packages/cli/src/cli/stream.ts deleted file mode 100644 index 6fe5d20f..00000000 --- a/packages/cli/src/cli/stream.ts +++ /dev/null @@ -1,616 +0,0 @@ -import chalk from 'chalk' -import { client } from '@memohai/sdk/client' -import { postBotsByBotIdCliMessages } from '@memohai/sdk' - -// --------------------------------------------------------------------------- -// SSE stream types (aligned with frontend useChat.ts) -// --------------------------------------------------------------------------- - -interface StreamEvent { - type?: string - delta?: string - toolName?: string - input?: unknown - result?: unknown - error?: string - message?: string - [key: string]: unknown -} - -// --------------------------------------------------------------------------- -// SSE parsing (directly from frontend useChat.ts) -// --------------------------------------------------------------------------- - -/** - * Read an SSE stream line-by-line, calling onData for each `data:` payload. - * Handles standard SSE format (events separated by double newlines). - */ -async function readSSEStream( - body: ReadableStream, - onData: (payload: string) => void, -): Promise { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { value, done } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - - const chunks = buffer.split('\n\n') - buffer = chunks.pop() ?? '' - - for (const chunk of chunks) { - for (const line of chunk.split('\n')) { - if (!line.startsWith('data:')) continue - const payload = line.replace(/^data:\s*/, '').trim() - if (payload && payload !== '[DONE]') onData(payload) - } - } - } - - // Flush remaining buffer - if (buffer.trim()) { - for (const line of buffer.split('\n')) { - const trimmed = line.trim() - if (!trimmed.startsWith('data:')) continue - const payload = trimmed.replace(/^data:\s*/, '').trim() - if (payload && payload !== '[DONE]') onData(payload) - } - } - } finally { - reader.releaseLock() - } -} - -/** - * Parse a raw SSE payload string into a StreamEvent. - * Handles double-encoded JSON and plain text deltas. - * (directly from frontend useChat.ts) - */ -function parseStreamPayload(payload: string): StreamEvent | null { - let current: unknown = payload - for (let i = 0; i < 2; i += 1) { - if (typeof current !== 'string') break - const raw = current.trim() - if (!raw || raw === '[DONE]') return null - try { - current = JSON.parse(raw) - } catch { - return { type: 'text_delta', delta: raw } as StreamEvent - } - } - - if (typeof current === 'string') { - return { type: 'text_delta', delta: current.trim() } as StreamEvent - } - if (current && typeof current === 'object') { - return normalizeStreamEvent(current as Record) - } - return null -} - -const LEGACY_STREAM_EVENT_TYPES = new Set([ - 'text_start', - 'text_delta', - 'text_end', - 'reasoning_start', - 'reasoning_delta', - 'reasoning_end', - 'tool_call_start', - 'tool_call_end', - 'attachment_delta', - 'agent_start', - 'agent_end', - 'processing_started', - 'processing_completed', - 'processing_failed', - 'error', -]) - -function normalizeStreamEvent(raw: Record): StreamEvent | null { - const eventType = String(raw.type ?? '').trim().toLowerCase() - if (!eventType) return null - if (LEGACY_STREAM_EVENT_TYPES.has(eventType)) { - return raw as StreamEvent - } - switch (eventType) { - case 'status': { - const status = String(raw.status ?? '').trim().toLowerCase() - if (status === 'started') return { type: 'processing_started' } - if (status === 'completed') return { type: 'processing_completed' } - if (status === 'failed') { - const err = String(raw.error ?? '').trim() - return { type: 'processing_failed', error: err, message: err } - } - return null - } - case 'delta': { - const delta = String(raw.delta ?? '') - const phase = String(raw.phase ?? '').trim().toLowerCase() - if (phase === 'reasoning') { - return { type: 'reasoning_delta', delta } - } - return { type: 'text_delta', delta } - } - case 'phase_start': { - const phase = String(raw.phase ?? '').trim().toLowerCase() - if (phase === 'reasoning') return { type: 'reasoning_start' } - if (phase === 'text') return { type: 'text_start' } - return null - } - case 'phase_end': { - const phase = String(raw.phase ?? '').trim().toLowerCase() - if (phase === 'reasoning') return { type: 'reasoning_end' } - if (phase === 'text') return { type: 'text_end' } - return null - } - case 'tool_call_start': - case 'tool_call_end': { - const toolCall = (raw.tool_call && typeof raw.tool_call === 'object') - ? raw.tool_call as Record - : {} - return { - type: eventType, - toolName: String(toolCall.name ?? ''), - toolCallId: String(toolCall.call_id ?? ''), - input: toolCall.input, - result: toolCall.result, - } as StreamEvent - } - case 'attachment': { - const attachments = Array.isArray(raw.attachments) - ? raw.attachments as Array> - : [] - if (!attachments.length) return null - return { type: 'attachment_delta', attachments } as StreamEvent - } - case 'processing_started': - case 'processing_completed': - case 'agent_start': - case 'agent_end': - return { type: eventType } as StreamEvent - case 'processing_failed': { - const err = String(raw.error ?? raw.message ?? '').trim() - return { type: 'processing_failed', error: err, message: err } as StreamEvent - } - case 'error': { - const err = String(raw.error ?? raw.message ?? 'Stream error').trim() - return { type: 'error', error: err, message: err } as StreamEvent - } - default: - return null - } -} - -// --------------------------------------------------------------------------- -// Tool display configuration -// --------------------------------------------------------------------------- - -type ToolDisplayMode = 'inline' | 'expanded' - -interface ToolDisplayConfig { - mode: ToolDisplayMode - expandParam?: string - label?: string -} - -const TOOL_DISPLAY: Record = { - exec: { mode: 'expanded', label: 'exec' }, - write: { mode: 'expanded', expandParam: 'content', label: 'write' }, -} - -const getToolDisplay = (toolName: string): ToolDisplayConfig => { - return TOOL_DISPLAY[toolName] ?? { mode: 'inline' } -} - -// --------------------------------------------------------------------------- -// Tool call formatting helpers -// --------------------------------------------------------------------------- - -const BOX_WIDTH = 60 - -const extractExecCommand = (toolInput: unknown): string => { - if (!toolInput || typeof toolInput !== 'object') return '' - const input = toolInput as Record - const command = typeof input.command === 'string' ? input.command : '' - const args = Array.isArray(input.args) ? input.args.map(String) : [] - if (/^(bash|sh|zsh)$/.test(command) && args.length >= 2) { - const flag = args[0] - if (flag === '-c' || flag === '-lc') { - return args.slice(1).join(' ') - } - } - return [command, ...args].filter(Boolean).join(' ') -} - -const formatExecCall = (toolInput: unknown) => { - const cmd = extractExecCommand(toolInput) - return chalk.dim(' ▶ ') + chalk.white('$ ') + chalk.bold.white(cmd) -} - -const extractEditInput = (toolInput: unknown) => { - if (!toolInput || typeof toolInput !== 'object') { - return { path: '', oldText: '', newText: '' } - } - const input = toolInput as Record - const path = typeof input.path === 'string' ? input.path : '' - const oldText = - typeof input.old_text === 'string' - ? input.old_text - : typeof input.oldText === 'string' - ? input.oldText - : '' - const newText = - typeof input.new_text === 'string' - ? input.new_text - : typeof input.newText === 'string' - ? input.newText - : '' - return { path, oldText, newText } -} - -const countLines = (text: string) => (text ? text.split('\n').length : 0) - -const pushDetailBlock = (lines: string[], title: string, content: string) => { - lines.push(chalk.cyan('│ ') + chalk.dim(title)) - const detailLines = content ? content.split('\n') : [] - if (!detailLines.length) { - lines.push(chalk.cyan('│ ') + chalk.dim('∅')) - return - } - const maxLines = 12 - const shown = detailLines.slice(0, maxLines) - for (const dl of shown) { - const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl - lines.push(chalk.cyan('│ ') + chalk.white(truncated)) - } - if (detailLines.length > maxLines) { - lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`)) - } -} - -const formatEditCall = (toolInput: unknown) => { - const { path, oldText, newText } = extractEditInput(toolInput) - const oldLines = countLines(oldText) - const newLines = countLines(newText) - const summary = ` path: ${path || '(unknown)'} · old: ${oldLines} lines · new: ${newLines} lines` - - const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐' - const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘' - - const lines: string[] = [] - lines.push(chalk.cyan(topBorder)) - lines.push(chalk.cyan('│ ') + chalk.bold.white('edit') + chalk.gray(summary)) - lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4))) - pushDetailBlock(lines, 'old_text', oldText) - lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4))) - pushDetailBlock(lines, 'new_text', newText) - lines.push(chalk.cyan(botBorder)) - return lines.join('\n') -} - -const unwrapToolResult = (result: unknown): Record | null => { - if (!result) return null - const extractFromContentBlocks = (arr: unknown[]): Record | null => { - for (const block of arr) { - if (block && typeof block === 'object') { - const b = block as Record - if (b.type === 'text' && typeof b.text === 'string') { - try { return JSON.parse(b.text) } catch { /* ignore */ } - } - } - } - return null - } - if (Array.isArray(result)) return extractFromContentBlocks(result) - if (typeof result === 'object') { - const obj = result as Record - if (Array.isArray(obj.content)) { - const extracted = extractFromContentBlocks(obj.content) - if (extracted) return extracted - } - return obj - } - if (typeof result === 'string') { - try { return JSON.parse(result) } catch { /* ignore */ } - } - return null -} - -const formatExecResult = (result: unknown) => { - const r = unwrapToolResult(result) - if (!r) return chalk.dim(' ╰─ done') - const exitCode = typeof r.exit_code === 'number' ? r.exit_code : (r.ok ? 0 : 1) - const ok = exitCode === 0 - const stdout = typeof r.stdout === 'string' ? r.stdout.trim() : '' - const stderr = typeof r.stderr === 'string' ? r.stderr.trim() : '' - const lines: string[] = [] - lines.push(chalk.dim(' ╰─ ') + (ok ? chalk.green(`✓ exit ${exitCode}`) : chalk.red(`✗ exit ${exitCode}`))) - const output = ok ? stdout : (stderr || stdout) - if (output) { - const outputLines = output.split('\n') - const maxLines = 8 - const shown = outputLines.slice(0, maxLines) - for (const ol of shown) { - const truncated = ol.length > 72 ? ol.slice(0, 69) + '...' : ol - lines.push(chalk.dim(' ') + (ok ? chalk.white(truncated) : chalk.yellow(truncated))) - } - if (outputLines.length > maxLines) { - lines.push(chalk.dim(` ... (${outputLines.length - maxLines} more lines)`)) - } - } - return lines.join('\n') -} - -const formatToolCallInline = (toolName: string, toolInput: unknown) => { - let params = '' - if (toolInput && typeof toolInput === 'object') { - const entries = Object.entries(toolInput as Record) - params = entries - .map(([k, v]) => { - const s = typeof v === 'string' ? v : JSON.stringify(v) - const short = s.length > 40 ? s.slice(0, 37) + '...' : s - return `${k}=${short}` - }) - .join(', ') - } - return chalk.dim(` ◆ ${toolName}`) + (params ? chalk.dim(`(${params})`) : '') -} - -const formatToolCallExpanded = (config: ToolDisplayConfig, toolName: string, toolInput: unknown) => { - const label = config.label ?? toolName - const inputObj = (toolInput && typeof toolInput === 'object' ? toolInput : {}) as Record - const summaryParts: string[] = [] - for (const [k, v] of Object.entries(inputObj)) { - if (k === config.expandParam) continue - const s = typeof v === 'string' ? v : JSON.stringify(v) - summaryParts.push(`${k}: ${s.length > 50 ? s.slice(0, 47) + '...' : s}`) - } - const summary = summaryParts.length ? ' ' + summaryParts.join(', ') : '' - let detail = '' - if (config.expandParam && config.expandParam in inputObj) { - const raw = inputObj[config.expandParam] - if (typeof raw === 'string') detail = raw - else if (Array.isArray(raw)) detail = raw.join(' ') - else detail = JSON.stringify(raw, null, 2) - } - const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐' - const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘' - const lines: string[] = [] - lines.push(chalk.cyan(topBorder)) - lines.push(chalk.cyan('│ ') + chalk.bold.white(label) + chalk.gray(summary)) - if (detail) { - lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4))) - const detailLines = detail.split('\n') - const maxLines = 20 - const shown = detailLines.slice(0, maxLines) - for (const dl of shown) { - const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl - lines.push(chalk.cyan('│ ') + chalk.white(truncated)) - } - if (detailLines.length > maxLines) { - lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`)) - } - } - lines.push(chalk.cyan(botBorder)) - return lines.join('\n') -} - -const formatToolResult = (toolName: string, result: unknown) => { - if (toolName === 'exec') return formatExecResult(result) - const config = getToolDisplay(toolName) - if (config.mode === 'expanded' || toolName === 'edit') { - const r = unwrapToolResult(result) - if (r) { - if ('ok' in r) { - return chalk.dim(' ╰─ ') + (r.ok ? chalk.green('✓ ok') : chalk.red('✗ failed')) - } - } - return chalk.dim(' ╰─ done') - } - return null -} - -// --------------------------------------------------------------------------- -// Event handler for terminal display -// --------------------------------------------------------------------------- - -function handleStreamEvent(event: StreamEvent): boolean { - const type = (event.type ?? '').toLowerCase() - // Track whether text has been written without a trailing newline - return handleStreamEventInner(type, event) -} - -let _printedText = false - -function handleStreamEventInner(type: string, event: StreamEvent): boolean { - switch (type) { - case 'text_start': - break - - case 'text_delta': - if (typeof event.delta === 'string') { - process.stdout.write(event.delta) - _printedText = true - } - break - - case 'text_end': - if (_printedText) { - process.stdout.write('\n') - _printedText = false - } - break - - case 'tool_call_start': { - if (_printedText) { - process.stdout.write('\n') - _printedText = false - } - const toolName = event.toolName as string - const toolInput = event.input - if (toolName === 'exec') { - console.log(formatExecCall(toolInput)) - } else if (toolName === 'edit') { - console.log(formatEditCall(toolInput)) - } else { - const displayConfig = getToolDisplay(toolName) - if (displayConfig.mode === 'expanded') { - console.log(formatToolCallExpanded(displayConfig, toolName, toolInput)) - } else { - console.log(formatToolCallInline(toolName, toolInput)) - } - } - break - } - - case 'tool_call_end': { - const toolName = event.toolName as string - const result = event.result - const resultLine = formatToolResult(toolName, result) - if (resultLine) console.log(resultLine) - break - } - - case 'reasoning_start': - if (_printedText) { - process.stdout.write('\n') - _printedText = false - } - process.stdout.write(chalk.dim(' 💭 ')) - break - - case 'reasoning_delta': - if (typeof event.delta === 'string') { - process.stdout.write(chalk.dim(event.delta)) - _printedText = true - } - break - - case 'reasoning_end': - if (_printedText) { - process.stdout.write('\n') - _printedText = false - } - break - - case 'error': { - const errMsg = typeof event.message === 'string' - ? event.message - : typeof event.error === 'string' - ? event.error - : 'Stream error' - console.log(chalk.red(`Error: ${errMsg}`)) - break - } - - case 'processing_started': - case 'processing_completed': - case 'processing_failed': - case 'agent_start': - case 'agent_end': - break - - default: { - // Fallback: try to extract text (aligned with frontend extractFallbackText) - if (typeof event.delta === 'string') { - process.stdout.write(event.delta) - _printedText = true - } else if (typeof (event as Record).text === 'string') { - process.stdout.write((event as Record).text as string) - _printedText = true - } else if (typeof (event as Record).content === 'string') { - process.stdout.write((event as Record).content as string) - _printedText = true - } - break - } - } - return true -} - -// --------------------------------------------------------------------------- -// Stream chat -// CLI channel flow: -// 1) open SSE subscription at /bots/{bot_id}/cli/stream -// 2) post message to /bots/{bot_id}/cli/messages -// --------------------------------------------------------------------------- - -export const streamChat = async (query: string, botId: string) => { - _printedText = false - - try { - const controller = new AbortController() - const { data: body } = await client.get({ - url: '/bots/{bot_id}/cli/stream', - path: { bot_id: botId }, - parseAs: 'stream', - signal: controller.signal, - throwOnError: true, - }) as { data: ReadableStream } - - if (!body) { - console.log(chalk.red('No response body')) - return false - } - - let completed = false - let failedMessage = '' - const streamTask = readSSEStream(body, (payload) => { - const event = parseStreamPayload(payload) - if (!event) return - handleStreamEvent(event) - const type = (event.type ?? '').toLowerCase() - if (type === 'processing_completed') { - completed = true - controller.abort() - return - } - if (type === 'processing_failed' || type === 'error') { - const msg = typeof event.message === 'string' - ? event.message - : typeof event.error === 'string' - ? event.error - : 'Stream error' - failedMessage = msg - controller.abort() - } - }) - .catch((err) => { - if ((err as Error).name !== 'AbortError') { - throw err - } - }) - - await postBotsByBotIdCliMessages({ - path: { bot_id: botId }, - body: { message: { text: query } }, - throwOnError: true, - }) - - await streamTask - - if (_printedText) { - process.stdout.write('\n') - } - if (failedMessage) { - console.log(chalk.red(`Stream error: ${failedMessage}`)) - return false - } - if (!completed) { - console.log(chalk.red('Stream ended before completion')) - return false - } - return true - } catch (err) { - if (_printedText) { - process.stdout.write('\n') - } - const msg = err instanceof Error ? err.message : String(err) - console.log(chalk.red(`Stream error: ${msg}`)) - return false - } -} diff --git a/packages/cli/src/core/client.ts b/packages/cli/src/core/client.ts deleted file mode 100644 index 1b5212dd..00000000 --- a/packages/cli/src/core/client.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { client } from '@memohai/sdk/client' -import { readConfig, readToken, getBaseURL } from '../utils/store' - -/** - * Configure the SDK client with base URL and auth interceptor. - * Call this once at CLI startup (before any API calls). - */ -export function setupClient() { - const config = readConfig() - client.setConfig({ baseUrl: getBaseURL(config) }) - - // Add auth token to every request (read lazily from store) - client.interceptors.request.use((request) => { - const token = readToken() - if (token?.access_token) { - request.headers.set('Authorization', `Bearer ${token.access_token}`) - } - return request - }) -} - -export { client } diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts deleted file mode 100644 index 83dae763..00000000 --- a/packages/cli/src/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './client' diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts deleted file mode 100644 index d5500857..00000000 --- a/packages/cli/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './core/index' -export * from './types/index' -export * from './utils/index' - diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts deleted file mode 100644 index 225ef836..00000000 --- a/packages/cli/src/types/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type { CliConfig, TokenInfo } from '../utils/store' - -// Re-export commonly used SDK types for convenience -export type { - BotsBot, - BotsCreateBotRequest, - BotsListBotsResponse, - BotsUpdateBotRequest, - HandlersLoginResponse, - HandlersChannelMeta, - ChannelChannelConfig, - ChannelChannelIdentityBinding, - ModelsGetResponse, - ModelsModelType, - ProvidersGetResponse, - ScheduleListResponse, - ScheduleSchedule, - SettingsSettings, - AccountsAccount, -} from '@memohai/sdk' diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts deleted file mode 100644 index 16c86332..00000000 --- a/packages/cli/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './store' diff --git a/packages/cli/src/utils/store.ts b/packages/cli/src/utils/store.ts deleted file mode 100644 index 71200344..00000000 --- a/packages/cli/src/utils/store.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { randomUUID } from 'node:crypto' - -export type CliConfig = { - host: string - port: number - session_id: string -} - -export type TokenInfo = { - access_token: string - token_type: string - expires_at: string - user_id: string - username?: string -} - -const defaultConfig: CliConfig = { - host: '127.0.0.1', - port: 8080, - session_id: '', -} - -const memohDir = () => join(homedir(), '.memoh') -const configPath = () => join(memohDir(), 'config.toml') -const tokenPath = () => join(memohDir(), 'token.json') - -export const ensureStore = () => { - const dir = memohDir() - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } -} - -const parseTomlConfig = (raw: string): CliConfig => { - const result: CliConfig = { ...defaultConfig } - const lines = raw.split(/\r?\n/) - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const match = trimmed.match(/^(\w+)\s*=\s*"?([^"]+)"?$/) - if (!match) continue - const key = match[1] - const value = match[2] - if (key === 'host') { - result.host = value - } else if (key === 'port') { - const parsed = Number.parseInt(value, 10) - if (!Number.isNaN(parsed)) result.port = parsed - } else if (key === 'session_id') { - result.session_id = value - } - } - return result -} - -const serializeTomlConfig = (config: CliConfig) => { - return `host = "${config.host}"\nport = ${config.port}\nsession_id = "${config.session_id}"\n` -} - -export const readConfig = (): CliConfig => { - ensureStore() - const path = configPath() - let config: CliConfig - if (!existsSync(path)) { - config = { ...defaultConfig } - } else { - const raw = readFileSync(path, 'utf-8') - config = parseTomlConfig(raw) - } - // Auto-generate session_id on first run - if (!config.session_id) { - config.session_id = `cli:${randomUUID()}` - writeFileSync(path, serializeTomlConfig(config), 'utf-8') - } - return config -} - -export const writeConfig = (config: CliConfig) => { - ensureStore() - writeFileSync(configPath(), serializeTomlConfig(config), 'utf-8') -} - -export const readToken = (): TokenInfo | null => { - ensureStore() - if (!existsSync(tokenPath())) return null - try { - const raw = readFileSync(tokenPath(), 'utf-8') - return JSON.parse(raw) as TokenInfo - } catch { - return null - } -} - -export const writeToken = (token: TokenInfo) => { - ensureStore() - writeFileSync(tokenPath(), JSON.stringify(token, null, 2), 'utf-8') -} - -export const clearToken = () => { - ensureStore() - writeFileSync(tokenPath(), '', 'utf-8') -} - -export const getBaseURL = (config: CliConfig) => { - return `http://${config.host}:${config.port}` -} - diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json deleted file mode 100644 index 280428ea..00000000 --- a/packages/cli/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022"], - "moduleResolution": "bundler", - "types": ["bun-types"], - "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/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts deleted file mode 100644 index 64dd89e1..00000000 --- a/packages/cli/tsup.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: { cli: 'src/cli/index.ts' }, - format: ['esm'], - target: 'node20', - platform: 'node', - bundle: true, - splitting: false, - clean: true, - // @memohai/sdk exports raw .ts, must be bundled - noExternal: [/^@memohai\/sdk/], - banner: { - js: '#!/usr/bin/env node', - }, -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d490f8f3..0786bbf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,43 +234,6 @@ importers: specifier: ^3.5.0 version: 3.5.26(typescript@5.9.3) - packages/cli: - dependencies: - '@memohai/sdk': - specifier: workspace:* - version: link:../sdk - chalk: - specifier: ^5.4.1 - version: 5.6.2 - commander: - specifier: ^12.1.0 - version: 12.1.0 - inquirer: - specifier: ^12.3.0 - version: 12.11.1(@types/node@22.19.5) - ora: - specifier: ^8.1.1 - version: 8.2.0 - table: - specifier: ^6.8.2 - version: 6.9.0 - typescript: - specifier: ^5 - version: 5.9.3 - devDependencies: - '@types/bun': - specifier: latest - version: 1.3.11 - '@types/node': - specifier: ^22.10.5 - version: 22.19.5 - bun-types: - specifier: latest - version: 1.3.11 - tsup: - specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.5))(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - packages/config: dependencies: toml: