From 219b32ebb8e345254f39c95f23c3e1175d171e8b Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 16:06:12 +0800 Subject: [PATCH 01/20] fix: supervisor race condition, store.ts error handling, and ignore .claude dir - Add scheduleLock to SubAgentSupervisor to prevent concurrent queue processing which could cause race conditions where max concurrent agents was exceeded - Add try/catch error handling to ShortTermMemory.saveToDisk() to gracefully handle file write failures instead of crashing - Add .claude/ to .gitignore to prevent skill documentation from being committed - Update yaml dependency version Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 ++- package-lock.json | 15 ++++++++------- package.json | 2 +- src/core/supervisor.ts | 21 ++++++++++++++------- src/memory/store.ts | 6 +++++- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 8c2c94e..7f7360a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ *.tsbuildinfo config/ soul/ -memory/ \ No newline at end of file +memory/ +.claude/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e6c412d..dce8bec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "node-cron": "^3.0.3", "ollama-ai-provider": "^1.2.0", "pino": "^9.6.0", - "yaml": "^2.7.0", + "yaml": "^2.8.4", "zod": "^3.25.76" }, "bin": { @@ -3509,9 +3509,10 @@ "optional": true }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -5599,9 +5600,9 @@ "optional": true }, "yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==" + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==" }, "zod": { "version": "3.25.76", diff --git a/package.json b/package.json index 710dc59..fa7d575 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "node-cron": "^3.0.3", "ollama-ai-provider": "^1.2.0", "pino": "^9.6.0", - "yaml": "^2.7.0", + "yaml": "^2.8.4", "zod": "^3.25.76" }, "optionalDependencies": { diff --git a/src/core/supervisor.ts b/src/core/supervisor.ts index 4e7aad7..92eaf08 100644 --- a/src/core/supervisor.ts +++ b/src/core/supervisor.ts @@ -21,6 +21,7 @@ export class SubAgentSupervisor { private fileLockManager: FileLockManager; private taskBoard: TaskBoard; private resourceManager: ResourceManager; + private scheduleLock = false; private agentConfig: MercuryConfig; private providers: ProviderRegistry; @@ -208,13 +209,19 @@ export class SubAgentSupervisor { } private async processWaitQueue(): Promise { - while (this.waitQueue.length > 0) { - const running = this.getRunningCount(); - if (running >= this.resourceManager.getMaxConcurrent()) break; - - const nextConfig = this.waitQueue.shift()!; - this.taskBoard.update(nextConfig.id, { status: 'running', progress: 'Starting...' }); - this.startAgentInBackground(nextConfig); + if (this.scheduleLock) return; + this.scheduleLock = true; + try { + while (this.waitQueue.length > 0) { + const running = this.getRunningCount(); + if (running >= this.resourceManager.getMaxConcurrent()) break; + + const nextConfig = this.waitQueue.shift()!; + this.taskBoard.update(nextConfig.id, { status: 'running', progress: 'Starting...' }); + this.startAgentInBackground(nextConfig); + } + } finally { + this.scheduleLock = false; } } diff --git a/src/memory/store.ts b/src/memory/store.ts index 9abcff9..ca83145 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -110,7 +110,11 @@ export class ShortTermMemory { private saveToDisk(conversationId: string, messages: MemoryEntry[]): void { const filepath = join(this.dir, `${conversationId}.json`); - writeFileSync(filepath, JSON.stringify(messages), 'utf-8'); + try { + writeFileSync(filepath, JSON.stringify(messages), 'utf-8'); + } catch (err) { + logger.error({ err, filepath }, 'Failed to save short-term memory to disk'); + } } } From bbb9f73efec8503941bb4eae5b89ea1a992b8b1b Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 17:06:48 +0800 Subject: [PATCH 02/20] feat: add MiniMax provider with Anthropic-compatible API Add MiniMax LLM provider that uses the Anthropic SDK format with base URL https://api.minimaxi.com/anthropic. Includes full integration with provider registry, configuration flow, and API key validation. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 79 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 24 ++++++++++++ src/providers/index.ts | 1 + src/providers/minimax.ts | 61 ++++++++++++++++++++++++++++++ src/providers/registry.ts | 4 ++ src/utils/config.ts | 4 +- 6 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 src/providers/minimax.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf08e2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +Mercury 是一个 soul-driven 的 AI agent,运行在 Node.js 上,使用 Vercel AI SDK 的 `generateText()` 实现 10 步 agentic loop。支持 CLI 和 Telegram 两种通道,通过权限系统(filesystem scoping + shell blocklist)实现 permission-hardened tools。 + +## 常用命令 + +```bash +npm run build # 构建 (tsup) +npm run dev # 开发模式 (tsup --watch) +npm run lint # 类型检查 (tsc --noEmit) +npm run test # 测试 (vitest run) +npm run test:watch # 测试 (watch 模式) +``` + +单文件构建验证: +```bash +npx tsc --noEmit +``` + +## 架构要点 + +### 核心循环(Agent Loop) +`generateText({ tools, maxSteps: 10 })` → LLM 决定 respond 或 call tool → 权限检查 → 执行 → 继续或返回 + +### 通道系统(Channels) +- `src/channels/cli.ts` — Readline 交互,内联权限提示 +- `src/channels/telegram.ts` — grammY 框架,流式响应,inline keyboard +- `src/channels/registry.ts` — 通道管理器 + +### 工具注册(Capabilities) +- 所有工具通过 `src/capabilities/registry.ts` 注册 +- 权限检查在 tool 执行前进行(filesystem scope / shell blocklist) +- 子命令上下文通过 `capabilities.getChatCommandContext()` 传递给 channel + +### 内存层级 +- `ShortTermMemory` — 每轮对话的 JSON 文件 +- `LongTermMemory` — 自动提取的事实(JSONL) +- `EpisodicMemory` — 带时间戳的事件日志(JSONL) +- `UserMemoryStore`(Second Brain)— SQLite + FTS5,10 种记忆类型,自主学习 + +### 子 Agent 系统(Subagents) +- `src/core/sub-agent.ts` — 独立 worker,隔离的 agentic loop +- `src/core/supervisor.ts` — 协调器,负责 spawn/halt/queue +- `src/core/file-lock.ts` — 读写锁(多读单写),自动释放,死锁检测 +- `src/core/task-board.ts` — 共享任务状态,持久化到磁盘 + +### Provider 系统 +`src/providers/registry.ts` — 多 provider 自动 fallback(DeepSeek → OpenAI → Anthropic → ...) + +### Soul 系统 +`soul/*.md` 文件定义人格,只有 name + description 加载到启动时,full instructions 按需加载。 + +### 编程模式 +`/code plan` → 分析代码库,呈现方案,不写代码 +`/code execute` → 逐步执行计划,build/test 后提交 + +## 运行时数据位置 + +所有数据在 `~/.mercury/`,不是项目目录: +- `~/.mercury/mercury.yaml` — 主配置 +- `~/.mercury/soul/*.md` — Soul 文件 +- `~/.mercury/memory/` — 记忆存储 +- `~/.mercury/permissions.yaml` — 权限清单 +- `~/.mercury/schedules.yaml` — 定时任务 + +## 配置结构 + +`src/utils/config.ts` 中的 `MercuryConfig` 接口定义了所有配置项,包括: +- `identity` — 名称、所有者、创建者 +- `providers` — 多个 LLM provider 配置 +- `channels.telegram` — Telegram bot token 和访问控制 +- `memory.secondBrain` — Second Brain 配置 +- `subagents` — 子 agent 并发配置 +- `spotify` — Spotify OAuth 配置 +- `github` — GitHub 集成配置 \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e2225bb..f9d30e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,6 +114,7 @@ const PROVIDER_OPTIONS: Array<{ key: ProviderName; label: string }> = [ { key: 'openaiCompat', label: 'OpenAI Compilations' }, { key: 'mimo', label: 'MiMo (Xiaomi)' }, { key: 'mimoTokenPlan', label: 'MiMo Token Plan (Xiaomi)' }, + { key: 'minimax', label: 'MiniMax' }, ]; function getConfiguredProviderNames(config: MercuryConfig): ProviderName[] { @@ -264,6 +265,12 @@ function validateApiKey(provider: ProviderName, value: string): string | null { : 'MiMo Token Plan keys must start with `tp-`.'; } + if (provider === 'minimax') { + return looksLikeToken(value) + ? null + : 'MiniMax keys must look like a real API token: long, no spaces, and not plain text.'; + } + return null; } @@ -817,6 +824,23 @@ async function configure(existingConfig?: MercuryConfig): Promise { config.providers.mimoTokenPlan.enabled = true; } } + + if (provider === 'minimax') { + const mask = isReconfig && config.providers.minimax.apiKey ? ` [${maskKey(config.providers.minimax.apiKey)}]` : ''; + const result = await promptApiKeyWithModelSelection( + config, + 'minimax', + 'MiniMax', + chalk.white(` MiniMax API key${mask}${isReconfig ? '' : ' (Enter to skip)'}: `), + isReconfig, + ); + if (!result.skipped && result.apiKey && result.model) { + config.providers.minimax.apiKey = result.apiKey; + config.providers.minimax.model = result.model; + config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic'; + config.providers.minimax.enabled = true; + } + } } const configuredProviders = getConfiguredProviderNames(config); diff --git a/src/providers/index.ts b/src/providers/index.ts index 568dbe4..0c54009 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,5 +4,6 @@ export { AnthropicProvider } from './anthropic.js'; export { DeepSeekProvider } from './deepseek.js'; export { OllamaProvider } from './ollama.js'; export { MiMoProvider } from './mimo.js'; +export { MiniMaxProvider } from './minimax.js'; export { ProviderRegistry } from './registry.js'; export type { LLMResponse, LLMStreamChunk } from './base.js'; diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts new file mode 100644 index 0000000..c38ccfc --- /dev/null +++ b/src/providers/minimax.ts @@ -0,0 +1,61 @@ +import { createAnthropic } from '@ai-sdk/anthropic'; +import { generateText, streamText } from 'ai'; +import { BaseProvider } from './base.js'; +import type { ProviderConfig } from '../utils/config.js'; +import type { LLMResponse, LLMStreamChunk } from './base.js'; + +export class MiniMaxProvider extends BaseProvider { + readonly name = 'minimax'; + readonly model: string; + private client: ReturnType; + private modelInstance: ReturnType['languageModel']>; + + constructor(config: ProviderConfig) { + super(config); + this.model = config.model; + + this.client = createAnthropic({ + apiKey: config.apiKey, + baseURL: config.baseUrl || 'https://api.minimaxi.com/anthropic', + }); + this.modelInstance = this.client(config.model); + } + + async generateText(prompt: string, systemPrompt: string): Promise { + const result = await generateText({ + model: this.modelInstance, + system: systemPrompt, + prompt, + }); + + return { + text: result.text, + inputTokens: result.usage?.inputTokens ?? 0, + outputTokens: result.usage?.outputTokens ?? 0, + totalTokens: (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0), + model: this.model, + provider: this.name, + }; + } + + async *streamText(prompt: string, systemPrompt: string): AsyncIterable { + const result = streamText({ + model: this.modelInstance, + system: systemPrompt, + prompt, + }); + + for await (const chunk of (await result).textStream) { + yield { text: chunk, done: false }; + } + yield { text: '', done: true }; + } + + isAvailable(): boolean { + return this.config.apiKey.length > 0; + } + + getModelInstance(): any { + return this.modelInstance; + } +} \ No newline at end of file diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 16a52ee..b10c267 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -6,6 +6,7 @@ import { AnthropicProvider } from './anthropic.js'; import { DeepSeekProvider } from './deepseek.js'; import { OllamaProvider } from './ollama.js'; import { MiMoProvider } from './mimo.js'; +import { MiniMaxProvider } from './minimax.js'; import { logger } from '../utils/logger.js'; export class ProviderRegistry { @@ -26,6 +27,7 @@ export class ProviderRegistry { config.providers.openaiCompat, config.providers.mimo, config.providers.mimoTokenPlan, + config.providers.minimax, ]; for (const pc of entries) { @@ -44,6 +46,8 @@ export class ProviderRegistry { provider = new OpenAICompatProvider(pc, { useChatApi: true }); } else if (pc.name === 'mimo' || pc.name === 'mimoTokenPlan') { provider = new MiMoProvider(pc); + } else if (pc.name === 'minimax') { + provider = new MiniMaxProvider(pc); } else { provider = new OpenAICompatProvider(pc); } diff --git a/src/utils/config.ts b/src/utils/config.ts index a219d25..79803c7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -55,7 +55,8 @@ export type ProviderName = | 'ollamaLocal' | 'openaiCompat' | 'mimo' - | 'mimoTokenPlan'; + | 'mimoTokenPlan' + | 'minimax'; export interface MercuryConfig { identity: { @@ -74,6 +75,7 @@ export interface MercuryConfig { openaiCompat: ProviderConfig; mimo: ProviderConfig; mimoTokenPlan: ProviderConfig; + minimax: ProviderConfig; }; channels: { telegram: { From c5843e61aa15c13879f7edacc883abbff2dd9d8a Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 17:17:30 +0800 Subject: [PATCH 03/20] fix: add minimax provider to getDefaultConfig() Add missing minimax provider configuration in getDefaultConfig() with environment variable support MINIMAX_API_KEY, MINIMAX_BASE_URL, MINIMAX_MODEL, and MINIMAX_ENABLED. Co-Authored-By: Claude Opus 4.7 --- src/utils/config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/utils/config.ts b/src/utils/config.ts index 79803c7..d425461 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -218,6 +218,13 @@ export function getDefaultConfig(): MercuryConfig { model: getEnv('MIMO_TOKEN_PLAN_MODEL', 'mimo-v2.5-pro'), enabled: getEnvBool('MIMO_TOKEN_PLAN_ENABLED', false), }, + minimax: { + name: 'minimax', + apiKey: getEnv('MINIMAX_API_KEY', ''), + baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimaxi.com/anthropic'), + model: getEnv('MINIMAX_MODEL', ''), + enabled: getEnvBool('MINIMAX_ENABLED', true), + }, }, channels: { telegram: { From 9eef0a2c5eb52a4d2e9ac2aa29c1010884b99ad4 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 17:23:57 +0800 Subject: [PATCH 04/20] fix: remove unnecessary await and ensure scheduleLock release - Remove redundant await in MiniMaxProvider.streamText() - Ensure scheduleLock is released in SubAgentSupervisor error path Co-Authored-By: Claude Opus 4.7 --- src/core/supervisor.ts | 1 + src/providers/minimax.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/supervisor.ts b/src/core/supervisor.ts index 92eaf08..1eda6f1 100644 --- a/src/core/supervisor.ts +++ b/src/core/supervisor.ts @@ -178,6 +178,7 @@ export class SubAgentSupervisor { this.activeAgents.delete(config.id); this.fileLockManager.releaseAll(config.id); this.pausedAgents.delete(config.id); + this.scheduleLock = false; await this.processWaitQueue(); }); } diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index c38ccfc..ab87e3f 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -45,7 +45,7 @@ export class MiniMaxProvider extends BaseProvider { prompt, }); - for await (const chunk of (await result).textStream) { + for await (const chunk of result.textStream) { yield { text: chunk, done: false }; } yield { text: '', done: true }; From a1bd205ec994df6aa3f77f6496d6d90f8c7d7ad1 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 17:31:30 +0800 Subject: [PATCH 05/20] fix: add minimax to provider models list Add MINIMAX_PREFERRED_MODELS constant and add minimax to preferredByProvider records in provider-models.ts. Co-Authored-By: Claude Opus 4.7 --- src/utils/provider-models.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/provider-models.ts b/src/utils/provider-models.ts index 8a4df08..c364786 100644 --- a/src/utils/provider-models.ts +++ b/src/utils/provider-models.ts @@ -71,6 +71,8 @@ const MIMO_TOKEN_PLAN_PREFERRED_MODELS = MIMO_PREFERRED_MODELS; const OPENAI_COMPAT_PREFERRED_MODELS = [] as const; +const MINIMAX_PREFERRED_MODELS = [] as const; + export class ProviderModelFetchError extends Error { constructor(message: string) { super(message); @@ -177,6 +179,7 @@ function chooseRecommendedModel( openaiCompat: OPENAI_COMPAT_PREFERRED_MODELS, mimo: MIMO_PREFERRED_MODELS, mimoTokenPlan: MIMO_TOKEN_PLAN_PREFERRED_MODELS, + minimax: MINIMAX_PREFERRED_MODELS, }; for (const candidate of preferredByProvider[provider]) { @@ -213,6 +216,7 @@ export function buildModelCatalog( openaiCompat: OPENAI_COMPAT_PREFERRED_MODELS, mimo: MIMO_PREFERRED_MODELS, mimoTokenPlan: MIMO_TOKEN_PLAN_PREFERRED_MODELS, + minimax: MINIMAX_PREFERRED_MODELS, }; const withoutRecommended = filtered.filter((model) => model !== recommendedModel); From 895b0a7372134cb7a729ac0c56bf7a50b8f0843a Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 17:59:17 +0800 Subject: [PATCH 06/20] fix: add /v1 to MiniMax base URL The correct endpoint is https://api.minimaxi.com/anthropic/v1/messages Co-Authored-By: Claude Opus 4.7 --- src/index.ts | 2 +- src/providers/minimax.ts | 2 +- src/utils/config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index f9d30e8..5736d7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -837,7 +837,7 @@ async function configure(existingConfig?: MercuryConfig): Promise { if (!result.skipped && result.apiKey && result.model) { config.providers.minimax.apiKey = result.apiKey; config.providers.minimax.model = result.model; - config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic'; + config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic/v1'; config.providers.minimax.enabled = true; } } diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index ab87e3f..e459a5a 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -16,7 +16,7 @@ export class MiniMaxProvider extends BaseProvider { this.client = createAnthropic({ apiKey: config.apiKey, - baseURL: config.baseUrl || 'https://api.minimaxi.com/anthropic', + baseURL: config.baseUrl || 'https://api.minimaxi.com/anthropic/v1', }); this.modelInstance = this.client(config.model); } diff --git a/src/utils/config.ts b/src/utils/config.ts index d425461..69c9f71 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -221,7 +221,7 @@ export function getDefaultConfig(): MercuryConfig { minimax: { name: 'minimax', apiKey: getEnv('MINIMAX_API_KEY', ''), - baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimaxi.com/anthropic'), + baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimaxi.com/anthropic/v1'), model: getEnv('MINIMAX_MODEL', ''), enabled: getEnvBool('MINIMAX_ENABLED', true), }, From 1f286839e942c31c05f320ae202e0d409a95d609 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 18:14:24 +0800 Subject: [PATCH 07/20] feat: add MiniMax model catalog fetch Add fetchMiniMaxModels function to fetch available models from MiniMax API /v1/models endpoint during setup. Co-Authored-By: Claude Opus 4.7 --- src/utils/provider-models.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/utils/provider-models.ts b/src/utils/provider-models.ts index c364786..5439671 100644 --- a/src/utils/provider-models.ts +++ b/src/utils/provider-models.ts @@ -378,6 +378,25 @@ async function fetchMiMoTokenPlanModels(config: ProviderConfig): Promise { + const data = await fetchJson( + 'https://api.minimaxi.com/anthropic/v1/models', + { + headers: { + 'x-api-key': config.apiKey, + 'anthropic-version': '2023-06-01', + }, + }, + 'Mercury could not fetch models for this MiniMax key. Please re-enter it.', + ); + + const ids = (data.data ?? []) + .map((model) => model.id?.trim() ?? '') + .filter((id) => id.startsWith('MiniMax-')); + + return buildModelCatalog('minimax', ids, config.model); +} + export async function fetchProviderModelCatalog( provider: ProviderName, config: ProviderConfig, @@ -410,5 +429,9 @@ export async function fetchProviderModelCatalog( return fetchMiMoTokenPlanModels(config); } + if (provider === 'minimax') { + return fetchMiniMaxModels(config); + } + return fetchOpenAICompatModels(provider, config); } From 76cb5c8bd86293b0a2a8642f7ce678c16e98c06a Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 22:13:07 +0800 Subject: [PATCH 08/20] docs: align README.zh-CN.md with English version - Fix Node.js version to 18+ in architecture section - Add exponential backoff crash recovery description to daemon mode - Expand contributing section with Agentic Expertise principles, code quality guidelines, getting started steps, and PR guidelines --- .gitignore | 4 ++- .npmignore | 3 +- README.zh-CN.md | 86 ++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 7f7360a..a3edd1f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ dist/ config/ soul/ memory/ -.claude/ \ No newline at end of file +.claude/ + +CLAUDE.md \ No newline at end of file diff --git a/.npmignore b/.npmignore index c5561b4..fdcfa14 100644 --- a/.npmignore +++ b/.npmignore @@ -15,4 +15,5 @@ RESEARCH.md .env.example *.log .DS_Store -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +CLAUDE.md \ No newline at end of file diff --git a/README.zh-CN.md b/README.zh-CN.md index c7f8099..76144cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,7 +47,7 @@ mercury up 该命令会安装系统服务、启动后台守护进程,并确保 Mercury 正在运行。如果 Mercury 已经运行,它只会确认状态并显示 PID。 -常用命令: +守护进程模式包含内置崩溃恢复——如果进程崩溃,会自动重启,采用指数退避策略(最多每分钟 10 次重启)。 ```bash mercury restart # 重启后台进程 @@ -201,30 +201,78 @@ Mercury 可以配置多个 LLM 提供商,并按顺序自动尝试。如果某 ## 架构 -- TypeScript + Node.js 20+ -- Vercel AI SDK v4,支持 `generateText`、`streamText` 和多步 Agent 循环 -- grammY Telegram Bot -- SQLite + FTS5 Second Brain -- JSONL 短期、长期和情景记忆 -- 后台守护进程、PID 文件和崩溃恢复 -- macOS、Linux、Windows 系统服务 +- **TypeScript + Node.js 18+** — ESM, tsup build +- **Vercel AI SDK v4** — `generateText` + `streamText`,10步 Agentic 循环,提供商兜底 +- **grammY** — Telegram Bot,支持打字指示器、可编辑流式消息和文件上传 +- **SQLite + FTS5** — Second Brain 全文本搜索、冲突解决、自动整理 +- **JSONL** — 短期、长期和情景对话记忆 +- **后台守护进程** — 后台生成 + PID 文件 + 看门狗崩溃恢复(指数退避,最多每分钟 10 次重启) +- **系统服务** — macOS LaunchAgent、Linux systemd、Windows Task Scheduler ## 参与贡献 -欢迎贡献修复、工具、记忆能力、渠道能力或文档改进。请保持 PR 聚焦,并在提交前运行: +欢迎贡献修复、工具、记忆能力、渠道能力或文档改进。Mercury 是为进化而构建的,我们欢迎社区的帮助。无论是修复 bug、添加工具、改进记忆还是优化灵魂——所有高质量的贡献都会被欣赏。 -```bash -npm install -npm run build -``` +### Agentic 专业能力 — 贡献者必须具备 -贡献 Mercury 时请特别注意: +Mercury 不只是一个开源项目——它是一个 **灵魂驱动的 Agent**,全天候运行,管理权限、记住上下文并在多个渠道交互。如果你正在贡献,你必须像 Agent 构建者一样思考,而不仅仅是库贡献者。以下是每个贡献者都应该内化的不可协商的原则: -- 工具必须走权限系统,不能绕过审批。 -- 面向 Agent 循环设计,尽量保持幂等。 -- 避免冗长输出和过度日志,Token 预算是核心约束。 -- CLI 和 Telegram 行为应尽量一致。 -- 新增依赖、破坏性变更和 soul/persona 系统调整应先讨论。 +| 原则 | 含义 | +|------|------| +| 🧠 **循环思维** | Mercury 在 10 步 Agentic 循环中运行。你的工具或功能每次对话会被调用多次。尽可能保持幂等。 | +| 🔐 **权限优先** | 每个接触外部世界的操作(文件、Shell、网络、Git)都必须经过权限系统。不要假设会获得批准。 | +| 💾 **内存感知** | 如果你的功能生成了关于用户的事实,考虑接入 Second Brain。如果它读取用户数据,先检查记忆。 | +| 📏 **Token 意识** | Mercury 有每日 Token 预算。日志、冗长输出和大上下文转储会快速消耗 Token。保持精简。 | +| 🔌 **渠道无关** | 工具应该在 CLI 和 Telegram 上行为一致。不要假设有终端、键盘或对面有人。 | +| 🔁 **优雅降级** | 如果提供商失败、工具出错或文件不存在——Mercury 应该恢复,而不是崩溃。始终处理边缘情况。 | +| 📋 **自我文档化** | 你的工具名称和描述是 Mercury 决定何时使用它的依据。让它们清晰、具体和面向行动。 | +| 🧪 **测试循环,而不仅仅是函数** | 一个在孤立状态下工作的工具在 Agentic 循环中可能会失败(例如返回太多数据、阻塞下一步)。端到端测试。 | + +### 代码质量 — 应该做 + +| 应该 | 为什么 | +|------|--------| +| ✅ 编写清晰、可读的 TypeScript,带显式类型 | Mercury's 代码库是类型安全的——保持这种方式 | +| ✅ 在公共函数和工具上添加 JSDoc 注释 | 帮助其他贡献者和 Agent 理解意图 | +| ✅ 保持函数小而单一职责 | 更容易测试、审查和推理 | +| ✅ 使用 async/await 而不是原始 Promise | 一致的错误处理和可读性 | +| ✅ 为新工具和内存功能编写测试 | 对于 24/7 Agent 来说可靠性很重要 | +| ✅ 遵循现有项目结构(`src/tools/`、`src/memory/`、`src/channels/`) | 保持代码库可导航 | +| ✅ 使用 Agent Skills 规范用于新的基于技能的功能 | 确保与技能生态系统的兼容性 | +| ✅ 在 PR 描述中记录破坏性变更 | 帮助维护者正确版本管理 | + +### 代码质量 — 不应该做 + +| 不应该 | 为什么 | +|--------|--------| +| ❌ 未经讨论不要添加依赖 | Mercury 是精简的——每个依赖都增加表面积 | +| ❌ 不要硬编码 API Key、Token 或路径 | 像代码库其他地方一样使用 config/env 变量 | +| ❌ 不要绕过权限系统 | 工具必须先请求再行动——这是 Mercury 的核心承诺 | +| ❌ 不要在热路径中引入同步/阻塞 I/O | Mercury 是异步优先的,有原因 | +| ❌ 不要提交大二进制文件或 secrets | 使用 `.gitignore` 和 env 文件 | +| ❌ 不要在没有讨论的情况下更改 soul/persona 系统 | 它是 Mercury 的核心——更改需要谨慎 | +| ❌ 不要提交未经测试的 Telegram 或守护进程更改 | 这些在合并后很难调试 | +| ❌ 不要忽视 Token 预算系统 | 每个工具都应该注意 Token 消耗 | + +### 快速开始 + +1. Fork 仓库 +2. 运行 `npm install` +3. 进行你的更改 +4. 运行 `npm run build` 验证编译 +5. 本地使用 `mercury` 测试 +6. 打开 PR,清晰描述你更改的内容和原因 + +### PR 指南 + +- 保持 PR 聚焦——每个 PR 一个功能/修复 +- 在描述中包含更改前后的行为 +- 如适用,标记相关 issue +- 响应审查反馈 + +### 需要帮助? + +打开 issue 或发送邮件至 [mercury@cosmicstack.org](mailto:mercury@cosmicstack.org)。我们很友好。 ## 许可证 From 7692e471a83bd499452177c5aa74bab61408dd96 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:08:12 +0800 Subject: [PATCH 09/20] docs: add MiniMax to provider tables in both README files --- .gitignore | 4 +++- README.md | 1 + README.zh-CN.md | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a3edd1f..e07897b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ soul/ memory/ .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +COPILOT.md +AGENT.md \ No newline at end of file diff --git a/README.md b/README.md index fd11b55..8ea3619 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ Configure multiple LLM providers. Mercury tries them in order and falls back aut | **DeepSeek** | deepseek-chat | `DEEPSEEK_API_KEY` | Default, cost-effective | | **OpenAI** | gpt-4o-mini | `OPENAI_API_KEY` | GPT-4o, o3, etc. | | **Anthropic** | claude-sonnet-4 | `ANTHROPIC_API_KEY` | Claude Sonnet, Haiku, Opus | +| **MiniMax** | (dynamic) | `MINIMAX_API_KEY` | Anthropic-compatible, dynamic model fetch | | **Grok (xAI)** | grok-4 | `GROK_API_KEY` | OpenAI-compatible endpoint | | **Ollama Cloud** | gpt-oss:120b | `OLLAMA_CLOUD_API_KEY` | Remote Ollama via API | | **Ollama Local** | gpt-oss:20b | No key needed | Local Ollama instance | diff --git a/README.zh-CN.md b/README.zh-CN.md index 76144cc..422db4f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -195,6 +195,7 @@ Mercury 可以配置多个 LLM 提供商,并按顺序自动尝试。如果某 | DeepSeek | `deepseek-chat` | `DEEPSEEK_API_KEY` | 默认、成本较低 | | OpenAI | `gpt-4o-mini` | `OPENAI_API_KEY` | 支持 GPT-4o、o3 等 | | Anthropic | `claude-sonnet-4` | `ANTHROPIC_API_KEY` | Claude Sonnet、Haiku、Opus | +| MiniMax | `动态获取` | `MINIMAX_API_KEY` | Anthropic 兼容接口,动态获取模型列表 | | Grok (xAI) | `grok-4` | `GROK_API_KEY` | OpenAI 兼容接口 | | Ollama Cloud | `gpt-oss:120b` | `OLLAMA_CLOUD_API_KEY` | 远程 Ollama API | | Ollama Local | `gpt-oss:20b` | 无需 Key | 本地 Ollama 实例 | From a37ef717eb3c00d7d361621410904bb8baa41bc4 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:09:03 +0800 Subject: [PATCH 10/20] fix: correct AGENT.md to AGENTS.md in .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e07897b..b0ec68d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ memory/ CLAUDE.md COPILOT.md -AGENT.md \ No newline at end of file +AGENTS.md \ No newline at end of file From 71114a0235a65f21ef04258665e5b2536fe35862 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:23:05 +0800 Subject: [PATCH 11/20] feat: add MiniMax endpoint selection (International/China) in setup --- src/index.ts | 11 ++++++++++- src/providers/minimax.ts | 2 +- src/utils/config.ts | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5736d7e..55146b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -837,7 +837,16 @@ async function configure(existingConfig?: MercuryConfig): Promise { if (!result.skipped && result.apiKey && result.model) { config.providers.minimax.apiKey = result.apiKey; config.providers.minimax.model = result.model; - config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic/v1'; + // Let user choose endpoint + console.log(chalk.white(' MiniMax endpoint:')); + console.log(chalk.white(' 1. International - https://api.minimax.io/anthropic')); + console.log(chalk.white(' 2. China (Mainland) - https://api.minimaxi.com/anthropic')); + const endpointChoice = await ask(chalk.white(' Choose endpoint (1 or 2): ')); + if (endpointChoice === '2') { + config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic'; + } else { + config.providers.minimax.baseUrl = 'https://api.minimax.io/anthropic'; + } config.providers.minimax.enabled = true; } } diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index e459a5a..b7d9c4d 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -16,7 +16,7 @@ export class MiniMaxProvider extends BaseProvider { this.client = createAnthropic({ apiKey: config.apiKey, - baseURL: config.baseUrl || 'https://api.minimaxi.com/anthropic/v1', + baseURL: config.baseUrl || 'https://api.minimax.io/anthropic', }); this.modelInstance = this.client(config.model); } diff --git a/src/utils/config.ts b/src/utils/config.ts index 69c9f71..07588b1 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -221,7 +221,7 @@ export function getDefaultConfig(): MercuryConfig { minimax: { name: 'minimax', apiKey: getEnv('MINIMAX_API_KEY', ''), - baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimaxi.com/anthropic/v1'), + baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimax.io/anthropic'), model: getEnv('MINIMAX_MODEL', ''), enabled: getEnvBool('MINIMAX_ENABLED', true), }, From cc77e7a0c6b89f5a9bcc12008958153787abb96c Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:24:02 +0800 Subject: [PATCH 12/20] fix: add /v1 suffix to MiniMax endpoint URLs --- src/index.ts | 8 ++++---- src/providers/minimax.ts | 2 +- src/utils/config.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 55146b7..bb1334e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -839,13 +839,13 @@ async function configure(existingConfig?: MercuryConfig): Promise { config.providers.minimax.model = result.model; // Let user choose endpoint console.log(chalk.white(' MiniMax endpoint:')); - console.log(chalk.white(' 1. International - https://api.minimax.io/anthropic')); - console.log(chalk.white(' 2. China (Mainland) - https://api.minimaxi.com/anthropic')); + console.log(chalk.white(' 1. International - https://api.minimax.io/anthropic/v1')); + console.log(chalk.white(' 2. China (Mainland) - https://api.minimaxi.com/anthropic/v1')); const endpointChoice = await ask(chalk.white(' Choose endpoint (1 or 2): ')); if (endpointChoice === '2') { - config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic'; + config.providers.minimax.baseUrl = 'https://api.minimaxi.com/anthropic/v1'; } else { - config.providers.minimax.baseUrl = 'https://api.minimax.io/anthropic'; + config.providers.minimax.baseUrl = 'https://api.minimax.io/anthropic/v1'; } config.providers.minimax.enabled = true; } diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index b7d9c4d..61a5e2e 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -16,7 +16,7 @@ export class MiniMaxProvider extends BaseProvider { this.client = createAnthropic({ apiKey: config.apiKey, - baseURL: config.baseUrl || 'https://api.minimax.io/anthropic', + baseURL: config.baseUrl || 'https://api.minimax.io/anthropic/v1', }); this.modelInstance = this.client(config.model); } diff --git a/src/utils/config.ts b/src/utils/config.ts index 07588b1..766b128 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -221,7 +221,7 @@ export function getDefaultConfig(): MercuryConfig { minimax: { name: 'minimax', apiKey: getEnv('MINIMAX_API_KEY', ''), - baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimax.io/anthropic'), + baseUrl: getEnv('MINIMAX_BASE_URL', 'https://api.minimax.io/anthropic/v1'), model: getEnv('MINIMAX_MODEL', ''), enabled: getEnvBool('MINIMAX_ENABLED', true), }, From d7ffe6a00babc31100d01dd7d73da29315ba1f74 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:37:23 +0800 Subject: [PATCH 13/20] gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b0ec68d..7b18290 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ memory/ CLAUDE.md COPILOT.md -AGENTS.md \ No newline at end of file +AGENTS.md + +docs/ \ No newline at end of file From 9ad399dadd6e7a9330e129f6810d702c11b13dcf Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:41:52 +0800 Subject: [PATCH 14/20] docs: add Feishu channel design spec --- .../specs/2026-05-03-feishu-channel-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-feishu-channel-design.md diff --git a/docs/superpowers/specs/2026-05-03-feishu-channel-design.md b/docs/superpowers/specs/2026-05-03-feishu-channel-design.md new file mode 100644 index 0000000..291027a --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-feishu-channel-design.md @@ -0,0 +1,211 @@ +# Feishu 渠道接入设计草案(MVP) + +**日期**:2026-05-03 +**分支**:`feature/feishu-channel` + +## 1. 背景 + +Mercury 目前已经有 CLI 和 Telegram 两个渠道,且通道抽象、消息路由、能力注册都已成型。现有实现说明: + +- `Channel` 接口已经统一了 `start/stop/send/sendFile/stream/typing/onMessage`。 +- `ChannelRegistry` 负责按配置注册通道并分发入站消息。 +- `Agent` 主循环按 `channelId` 将结果发回源渠道。 + +这意味着 Feishu 不需要重写 Agent,只需要补一个新的通道实现,并把配置与路由接上。 + +## 2. 目标 + +本阶段只做 **Feishu 私聊文本 + 基础访问控制**。 + +### 成功标准 + +- 能在 Feishu 私聊中接收用户消息。 +- 已批准用户的消息能进入 Mercury 主循环。 +- Mercury 的回复能回到原 Feishu 会话。 +- 未批准用户只能进入 pending,不会直接和 Agent 交互。 +- Feishu 通道配置缺失时,不能影响 CLI / Telegram 启动。 + +## 3. 非目标 + +本阶段明确不做: + +- 群聊 @ 处理 +- 卡片交互 +- 文件上传/发送 +- 流式逐字编辑 +- 多租户支持 +- 复杂 RBAC + +## 4. 方案对比 + +### 方案 A:长连接事件订阅 + +由 Feishu 官方长连接/事件订阅方式接收消息,Mercury 常驻进程直接消费事件。 + +**优点** +- 更适合常驻 Agent。 +- 事件入口与进程生命周期一致。 +- 不需要额外公网 Webhook 入口的复杂部署。 + +**缺点** +- 依赖 Feishu 事件订阅能力与 SDK 接入方式。 +- 需要处理事件幂等与会话映射。 + +### 方案 B:Webhook 事件回调 + +Feishu 将事件 POST 到 Mercury 暴露的 HTTP endpoint。 + +**优点** +- 实现概念清晰。 +- 如果已有公网入口,接入路径直接。 + +**缺点** +- 需要验签、解密、重试、幂等处理。 +- 部署条件更苛刻,对本地/自托管不友好。 + +### 方案 C:双模式兼容 + +同时支持长连接和 Webhook。 + +**优点** +- 覆盖最广。 + +**缺点** +- 首版复杂度明显上升。 +- 会把 MVP 的开发和测试面拉大。 + +### 推荐 + +**推荐方案 A**。原因是 Mercury 本身就是一个 24/7 常驻 Agent,长连接和它的运行模型最一致,也最适合先验证 Feishu 渠道是否值得长期维护。 + +## 5. 总体设计 + +### 5.1 新增通道实现 + +新增 `src/channels/feishu.ts`,实现现有 `Channel` 接口。 + +MVP 需要的方法: + +- `start()`:初始化 Feishu 连接和事件监听。 +- `stop()`:停止监听并释放资源。 +- `send()`:发送纯文本回复。 +- `stream()`:先退化为一次性发送。 +- `onMessage()`:把 Feishu 入站消息转换为 `ChannelMessage`。 +- `isReady()`:暴露通道就绪状态。 + +暂不实现: + +- `sendFile()` +- 复杂 `typing()` +- 卡片消息流式编辑 + +### 5.2 注册与路由 + +- 在 `src/channels/registry.ts` 中按 `channels.feishu.enabled` 和必要凭据注册 `FeishuChannel`。 +- 在 `src/index.ts` 中将 `send_message` / `send_file` 的目标通道路由改为“优先回到当前消息来源渠道”。 +- 保持 CLI 和 Telegram 现有行为不变。 + +### 5.3 配置扩展 + +在 `src/utils/config.ts` 扩展 `MercuryConfig.channels`: + +```ts +channels: { + telegram: { ... }, + feishu: { + enabled: boolean; + appId: string; + appSecret: string; + allowedUserIds: string[]; + admins: FeishuAccessUser[]; + members: FeishuAccessUser[]; + pending: FeishuPendingRequest[]; + } +} +``` + +说明: + +- `allowedUserIds` 是可选白名单,作为最小 ACL。 +- `admins / members / pending` 的结构只用于 Feishu,不与 Telegram 共享。 +- 用户主键使用 Feishu 的稳定用户 ID,不使用昵称。 + +### 5.4 消息模型 + +Feishu 入站消息统一转换为 `ChannelMessage`,字段要求: + +- `channelType = 'feishu'` +- `channelId` 使用 Feishu 会话标识 +- `senderId` 使用 Feishu 用户唯一 ID +- `senderName` 作为辅助显示,不作为身份依据 +- `metadata` 保存 Feishu 原始事件中的必要标识,用于幂等和定位 + +### 5.5 访问控制 + +MVP 采用与 Telegram 类似的三段式状态: + +1. 新用户消息进入 pending。 +2. CLI 侧审核通过后进入 members 或 admins。 +3. 只有已批准用户可以进入 Agent 主循环。 + +首版不做 Feishu 内部审批 UI,审批动作仍由 CLI 侧完成,保持现有 Mercury 的运维路径一致。 + +## 6. 数据流 + +1. Feishu 事件到达 `FeishuChannel`。 +2. 通道层先做基础校验和幂等判断。 +3. 未授权用户进入 pending;已授权用户被封装为 `ChannelMessage`。 +4. `ChannelRegistry` 将消息转交 `Agent`。 +5. `Agent` 执行工具调用与推理。 +6. `send()` 按消息来源渠道把结果发回 Feishu。 + +## 7. 错误处理与安全 + +### 7.1 错误处理 + +- 配置缺失:跳过 Feishu 注册,不影响其他通道。 +- 外部接口失败:记录日志并降级,不让主进程崩溃。 +- 重复事件:以事件 ID 做幂等,防止重复入队。 +- 非私聊消息:先忽略,不扩大首版范围。 +- 文本过长:先做简单分段或截断,保证可发送。 + +### 7.2 安全 + +- 用户身份只信任 Feishu 稳定 ID,不信任昵称或展示名。 +- 如果后续启用 Webhook,再补验签与解密。 +- 只保留最小必要的事件字段,避免把原始 payload 大量落日志。 + +## 8. 测试计划 + +### 单元/集成检查 + +- 通道注册:Feishu enabled 时能注册,disabled 时不注册。 +- 路由:Feishu 入站消息回复仍回到 Feishu。 +- 访问控制:未批准用户只会进入 pending。 +- 幂等:重复事件不会导致重复处理。 +- 配置回归:现有 CLI / Telegram 行为不变。 + +### 基线验证 + +- `npm run build` +- `npm run lint` +- `npm run test` + +## 9. 交付边界 + +第一阶段交付只要求: + +- Feishu 私聊能与 Mercury 互通文本。 +- 基础访问控制可用。 +- 不破坏现有 CLI / Telegram。 + +后续可在同一分支继续扩展: + +- 群聊 @ +- 卡片交互 +- 文件发送 +- 更完整的 Feishu 事件与状态管理 + +## 10. 结论 + +Feishu 渠道适合以独立通道方式接入,且第一版应该尽量收敛到 **私聊文本 + 基础访问控制**。这样可以快速验证渠道价值,同时把风险控制在可回滚范围内。 From 907666c22508d674ffd0513f22225dcc5fa96a09 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Sun, 3 May 2026 23:44:42 +0800 Subject: [PATCH 15/20] git --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7b18290..ed9e9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ CLAUDE.md COPILOT.md AGENTS.md -docs/ \ No newline at end of file +docs/superpowers/* \ No newline at end of file From 2f6c330176c6eaf3be6efbf0fcc519adcedcaf8e Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Mon, 4 May 2026 00:00:53 +0800 Subject: [PATCH 16/20] docs: refine feishu implementation plan --- .../plans/2026-05-03-feishu-channel.md | 709 ++++++++++++++++++ 1 file changed, 709 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-feishu-channel.md diff --git a/docs/superpowers/plans/2026-05-03-feishu-channel.md b/docs/superpowers/plans/2026-05-03-feishu-channel.md new file mode 100644 index 0000000..9764dc6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-feishu-channel.md @@ -0,0 +1,709 @@ +# Feishu Channel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Feishu channel that accepts private messages, returns text replies, and enforces a minimal approval flow without disturbing CLI or Telegram. + +**Architecture:** Keep Mercury’s existing channel abstraction intact and add Feishu as one more `Channel` implementation. Use a small Feishu-specific access store in `src/utils/config.ts`, a dedicated adapter in `src/channels/feishu.ts`, and a tiny outbound-routing helper so replies always go back to the channel that received the message. Keep the first release narrow: private chat only, text only, CLI approval only. + +**Tech Stack:** TypeScript, Node.js 20, existing Mercury channel framework, `@larksuiteoapi/node-sdk`, Vitest. + +--- + +### Task 1: Add Feishu access state and config helpers + +**Files:** +- Modify: `src/utils/config.ts` +- Create: `src/utils/feishu-access.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { + addFeishuPendingRequest, + approveFeishuPendingRequest, + clearFeishuAccess, + demoteFeishuAdmin, + getDefaultConfig, + getFeishuAdmins, + getFeishuAccessSummary, + getFeishuApprovedUsers, + getFeishuPendingRequests, + findFeishuApprovedUser, + findFeishuPendingRequest, + hasFeishuAdmins, + isFeishuAutoAllowed, + promoteFeishuUserToAdmin, + rejectFeishuPendingRequest, + removeFeishuUser, +} from './config.js'; + +describe('feishu access config helpers', () => { + it('creates, approves, promotes, demotes, rejects, and clears Feishu access', () => { + const config = getDefaultConfig(); + config.channels.feishu.allowedUserIds = ['ou_allow']; + + addFeishuPendingRequest(config, { openId: 'ou_1', chatId: 'oc_1', displayName: 'alpha' }); + addFeishuPendingRequest(config, { openId: 'ou_2', chatId: 'oc_2', displayName: 'beta' }); + + expect(isFeishuAutoAllowed(config, 'ou_allow')).toBe(true); + expect(approveFeishuPendingRequest(config, 'ou_1', 'admin')?.openId).toBe('ou_1'); + expect(approveFeishuPendingRequest(config, 'ou_2', 'member')?.openId).toBe('ou_2'); + + expect(promoteFeishuUserToAdmin(config, 'ou_2')?.openId).toBe('ou_2'); + expect(demoteFeishuAdmin(config, 'ou_1')?.openId).toBe('ou_1'); + expect(rejectFeishuPendingRequest(config, 'ou_missing')).toBeNull(); + expect(removeFeishuUser(config, 'ou_2')?.openId).toBe('ou_2'); + + clearFeishuAccess(config); + expect(getFeishuAccessSummary(config)).toBe('0 admins, 0 members, 0 pending'); + }); +}); +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `npx vitest run src/utils/feishu-access.test.ts -t "feishu access config helpers"` +Expected: fail with missing Feishu helper functions and/or missing `channels.feishu` config fields. + +- [ ] **Step 3: Implement the minimal config helpers** + +Add a Feishu section to `MercuryConfig.channels` and `getDefaultConfig()`: + +```ts +feishu: { + enabled: getEnvBool('FEISHU_ENABLED', false), + appId: getEnv('FEISHU_APP_ID', ''), + appSecret: getEnv('FEISHU_APP_SECRET', ''), + allowedUserIds: getEnv('FEISHU_ALLOWED_USER_IDS', '') + .split(',') + .filter(Boolean) + .map((value) => value.trim()), + admins: [], + members: [], + pending: [], +}, +``` + +Add these helpers next to the Telegram helpers in `src/utils/config.ts`: + +```ts +export interface FeishuAccessUser { + openId: string; + chatId: string; + displayName?: string; + requestedAt?: string; + approvedAt: string; +} + +export interface FeishuPendingRequest { + openId: string; + chatId: string; + displayName?: string; + requestedAt: string; +} + +/** Return the approved Feishu users. */ +export function getFeishuApprovedUsers(config: MercuryConfig): FeishuAccessUser[] { + return [...config.channels.feishu.admins, ...config.channels.feishu.members]; +} + +/** Return the Feishu admins. */ +export function getFeishuAdmins(config: MercuryConfig): FeishuAccessUser[] { + return config.channels.feishu.admins; +} + +/** Return the pending Feishu requests. */ +export function getFeishuPendingRequests(config: MercuryConfig): FeishuPendingRequest[] { + return config.channels.feishu.pending; +} + +/** Find an approved Feishu user by openId. */ +export function findFeishuApprovedUser(config: MercuryConfig, openId: string): FeishuAccessUser | undefined { + return getFeishuApprovedUsers(config).find((user) => user.openId === openId); +} + +/** Find a pending Feishu request by openId. */ +export function findFeishuPendingRequest(config: MercuryConfig, openId: string): FeishuPendingRequest | undefined { + return config.channels.feishu.pending.find((request) => request.openId === openId); +} + +/** Check whether the config already has a Feishu admin. */ +export function hasFeishuAdmins(config: MercuryConfig): boolean { + return config.channels.feishu.admins.length > 0; +} + +/** Check whether a Feishu openId is auto-allowed. */ +export function isFeishuAutoAllowed(config: MercuryConfig, openId: string): boolean { + return config.channels.feishu.allowedUserIds.includes(openId); +} + +/** Summarize Feishu access state. */ +export function getFeishuAccessSummary(config: MercuryConfig): string { + return `${config.channels.feishu.admins.length} admin${config.channels.feishu.admins.length === 1 ? '' : 's'}, ` + + `${config.channels.feishu.members.length} member${config.channels.feishu.members.length === 1 ? '' : 's'}, ` + + `${config.channels.feishu.pending.length} pending`; +} + +/** Add a Feishu pending request. */ +export function addFeishuPendingRequest( + config: MercuryConfig, + request: Omit & { requestedAt?: string }, +): FeishuPendingRequest { + const existing = findFeishuPendingRequest(config, request.openId); + if (existing) { + existing.chatId = request.chatId; + existing.displayName = request.displayName || existing.displayName; + return existing; + } + + const created: FeishuPendingRequest = { + ...request, + requestedAt: request.requestedAt || new Date().toISOString(), + }; + config.channels.feishu.pending.push(created); + return created; +} + +/** Approve a Feishu pending request. */ +export function approveFeishuPendingRequest( + config: MercuryConfig, + openId: string, + role: 'admin' | 'member' = 'member', +): FeishuAccessUser | null { + const request = findFeishuPendingRequest(config, openId); + if (!request) return null; + + const approvedUser: FeishuAccessUser = { + openId: request.openId, + chatId: request.chatId, + displayName: request.displayName, + requestedAt: request.requestedAt, + approvedAt: new Date().toISOString(), + }; + + config.channels.feishu.pending = config.channels.feishu.pending.filter((entry) => entry.openId !== openId); + config.channels.feishu.admins = config.channels.feishu.admins.filter((entry) => entry.openId !== openId); + config.channels.feishu.members = config.channels.feishu.members.filter((entry) => entry.openId !== openId); + + if (role === 'admin') { + config.channels.feishu.admins.push(approvedUser); + } else { + config.channels.feishu.members.push(approvedUser); + } + + return approvedUser; +} + +/** Reject a Feishu pending request. */ +export function rejectFeishuPendingRequest(config: MercuryConfig, openId: string): FeishuPendingRequest | null { + const request = findFeishuPendingRequest(config, openId); + if (!request) return null; + config.channels.feishu.pending = config.channels.feishu.pending.filter((entry) => entry.openId !== openId); + return request; +} + +/** Remove a Feishu user from approved access. */ +export function removeFeishuUser(config: MercuryConfig, openId: string): FeishuAccessUser | null { + const admin = config.channels.feishu.admins.find((entry) => entry.openId === openId); + if (admin) { + config.channels.feishu.admins = config.channels.feishu.admins.filter((entry) => entry.openId !== openId); + return admin; + } + + const member = config.channels.feishu.members.find((entry) => entry.openId === openId); + if (member) { + config.channels.feishu.members = config.channels.feishu.members.filter((entry) => entry.openId !== openId); + return member; + } + + return null; +} + +/** Promote a Feishu member to admin. */ +export function promoteFeishuUserToAdmin(config: MercuryConfig, openId: string): FeishuAccessUser | null { + const member = config.channels.feishu.members.find((entry) => entry.openId === openId); + if (!member) return null; + config.channels.feishu.members = config.channels.feishu.members.filter((entry) => entry.openId !== openId); + config.channels.feishu.admins.push(member); + return member; +} + +/** Demote a Feishu admin to member. */ +export function demoteFeishuAdmin(config: MercuryConfig, openId: string): FeishuAccessUser | null { + if (config.channels.feishu.admins.length <= 1) { + return null; + } + + const admin = config.channels.feishu.admins.find((entry) => entry.openId === openId); + if (!admin) return null; + config.channels.feishu.admins = config.channels.feishu.admins.filter((entry) => entry.openId !== openId); + config.channels.feishu.members.push(admin); + return admin; +} + +/** Clear all Feishu access state. */ +export function clearFeishuAccess(config: MercuryConfig): MercuryConfig { + config.channels.feishu.admins = []; + config.channels.feishu.members = []; + config.channels.feishu.pending = []; + return config; +} +``` + +Keep Telegram helpers unchanged; do not refactor them as part of this task. + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `npx vitest run src/utils/feishu-access.test.ts -t "feishu access config helpers"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/config.ts src/utils/feishu-access.test.ts +git commit -m "feat: add feishu access helpers" +``` + +--- + +### Task 2: Add the Feishu channel adapter and registry wiring + +**Files:** +- Create: `src/channels/feishu.ts` +- Create: `src/channels/feishu.test.ts` +- Modify: `src/channels/index.ts` +- Modify: `src/channels/registry.ts` +- Modify: `package.json` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { normalizeFeishuEvent, resolveFeishuTargetId } from './feishu.js'; + +describe('feishu adapter helpers', () => { + it('normalizes a private text event to a Mercury channel message', () => { + const message = normalizeFeishuEvent({ + header: { + event_id: 'evt_1', + event_type: 'im.message.receive_v1', + create_time: '1710000000000', + token: 'token', + }, + event: { + sender: { sender_id: { open_id: 'ou_1' } }, + message: { + message_id: 'msg_1', + chat_id: 'oc_1', + message_type: 'text', + content: '{"text":"hello"}', + }, + }, + }); + + expect(message).toMatchObject({ + channelType: 'feishu', + channelId: 'feishu:oc_1', + senderId: 'ou_1', + content: 'hello', + }); + }); + + it('resolves Mercury target ids back to Feishu chat ids', () => { + expect(resolveFeishuTargetId('feishu:oc_2')).toBe('oc_2'); + expect(resolveFeishuTargetId('oc_3')).toBe('oc_3'); + }); +}); +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `npx vitest run src/channels/feishu.test.ts -t "feishu adapter helpers"` +Expected: fail because the adapter helpers and channel class do not exist yet. + +- [ ] **Step 3: Install the official Feishu SDK** + +Run: `npm install @larksuiteoapi/node-sdk` +Expected: `package.json` and the lockfile record the official Feishu SDK dependency. + +- [ ] **Step 4: Implement the Feishu adapter behind a thin transport interface** + +Create `src/channels/feishu.ts` with a thin wrapper around the official SDK so the Mercury channel can be tested without a live bot: + +```ts +import * as Lark from '@larksuiteoapi/node-sdk'; +import type { ChannelMessage } from '../types/channel.js'; +import type { MercuryConfig } from '../utils/config.js'; +import { + addFeishuPendingRequest, + approveFeishuPendingRequest, + findFeishuApprovedUser, + isFeishuAutoAllowed, + saveConfig, +} from '../utils/config.js'; +import { BaseChannel } from './base.js'; + +export interface FeishuEventEnvelope { /* event_id, chat_id, open_id, text content */ } + +export interface FeishuTransport { + start(onEvent: (event: FeishuEventEnvelope) => Promise): Promise; + stop(): Promise; + sendText(chatId: string, content: string): Promise; +} + +export function resolveFeishuTargetId(targetId?: string): string | undefined { + if (!targetId) return undefined; + return targetId.startsWith('feishu:') ? targetId.slice('feishu:'.length) : targetId; +} + +export function normalizeFeishuEvent(envelope: FeishuEventEnvelope): ChannelMessage | null { + const chatId = envelope.event?.message?.chat_id; + const openId = envelope.event?.sender?.sender_id?.open_id; + const rawContent = envelope.event?.message?.content; + const messageType = envelope.event?.message?.message_type; + + if (!chatId || !openId || messageType !== 'text' || !rawContent) return null; + + let parsed: { text?: string }; + try { + parsed = JSON.parse(rawContent) as { text?: string }; + } catch { + return null; + } + + const text = typeof parsed.text === 'string' ? parsed.text.trim() : ''; + if (!text) return null; + + return { + id: envelope.header.event_id, + channelId: `feishu:${chatId}`, + channelType: 'feishu', + senderId: openId, + content: text, + timestamp: Number(envelope.header.create_time), + metadata: { + chatId, + eventId: envelope.header.event_id, + messageId: envelope.event.message?.message_id, + }, + }; +} + +export class FeishuChannel extends BaseChannel { + readonly type = 'feishu' as const; + private transport: FeishuTransport; + + constructor(private config: MercuryConfig) { + super(); + this.transport = createFeishuTransport(config); + } + + async start(): Promise { + await this.transport.start(async (envelope) => { + const message = normalizeFeishuEvent(envelope); + if (!message) return; + + if (isFeishuAutoAllowed(this.config, message.senderId) || findFeishuApprovedUser(this.config, message.senderId)) { + this.emit(message); + return; + } + + addFeishuPendingRequest(this.config, { + openId: message.senderId, + chatId: message.channelId.slice('feishu:'.length), + displayName: message.senderName, + }); + saveConfig(this.config); + await this.transport.sendText(resolveFeishuTargetId(message.channelId)!, 'Access pending. Ask Mercury CLI to approve this Feishu user.'); + }); + this.ready = true; + } + + async stop(): Promise { + await this.transport.stop(); + this.ready = false; + } + + async send(content: string, targetId?: string): Promise { + const chatId = resolveFeishuTargetId(targetId); + if (!chatId) return; + await this.transport.sendText(chatId, content); + } + + async sendFile(): Promise { + throw new Error('Feishu file sending is not part of the MVP'); + } + + async stream(content: AsyncIterable, targetId?: string): Promise { + let full = ''; + for await (const chunk of content) full += chunk; + await this.send(full, targetId); + return full; + } + + async typing(): Promise { + return; + } + + async askToContinue(): Promise { + return true; + } +} + +function createFeishuTransport(config: MercuryConfig): FeishuTransport { + const client = new Lark.Client({ + appId: config.channels.feishu.appId, + appSecret: config.channels.feishu.appSecret, + }); + const wsClient = new Lark.WSClient({ + appId: config.channels.feishu.appId, + appSecret: config.channels.feishu.appSecret, + }); + + return { + start: async (onEvent) => { + wsClient.start({ + eventDispatcher: new Lark.EventDispatcher({}).register({ + 'im.message.receive_v1': async (event: FeishuEventEnvelope) => { + await onEvent(event); + }, + }), + }); + }, + stop: async () => { + await wsClient.stop(); + }, + sendText: async (chatId, content) => { + await client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text: content }), + }, + }); + }, + }; +} +``` + +Wire it into the registry and exports: + +```ts +// src/channels/index.ts +export { FeishuChannel } from './feishu.js'; + +// src/channels/registry.ts +import { FeishuChannel } from './feishu.js'; + +if (config.channels.feishu.enabled && config.channels.feishu.appId && config.channels.feishu.appSecret) { + this.register('feishu', new FeishuChannel(config)); +} +``` + +Add a one-line JSDoc comment to each exported helper and to `FeishuChannel` so the new public surface matches the repository’s code-quality rule. + +- [ ] **Step 5: Run the test and confirm it passes** + +Run: `npx vitest run src/channels/feishu.test.ts -t "feishu adapter helpers"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json src/channels/feishu.ts src/channels/feishu.test.ts src/channels/index.ts src/channels/registry.ts +git commit -m "feat: add feishu channel adapter" +``` + +--- + +### Task 3: Route replies through the source channel and expose Feishu CLI controls + +**Files:** +- Create: `src/core/channel-routing.ts` +- Create: `src/core/channel-routing.test.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { pickOutboundChannelType } from './channel-routing.js'; + +describe('pickOutboundChannelType', () => { + it('prefers the current ready channel', () => { + expect( + pickOutboundChannelType({ + currentChannelType: 'feishu', + currentChannelId: 'feishu:oc_1', + readyChannels: ['cli', 'feishu'], + fallbackChannel: 'cli', + }), + ).toBe('feishu'); + }); + + it('falls back to the notification channel when the current channel is not ready', () => { + expect( + pickOutboundChannelType({ + currentChannelType: 'feishu', + currentChannelId: 'feishu:oc_1', + readyChannels: ['cli'], + fallbackChannel: 'cli', + }), + ).toBe('cli'); + }); +}); +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `npx vitest run src/core/channel-routing.test.ts -t "pickOutboundChannelType"` +Expected: fail because the helper does not exist yet. + +- [ ] **Step 3: Implement the outbound routing helper and use it from `src/index.ts`** + +Add `src/core/channel-routing.ts`: + +```ts +import type { ChannelType } from '../types/channel.js'; + +export interface OutboundChannelContext { + currentChannelType: ChannelType; + currentChannelId: string; + readyChannels: ChannelType[]; + fallbackChannel: ChannelType; +} + +export function pickOutboundChannelType(context: OutboundChannelContext): ChannelType { + if (context.readyChannels.includes(context.currentChannelType)) { + return context.currentChannelType; + } + if (context.readyChannels.includes(context.fallbackChannel)) { + return context.fallbackChannel; + } + return context.readyChannels[0] ?? context.fallbackChannel; +} +``` + +Then in `src/index.ts`: + +```ts +capabilities.setSendFileHandler(async (filePath: string) => { + const { channelId, channelType } = capabilities.getChannelContext(); + const targetType = pickOutboundChannelType({ + currentChannelType: channelType as ChannelType, + currentChannelId: channelId, + readyChannels: channels.getActiveChannels(), + fallbackChannel: 'cli', + }); + const targetChannel = channels.get(targetType); + if (targetChannel) { + await targetChannel.sendFile(filePath, channelId); + return; + } + throw new Error(`No outbound channel available for ${filePath}`); +}); + +capabilities.setSendMessageHandler(async (content: string) => { + const { channelId, channelType } = capabilities.getChannelContext(); + const targetType = pickOutboundChannelType({ + currentChannelType: channelType as ChannelType, + currentChannelId: channelId, + readyChannels: channels.getActiveChannels(), + fallbackChannel: 'cli', + }); + const targetChannel = channels.get(targetType); + if (!targetChannel) { + throw new Error('No outbound channel available.'); + } + await targetChannel.send(content, channelId); +}); +``` + +Add Feishu setup/status output next to Telegram in the CLI wizard and `status` command: + +```ts +console.log(chalk.bold.white(' Feishu (optional)')); +console.log(chalk.dim(' Mercury can also connect to Feishu private chats.')); +console.log(chalk.dim(' Leave empty to skip. You can add it later with mercury doctor.')); + +const feishuAppId = await ask(chalk.white(' Feishu App ID: ')); +const feishuAppSecret = await ask(chalk.white(' Feishu App Secret: ')); +const feishuAllowed = await ask(chalk.white(' Feishu Allowed User IDs (comma-separated, optional): ')); +``` + +Add a Feishu command group that mirrors Telegram access management: + +```ts +const feishuCmd = program.command('feishu').description('Manage Feishu access approvals and admins'); +feishuCmd.command('list').description('Show approved Feishu users and pending requests'); +feishuCmd.command('approve ').description('Approve a pending Feishu access request by openId'); +feishuCmd.command('reject ').description('Reject a pending Feishu access request'); +feishuCmd.command('remove ').description('Remove an approved Feishu admin or member'); +feishuCmd.command('promote ').description('Promote a Feishu member to admin'); +feishuCmd.command('demote ').description('Demote a Feishu admin to member'); +feishuCmd.command('reset').description('Clear all Feishu access state'); +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `npx vitest run src/core/channel-routing.test.ts -t "pickOutboundChannelType"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/channel-routing.ts src/core/channel-routing.test.ts src/index.ts +git commit -m "feat: route replies through active channel" +``` + +--- + +### Task 4: Verify the complete Feishu MVP path + +**Files:** +- No new files; verify the files changed in Tasks 1–3. + +- [ ] **Step 1: Run the full build** + +Run: `npm run build` +Expected: TypeScript builds successfully and tsup completes without errors. + +- [ ] **Step 2: Run lint/type-check** + +Run: `npm run lint` +Expected: `tsc --noEmit` passes with no type errors. + +- [ ] **Step 3: Run the test suite** + +Run: `npm run test` +Expected: All Vitest tests pass, including the new Feishu helper tests. + +- [ ] **Step 4: Run the CLI and daemon checks** + +Run: `mercury status` +Expected: output shows Feishu enabled/disabled state and Feishu access summary. + +Run: `mercury feishu list` +Expected: output lists approved and pending Feishu users without throwing. + +Run: `mercury start` +Expected: Mercury starts normally with Feishu configured or disabled. + +- [ ] **Step 5: Perform a manual smoke check with a configured Feishu bot** + +1. Start Mercury with Feishu enabled. +2. Send a private message from an unapproved Feishu user. +3. Confirm the user appears in `feishu list` as pending. +4. Approve that `openId` with the CLI command. +5. Send another message from the same Feishu chat. +6. Confirm Mercury replies back into the same Feishu chat. + +- [ ] **Step 6: Commit the verified implementation** + +```bash +git add src/utils/config.ts src/utils/feishu-access.test.ts src/channels/feishu.ts src/channels/feishu.test.ts src/channels/index.ts src/channels/registry.ts src/core/channel-routing.ts src/core/channel-routing.test.ts src/index.ts +git commit -m "feat: add feishu channel MVP" +``` From 82e29f81b13ef97840d55374c2c447a77f6165b7 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Mon, 4 May 2026 00:07:05 +0800 Subject: [PATCH 17/20] docs: tighten feishu implementation plan --- .../plans/2026-05-03-feishu-channel.md | 112 ++++++++++++++++-- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-05-03-feishu-channel.md b/docs/superpowers/plans/2026-05-03-feishu-channel.md index 9764dc6..f0a1fa7 100644 --- a/docs/superpowers/plans/2026-05-03-feishu-channel.md +++ b/docs/superpowers/plans/2026-05-03-feishu-channel.md @@ -489,6 +489,8 @@ function createFeishuTransport(config: MercuryConfig): FeishuTransport { } ``` +Before coding, verify the exact `@larksuiteoapi/node-sdk` shapes for `WSClient.start`, `EventDispatcher.register`, and `client.im.v1.message.create` in the official docs, then keep this wrapper thin and match those documented parameter names exactly. + Wire it into the registry and exports: ```ts @@ -537,7 +539,6 @@ describe('pickOutboundChannelType', () => { expect( pickOutboundChannelType({ currentChannelType: 'feishu', - currentChannelId: 'feishu:oc_1', readyChannels: ['cli', 'feishu'], fallbackChannel: 'cli', }), @@ -548,7 +549,6 @@ describe('pickOutboundChannelType', () => { expect( pickOutboundChannelType({ currentChannelType: 'feishu', - currentChannelId: 'feishu:oc_1', readyChannels: ['cli'], fallbackChannel: 'cli', }), @@ -571,7 +571,6 @@ import type { ChannelType } from '../types/channel.js'; export interface OutboundChannelContext { currentChannelType: ChannelType; - currentChannelId: string; readyChannels: ChannelType[]; fallbackChannel: ChannelType; } @@ -594,7 +593,6 @@ capabilities.setSendFileHandler(async (filePath: string) => { const { channelId, channelType } = capabilities.getChannelContext(); const targetType = pickOutboundChannelType({ currentChannelType: channelType as ChannelType, - currentChannelId: channelId, readyChannels: channels.getActiveChannels(), fallbackChannel: 'cli', }); @@ -610,7 +608,6 @@ capabilities.setSendMessageHandler(async (content: string) => { const { channelId, channelType } = capabilities.getChannelContext(); const targetType = pickOutboundChannelType({ currentChannelType: channelType as ChannelType, - currentChannelId: channelId, readyChannels: channels.getActiveChannels(), fallbackChannel: 'cli', }); @@ -638,15 +635,106 @@ Add a Feishu command group that mirrors Telegram access management: ```ts const feishuCmd = program.command('feishu').description('Manage Feishu access approvals and admins'); -feishuCmd.command('list').description('Show approved Feishu users and pending requests'); -feishuCmd.command('approve ').description('Approve a pending Feishu access request by openId'); -feishuCmd.command('reject ').description('Reject a pending Feishu access request'); -feishuCmd.command('remove ').description('Remove an approved Feishu admin or member'); -feishuCmd.command('promote ').description('Promote a Feishu member to admin'); -feishuCmd.command('demote ').description('Demote a Feishu admin to member'); -feishuCmd.command('reset').description('Clear all Feishu access state'); +feishuCmd.command('list') + .description('Show approved Feishu users and pending requests') + .action(() => { + const config = loadConfig(); + console.log(''); + console.log(` Feishu Access: ${chalk.white(getFeishuAccessSummary(config))}`); + console.log(` Admins: ${config.channels.feishu.admins.length > 0 ? chalk.green(getFeishuAdmins(config).map(formatFeishuUser).join(', ')) : chalk.dim('none')}`); + console.log(` Members: ${config.channels.feishu.members.length > 0 ? chalk.green(getFeishuApprovedUsers(config).filter((user) => !config.channels.feishu.admins.some((admin) => admin.openId === user.openId)).map(formatFeishuUser).join(', ')) : chalk.dim('none')}`); + console.log(` Pending: ${config.channels.feishu.pending.length > 0 ? chalk.yellow(getFeishuPendingRequests(config).map(formatFeishuPending).join(', ')) : chalk.dim('none')}`); + console.log(''); + }); + +feishuCmd.command('approve ').description('Approve a pending Feishu access request by openId').action((openId: string) => { + const config = loadConfig(); + const approved = approveFeishuPendingRequest(config, openId, hasFeishuAdmins(config) ? 'member' : 'admin'); + if (!approved) { + console.log(''); + console.log(chalk.red(` No pending Feishu request found for openId ${openId}.`)); + console.log(''); + return; + } + saveConfig(config); + console.log(''); + console.log(chalk.green(` ✓ Approved Feishu ${formatFeishuUser(approved)}.`)); + restartDaemonIfRunning('Restarting the background daemon to apply the change immediately...'); + console.log(''); +}); +feishuCmd.command('reject ').description('Reject a pending Feishu access request').action((openId: string) => { + const config = loadConfig(); + const rejected = rejectFeishuPendingRequest(config, openId); + if (!rejected) { + console.log(''); + console.log(chalk.red(` No pending Feishu request found for openId ${openId}.`)); + console.log(''); + return; + } + saveConfig(config); + console.log(''); + console.log(chalk.green(` ✓ Rejected Feishu request for ${formatFeishuPending(rejected)}.`)); + restartDaemonIfRunning('Restarting the background daemon to apply the change immediately...'); + console.log(''); +}); +feishuCmd.command('remove ').description('Remove an approved Feishu admin or member').action((openId: string) => { + const config = loadConfig(); + const removed = removeFeishuUser(config, openId); + if (!removed) { + console.log(''); + console.log(chalk.red(` No approved Feishu user found for openId ${openId}.`)); + console.log(''); + return; + } + saveConfig(config); + console.log(''); + console.log(chalk.green(` ✓ Removed Feishu access for ${formatFeishuUser(removed)}.`)); + restartDaemonIfRunning('Restarting the background daemon to apply the change immediately...'); + console.log(''); +}); +feishuCmd.command('promote ').description('Promote a Feishu member to admin').action((openId: string) => { + const config = loadConfig(); + const promoted = promoteFeishuUserToAdmin(config, openId); + if (!promoted) { + console.log(''); + console.log(chalk.red(` No Feishu member found for openId ${openId}.`)); + console.log(''); + return; + } + saveConfig(config); + console.log(''); + console.log(chalk.green(` ✓ Promoted ${formatFeishuUser(promoted)} to Feishu admin.`)); + restartDaemonIfRunning('Restarting the background daemon to apply the change immediately...'); + console.log(''); +}); +feishuCmd.command('demote ').description('Demote a Feishu admin to member').action((openId: string) => { + const config = loadConfig(); + const demoted = demoteFeishuAdmin(config, openId); + if (!demoted) { + console.log(''); + console.log(chalk.red(` No Feishu admin found for openId ${openId}, or this is the last admin.`)); + console.log(''); + return; + } + saveConfig(config); + console.log(''); + console.log(chalk.green(` ✓ Demoted ${formatFeishuUser(demoted)} to Feishu member.`)); + restartDaemonIfRunning('Restarting the background daemon to apply the change immediately...'); + console.log(''); +}); +feishuCmd.command('reset').description('Clear all Feishu access state').action(() => { + const config = loadConfig(); + clearFeishuAccess(config); + saveConfig(config); + console.log(''); + console.log(chalk.green(' ✓ Cleared all Feishu access state.')); + restartDaemonIfRunning('Restarting the background daemon to apply the change immediately...'); + console.log(''); +}); ``` +Keep the CLI behavior aligned with Telegram: every Feishu command should print a blank line before and after, should save config only after a successful mutation, and should show an explicit error message when the target `openId` is not found or a demotion would leave zero admins. + - [ ] **Step 4: Run the test and confirm it passes** Run: `npx vitest run src/core/channel-routing.test.ts -t "pickOutboundChannelType"` From 71cb20d9e6226d52b62e6755a90771618601ba6f Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Mon, 4 May 2026 00:32:08 +0800 Subject: [PATCH 18/20] feat: add Feishu channel support (MVP) - Add Feishu access state and config helpers in config.ts - Add FeishuChannel adapter using @larksuiteoapi/node-sdk - Add channel-routing.ts with pickOutboundChannelType helper - Add Feishu setup wizard prompts in CLI setup flow - Update README with Feishu documentation - Add Feishu CLI commands: list, approve, reject, remove, promote, demote, reset - Fix review feedback: JSON.parse validation, non-null assertions, WebSocket error handling - Add tests for Feishu config helpers and adapter functions --- README.md | 24 +- package-lock.json | 903 ++++++++++++++++++++++++++++++- package.json | 1 + src/channels/feishu.test.ts | 32 ++ src/channels/feishu.ts | 201 +++++++ src/channels/index.ts | 1 + src/channels/registry.ts | 5 + src/core/channel-routing.test.ts | 24 + src/core/channel-routing.ts | 18 + src/index.ts | 40 ++ src/types/channel.ts | 2 +- src/utils/config.ts | 185 +++++++ src/utils/feishu-access.test.ts | 41 ++ 13 files changed, 1468 insertions(+), 9 deletions(-) create mode 100644 src/channels/feishu.test.ts create mode 100644 src/channels/feishu.ts create mode 100644 src/core/channel-routing.test.ts create mode 100644 src/core/channel-routing.ts create mode 100644 src/utils/feishu-access.test.ts diff --git a/README.md b/README.md index 8ea3619..9cc2874 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- Remembers what matters. Asks before it acts. Runs 24/7 from CLI or Telegram. 31 built-in tools, extensible skills, SQLite-backed Second Brain memory. + Remembers what matters. Asks before it acts. Runs 24/7 from CLI, Telegram, or Feishu. 31 built-in tools, extensible skills, SQLite-backed Second Brain memory.

@@ -39,7 +39,7 @@ npm i -g @cosmicstack/mercury-agent mercury ``` -First run triggers the setup wizard — enter your name, an API key, and optionally a Telegram bot token. Takes 30 seconds. +First run triggers the setup wizard — enter your name, an API key, and optionally Telegram/Feishu tokens. Takes 30 seconds. To reconfigure later (change keys, name, settings): @@ -127,6 +127,13 @@ In daemon mode, Telegram becomes your primary channel — CLI is log-only since | `mercury telegram promote ` | Promote a Telegram member to admin | | `mercury telegram demote ` | Demote a Telegram admin to member | | `mercury telegram reset` | Clear all Telegram access and start fresh | +| `mercury feishu list` | List approved and pending Feishu users | +| `mercury feishu approve ` | Approve a pending Feishu access request | +| `mercury feishu reject ` | Reject a pending Feishu access request | +| `mercury feishu remove ` | Remove an approved Feishu user | +| `mercury feishu promote ` | Promote a Feishu member to admin | +| `mercury feishu demote ` | Demote a Feishu admin to member | +| `mercury feishu reset` | Clear all Feishu access and start fresh | | `mercury service install` | Install as system service (auto-start on boot) | | `mercury service uninstall` | Uninstall system service | | `mercury service status` | Show system service status | @@ -172,6 +179,7 @@ Type these during a conversation — they don't consume API tokens. Work on both |---------|----------| | **CLI** | Readline prompt, arrow-key command menus, real-time text streaming with markdown re-rendering, permission mode picker | | **Telegram** | HTML formatting, editable streaming messages, file uploads, typing indicators, multi-user access with admin/member roles | +| **Feishu** | Private chat support, auto-allowed user list, multi-user access with admin/member roles | ### Telegram Access @@ -185,6 +193,18 @@ Mercury uses an **organization access model** with admins and members. CLI commands: `mercury telegram list|approve|reject|remove|promote|demote|reset` +### Feishu Access + +Feishu uses the same **organization access model** with admins and members. + +- **First-time setup:** During `mercury setup`, enter your Feishu App ID and App Secret. Optionally add comma-separated user IDs to auto-allow. +- **Additional users:** Users send a message to your Feishu bot. Admins approve or reject from the CLI with `mercury feishu approve `. +- **Roles:** Admins can approve/reject requests, promote/demote users, and reset access. Members can chat with Mercury. +- **Reset:** Run `mercury feishu reset` in the CLI to clear all access and start fresh. +- Private chats only — group messages are not supported in MVP. + +CLI commands: `mercury feishu list|approve|reject|remove|promote|demote|reset` + ## Scheduler - **Recurring**: `schedule_task` with cron expressions (`0 9 * * *` for daily at 9am) diff --git a/package-lock.json b/package-lock.json index dce8bec..89f43b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@ai-sdk/deepseek": "^2.0.29", "@ai-sdk/openai": "^3.0.53", "@grammyjs/auto-retry": "^2.0.2", + "@larksuiteoapi/node-sdk": "^1.62.1", "ai": "^6.0.168", "chalk": "^5.4.0", "commander": "^12.1.0", @@ -723,6 +724,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@larksuiteoapi/node-sdk": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.62.1.tgz", + "integrity": "sha512-o9oAjv5Ffnp/6iXIJLHrO6N0US/r2ZZy3xmO6ylGegjuVSC05cx0fADA38Dc1h0FV8T9BDK+ariWk84TNMGbKg==", + "license": "MIT", + "dependencies": { + "axios": "~1.13.3", + "lodash.identity": "^3.0.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0", + "protobufjs": "^7.2.6", + "qs": "^6.14.2", + "ws": "^8.19.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -736,6 +752,70 @@ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", @@ -1102,7 +1182,6 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1331,6 +1410,12 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1339,6 +1424,17 @@ "node": ">=8.0.0" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1443,6 +1539,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1501,6 +1626,18 @@ "license": "ISC", "optional": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1581,6 +1718,15 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1602,6 +1748,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1612,12 +1772,57 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1739,6 +1944,42 @@ "rollup": "^4.34.8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1760,6 +2001,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -1767,6 +2054,18 @@ "license": "MIT", "optional": true }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/grammy": { "version": "1.42.0", "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", @@ -1781,6 +2080,45 @@ "node": "^12.20.0 || >=14.13.1" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1865,6 +2203,30 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/lodash.identity": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", + "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1891,6 +2253,36 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2025,6 +2417,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ollama-ai-provider": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", @@ -2271,6 +2675,36 @@ } ] }, + "node_modules/protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -2282,6 +2716,21 @@ "once": "^1.3.1" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2439,6 +2888,78 @@ "node": ">=10" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2828,8 +3349,7 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/util-deprecate": { "version": "1.0.2", @@ -3508,6 +4028,27 @@ "license": "ISC", "optional": true }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.4", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", @@ -3895,6 +4436,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@larksuiteoapi/node-sdk": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.62.1.tgz", + "integrity": "sha512-o9oAjv5Ffnp/6iXIJLHrO6N0US/r2ZZy3xmO6ylGegjuVSC05cx0fADA38Dc1h0FV8T9BDK+ariWk84TNMGbKg==", + "requires": { + "axios": "~1.13.3", + "lodash.identity": "^3.0.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0", + "protobufjs": "^7.2.6", + "qs": "^6.14.2", + "ws": "^8.19.0" + } + }, "@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -3905,6 +4460,60 @@ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==" + }, "@rollup/rollup-android-arm-eabi": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", @@ -4120,7 +4729,6 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "requires": { "undici-types": "~6.21.0" } @@ -4284,11 +4892,26 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" }, + "axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "requires": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4349,6 +4972,24 @@ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, "chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -4388,6 +5029,14 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "optional": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -4440,6 +5089,11 @@ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "optional": true }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4451,6 +5105,16 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==" }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4460,12 +5124,41 @@ "once": "^1.4.0" } }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, "es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, "esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -4555,6 +5248,23 @@ "rollup": "^4.34.8" } }, + "follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" + }, + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -4568,12 +5278,48 @@ "dev": true, "optional": true }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "optional": true }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, "grammy": { "version": "1.42.0", "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", @@ -4585,6 +5331,27 @@ "node-fetch": "^2.7.0" } }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "requires": { + "function-bind": "^1.1.2" + } + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4640,6 +5407,26 @@ "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", "dev": true }, + "lodash.identity": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", + "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==" + }, + "long": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, "loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4660,6 +5447,24 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==" }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -4748,6 +5553,11 @@ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true }, + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, "ollama-ai-provider": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", @@ -4894,6 +5704,30 @@ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==" }, + "protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4904,6 +5738,14 @@ "once": "^1.3.1" } }, + "qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "requires": { + "side-channel": "^1.1.0" + } + }, "quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -5006,6 +5848,50 @@ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "optional": true }, + "side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + } + }, "siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5278,8 +6164,7 @@ "undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "util-deprecate": { "version": "1.0.2", @@ -5599,6 +6484,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "optional": true }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "requires": {} + }, "yaml": { "version": "2.8.4", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", diff --git a/package.json b/package.json index fa7d575..b1a2cc7 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@ai-sdk/deepseek": "^2.0.29", "@ai-sdk/openai": "^3.0.53", "@grammyjs/auto-retry": "^2.0.2", + "@larksuiteoapi/node-sdk": "^1.62.1", "ai": "^6.0.168", "chalk": "^5.4.0", "commander": "^12.1.0", diff --git a/src/channels/feishu.test.ts b/src/channels/feishu.test.ts new file mode 100644 index 0000000..2d6f789 --- /dev/null +++ b/src/channels/feishu.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeFeishuEvent, resolveFeishuTargetId } from './feishu.js'; + +describe('feishu adapter helpers', () => { + it('normalizes a private text event to a Mercury channel message', () => { + const message = normalizeFeishuEvent({ + event_id: 'evt_1', + event_type: 'im.message.receive_v1', + create_time: '1710000000000', + token: 'token', + sender: { sender_id: { open_id: 'ou_1' } }, + message: { + message_id: 'msg_1', + chat_id: 'oc_1', + message_type: 'text', + content: '{"text":"hello"}', + }, + }); + + expect(message).toMatchObject({ + channelType: 'feishu', + channelId: 'feishu:oc_1', + senderId: 'ou_1', + content: 'hello', + }); + }); + + it('resolves Mercury target ids back to Feishu chat ids', () => { + expect(resolveFeishuTargetId('feishu:oc_2')).toBe('oc_2'); + expect(resolveFeishuTargetId('oc_3')).toBe('oc_3'); + }); +}); diff --git a/src/channels/feishu.ts b/src/channels/feishu.ts new file mode 100644 index 0000000..cdad0ac --- /dev/null +++ b/src/channels/feishu.ts @@ -0,0 +1,201 @@ +import * as Lark from '@larksuiteoapi/node-sdk'; +import type { ChannelMessage } from '../types/channel.js'; +import type { MercuryConfig } from '../utils/config.js'; +import { + addFeishuPendingRequest, + findFeishuApprovedUser, + isFeishuAutoAllowed, + saveConfig, +} from '../utils/config.js'; +import { logger } from '../utils/logger.js'; +import { BaseChannel } from './base.js'; + +/** Feishu event envelope for message receive events (SDK v1 format). */ +export interface FeishuEventEnvelope { + event_id?: string; + token?: string; + create_time?: string; + event_type?: string; + tenant_key?: string; + ts?: string; + uuid?: string; + type?: string; + app_id?: string; + sender?: { + sender_id?: { union_id?: string; user_id?: string; open_id?: string }; + sender_type?: string; + tenant_key?: string; + }; + message?: { + message_id?: string; + root_id?: string; + parent_id?: string; + create_time?: string; + chat_id?: string; + chat_type?: string; + message_type?: string; + content?: string; + }; +} + +/** Transport interface for Feishu event handling. */ +export interface FeishuTransport { + start(onEvent: (event: FeishuEventEnvelope) => Promise): Promise; + stop(): Promise; + sendText(chatId: string, content: string): Promise; +} + +/** Resolve Mercury target ids back to Feishu chat ids. */ +export function resolveFeishuTargetId(targetId?: string): string | undefined { + if (!targetId) return undefined; + return targetId.startsWith('feishu:') ? targetId.slice('feishu:'.length) : targetId; +} + +/** Normalize a Feishu event envelope to a Mercury channel message. */ +export function normalizeFeishuEvent(envelope: FeishuEventEnvelope): ChannelMessage | null { + const chatId = envelope.message?.chat_id; + const openId = envelope.sender?.sender_id?.open_id; + const rawContent = envelope.message?.content; + const messageType = envelope.message?.message_type; + + // Only process text messages, ignore others silently + if (messageType !== 'text' || !rawContent) return null; + if (!chatId || !openId) return null; + + let parsed: { text?: string }; + try { + parsed = JSON.parse(rawContent) as { text?: string }; + if (!parsed || typeof parsed !== 'object' || typeof parsed.text !== 'string') { + return null; + } + } catch { + return null; + } + + const text = parsed.text.trim(); + if (!text) return null; + + return { + id: envelope.event_id || envelope.uuid || '', + channelId: `feishu:${chatId}`, + channelType: 'feishu', + senderId: openId, + content: text, + timestamp: Number(envelope.create_time || Date.now()), + metadata: { + chatId, + eventId: envelope.event_id, + messageId: envelope.message?.message_id, + }, + }; +} + +/** Feishu channel implementation. */ +export class FeishuChannel extends BaseChannel { + readonly type = 'feishu' as const; + private transport: FeishuTransport; + + constructor(private config: MercuryConfig) { + super(); + this.transport = createFeishuTransport(config); + } + + async start(): Promise { + await this.transport.start(async (envelope) => { + const message = normalizeFeishuEvent(envelope); + if (!message) return; + + if (isFeishuAutoAllowed(this.config, message.senderId) || findFeishuApprovedUser(this.config, message.senderId)) { + this.emit(message); + return; + } + + addFeishuPendingRequest(this.config, { + openId: message.senderId, + chatId: message.channelId.slice('feishu:'.length), + displayName: message.senderName, + }); + saveConfig(this.config); + const targetChatId = resolveFeishuTargetId(message.channelId); + if (targetChatId) { + await this.transport.sendText( + targetChatId, + 'Access pending. Ask Mercury CLI to approve this Feishu user.', + ); + } + }); + this.ready = true; + } + + async stop(): Promise { + await this.transport.stop(); + this.ready = false; + } + + async send(content: string, targetId?: string, _elapsedMs?: number): Promise { + const chatId = resolveFeishuTargetId(targetId); + if (!chatId) return; + await this.transport.sendText(chatId, content); + } + + async sendFile(_filePath: string, _targetId?: string): Promise { + // Feishu file sending is not part of the MVP - no-op + } + + async stream(content: AsyncIterable, targetId?: string): Promise { + let full = ''; + for await (const chunk of content) full += chunk; + await this.send(full, targetId); + return full; + } + + async typing(_targetId?: string): Promise { + return; + } + + async askToContinue(_question: string, _targetId?: string): Promise { + return true; + } +} + +/** Create a Feishu transport using the official SDK. */ +function createFeishuTransport(config: MercuryConfig): FeishuTransport { + const client = new Lark.Client({ + appId: config.channels.feishu.appId, + appSecret: config.channels.feishu.appSecret, + }); + const wsClient = new Lark.WSClient({ + appId: config.channels.feishu.appId, + appSecret: config.channels.feishu.appSecret, + }); + + return { + start: async (onEvent) => { + try { + wsClient.start({ + eventDispatcher: new Lark.EventDispatcher({}).register({ + 'im.message.receive_v1': async (event: FeishuEventEnvelope) => { + await onEvent(event); + }, + }), + }); + } catch (err) { + logger.error({ err }, 'Failed to start Feishu WebSocket'); + throw err; + } + }, + stop: async () => { + wsClient.close(); + }, + sendText: async (chatId, content) => { + await client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text: content }), + }, + }); + }, + }; +} diff --git a/src/channels/index.ts b/src/channels/index.ts index 6d1dffc..4a75e7c 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -1,5 +1,6 @@ export { BaseChannel } from './base.js'; export type { Channel } from './base.js'; export { CLIChannel } from './cli.js'; +export { FeishuChannel } from './feishu.js'; export { TelegramChannel } from './telegram.js'; export { ChannelRegistry } from './registry.js'; \ No newline at end of file diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 9768c54..15520a4 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,6 +1,7 @@ import type { Channel } from './base.js'; import type { ChannelMessage, ChannelType } from '../types/channel.js'; import { CLIChannel } from './cli.js'; +import { FeishuChannel } from './feishu.js'; import { TelegramChannel } from './telegram.js'; import type { MercuryConfig } from '../utils/config.js'; import { logger } from '../utils/logger.js'; @@ -14,6 +15,10 @@ export class ChannelRegistry { if (config.channels.telegram.enabled && config.channels.telegram.botToken) { this.register('telegram', new TelegramChannel(config)); } + + if (config.channels.feishu.enabled && config.channels.feishu.appId && config.channels.feishu.appSecret) { + this.register('feishu', new FeishuChannel(config)); + } } register(type: ChannelType, channel: Channel): void { diff --git a/src/core/channel-routing.test.ts b/src/core/channel-routing.test.ts new file mode 100644 index 0000000..edb9494 --- /dev/null +++ b/src/core/channel-routing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { pickOutboundChannelType } from './channel-routing.js'; + +describe('pickOutboundChannelType', () => { + it('prefers the current ready channel', () => { + expect( + pickOutboundChannelType({ + currentChannelType: 'feishu', + readyChannels: ['cli', 'feishu'], + fallbackChannel: 'cli', + }), + ).toBe('feishu'); + }); + + it('falls back to the notification channel when the current channel is not ready', () => { + expect( + pickOutboundChannelType({ + currentChannelType: 'feishu', + readyChannels: ['cli'], + fallbackChannel: 'cli', + }), + ).toBe('cli'); + }); +}); diff --git a/src/core/channel-routing.ts b/src/core/channel-routing.ts new file mode 100644 index 0000000..b70a463 --- /dev/null +++ b/src/core/channel-routing.ts @@ -0,0 +1,18 @@ +import type { ChannelType } from '../types/channel.js'; + +export interface OutboundChannelContext { + currentChannelType: ChannelType; + readyChannels: ChannelType[]; + fallbackChannel: ChannelType; +} + +/** Pick the outbound channel type based on context. */ +export function pickOutboundChannelType(context: OutboundChannelContext): ChannelType { + if (context.readyChannels.includes(context.currentChannelType)) { + return context.currentChannelType; + } + if (context.readyChannels.includes(context.fallbackChannel)) { + return context.fallbackChannel; + } + return context.readyChannels[0] ?? context.fallbackChannel; +} diff --git a/src/index.ts b/src/index.ts index bb1334e..567678a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { getMercuryHome, ensureCreatorField, clearTelegramAccess, + clearFeishuAccess, isProviderConfigured, getTelegramAccessSummary, getTelegramApprovedUsers, @@ -23,6 +24,7 @@ import { promoteTelegramUserToAdmin, demoteTelegramAdmin, hasTelegramAdmins, + getFeishuAccessSummary, } from './utils/config.js'; import type { MercuryConfig } from './utils/config.js'; import type { ProviderName } from './utils/config.js'; @@ -916,6 +918,44 @@ async function configure(existingConfig?: MercuryConfig): Promise { await completeInitialTelegramPairing(config); + hr(); + console.log(''); + console.log(chalk.bold.white(' Feishu (optional)')); + if (isReconfig) { + console.log(chalk.dim(' Leave empty to keep current value. Enter "none" to disable.')); + } else { + console.log(chalk.dim(' Leave empty to skip. You can add it later with mercury doctor.')); + console.log(chalk.dim(' To set up a Feishu bot:')); + console.log(chalk.dim(' 1. Go to https://open.feishu.cn/app and create an app')); + console.log(chalk.dim(' 2. Enable "Bot" capability and "Subscribe to messages"')); + console.log(chalk.dim(' 3. Get App ID and App Secret from the app credentials page')); + console.log(chalk.dim(' After setup, users send a message to request access.')); + console.log(chalk.dim(' Approve access from the CLI with: mercury feishu approve ')); + } + console.log(''); + + const fsMask = isReconfig && config.channels.feishu.appId ? ` [${maskKey(config.channels.feishu.appId)}]` : ''; + const feishuAppId = await ask(chalk.white(` Feishu App ID${fsMask}: `)); + if (isReconfig && feishuAppId.toLowerCase() === 'none') { + config.channels.feishu.enabled = false; + config.channels.feishu.appId = ''; + config.channels.feishu.appSecret = ''; + clearFeishuAccess(config); + } else if (feishuAppId) { + if (feishuAppId !== config.channels.feishu.appId) { + clearFeishuAccess(config); + } + config.channels.feishu.appId = feishuAppId; + const feishuAppSecret = await ask(chalk.white(' Feishu App Secret: ')); + config.channels.feishu.appSecret = feishuAppSecret; + config.channels.feishu.enabled = true; + + const feishuAllowed = await ask(chalk.white(' Auto-allowed User IDs (comma-separated, optional): ')); + if (feishuAllowed) { + config.channels.feishu.allowedUserIds = feishuAllowed.split(',').map((id) => id.trim()).filter(Boolean); + } + } + hr(); console.log(''); console.log(chalk.bold.white(' GitHub Integration (optional)')); diff --git a/src/types/channel.ts b/src/types/channel.ts index 2b494dc..089bcc9 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -16,7 +16,7 @@ export interface TelegramPendingRequest { pairingCode?: string; } -export type ChannelType = 'cli' | 'telegram' | 'internal' | 'signal' | 'discord' | 'slack' | 'whatsapp'; +export type ChannelType = 'cli' | 'telegram' | 'feishu' | 'internal' | 'signal' | 'discord' | 'slack' | 'whatsapp'; export interface ChannelMessage { id: string; diff --git a/src/utils/config.ts b/src/utils/config.ts index 766b128..833e421 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -46,6 +46,21 @@ export interface TelegramPendingRequest { pairingCode?: string; } +export interface FeishuAccessUser { + openId: string; + chatId: string; + displayName?: string; + requestedAt?: string; + approvedAt: string; +} + +export interface FeishuPendingRequest { + openId: string; + chatId: string; + displayName?: string; + requestedAt: string; +} + export type ProviderName = | 'openai' | 'anthropic' @@ -91,6 +106,15 @@ export interface MercuryConfig { pairedChatId?: number; pairedUsername?: string; }; + feishu: { + enabled: boolean; + appId: string; + appSecret: string; + allowedUserIds: string[]; + admins: FeishuAccessUser[]; + members: FeishuAccessUser[]; + pending: FeishuPendingRequest[]; + }; }; github: { username: string; @@ -240,6 +264,18 @@ export function getDefaultConfig(): MercuryConfig { members: [], pending: [], }, + feishu: { + enabled: getEnvBool('FEISHU_ENABLED', false), + appId: getEnv('FEISHU_APP_ID', ''), + appSecret: getEnv('FEISHU_APP_SECRET', ''), + allowedUserIds: getEnv('FEISHU_ALLOWED_USER_IDS', '') + .split(',') + .filter(Boolean) + .map((value) => value.trim()), + admins: [], + members: [], + pending: [], + }, }, github: { username: getEnv('GITHUB_USERNAME', ''), @@ -526,6 +562,155 @@ export function clearTelegramAccess(config: MercuryConfig): MercuryConfig { return config; } +// Feishu access helpers + +/** Return the approved Feishu users. */ +export function getFeishuApprovedUsers(config: MercuryConfig): FeishuAccessUser[] { + return [...config.channels.feishu.admins, ...config.channels.feishu.members]; +} + +/** Return the Feishu admins. */ +export function getFeishuAdmins(config: MercuryConfig): FeishuAccessUser[] { + return config.channels.feishu.admins; +} + +/** Return the pending Feishu requests. */ +export function getFeishuPendingRequests(config: MercuryConfig): FeishuPendingRequest[] { + return config.channels.feishu.pending; +} + +/** Find an approved Feishu user by openId. */ +export function findFeishuApprovedUser(config: MercuryConfig, openId: string): FeishuAccessUser | undefined { + return getFeishuApprovedUsers(config).find((user) => user.openId === openId); +} + +/** Find a pending Feishu request by openId. */ +export function findFeishuPendingRequest(config: MercuryConfig, openId: string): FeishuPendingRequest | undefined { + return config.channels.feishu.pending.find((request) => request.openId === openId); +} + +/** Check whether the config already has a Feishu admin. */ +export function hasFeishuAdmins(config: MercuryConfig): boolean { + return config.channels.feishu.admins.length > 0; +} + +/** Check whether a Feishu openId is auto-allowed. */ +export function isFeishuAutoAllowed(config: MercuryConfig, openId: string): boolean { + return config.channels.feishu.allowedUserIds.includes(openId); +} + +/** Summarize Feishu access state. */ +export function getFeishuAccessSummary(config: MercuryConfig): string { + return `${config.channels.feishu.admins.length} admin${config.channels.feishu.admins.length === 1 ? '' : 's'}, ` + + `${config.channels.feishu.members.length} member${config.channels.feishu.members.length === 1 ? '' : 's'}, ` + + `${config.channels.feishu.pending.length} pending`; +} + +/** Add a Feishu pending request. */ +export function addFeishuPendingRequest( + config: MercuryConfig, + request: Omit & { requestedAt?: string }, +): FeishuPendingRequest { + const existing = findFeishuPendingRequest(config, request.openId); + if (existing) { + existing.chatId = request.chatId; + existing.displayName = request.displayName || existing.displayName; + return existing; + } + + const created: FeishuPendingRequest = { + ...request, + requestedAt: request.requestedAt || new Date().toISOString(), + }; + config.channels.feishu.pending.push(created); + return created; +} + +/** Approve a Feishu pending request. */ +export function approveFeishuPendingRequest( + config: MercuryConfig, + openId: string, + role: 'admin' | 'member' = 'member', +): FeishuAccessUser | null { + const request = findFeishuPendingRequest(config, openId); + if (!request) return null; + + const approvedUser: FeishuAccessUser = { + openId: request.openId, + chatId: request.chatId, + displayName: request.displayName, + requestedAt: request.requestedAt, + approvedAt: new Date().toISOString(), + }; + + config.channels.feishu.pending = config.channels.feishu.pending.filter((entry) => entry.openId !== openId); + config.channels.feishu.admins = config.channels.feishu.admins.filter((entry) => entry.openId !== openId); + config.channels.feishu.members = config.channels.feishu.members.filter((entry) => entry.openId !== openId); + + if (role === 'admin') { + config.channels.feishu.admins.push(approvedUser); + } else { + config.channels.feishu.members.push(approvedUser); + } + + return approvedUser; +} + +/** Reject a Feishu pending request. */ +export function rejectFeishuPendingRequest(config: MercuryConfig, openId: string): FeishuPendingRequest | null { + const request = findFeishuPendingRequest(config, openId); + if (!request) return null; + config.channels.feishu.pending = config.channels.feishu.pending.filter((entry) => entry.openId !== openId); + return request; +} + +/** Remove a Feishu user from approved access. */ +export function removeFeishuUser(config: MercuryConfig, openId: string): FeishuAccessUser | null { + const admin = config.channels.feishu.admins.find((entry) => entry.openId === openId); + if (admin) { + config.channels.feishu.admins = config.channels.feishu.admins.filter((entry) => entry.openId !== openId); + return admin; + } + + const member = config.channels.feishu.members.find((entry) => entry.openId === openId); + if (member) { + config.channels.feishu.members = config.channels.feishu.members.filter((entry) => entry.openId !== openId); + return member; + } + + return null; +} + +/** Promote a Feishu member to admin. */ +export function promoteFeishuUserToAdmin(config: MercuryConfig, openId: string): FeishuAccessUser | null { + const member = config.channels.feishu.members.find((entry) => entry.openId === openId); + if (!member) return null; + config.channels.feishu.members = config.channels.feishu.members.filter((entry) => entry.openId !== openId); + config.channels.feishu.admins.push(member); + return member; +} + +/** Demote a Feishu admin to member. */ +export function demoteFeishuAdmin(config: MercuryConfig, openId: string): FeishuAccessUser | null { + if (config.channels.feishu.admins.length <= 1) { + return null; + } + + const admin = config.channels.feishu.admins.find((entry) => entry.openId === openId); + if (!admin) return null; + config.channels.feishu.admins = config.channels.feishu.admins.filter((entry) => entry.openId !== openId); + config.channels.feishu.members.push(admin); + return admin; +} + +/** Clear all Feishu access state. */ +export function clearFeishuAccess(config: MercuryConfig): MercuryConfig { + config.channels.feishu.admins = []; + config.channels.feishu.members = []; + config.channels.feishu.pending = []; + return config; +} + export function clearTelegramPairing(config: MercuryConfig): MercuryConfig { return clearTelegramAccess(config); } diff --git a/src/utils/feishu-access.test.ts b/src/utils/feishu-access.test.ts new file mode 100644 index 0000000..6345bdc --- /dev/null +++ b/src/utils/feishu-access.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + addFeishuPendingRequest, + approveFeishuPendingRequest, + clearFeishuAccess, + demoteFeishuAdmin, + getDefaultConfig, + getFeishuAdmins, + getFeishuAccessSummary, + getFeishuApprovedUsers, + getFeishuPendingRequests, + findFeishuApprovedUser, + findFeishuPendingRequest, + hasFeishuAdmins, + isFeishuAutoAllowed, + promoteFeishuUserToAdmin, + rejectFeishuPendingRequest, + removeFeishuUser, +} from './config.js'; + +describe('feishu access config helpers', () => { + it('creates, approves, promotes, demotes, rejects, and clears Feishu access', () => { + const config = getDefaultConfig(); + config.channels.feishu.allowedUserIds = ['ou_allow']; + + addFeishuPendingRequest(config, { openId: 'ou_1', chatId: 'oc_1', displayName: 'alpha' }); + addFeishuPendingRequest(config, { openId: 'ou_2', chatId: 'oc_2', displayName: 'beta' }); + + expect(isFeishuAutoAllowed(config, 'ou_allow')).toBe(true); + expect(approveFeishuPendingRequest(config, 'ou_1', 'admin')?.openId).toBe('ou_1'); + expect(approveFeishuPendingRequest(config, 'ou_2', 'member')?.openId).toBe('ou_2'); + + expect(promoteFeishuUserToAdmin(config, 'ou_2')?.openId).toBe('ou_2'); + expect(demoteFeishuAdmin(config, 'ou_1')?.openId).toBe('ou_1'); + expect(rejectFeishuPendingRequest(config, 'ou_missing')).toBeNull(); + expect(removeFeishuUser(config, 'ou_2')?.openId).toBe('ou_2'); + + clearFeishuAccess(config); + expect(getFeishuAccessSummary(config)).toBe('0 admins, 0 members, 0 pending'); + }); +}); From 0d87d431cf92e8637cdd17f8ac807a64169b55f3 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Mon, 4 May 2026 00:41:55 +0800 Subject: [PATCH 19/20] docs: update Chinese README with Feishu channel documentation --- README.zh-CN.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 422db4f..7f2fc83 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ [English](README.md) | 简体中文 -Mercury 会记住重要信息,在执行有风险的操作前先请求确认,并且可以通过 CLI 或 Telegram 以 24/7 后台进程运行。它适合需要本地文件操作、命令执行、长期记忆、定时任务和多模型兜底能力的个人 AI 助手场景。 +Mercury 会记住重要信息,在执行有风险的操作前先请求确认,并且可以通过 CLI、Telegram 或飞书以 24/7 后台进程运行。它适合需要本地文件操作、命令执行、长期记忆、定时任务和多模型兜底能力的个人 AI 助手场景。 ## 快速开始 @@ -21,7 +21,7 @@ npm i -g @cosmicstack/mercury-agent mercury ``` -首次运行会启动配置向导。你需要输入姓名、模型 API Key,并可选择配置 Telegram Bot Token。之后如需重新配置: +首次运行会启动配置向导。你需要输入姓名、模型 API Key,并可选择配置 Telegram/飞书 Token。之后如需重新配置: ```bash mercury doctor @@ -88,6 +88,13 @@ mercury status # 查看运行状态 | `mercury telegram promote ` | 将 Telegram 成员提升为管理员 | | `mercury telegram demote ` | 将 Telegram 管理员降级为成员 | | `mercury telegram reset` | 清空 Telegram 访问状态并重新开始 | +| `mercury feishu list` | 查看已批准和待处理的飞书用户 | +| `mercury feishu approve ` | 批准飞书访问请求 | +| `mercury feishu reject ` | 拒绝飞书访问请求 | +| `mercury feishu remove ` | 移除已批准飞书用户 | +| `mercury feishu promote ` | 将飞书成员提升为管理员 | +| `mercury feishu demote ` | 将飞书管理员降级为成员 | +| `mercury feishu reset` | 清空飞书访问状态并重新开始 | | `mercury service install` | 安装开机自启系统服务 | | `mercury service uninstall` | 卸载系统服务 | | `mercury service status` | 查看系统服务状态 | @@ -133,6 +140,7 @@ mercury status # 查看运行状态 |------|------| | CLI | Readline 提示符、方向键命令菜单、实时文本流、Markdown 重渲染、权限模式选择 | | Telegram | HTML 格式化、可编辑流式消息、文件上传、输入状态、多用户访问和管理员/成员角色 | +| 飞书 | 私聊支持、自动批准用户列表、管理员/成员角色 | ### Telegram 访问模型 @@ -144,6 +152,20 @@ Mercury 使用组织式访问模型,包含管理员和成员。 - 重置:管理员可在 Telegram 发送 `/unpair`,或在 CLI 中执行 `mercury telegram reset`。 - 仅支持私聊,群聊消息会被忽略。 +CLI 命令:`mercury telegram list|approve|reject|remove|promote|demote|reset` + +### 飞书访问模型 + +飞书使用同样的组织式访问模型,包含管理员和成员。 + +- 首次设置:在 `mercury setup` 中输入飞书 App ID 和 App Secret。可选填逗号分隔的用户 ID 列表实现自动批准。 +- 新用户:向你的飞书机器人发送消息请求访问,由管理员在 CLI 中批准或拒绝。 +- 角色:管理员可以批准、拒绝、提升、降级和重置访问;成员可以与 Mercury 对话。 +- 重置:在 CLI 中执行 `mercury feishu reset` 清空所有访问状态。 +- 仅支持私聊,群聊暂不支持(MVP)。 + +CLI 命令:`mercury feishu list|approve|reject|remove|promote|demote|reset` + ## 调度器 - **周期任务**:使用 cron 表达式,例如 `0 9 * * *` 表示每天 9 点。 From b945d7fae9204a6a3e173da107ce87e4c86ffd16 Mon Sep 17 00:00:00 2001 From: Chasen Liao <2558891266@qq.com> Date: Mon, 4 May 2026 17:19:07 +0800 Subject: [PATCH 20/20] docs: add Chinese translation for DECISIONS.md --- DECISIONS.md | 2 ++ DECISIONS.zh-CN.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 DECISIONS.zh-CN.md diff --git a/DECISIONS.md b/DECISIONS.md index 5f10557..133fd04 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,8 @@ > Architecture Decision Records. New ones appended as we go. +[中文版](./DECISIONS.zh-CN.md) + ## ADR-001: TypeScript + Node.js - **Context**: Need a runtime for 24/7 headless agent with future GUI, mobile, and chat integrations. diff --git a/DECISIONS.zh-CN.md b/DECISIONS.zh-CN.md new file mode 100644 index 0000000..9f4336b --- /dev/null +++ b/DECISIONS.zh-CN.md @@ -0,0 +1,85 @@ +# Mercury — 架构决策 + +> 架构决策记录。新的决策将随时追加。 + +[English](./DECISIONS.md) + +## ADR-001: TypeScript + Node.js + +- **背景**: 需要一个 24/7 无头代理运行时,同时考虑未来集成 GUI、移动端和聊天渠道。 +- **决策**: 使用 TypeScript + Node.js。 +- **结果**: 最佳的 AI SDK 生态系统(Vercel AI SDK)、Ink 用于 TUI、grammY 用于 Telegram,最易扩展至所有未来渠道。 + +## ADR-002: Ink 用于 TUI + +- **背景**: CLI 需要生动有趣 — 动画、进度条、打字机效果。 +- **决策**: Ink + React 用于终端 UI。 +- **结果**: 比 Commander 学习曲线更陡,但体验卓越。初期 CLI 使用 readline;Ink 在第二阶段引入。 + +## ADR-003: 平面文件存储 + +- **背景**: 内存需要简单、可检查、对 Git 友好。 +- **决策**: 长期/情景记忆用 JSONL,短期记忆用 JSON。 +- **结果**: 易于调试,无需数据库依赖。后续可能需要 SQLite 实现语义搜索。 + +## ADR-004: grammY 用于 Telegram + +- **背景**: 需要 Telegram 集成,支持流式输出和打字状态。 +- **决策**: grammY + @grammyjs/stream + @grammyjs/auto-retry。 +- **结果**: 最好的 TypeScript Telegram 框架。内置流式支持,社区活跃。 + +## ADR-005: Vercel AI SDK 用于 LLM + +- **背景**: 需要支持多个提供商(OpenAI、Anthropic、DeepSeek)并实现流式输出。 +- **决策**: 使用 Vercel AI SDK(`ai` 包)配合各提供商适配器。 +- **结果**: 统一 API、内置流式输出、工具调用。切换提供商只需改一行代码。 + +## ADR-006: Soul 分离为独立 Markdown 文件 + +- **背景**: 代理人格需要可编辑、可版本化、令牌高效。 +- **决策**: 四个独立 Markdown 文件:soul.md、persona.md、taste.md、heartbeat.md。每次请求只注入 soul + persona;taste + heartbeat 选择性注入。 +- **结果**: 身份基线约 350 tokens。主人可以在不修改代码的情况下编辑人格。 + +## ADR-007: Agent Skills 规范 + +- **背景**: Skills 需要模块化、可运行时安装、令牌高效。 +- **决策**: 采用 Agent Skills 规范(agentskills.io)。Skills 使用带有 YAML frontmatter 的 `SKILL.md` + markdown 说明。存储在 `~/.mercury/skills/`。渐进披露:启动时只加载 name + description;调用时才加载完整说明。 +- **结果**: Skills 是人类可读的 markdown,无需代码。令牌预算保持低位。通过粘贴内容或 URL 安装。 + +## ADR-008: 支持 YAML 持久化的调度器 + +- **背景**: Mercury 需要设置提醒、执行周期性任务、按计划触发 skills。 +- **决策**: 将 `schedule_task`、`list_scheduled_tasks`、`cancel_scheduled_task` 暴露为 AI 可调用工具。将计划任务持久化到 `~/.mercury/schedules.yaml`。启动时恢复。任务作为内部(非渠道)消息通过代理循环触发。 +- **结果**: Mercury 可以自主调度工作。任务在重启后存活。内部执行使计划任务对渠道不可见,除非代理显式发送输出。 + +## ADR-009: 自定义混合Daemon化方案 + +- **背景**: Mercury 24/7 运行但目前仅前台模式。关闭终端会终止进程,导致 Telegram、定时任务和心跳中断。非技术用户不应需要手动安装 PM2/forever/systemd 脚本。 +- **决策**: 在 Mercury 中原生构建自定义混合 daemon 管理器。无外部依赖。分三层: + 1. **后台启动** — `child_process.spawn({detached: true})` + PID 文件 + 日志重定向。通过 `mercury start -d` 激活。 + 2. **看门狗** — 内置指数退避崩溃恢复(基础 1s,1.25 倍,最多 10 次重启/60s)。仅在 daemon 模式激活。 + 3. **平台服务生成器** — `mercury service install` 检测操作系统并生成相应配置:Linux 上为 `systemd --user` unit,macOS 上为 `~/Library/LaunchAgents` plist,Windows 上为启动快捷方式。Mac/Linux 无需 root。 +- **备选方案考虑**: + - `node-windows/mac/linux` 三件套 — 部分已停止维护,Mac 上需要 sudo,node-linux 已停止开发 + - PM2 作为依赖 — 15MB,50+ 依赖,AGPL-3.0 许可证 + - PM2 作为用户安装 — 要求非技术用户学习单独工具 + - `forever` — 已被官方弃用 + - 仅原生后台模式 — 无崩溃恢复,无启动自启 +- **结果**: 核心 daemon 化零外部依赖。启动服务为用户级(Mac/Linux 无需 sudo)。Windows 获取后台模式 + 文档化的 PM2 路径。前台模式不变 — daemon 模式为可选。在 daemon 模式下,CLI 变为仅日志;Telegram(或其他远程渠道)为交互界面。 + +## ADR-010: 第二大脑 — SQLite 支持的自主结构化记忆 + +- **背景**: Mercury 需要一个持久的用户模型,从对话中学习。此前的 LongTermMemory(平面 JSONL)太简单 — 仅关键字搜索,无结构,无合并,无冲突处理,无层级。第二大脑已有部分实现(用于 SQLite 的 second-brain-db.ts,用于 JSON 的 user-memory.ts),但两者都是断开的死代码。 +- **决策**: 使用 SQLite(better-sqlite3)作为存储后端,结合 UserMemoryStore 业务逻辑层构建统一第二大脑。关键原则: + - **自主**: 无审核队列,无用户审批。记忆通过置信度自动存储、合并、去冲突。弱记忆以低分保留,自然衰减。 + - **自动冲突解决**: 检测到极性冲突时(如"偏好 X"vs"不偏好 X"),高置信度记忆静默胜出。置信度相等时 → 较新的胜出。 + - **自动分层**: 目标和项目等记忆类型初始为 `active`(有时限);身份和偏好初始为 `durable`。强化 3+ 次的记忆从 active 晋升为 durable。 + - **过期清理**: 21 天未见的 active 推断记忆被清除。120 天无强化的 durable 推断记忆置信度衰减;低于 0.3 时被清除。 + - **对用户不可见**: 记忆提取在响应发送后作为后台任务执行。代理循环中无工具调用,无状态消息。用户无需等待。 + - **10 种记忆类型**: identity、preference、goal、project、habit、decision、constraint、relationship、episode、reflection。 + - **FTS5 全文搜索** 用于 `/memory search` 命令。 +- **备选方案考虑**: + - 仅 JSON(UserMemoryStore 原样)— 逻辑好但无搜索,扩展性差 + - 仅 SQLite(SecondBrainDB 原样)— 存储好但无合并/冲突/反思逻辑 + - 向量嵌入 — 对当前规模过于奢侈,增加重量级依赖 +- **结果**: WAL 模式的 SQLite 为提示注入提供快速读取(微秒级)。FTS5 支持快速搜索。业务逻辑(合并、冲突、反思、分层、过期)继承自 UserMemoryStore。一个原生依赖(better-sqlite3)。用户的唯一控制:观察(概览、最近、搜索)、暂停/恢复学习、清除全部。