diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..17135cb6d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# ============================================================================= +# LLM Provider Configuration +# ============================================================================= +# Copy this file to .env and fill in your values: +# cp .env.example .env +# +# Then load before running: +# source .env && pnpm dev:console +# ============================================================================= + +# Default LLM provider (e.g., openai, anthropic, deepseek, kimi-coding, groq, mistral) +export LLM_PROVIDER= + +# --- OpenAI --- +export OPENAI_API_KEY= +export OPENAI_BASE_URL= +export OPENAI_MODEL= + +# --- Anthropic --- +export ANTHROPIC_API_KEY= +export ANTHROPIC_BASE_URL= +export ANTHROPIC_MODEL= + +# --- DeepSeek --- +export DEEPSEEK_API_KEY= +export DEEPSEEK_BASE_URL= +export DEEPSEEK_MODEL= + +# --- Kimi (Moonshot) --- +export MOONSHOT_API_KEY= +export MOONSHOT_BASE_URL= +export MOONSHOT_MODEL= + +# --- Groq --- +export GROQ_API_KEY= +export GROQ_BASE_URL= +export GROQ_MODEL= + +# --- Mistral --- +export MISTRAL_API_KEY= +export MISTRAL_BASE_URL= +export MISTRAL_MODEL= + +# --- Together --- +export TOGETHER_API_KEY= +export TOGETHER_BASE_URL= +export TOGETHER_MODEL= + +# --- Google --- +export GOOGLE_API_KEY= +export GOOGLE_BASE_URL= +export GOOGLE_MODEL= diff --git a/.gitignore b/.gitignore index 118f0f6f14..60d83b648a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ release # env .env* +!.env.example # platform specific *.dmg diff --git a/README.md b/README.md index e9294e9ff1..8cac46f3eb 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,50 @@ skills/ # Bundled skills (commit, code-review) ```bash pnpm install -pnpm dev ``` +### Environment Configuration + +The Agent requires LLM provider credentials. Copy the example and fill in your values: + +```bash +cp .env.example .env +# Edit .env with your API keys +``` + +Example `.env` for OpenAI: + +```bash +export LLM_PROVIDER=openai +export OPENAI_API_KEY=sk-xxx +export OPENAI_BASE_URL=https://api.openai.com/v1 +export OPENAI_MODEL=gpt-4o +``` + +Load the environment before starting services that use the Agent: + +```bash +# Hub Console (requires LLM env vars) +source .env && pnpm dev:console + +# Agent CLI +source .env && pnpm agent:cli "hello" + +# Gateway (no LLM env vars needed) +pnpm dev:gateway +``` + +See `.env.example` for all supported providers (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, etc.). + +### Configuration Priority + +Each setting is resolved in order (first match wins): + +1. **CLI argument** — `--provider`, `--model`, `--api-key`, `--base-url` +2. **Environment variable** — `LLM_PROVIDER`, `OPENAI_MODEL`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, etc. +3. **Session metadata** — restored from previous session +4. **Default** — `kimi-coding` provider with `kimi-k2-thinking` model + ## Agent CLI Use the agent module directly from the CLI for isolated testing. diff --git a/src/agent/cli.ts b/src/agent/cli.ts index ab017aceeb..0cbd6662a0 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -5,6 +5,8 @@ type CliOptions = { profile?: string | undefined; provider?: string | undefined; model?: string | undefined; + apiKey?: string | undefined; + baseUrl?: string | undefined; system?: string | undefined; thinking?: string | undefined; cwd?: string | undefined; @@ -21,6 +23,8 @@ function printUsage() { console.log(" --profile ID Load agent profile (identity, soul, tools, memory)"); console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)"); console.log(" --model NAME Model name"); + console.log(" --api-key KEY API key (overrides environment variable)"); + console.log(" --base-url URL Custom base URL for the provider"); console.log(" --system TEXT System prompt (ignored if --profile is set)"); console.log(" --thinking LEVEL Thinking level"); console.log(" --cwd DIR Working directory for commands"); @@ -53,6 +57,14 @@ function parseArgs(argv: string[]) { opts.model = args.shift(); continue; } + if (arg === "--api-key") { + opts.apiKey = args.shift(); + continue; + } + if (arg === "--base-url") { + opts.baseUrl = args.shift(); + continue; + } if (arg === "--system") { opts.system = args.shift(); continue; @@ -112,6 +124,8 @@ async function main() { profileId: opts.profile, provider: opts.provider, model: opts.model, + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, systemPrompt: opts.system, thinkingLevel: opts.thinking as any, cwd: opts.cwd, diff --git a/src/agent/profile-cli.ts b/src/agent/profile-cli.ts index e708e2ce52..d3055734c1 100644 --- a/src/agent/profile-cli.ts +++ b/src/agent/profile-cli.ts @@ -10,7 +10,6 @@ */ import { existsSync, readdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { createAgentProfile, @@ -18,8 +17,9 @@ import { getProfileDir, profileExists, } from "./profile/index.js"; +import { DATA_DIR } from "../shared/index.js"; -const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); +const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles"); type Command = "new" | "list" | "show" | "edit" | "help"; diff --git a/src/agent/profile/storage.ts b/src/agent/profile/storage.ts index fc281bf383..fa8493fe6e 100644 --- a/src/agent/profile/storage.ts +++ b/src/agent/profile/storage.ts @@ -3,11 +3,11 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { PROFILE_FILES, type AgentProfile } from "./types.js"; +import { DATA_DIR } from "../../shared/index.js"; -const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); +const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles"); export interface StorageOptions { baseDir?: string | undefined; diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 34cb90de20..8eaee194be 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -13,9 +13,12 @@ import { } from "./context-window/index.js"; /** - * Get API Key based on provider + * Get API Key based on provider. + * Priority: explicit key > provider-specific env var > generic env var format. */ -function resolveApiKey(provider: string): string | undefined { +function resolveApiKey(provider: string, explicitKey?: string): string | undefined { + if (explicitKey) return explicitKey; + const providerEnvMap: Record = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", @@ -39,6 +42,66 @@ function resolveApiKey(provider: string): string | undefined { return process.env[`${normalizedProvider}_API_KEY`]; } +/** + * Get Base URL based on provider. + * Priority: explicit URL > provider-specific env var > generic env var format. + */ +function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { + if (explicitUrl) return explicitUrl; + + const providerEnvMap: Record = { + openai: "OPENAI_BASE_URL", + anthropic: "ANTHROPIC_BASE_URL", + google: "GOOGLE_BASE_URL", + "google-genai": "GOOGLE_BASE_URL", + kimi: "MOONSHOT_BASE_URL", + "kimi-coding": "MOONSHOT_BASE_URL", + deepseek: "DEEPSEEK_BASE_URL", + groq: "GROQ_BASE_URL", + mistral: "MISTRAL_BASE_URL", + together: "TOGETHER_BASE_URL", + }; + + const envVar = providerEnvMap[provider]; + if (envVar) { + return process.env[envVar]; + } + + // Try generic format: PROVIDER_BASE_URL + const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); + return process.env[`${normalizedProvider}_BASE_URL`]; +} + +/** + * Get Model ID based on provider. + * Priority: explicit model > provider-specific env var > generic env var format. + */ +function resolveModelId(provider: string, explicitModel?: string): string | undefined { + if (explicitModel) return explicitModel; + + const providerEnvMap: Record = { + openai: "OPENAI_MODEL", + anthropic: "ANTHROPIC_MODEL", + google: "GOOGLE_MODEL", + "google-genai": "GOOGLE_MODEL", + kimi: "MOONSHOT_MODEL", + "kimi-coding": "MOONSHOT_MODEL", + deepseek: "DEEPSEEK_MODEL", + groq: "GROQ_MODEL", + mistral: "MISTRAL_MODEL", + together: "TOGETHER_MODEL", + }; + + const envVar = providerEnvMap[provider]; + if (envVar) { + return process.env[envVar]; + } + + // Try generic format: PROVIDER_MODEL + const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); + return process.env[`${normalizedProvider}_MODEL`]; +} + export class Agent { private readonly agent: PiAgentCore; private readonly output; @@ -57,7 +120,16 @@ export class Agent { this.output = createAgentOutput({ stdout, stderr }); this.debug = options.debug ?? false; - this.agent = new PiAgentCore(); + // Resolve provider and model from options > env vars > defaults + const resolvedProvider = options.provider ?? process.env.LLM_PROVIDER ?? "kimi-coding"; + const resolvedModel = resolveModelId(resolvedProvider, options.model); + const apiKey = resolveApiKey(resolvedProvider, options.apiKey); + + this.agent = new PiAgentCore( + apiKey + ? { getApiKey: (_provider: string) => apiKey } + : {}, + ); // Load Agent Profile (if profileId is specified) let systemPrompt: string | undefined; @@ -101,11 +173,15 @@ export class Agent { return tempSession.getMeta(); })(); - const model = options.provider && options.model ? resolveModel(options) : resolveModel({ - ...options, - provider: storedMeta?.provider, - model: storedMeta?.model, - }); + const effectiveProvider = resolvedModel ? resolvedProvider : (options.provider ?? storedMeta?.provider); + const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model; + let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel }); + + // Override base URL if provided via options or environment variable + const baseUrl = resolveBaseUrl(model.provider, options.baseUrl); + if (baseUrl) { + model = { ...model, baseUrl }; + } // === Context Window Guard === this.contextWindowGuard = checkContextWindow({ @@ -133,7 +209,7 @@ export class Agent { const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式 // 获取 API Key(用于 summary 模式) - const apiKey = compactionMode === "summary" ? resolveApiKey(model.provider) : undefined; + const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined; // 创建 SessionManager(带 context window 配置) this.session = new SessionManager({ @@ -147,7 +223,7 @@ export class Agent { minKeepMessages: options.minKeepMessages, // Summary 模式参数 model: compactionMode === "summary" ? model : undefined, - apiKey, + apiKey: summaryApiKey, customInstructions: options.summaryInstructions, }); diff --git a/src/agent/session/storage.ts b/src/agent/session/storage.ts index fbffa76a15..b219fd95ef 100644 --- a/src/agent/session/storage.ts +++ b/src/agent/session/storage.ts @@ -1,15 +1,15 @@ -import { homedir } from "os"; import { join } from "path"; import { existsSync, mkdirSync, readFileSync } from "fs"; import { appendFile, writeFile } from "fs/promises"; import type { SessionEntry } from "./types.js"; +import { DATA_DIR } from "../../shared/index.js"; export type SessionStorageOptions = { baseDir?: string | undefined; }; export function resolveBaseDir(options?: SessionStorageOptions) { - return options?.baseDir ?? join(homedir(), ".super-multica", "sessions"); + return options?.baseDir ?? join(DATA_DIR, "sessions"); } export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) { diff --git a/src/agent/skills/loader.ts b/src/agent/skills/loader.ts index a6f11c6607..875ad4f7d9 100644 --- a/src/agent/skills/loader.ts +++ b/src/agent/skills/loader.ts @@ -5,17 +5,17 @@ */ import { existsSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { Skill, SkillSource, SkillManagerOptions } from "./types.js"; import { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js"; import { parseSkillFile } from "./parser.js"; +import { DATA_DIR } from "../../shared/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); /** Default profile base directory */ -const DEFAULT_PROFILE_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); +const DEFAULT_PROFILE_BASE_DIR = join(DATA_DIR, "agent-profiles"); /** Bundled skills directory (relative to package) */ const BUNDLED_DIR = join(__dirname, "../../../skills"); diff --git a/src/agent/types.ts b/src/agent/types.ts index 94ab9c451c..405f0cc741 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -17,6 +17,10 @@ export type AgentOptions = { profileBaseDir?: string | undefined; provider?: string | undefined; model?: string | undefined; + /** Custom API key (overrides environment variable) */ + apiKey?: string | undefined; + /** Custom base URL for the provider endpoint */ + baseUrl?: string | undefined; /** System prompt, if profileId is set will auto-construct from profile */ systemPrompt?: string | undefined; thinkingLevel?: ThinkingLevel | undefined; diff --git a/src/hub/agent-store.ts b/src/hub/agent-store.ts new file mode 100644 index 0000000000..c5aabe1980 --- /dev/null +++ b/src/hub/agent-store.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DATA_DIR } from "../shared/index.js"; + +export interface AgentRecord { + id: string; + createdAt: number; +} + +const AGENTS_DIR = join(DATA_DIR, "agents"); +const AGENTS_FILE = join(AGENTS_DIR, "agents.json"); + +function ensureDir(): void { + if (!existsSync(AGENTS_DIR)) { + mkdirSync(AGENTS_DIR, { recursive: true }); + } +} + +export function loadAgentRecords(): AgentRecord[] { + if (!existsSync(AGENTS_FILE)) return []; + try { + const content = readFileSync(AGENTS_FILE, "utf-8"); + return JSON.parse(content) as AgentRecord[]; + } catch { + return []; + } +} + +export function saveAgentRecords(records: AgentRecord[]): void { + ensureDir(); + writeFileSync(AGENTS_FILE, JSON.stringify(records, null, 2), "utf-8"); +} + +export function addAgentRecord(record: AgentRecord): void { + const records = loadAgentRecords(); + if (records.some((r) => r.id === record.id)) return; + records.push(record); + saveAgentRecords(records); +} + +export function removeAgentRecord(id: string): void { + const records = loadAgentRecords(); + const filtered = records.filter((r) => r.id !== id); + if (filtered.length !== records.length) { + saveAgentRecords(filtered); + } +} diff --git a/src/hub/device.ts b/src/hub/device.ts index 1b375b1bba..4e637b6259 100644 --- a/src/hub/device.ts +++ b/src/hub/device.ts @@ -1,10 +1,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { v7 as uuidv7 } from "uuid"; +import { DATA_DIR } from "../shared/index.js"; -const MULTICA_DIR = join(homedir(), ".multica"); -const DEVICE_ID_FILE = join(MULTICA_DIR, "device-id"); +const DEVICE_ID_FILE = join(DATA_DIR, "device-id"); /** * 获取当前设备的 ID。 @@ -16,7 +15,7 @@ export function getDeviceId(): string { return readFileSync(DEVICE_ID_FILE, "utf-8").trim(); } catch { const id = uuidv7(); - mkdirSync(MULTICA_DIR, { recursive: true }); + mkdirSync(DATA_DIR, { recursive: true }); writeFileSync(DEVICE_ID_FILE, id, "utf-8"); return id; } diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 2719558371..77607d0d55 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -3,6 +3,7 @@ import type { ConnectionState } from "../shared/gateway-sdk/types.js"; import { AsyncAgent } from "../agent/async-agent.js"; import { getDeviceId } from "./device.js"; import { GatewayClient } from "../shared/gateway-sdk/client.js"; +import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js"; export class Hub { private readonly agents = new Map(); @@ -23,6 +24,18 @@ export class Hub { this.deviceId = getDeviceId(); this.client = this.createClient(this.url); this.client.connect(); + this.restoreAgents(); + } + + /** Restore agents from persistent storage */ + private restoreAgents(): void { + const records = loadAgentRecords(); + for (const record of records) { + this.createAgent(record.id, { persist: false }); + } + if (records.length > 0) { + console.log(`[Hub] Restored ${records.length} agent(s)`); + } } private createClient(url: string): GatewayClient { @@ -82,7 +95,7 @@ export class Hub { } /** Create new Agent, or rebuild with existing ID */ - createAgent(id?: string): AsyncAgent { + createAgent(id?: string, options?: { persist?: boolean }): AsyncAgent { if (id) { const existing = this.agents.get(id); if (existing && !existing.closed) { @@ -93,6 +106,11 @@ export class Hub { const agent = new AsyncAgent({ sessionId: id }); this.agents.set(agent.sessionId, agent); + // Persist to agent store (skip during restore to avoid duplicates) + if (options?.persist !== false) { + addAgentRecord({ id: agent.sessionId, createdAt: Date.now() }); + } + // Internally consume messages produced by agent void this.consumeAgent(agent); @@ -130,6 +148,7 @@ export class Hub { agent.close(); this.agents.delete(id); this.agentSenders.delete(id); + removeAgentRecord(id); return true; } diff --git a/src/shared/index.ts b/src/shared/index.ts index 4ed9a7cad6..f20f47fd8f 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,4 +1,5 @@ export * from "./types.js"; +export * from "./paths.js"; export * from "./errors.js"; export * from "./retry.js"; export * from "./cancellation.js"; diff --git a/src/shared/paths.ts b/src/shared/paths.ts new file mode 100644 index 0000000000..3d2e006b7d --- /dev/null +++ b/src/shared/paths.ts @@ -0,0 +1,5 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; + +/** Root data directory: ~/.super-multica */ +export const DATA_DIR = join(homedir(), ".super-multica");