|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## Commands |
| 6 | + |
| 7 | +```bash |
| 8 | +# Type-check the plugin (no emit) |
| 9 | +npm run type-check # tsc --noEmit |
| 10 | + |
| 11 | +# Run individual test scripts (no build step needed — tsx runs TS directly) |
| 12 | +npx tsx scripts/generate-bot-token.ts |
| 13 | +npx tsx scripts/discover-channels.ts |
| 14 | +npx tsx scripts/test-client.ts |
| 15 | +npx tsx scripts/test-roundtrip.ts |
| 16 | +npx tsx scripts/test-thread.ts |
| 17 | + |
| 18 | +# Restart the gateway after code changes |
| 19 | +openclaw gateway restart |
| 20 | +``` |
| 21 | + |
| 22 | +There is no build step for the plugin itself. OpenClaw loads it directly from TypeScript source via `tsx`. The `scripts/` directory is excluded from `tsconfig.json`; scripts are run standalone with `npx tsx`. |
| 23 | + |
| 24 | +The `openclaw/plugin-sdk` import alias resolves to `../openclaw/dist/plugin-sdk/plugin-sdk/index.d.ts`. If that file is missing, rebuild it: |
| 25 | + |
| 26 | +```bash |
| 27 | +cd ../openclaw && tsc -p tsconfig.plugin-sdk.dts.json |
| 28 | +``` |
| 29 | + |
| 30 | +## Architecture |
| 31 | + |
| 32 | +### Plugin registration |
| 33 | + |
| 34 | +`index.ts` is the plugin entry point. It exports a default object conforming to `OpenClawPluginDefinition`: the `register(api)` method stores the framework runtime singleton (`setStreamChatRuntime`) and calls `api.registerChannel({ plugin: streamchatPlugin })`. |
| 35 | + |
| 36 | +The OpenClaw framework discovers the plugin via `plugins.load.paths` in `~/.openclaw/openclaw.json`, looks for `openclaw.plugin.json` in the directory, then loads the extension listed in `package.json#openclaw.extensions`. |
| 37 | + |
| 38 | +**Config wiring gotchas:** |
| 39 | +- The `plugins.entries` key must equal the manifest `id` field (`"streamchat"`), not the package name (`@wunderchat/openclaw-channel-streamchat`) or directory name. Using the wrong key causes a `plugin not found` validation error at startup. |
| 40 | +- `PluginEntryConfig` only accepts `{ enabled, config }`. Any other field (e.g. `source`) will be rejected with `Unrecognized key`. |
| 41 | +- Config validation runs before plugin loading, so `plugins.load.paths` and `plugins.entries` must both be correct before the gateway will start. |
| 42 | +- There will always be a harmless cosmetic warning: `plugin id mismatch (manifest uses "streamchat", entry hints "openclaw-channel-streamchat")`. This is because the directory name differs from the manifest id and can be ignored. |
| 43 | + |
| 44 | +### Source module map |
| 45 | + |
| 46 | +| File | Responsibility | |
| 47 | +|---|---| |
| 48 | +| `src/channel.ts` | Main plugin export (`streamchatPlugin`). Contains `handleStreamChatMessage` (inbound dispatch) and the `ChannelPlugin` adapter implementations: `config`, `outbound`, `gateway`, `status`. | |
| 49 | +| `src/stream-chat-runtime.ts` | `StreamChatClientRuntime` — wraps the `stream-chat` SDK. Connects as bot user (`allowServerSideConnect: true` is required for Node.js server contexts), queries + watches channels on startup, auto-watches channels added later via `notification.added_to_channel`. | |
| 50 | +| `src/streaming.ts` | `StreamingHandler` — manages the AI streaming lifecycle per run: creates placeholder message → sends `ai_indicator` events → calls `partialUpdateMessage` on throttled chunks → finalizes on completion. | |
| 51 | +| `src/run-context.ts` | `RunContextMap` — binds an OpenClaw `runId` (UUID generated per inbound message) to delivery routing state: `channelId`, `threadParentId`, `responseMessageId`. TTL of 5 min. | |
| 52 | +| `src/envelope.ts` | `buildEnvelope` — wraps the raw message text in `[Thread]` / `[Replying to]` XML-like tags so the LLM receives thread and quote context in the single-session model. | |
| 53 | +| `src/types.ts` | `StreamChatChannelConfig`, `ResolvedAccount`, `RunContext`, `EnvelopeResult` interfaces, plus config helper functions (`getStreamChatConfig`, `resolveStreamChatAccount`). | |
| 54 | +| `src/config-schema.ts` | Zod schema for `channels.streamchat.*` config. Uses `z.lazy()` for the recursive `accounts` sub-map (multi-account support). | |
| 55 | +| `src/runtime.ts` | Module-level singleton accessor (`getStreamChatRuntime` / `setStreamChatRuntime`) for the `PluginRuntime` injected by OpenClaw at registration time. | |
| 56 | +| `src/stream-chat.d.ts` | Module augmentation adding `generating?: boolean` and `ai_generated?: boolean` to `CustomMessageData`. | |
| 57 | +| `src/utils.ts` | `truncate` and `safeAsync` helpers. | |
| 58 | + |
| 59 | +### Inbound flow |
| 60 | + |
| 61 | +``` |
| 62 | +message.new (WebSocket) |
| 63 | + → handleStreamChatMessage |
| 64 | + → skip if event.user.id === botUserId (own messages) |
| 65 | + → skip if message.ai_generated === true (own placeholder/streamed messages) |
| 66 | + → resolveAgentRoute (peer kind: "channel", id: channelId) |
| 67 | + → buildEnvelope (wraps text with thread/reply context tags) |
| 68 | + → finalizeInboundContext |
| 69 | + → recordInboundSession |
| 70 | + → dispatchReplyWithBufferedBlockDispatcher |
| 71 | + deliver(payload, info) called per block: |
| 72 | + info.kind === "tool" → onRunProgress (EXTERNAL_SOURCES indicator) |
| 73 | + text chunk → onRunStarted (first) + onTextChunk |
| 74 | + after dispatcher returns: |
| 75 | + → onRunCompleted (final partialUpdateMessage + ai_indicator.clear) |
| 76 | +``` |
| 77 | + |
| 78 | +The `ai_generated: true` check is critical — without it the bot would trigger on its own empty placeholder message created by `onRunStarted`, causing an infinite loop. |
| 79 | + |
| 80 | +### Event mapping |
| 81 | + |
| 82 | +How each signal from the OpenClaw pipeline translates into Stream Chat API calls or channel events: |
| 83 | + |
| 84 | +| Trigger | Stream Chat action | Notes | |
| 85 | +|---|---|---| |
| 86 | +| Inbound message received | `channel.sendReaction(msgId, { type: "eyes" })` | Ack reaction, fire-and-forget | |
| 87 | +| First `deliver` text block | `channel.sendMessage({ text: "", ai_generated: true })` | Creates the bot's placeholder message | |
| 88 | +| First `deliver` text block | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_THINKING" })` | Immediately followed by GENERATING below | |
| 89 | +| First text chunk processed | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_GENERATING" })` | Transitions from THINKING on the very first `onTextChunk` call | |
| 90 | +| Text chunk — throttled flush | `client.partialUpdateMessage(msgId, { set: { text, generating: true } })` | Odd chunks 1,3,5,7; then every N (default 15). Chained via `lastUpdatePromise` to avoid out-of-order updates | |
| 91 | +| `deliver` with `info.kind === "tool"` | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_EXTERNAL_SOURCES" })` | Only emitted once per run (de-duplicated by `indicatorState`); only if streaming has already started | |
| 92 | +| Dispatcher resolves (run complete) | `client.partialUpdateMessage(msgId, { set: { text, generating: false } })` | Final flush, waits for any in-flight partial updates first | |
| 93 | +| Dispatcher resolves (run complete) | `channel.sendEvent({ type: "ai_indicator.clear" })` | Clears the indicator bubble | |
| 94 | +| Dispatcher resolves (run complete) | `channel.deleteReaction(inboundMsgId, "eyes")` → `channel.sendReaction(inboundMsgId, { type: "white_check_mark" })` | Reaction swap on the original user message | |
| 95 | +| `deliver` with `payload.isError` | `client.partialUpdateMessage(msgId, { set: { text: "…\n\nError: …", generating: false } })` | Appends error to any partial text already accumulated | |
| 96 | +| `deliver` with `payload.isError` | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_ERROR" })` | Leaves the error indicator visible (no `ai_indicator.clear`) | |
| 97 | +| `ai_indicator.stop` from client | `client.partialUpdateMessage(msgId, { set: { generating: false } })` | Clears the generating flag without touching the accumulated text | |
| 98 | +| `ai_indicator.stop` from client | `channel.sendEvent({ type: "ai_indicator.clear" })` | | |
| 99 | + |
| 100 | +The `ai_indicator` events are sent via `safeSendEvent`, which retries up to 5 times on 429/5xx with exponential backoff (100 ms base, doubles each attempt) and swallows the error rather than aborting delivery if all retries fail. |
| 101 | + |
| 102 | +### Outbound streaming lifecycle |
| 103 | + |
| 104 | +Each agent run that produces text goes through these steps in `StreamingHandler`: |
| 105 | + |
| 106 | +1. `onRunStarted` — `channel.sendMessage({ text: "", ai_generated: true })` → `ai_indicator.update(AI_STATE_THINKING)` |
| 107 | +2. `onTextChunk` — accumulates text, switches indicator to `AI_STATE_GENERATING` on first chunk, calls `client.partialUpdateMessage({ set: { text, generating: true } })` throttled (early burst: odd chunks < 8; then every Nth chunk, default N=15) |
| 108 | +3. `onRunCompleted` — waits for in-flight partial updates, sends final `partialUpdateMessage({ generating: false })`, sends `ai_indicator.clear` |
| 109 | + |
| 110 | +Force-stop (`ai_indicator.stop` from client) calls `onForceStop`, which clears `generating` without overwriting the accumulated text. |
| 111 | + |
| 112 | +### Session model |
| 113 | + |
| 114 | +Each Stream Chat channel maps to exactly one OpenClaw session: |
| 115 | + |
| 116 | +``` |
| 117 | +agent:<agentId>:streamchat:channel:<channelId> |
| 118 | +``` |
| 119 | + |
| 120 | +This is achieved by passing `peer: { kind: "channel", id: channelId }` to `resolveAgentRoute`. The "channel" peer kind bypasses the `dmScope` logic and always builds per-channel keys. Do not use `peer.kind: "direct"` — with the framework default of `dmScope: "main"`, all direct-peer messages collapse into a single shared session (`agent:main:main`), so all channels would share one conversation context. |
| 121 | + |
| 122 | +All messages in a channel — main feed and threads — go to the same session. Thread context is injected into the prompt via `buildEnvelope` wrappers, not via separate sessions. This preserves cross-thread LLM context. |
| 123 | + |
| 124 | +### Multi-account support |
| 125 | + |
| 126 | +Config supports a flat default account or named sub-accounts: |
| 127 | + |
| 128 | +```jsonc |
| 129 | +"channels": { |
| 130 | + "streamchat": { |
| 131 | + "apiKey": "...", // default account |
| 132 | + "accounts": { |
| 133 | + "workspace-b": { "apiKey": "..." } // named account |
| 134 | + } |
| 135 | + } |
| 136 | +} |
| 137 | +``` |
| 138 | + |
| 139 | +`resolveStreamChatAccount(cfg, accountId)` merges the named account config over the base config. Each account gets its own `StreamChatClientRuntime`, `RunContextMap`, and `StreamingHandler` instance (created in `gateway.startAccount`). |
| 140 | + |
| 141 | +## Key design decisions |
| 142 | + |
| 143 | +- **Bot token in config, secret is not.** The API secret is only used in `scripts/generate-bot-token.ts` to mint a JWT. Only the resulting token is stored in `openclaw.json`. |
| 144 | +- **`deliver` callback vs. completion signal.** `dispatchReplyWithBufferedBlockDispatcher` signals completion by resolving its promise, not by passing an `isComplete` flag. The `info.kind` parameter (`"tool" | "block" | "final"`) distinguishes delivery type. `onRunCompleted` is called after the dispatcher awaits. The `ReplyPayload` type has `text` and `isError` as the only relevant fields — there is no `markdown`, `isComplete`, or `toolName` field, despite what seems intuitive. |
| 145 | +- **Partial updates are chained via `lastUpdatePromise`.** Each `partialUpdateMessage` is `.then()`-chained onto the previous one to avoid out-of-order message text. |
| 146 | +- **`safeSendEvent` swallows errors.** Indicator events are best-effort; a failed `ai_indicator` update must not abort message delivery. Retries: 5 attempts, exponential backoff starting at 100 ms, only on 429/5xx. |
| 147 | +- **`seenThreads` is process-scoped.** The `Set<string>` tracking "first message in thread" lives at module level, so it persists across gateway reloads until the process restarts. This is intentional — it avoids re-sending parent context for active threads after a config reload. |
0 commit comments