# AGENTS.md - xopc Development Guide > Guide for AI assistants working on this repository. --- ## Table of Contents - [Project Overview](#project-overview) - [Quick Start](#quick-start) - [Tech Stack](#tech-stack) - [Project Structure](#project-structure) - [Model Registry](#model-registry-architecture) - [Code Style](#code-style-guidelines) - [Logging conventions](#logging-conventions) - [Key Patterns](#key-patterns) - [Common Tasks](#common-tasks) - [Configuration](#configuration) - [Environment Variables](#environment-variables) - [Testing](#testing) - [Web UI](#web-ui) - [Debugging](#debugging) - [Troubleshooting](#troubleshooting) - [When Making Changes](#when-making-changes) --- ## Project Overview **xopc** (`@xopcai/xopc`) is a personal AI assistant on Node.js + TypeScript: CLI, HTTP/realtime **gateway** (REST + WebSocket), and a **React** gateway console (`web/`). Channels (e.g. Telegram) load as extensions; additional backends appear in config/registry as the project evolves. | Metric | Value | |--------|-------| | Core | TypeScript on Node.js **>= 26.12.1** | | LLM layer | **~23** built-in providers via `@earendil-works/pi-ai` (`KnownProvider`); more via `models.json` | | Tests | **vitest** (`src/**/__tests__/*.test.ts`) | --- ## Quick Start ```bash pnpm install pnpm run dev -- # no build required for dev CLI pnpm run build # Node: tsdown (`tsdown.config.ts`) + web; types: `pnpm run typecheck` pnpm test # fast unit suite pnpm run test:all # complete regression suite ``` Examples: `pnpm run dev -- agent -i` · `pnpm run dev -- agent -m "Hello"` --- ## Tech Stack | Area | Stack | |------|--------| | Agent | `@earendil-works/pi-agent-core`, `@earendil-works/pi-ai` | | CLI | `commander` | | Config | `zod` | | Tools (schemas) | `@sinclair/typebox` | | Gateway console | **React** + Vite + Tailwind v4 (`web/` package) | | Tests | `vitest` | --- ## Project Structure **Runtime (`src/`)** — main areas agents touch: | Path | Role | |------|------| | `agent/` | `AgentService`, tools, memory, orchestration (core entry files at root; helpers grouped under `context/`, `lifecycle/`, `prompt/`, `transcript/` — transcript hygiene, thinking-level types, etc.) | | `agent/mcp/` | Outbound MCP configuration projection into pi createMcpExtension; no host-owned MCP runtime | | `mcp/` | Inbound channel bridge (`xopc mcp capabilities`) — stdio MCP server + gateway REST/realtime client | | `channels/` | `ChannelPlugin`, manager, inbound/outbound, `attachments/`, `plugins/bundled.ts` | | `gateway/` | HTTP + realtime WebSocket server, API for UI; `heartbeat/` keep-alive service | | `cli/` | Commands (self-registration via `registry`) | | `config/` | Schema, loader, paths | | `providers/` | `resolveModel`, API keys, pi-ai bridge | | `session/` | Conversation session store | | `infra/` | Infrastructure primitives (`retry`, rate-limit, `bus/` message bus) | | `extensions/` | Extension runtime; `extensions/sdk/` re-exports `@xopcai/xopc/extension-sdk` | Also present (follow local patterns): `auth/`, `automations/`, `chat-commands/` (in-chat slash commands), `daemon/`, `routing/`, `voice/stt/`, `voice/tts/`, `utils/` (`logger.ts` barrel → `logger/` implementation + `helpers.ts`), `markdown/`, `errors/`, etc. **Gateway console (`web/`)** — React SPA (Vite + Tailwind v4): hash router, REST + realtime WebSocket to the gateway, Zustand + SWR. Production build outputs to `dist/gateway/static/root` (same static root the gateway serves). **Extensions (`extensions/`)** — optional add-ons; **Telegram / Weixin** channel *sources* live in `extensions/telegram` and `extensions/weixin` but are **`private`** workspace packages (not published). **`tsdown`** (Rolldown) **unbundle** mode emits `dist/src/**` and `dist/extensions/**` in one pass. Wiring: `src/generated/bundled-channel-plugins.ts` (`pnpm run generate:bundled-channels`) and `src/channels/plugins/bundled.ts`. --- ## Model Registry Architecture `src/providers/index.ts` sits on **`@earendil-works/pi-ai`**: resolve models, map API keys from config/env, expose provider lists to CLI/UI. The built-in provider id set matches upstream **`KnownProvider`**; **`PROVIDER_META`** adds display names and categories (common / specialty / enterprise / oauth). Extra vendors are added only when present in `models.json`. Keys load at process start—restart after credential changes. | Function | Purpose | |----------|---------| | `resolveModel(ref)` | Model id + optional `provider/` prefix | | `getApiKey` / `isProviderConfigured` | Auth from config or env | | `getAllProviders` / `getModelsByProvider` | Discovery for UI | Details: [docs/models.md](./docs/models.md). --- ## Code Style Guidelines - **Comments:** English only; minimal—non-obvious logic, edge cases, exported APIs (JSDoc). - **Naming:** `camelCase` (code), `PascalCase` (types/classes), `UPPER_SNAKE_CASE` (constants), `_unused` for unused params, `_privateMethod` for private helpers. - **Imports:** external deps → internal absolute → relative (blank lines between groups). Example in repo: `src/agent/tools/*.ts`. - **Files:** `camelCase.ts` sources; `*.test.ts` tests; `*.types.ts` for dedicated type modules. --- ## Logging conventions Use **`createLogger('Prefix')`** from `src/utils/logger.ts` (Pino under the hood). Prefer a **stable module prefix** (e.g. `AgentService`, `Hono:Auth`) so gateway **Log Manager** and file logs filter cleanly. ### Shape: object first, message second ```typescript const log = createLogger('MyModule'); log.info({ conversationId, durationMs }, 'Session saved'); log.warn({ path, errorMessage: em }, `Config read failed: ${em}`); log.error({ err, requestId, phase: 'outbound_consume' }, `Outbound pipeline failed: ${em}`); ``` - **First argument:** structured fields (`err`, `conversationId`, `path`, `tool`, `phase`, counts, ids). Pass **`Error` instances as `err`** so the formatter keeps **name / message / stack**. - **Second argument (`msg`):** a **short, scannable sentence** for humans and UIs that mostly show the message column. Repeat the **one-line outcome** there (e.g. failure reason), not only in fields. - For non-`Error` throws, add **`errorMessage: String(x)`** (and/or embed the text in `msg`) so logs stay grep-friendly. ### What to include for debugging - **Identity:** `conversationId`, `channel`, `chatId`, `requestId` (often injected via async context—see below), file **`path`**, **`tool` / `toolName`**, **`modelRef`** or `provider` + `modelId`. - **Operation:** a **`phase`** or verb in `msg` (`inbound_consume`, `publishOutbound`, `lifecycle emit llm_request`, …). - **Bounded previews:** long strings as **`contentPreview` / `linePreview` / `goalPreview`** (truncated), not full payloads. ### What to avoid - **Vague `msg` only** (`'failed'`, `'Error'`) with no structured context. - **Spam:** do not emit the same **warn/error on every iteration** once a condition is true (e.g. “approaching limit” on each LLM call). Log **once** when crossing the threshold or once per logical phase. - **Secrets:** avoid logging raw tokens, API keys, or full `Authorization` headers. Structured redaction runs in `src/utils/logger/redact.ts`; disable only with care via **`XOPC_LOG_REDACTION=false`**. ### Request correlation (gateway / agent) HTTP and request-stream paths attach context via **`runWithLogContext` / `updateAsyncLogContext`** (`src/utils/logger/context.ts`) and gateway middleware (`src/gateway/hono/middleware/log-context.ts`). Prefer keeping **`requestId`** (and related keys) in async context so logs tie to a single API call without threading an argument through every function. ### Reference - Implementation: `src/utils/logger/` (`index.ts`, `context.ts`, `log-store.ts`, `redact.ts`, …). - Longer notes: `src/utils/README.logger.md`. --- ## Key Patterns ### CLI self-registration `src/cli/commands/.ts` calls `register({ id, factory, metadata })`; wire the module from `src/cli/index.ts`. ### Tools (Typebox) ```typescript const MyToolSchema = Type.Object({ param: Type.String() }); export const myTool: AgentTool = { name: 'my_tool', parameters: MyToolSchema, async execute(toolCallId, params, signal, onUpdate) { return { content: [{ type: 'text', text: '…' }], details: {} }; }, }; ``` Register in `AgentService` / tools index as existing tools do. ### AgentService `MessageBus` + `AgentService` with `workspace`, `model`, and `config` (including `tools.web.search` for web search); `await agent.start()`. ### Channels Implement `ChannelPlugin` (`src/channels/plugin-types.ts`). Bundled list: `src/channels/plugins/bundled.ts` (imports `src/generated/bundled-channel-plugins.ts`). Telegram / Weixin: sources under `extensions/telegram`, `extensions/weixin`; stable imports from `src/channels/telegram/index.js`, `src/channels/weixin/index.js`. **Access:** DM policies `pairing` \| `allowlist` \| `open` \| `disabled`; group `open` \| `disabled` \| `allowlist`. See [Configuration](#configuration). ### Telegram draft streaming ```typescript import { DraftStreamManager } from '@xopcai/xopc/channels/telegram/draft-stream.js'; ``` --- ## Common Tasks | Task | Steps | |------|--------| | New CLI command | `src/cli/commands/.ts` + register + import in `src/cli/index.ts` | | Package / gateway update | `src/infra/update-runner.ts` (`runGatewayUpdateWithPostSteps`), `src/extensions/update.ts`, `src/infra/update-restart.ts`; CLI `update`, gateway `POST /api/update/*` — see [docs/update.md](./docs/update.md) | | New tool | `src/agent/tools/.ts` → export from `src/agent/tools/index.ts` → wire in `AgentService` | | New provider | Prefer upstream **`pi-ai`**; else OpenRouter / Vercel AI Gateway for custom bases. See [pi-ai](https://github.com/earendil-works/pi-mono). | | New channel plugin | `ChannelPlugin` + optional `defineChannelPluginEntry` → `bundled.ts` if shipping in core | | New gateway console screen | `web/src/pages/.tsx` or `web/src/features//`; register route in `web/src/app.tsx`; follow [Web UI](#web-ui). | | New authenticated Gateway API | Add the handler to its route module, then **always** add or verify its matcher in `src/gateway/hono/routes/lazy-bundles.ts` and add a mapping assertion in `src/gateway/hono/routes/__tests__/lazy-bundles.test.ts`. | | MCP servers / channel bridge | Config: `mcp.servers` in `xopc.json`; outbound pi projection `src/agent/mcp/`; inbound `src/mcp/` + `xopc mcp capabilities`; UI capability center connectors; native OAuth/diagnostics via `xopc mcp login/logout/list`. See [docs/cli/mcp.md](./docs/cli/mcp.md). | | Dependencies | **`pnpm` only** — never commit `package-lock.json` (use `pnpm-lock.yaml`). | | GitHub issues / PRs | Templates under `.github/ISSUE_TEMPLATE/`; process in **[CONTRIBUTING.md](./CONTRIBUTING.md)**; sync labels with `./scripts/sync-github-labels.sh` | | Electron desktop | Packaged app: `pnpm run build && pnpm run electron:build`. The **main process** keeps Electron shell data in `userData`, but xopc config/state/workspace use the shared `~/.xopc` root (`XOPC_STATE_DIR`, `XOPC_CONFIG_PATH`, `XOPC_WORKSPACE`). First-time shared config init uses **Zod (`ConfigSchema`) only** via `initWorkspace({ skipChannelPluginValidation: true })` so it does not load bundled channel plugins. **Channel plugin `configSchema.validate`** still runs when the **gateway subprocess** (`out/server/index.js`) starts. | --- ## Configuration **Default path:** `~/.xopc/xopc.json` (override with `XOPC_CONFIG` or `XOPC_CONFIG_PATH`). | Section | Purpose | |---------|---------| | `providers` | LLM API keys | | `agents.default` | Default agent id for routing/session creation | | `agents.defaults` | One global model, skill, tool, and runtime policy inherited by every agent | | `userContext` | Global user understanding, memory, privacy, provider routing, and dreaming settings shared by every agent | | `agents.list` | Agent ids, personality, workspace, and optional capability overrides | | `channels` | Telegram and other channel configs | | `gateway` | HTTP + realtime WebSocket | | `mcp` | Outbound MCP server registry (`mcp.servers`); execution and OAuth owned by pi | | `extensions` | Enable/disable extensions | ### Multiple agents (`agents.list`) Runtime configuration has exactly two layers: **`agents.defaults`**, then the matching entry in **`agents.list`**. The selected conversation metadata agent id resolves to one enabled entry; its explicitly configured fields override or merge with the global defaults according to that field's schema. There is no preset graph, generic recursive merge, lock, or compatibility layer. Default agent id: **`agents.default`**, else the first enabled entry, else **`main`**. On-disk paths (`~/.xopc/agents//` including profile Markdown and Markdown workspace roots) resolve via **`src/agent/agent-scope.ts`**. Use **`xopc agents list`**, **`xopc agents add`**, **`xopc agents delete`** to manage `agents.list` and initialize directories — there is no separate agent registry outside config. ### Telegram (multi-account sketch) ```json { "channels": { "telegram": { "enabled": true, "accounts": { "personal": { "botToken": "…", "dmPolicy": "allowlist", "groupPolicy": "open", "allowFrom": [123456789], "streamMode": "partial" } } } } } ``` `dmPolicy` / `groupPolicy` / `streamMode` (`off` \| `partial` \| `block`) — full examples in repo docs or tests. --- ## Environment Variables | Variable | Purpose | |----------|---------| | Per-provider env vars | See `src/providers/env-keys.ts` **`PROVIDER_ENV_MAP`** (aligned with pi-ai, e.g. `OPENAI_API_KEY`, `ANTHROPIC_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`, `AI_GATEWAY_API_KEY` / `VERCEL_AI_GATEWAY_API_KEY`, …). DashScope (image, speech, STT): `DASHSCOPE_API_KEY` (`dashscope` service id). Custom OpenAI-compatible vendors: configure in `models.json`. | | `TELEGRAM_BOT_TOKEN` | Telegram (if not only in config) | | `XOPC_CONFIG`, `XOPC_CONFIG_PATH` | Config file path | | `XOPC_SKILLS_STORE_URL` | Overrides `gateway.skillsStoreBaseUrl` (skills marketplace REST base) | | `XOPC_WORKSPACE` | Workspace directory | | `XOPC_LOG_LEVEL` | `trace` … `fatal` (default `info`) | | `XOPC_LOG_DIR`, `XOPC_LOG_CONSOLE`, `XOPC_LOG_FILE`, `XOPC_LOG_RETENTION_DAYS` | Logging | | `XOPC_PRETTY_LOGS` | Dev-friendly log formatting | | `XOPC_GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID`, `XOPC_GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Google Antigravity OAuth (Cloud Code–style desktop client; required for `xopc auth` / gateway OAuth on that provider — set locally, never commit) | | `XOPC_GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID`, `XOPC_GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET` | Google Gemini CLI (Cloud Code Assist) OAuth — same as above | --- ## Testing ```bash pnpm test # unit tests pnpm run test:integration pnpm run test:web pnpm run test:extensions pnpm run test:packages pnpm run test:all # every Vitest project pnpm vitest run src/agent/tools/__tests__/send-media.test.ts pnpm vitest --watch pnpm vitest run --coverage ``` Co-located tests: `src/**/__tests__/*.test.ts`. Use `describe` / `it` / `expect` / `vi.mock` like existing files. --- See [Testing](#testing). ### Remote access Gateway defaults to loopback. External access layers: | Layer | Config / CLI | Docs | |-------|----------------|------| | Tailscale Serve | `gateway.tailscale.mode=serve`, `xopc gateway --tailscale serve` | [docs/gateway/tailscale.md](./docs/gateway/tailscale.md) | | SSH tunnel | `xopc gateway ssh-tunnel --target user@host` | [docs/gateway/remote.md](./docs/gateway/remote.md) | | FRP public tunnel | `tunnel.*`, `#/settings/remote-access` | [docs/remote-access.md](./docs/remote-access.md) · [tunnel-security](./docs/tunnel-security.md) | | CLI remote mode | `gateway.mode=remote`, `gateway.remote.*` | [docs/network.md](./docs/network.md) | Key paths: `src/infra/tailscale.ts`, `src/gateway/tailscale-lifecycle.ts`, `src/remote-access/`, `src/tunnel/`. --- ## Web UI ### Gateway console (React) The **gateway console** is the **`web/`** package: **React 19**, **React Router 7** (`createHashRouter`), **Vite**, **Tailwind CSS v4** (`@import "tailwindcss"` in app CSS), **Zustand** (gateway/theme/locale stores), **SWR** (`SwrProvider`), **Lucide** icons, **Radix** primitives where needed (e.g. `Dialog`). Roadmap and parity notes: [docs/web-migration-plan.md](./docs/web-migration-plan.md). ```bash cd web && pnpm install && pnpm run dev # Vite dev server cd web && pnpm run build # → ../dist/gateway/static/root (gateway static root) ``` | Area | Location / convention | |------|------------------------| | App shell, nav | `web/src/components/shell/` (`app-shell.tsx`, `sidebar.tsx`, …) | | Routes | `web/src/app.tsx` (`createHashRouter`); pages under `web/src/pages/` | | Feature modules | `web/src/features//` (e.g. `chat/`, `gateway/`, `sessions/`) | | Component primitives | `web/src/components/ui/` (Radix-oriented building blocks) | | API access | `web/src/lib/fetch.ts` (`apiFetch` / `fetchJson`) + `apiUrl()`; sends `Authorization: Bearer ` from `gateway-store` | | Gateway token / URL | `web/src/stores/gateway-store.ts` | | i18n | `web/src/i18n/messages.ts` (`en` / `zh`) | | Global styles + tokens | `web/src/styles/globals.css` (`@theme { … }` for semantic colors) | **Gateway integration:** - **REST:** same origin `fetch` via `apiUrl('/api/...')`; 401 → `gateway-store` `onUnauthorized`. - **Agent streaming:** submit input with `POST /api/sessions/:conversationId/inputs`, then subscribe to `run:` on the shared realtime connection. See `web/src/features/chat/`. - **Realtime events:** issue a one-time ticket with `POST /api/realtime/tickets`, then connect to `WS /api/realtime/v1/ws`; the bridge lives in `web/src/features/gateway/gateway-realtime-bridge.tsx`. Dots in event names become hyphenated `window` events (e.g. `config.reload` → `config-reload`). - **Navigate to chat from other pages:** `window.dispatchEvent(new CustomEvent('navigate-to-chat', { detail: { conversationId } }))` — handled in `AppShell`. **Design system:** Follow **[docs/design/ui-design-system.md](./docs/design/ui-design-system.md)** — calm slate neutrals, **blue** only for primary actions / links / AI hints; prefer borders over heavy shadows in dark mode; short copy. Implement with **`web/src/styles/globals.css`** semantic tokens (`bg-surface-*`, `text-fg*`, `border-edge`, `accent`, etc.) and Tailwind utilities—do not add a second token system under `web/` unless extending `@theme` there. - **Loading states:** Data-loading UI in the gateway console must use skeleton screens instead of plain loading text or page-level spinners. Use `web/src/components/ui/skeleton.tsx` for local placeholders, and reuse `web/src/features/settings/settings-loading-skeleton.tsx` for settings page/panel/list loading states. Keep button-level spinners only for short user-triggered actions such as save, refresh, OAuth, and diagnostics. - **Select controls:** Use the project-owned select components from `web/src/components/ui/popover-select.tsx` (`Select`, `SelectOption`, or `PopoverSelect`) for dropdowns and option picking. Do not introduce raw native `