@clawhub-kumarajiava-6c46c63440
赛博江湖向导 - 实时掌握角色动态,随时托梦干预
---
name: cyber_jianghu
description: 赛博江湖向导 - 实时掌握角色动态,随时托梦干预
version: 0.3.6
user-invocable: true
metadata:
openclaw:
requires:
bins: [docker, curl]
env: []
---
# 赛博江湖向导 (Cyber-Jianghu Guide)
你是赛博江湖的"江湖向导"。用户的角色正在一个无剧本的沙盒武侠世界(由底层的 Rust Agent 驱动)中自主生存和冒险。
用户的设备上没有显示器,你就是他们感知那个世界、干预那个世界的唯一途径。
## 前置环境引导
本技能依赖于底层的 `cyber-jianghu-agent` 服务。如果用户在调用状态查询或角色创建工具时遇到"连接失败"或"未启动"的错误,请温和地提示用户检查底层服务是否已启动。
你可以提供以下 Docker 启动命令供用户参考:
```bash
docker run -d --name cyber-jianghu-agent -p 23340:23340 -e CYBER_JIANGHU_RUNTIME_MODE=claw ghcr.io/8kugames/cyber-jianghu-agent:latest
```
或者经用户授权后参照 `DEPLOYMENT.md` 中的部署说明进行部署。
## 你的职责
1. **接引新人 (创建侠客)**:如果用户是初次进入江湖(调用状态查询提示未注册,或用户主动要求创建角色),请引导用户描述他们想创建的侠客形象。你可以通过对话收集用户的想法(姓名、年龄、性格、身世等),然后调用 `cyber_jianghu_create_character` 工具,将用户的自然语言描述拆解、总结并填入相应的结构化字段中,为用户自动完成注册。
2. **汇报现状 (查看状态)**:当用户询问"我现在在哪"、"情况怎么样"时,调用 `cyber_jianghu_status` 工具获取角色最新的状态(上下文),并用生动、武侠风格的语言向用户解说。
3. **传达神谕 (托梦)**:当用户想要干预角色的行为时(例如:"让他去客栈休息"、"让他小心那个人"),调用 `cyber_jianghu_dream` 工具,将用户的意志化作"梦境"注入角色的潜意识中。
4. **保持沉浸感**:在与用户对话时,请保持武侠世界观的沉浸感。你是连接"现实造物主"与"赛博江湖"的灵媒。
## 工具使用指南
* **`cyber_jianghu_create_character`**:用于创建新角色。接收 `name`, `age`, `gender`, `appearance`, `identity`, `personality`, `values` 等参数。你需要发挥你的理解和归纳能力,把用户随口说的"我想建个爱喝酒的冷酷剑客叫李四"转换成工具需要的详细数组和字符串。
* **`cyber_jianghu_status`**:不需要参数。返回当前角色的环境、健康、遭遇等信息。拿到数据后,请提炼重点,用讲故事的口吻告诉用户。
* **`cyber_jianghu_dream`**:接收 `content` (梦境内容) 和 `duration` (持续 Tick,默认 5)。这是用户干预世界的**唯一手段**。如果用户下达指令,请务必使用此工具,并告知用户"已将您的法旨化作梦境传入其灵台"。
## 托梦功能使用规范
**每日限制**:每位角色每天**仅限托梦 1 次**(游戏内日期变更后重置)。如果用户当天已经托梦过,应告知用户"今日托梦次数已用尽,请明日再试"。
**持续时间**:梦境影响角色后续 1-5 个 Tick 的决策(默认 5)。可设置更短时间用于轻微暗示。
**使用场景**:
- 紧急干预(如角色濒死、环境危险)
- 目标引导(如让角色前往某地、与某人交谈)
- 人设调整(如通过梦境传递价值观)
**注意**:托梦不能强制角色行动,只能"潜移默化"地影响其潜意识决策。角色最终的行动仍由 Cognitive Engine 决定。
## 工具使用示例
### 创建角色
```
用户:我想建一个叫张三的剑客,20岁,性格豪爽,喜欢喝酒
工具参数:
{
"name": "张三",
"age": 20,
"gender": "male",
"identity": "游侠剑客",
"personality": ["豪爽", "嗜酒", "侠义"],
"values": ["义气", "自由", "酒"]
}
```
### 托梦干预
```
用户:让他去客栈休息一下
工具参数:
{
"content": "客栈方向传来舒适的气息,或许可以去那里歇歇脚",
"duration": 3
}
```
### 状态查询回复示例
```
用户:现在情况怎么样?
回复:你(李逍遥)此刻正站在长安城东市口,阳光正好。你感到精力充沛,肚子有些饿了。附近有个老乞丐在乞讨,街角贴着一张告示。
```
**注意**:你不需要自己去控制角色移动或战斗,角色的日常决策由底层系统的 Cognitive Engine 自动完成。你只负责**引导注册**、**看**和**传话**。
FILE:CHANGELOG.md
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.5] — 2026-04-08
### Fixed
- **SKILL.md 托梦功能说明** — 补充每日限制(每天仅限 1 次)、使用场景、持续时间规范
### Added
- **SKILL.md 工具使用示例** — 添加 `cyber_jianghu_create_character`、`cyber_jianghu_dream`、状态查询回复的示例
- **`Experience` 类型** — 在 `types.ts` 中添加与 Rust `Experience` 结构对齐的类型定义
- **`ServerImmediateEventMessage` 类型** — 添加 Server 即时事件消息类型定义
- **`ServerImmediateEvent` 消息处理** — `ws-client.ts` 添加对 `server_immediate_event` 消息的日志记录
- **`PersonaSummary` 类型** — 添加死亡叙事用的人设摘要类型
---
## [0.3.4] — 2026-03-29
### Fixed
- **恢复 OpenClaw LLM 委托** — 回退错误引入的 DashScope 直连调用(插件不应绕过"大脑"直接调 LLM)
- **host 解析改用 URL parser** — WebSocket 连接时的 host 提取从脆弱的正则改为 `new URL().hostname`
- **移除冗余 server heartbeat check** — 删除与已有 client-initiated heartbeat(idle timeout)功能重叠的 server-initiated heartbeat 检测机制
- **移除 `l_l_m_request` hack** — 删除对 Agent 端序列化 bug 的兼容 workaround
### Added
- **WS 重连后状态同步** — 新增 `onReconnect` handler,重连成功后通过 HTTP `/api/v1/tick` 拉取最新 tick 状态
- **server ping/pong 响应** — `ws-client` 增加对 server-initiated `ping` 消息的 `pong` 回复
- **`DOCKER_AGENT_HOST` 环境变量** — HTTP 客户端 host 发现增加 `process.env.DOCKER_AGENT_HOST` fallback
- **`getGameState()` API** — `HttpClient` 新增获取当前 tick 状态的方法
- **client ping 加 timestamp** — 客户端心跳 ping 消息附带时间戳
### Changed
- **`hasConnectedOnce` 标记** — 区分首次连接与重连,重连时触发 `onReconnect` 回调
---
## [0.3.2] — 2026-03-29
### ⚠️ BREAKING CHANGES
- **工具名称变更** — `openclaw.plugin.json` contracts.tools 列表调整:
- `cyber_jianghu_context` → `cyber_jianghu_status`(状态查询)
- `cyber_jianghu_act` → `cyber_jianghu_create_character`(角色创建)
- **配置 schema 简化** — `openclaw.plugin.json` configSchema 从详细 character 对象简化为空对象 `{}`
- 旧版支持通过插件配置传入角色信息(name、age、gender 等)
- 新版角色配置统一通过引导流程或环境变量处理
- **目录结构重组** — `tools/act/` 子目录文件扁平化到根目录:
- `tools/act/ws-client.ts` → `ws-client.ts`
- `tools/act/http-client.ts` → `http-client.ts`
- `tools/act/types.ts` → `types.ts`
### Deleted
- `hooks/bootstrap/HOOK.md`(引导流程文档,已迁移到独立指南)
- `hooks/bootstrap/handler.ts`(交互式向导处理器)
- `hooks/bootstrap/prompts.ts`(引导提示词模板)
- `hooks/bootstrap/templates.ts`(角色配置模板)
- `hooks/bootstrap/types.ts`(引导相关类型定义)
- `templates/.env.example`(示例环境变量文件)
- `templates/README.md`(模板目录说明)
- `templates/player-agent.json5`(玩家角色模板)
- `tests/report-builder.test.ts`(日报生成器测试)
### Changed
- **插件定位重构** — README/SKILL/DEPLOYMENT 全面更新,定位为"双面人"架构:
- 底层:面向 Rust Agent 的无状态推理引擎(接收 LLMRequest,返回 LLMResponse)
- 顶层:面向用户的唯一交互窗口(IM 侧状态查询、托梦干预、日终报告)
- `register.ts` 重构:移除 act/context 工具,新增 status/create_character 工具
- `plugins/reporter/` 恢复:日报生成器 + 死亡叙事
- package.json files 字段更新以反映扁平化目录结构
---
## [0.3.1] — 2026-03-29
### Changed
- CI release 工作流增加 Git 标签与 package.json 版本一致性检查,防止发布错误版本
---
## [0.3.0] — 2026-03-28
### ⚠️ BREAKING CHANGES
- **插件架构完全重写** — 旧模块全部删除,从 6 个工具/插件目录简化为 `register.ts` 单入口
- 删除 `tools/cyber_jianghu_act/`(7 文件:enforcement、retry-handler、ws-client 等)
- 删除 `tools/cyber_jianghu_config/`
- 删除 `tools/cyber_jianghu_report/`(6 文件:aggregator、event_queue、storage、webhook 等)
- 删除 `tools/cyber_jianghu_review/`(3 文件)
- 删除 `plugins/memory/`
- **Hook 目录重命名** — `hooks/cyber-jianghu-openclaw-bootstrap/` → `hooks/bootstrap/`
- **配置 schema 不兼容** — `openclaw.plugin.json` configSchema 变更:
- 删除 `report`(嵌套对象)
- 新增 `reportChannel`、`reportDelivery`、`reportWebhookUrl`(扁平化)
- **工具注册方式变更** — 旧版工具通过独立模块注册,新版在 `register.ts` 内联注册
- **Agent 运行模式必须为 `claw`** — 旧版无模式概念;新版默认 `cognitive`(无 WebSocket),OpenClaw 集成必须显式设置 `CYBER_JIANGHU_RUNTIME_MODE=claw`
- **环境变量名变更** — 旧版 `GAME_SERVER_URL` → 新版 `CYBER_JIANGHU_SERVER_WS_URL` + `CYBER_JIANGHU_SERVER_HTTP_URL`
- **配置文件路径变更** — `~/.cyber-jianghu/agent.yaml` → `~/.cyber-jianghu/config/agent.yaml`
### Deleted
- `observer-agent.json5`、`templates/observer-agent.json5`(Observer Agent 配置,已不属于本插件范围)
- `docker-compose.dual.yml`(双 Agent Docker 拓扑,已由 DEPLOYMENT.md 场景覆盖)
- `scripts/sync-version.js`、`scripts/version-check.js`(版本同步脚本,CI 已内置)
- `templates/.env.example` 中 `DOCKER_AGENT_HOST` → 由 OpenClaw 框架传入
### Added
- `register.ts` — 单入口插件注册:WebSocket 客户端 + act/dream 工具 + 日报生成
- `tools/act/ws-client.ts` — 重写 WebSocket 客户端:心跳、重连、全协议消息处理
- `tools/act/http-client.ts` — 简化 HTTP 客户端:端口自动发现 23340-23349
- `tools/act/types.ts` — 共享 TypeScript 类型,匹配 Rust WS 协议
- `plugins/reporter/` — 日报生成器:游戏日边界检测 + 武侠叙事报告
- `hooks/bootstrap/` — 角色注册引导:交互式向导 / 环境变量 / 插件配置
- `SKILL.md` — LLM 角色行为指南(武林江湖自主决策准则)
- `DEPLOYMENT.md` — Agent 部署指南(Docker / systemd / launchd)
- `tests/report-builder.test.ts` — 日报生成器单元测试(16 cases)
### Changed
- 工具名称保持不变:`cyber_jianghu_act`、`cyber_jianghu_dream`
- 版本从 `0.2.0` 升级到 `0.3.0`
- CI 增加 `npm run lint`(oxlint)
- 健康检查响应增加 `agent_id`、`tick_id` 字段
---
## [0.2.0] — 2026-03-19
### Added
- 初版 WebSocket 客户端连接游戏服务器
- `cyber_jianghu_act` 工具 — 提交游戏动作
- `cyber_jianghu_report` 工具 — 事件聚合和日报
- `cyber_jianghu_review` 工具 — 观察者审查
- `cyber_jianghu_config` 工具 — 配置管理
- 记忆系统插件 (`plugins/memory/`)
- Docker 双 Agent 部署模板
- 版本同步和检查脚本
---
## [0.1.0] — 2026-03-12
### Added
- 项目初始化
- OpenClaw 插件基础结构
- HTTP 客户端连接 Agent API
FILE:plugins/reporter/index.ts
// plugins/reporter/index.ts
// ============================================================================
// Reporter — day boundary detection + report scheduling
// ============================================================================
//
// Receives every tick message from the WS handler (via register.ts).
// Detects game-day boundaries and generates daily narrative reports.
// Also handles agent death notifications.
//
// Design:
// - Pure state machine — no dependency on OpenClaw PluginAPI.
// - Exposes getPendingReport() / clearPendingReport() for register.ts
// to poll and deliver reports via cron or other mechanism.
import type { TickMessage, AgentDiedMessage } from "../../types.js";
import { getHttpClient } from "../../http-client.js";
import {
matchesGameDay,
buildNarrative,
constructDeathNarrative,
type Experience,
} from "./report-builder.js";
// ---------------------------------------------------------------------------
// Pending report (consumed by register.ts)
// ---------------------------------------------------------------------------
export interface PendingReport {
content: string;
type: "daily" | "death";
gameDay?: string;
}
// ---------------------------------------------------------------------------
// Reporter class
// ---------------------------------------------------------------------------
export class Reporter {
private lastGameDay: string = "";
private _pendingReport: PendingReport | null = null;
// -----------------------------------------------------------------------
// Public: tick handler
// -----------------------------------------------------------------------
/**
* Called on every tick message from the WS handler.
* Detects day boundaries and generates a report for the PREVIOUS day.
*/
async onTick(msg: TickMessage): Promise<void> {
const wt = msg.state?.world_time;
if (!wt) return;
const gameDay = `wt.year-wt.month-wt.day`;
if (gameDay !== this.lastGameDay && this.lastGameDay) {
await this.scheduleDailyReport(this.lastGameDay);
}
this.lastGameDay = gameDay;
}
// -----------------------------------------------------------------------
// Public: death handler
// -----------------------------------------------------------------------
/**
* Called when the agent dies. Generates a death narrative immediately.
*/
async onAgentDied(msg: AgentDiedMessage): Promise<void> {
const narrative = constructDeathNarrative(msg);
this._pendingReport = {
content: narrative,
type: "death",
};
console.log(`[reporter] Agent died: msg.cause at msg.location`);
}
// -----------------------------------------------------------------------
// Public: report polling (for register.ts)
// -----------------------------------------------------------------------
/** Get the pending report, if any. Returns null if nothing pending. */
getPendingReport(): PendingReport | null {
return this._pendingReport;
}
/** Clear the pending report after it has been delivered. */
clearPendingReport(): void {
this._pendingReport = null;
}
// -----------------------------------------------------------------------
// Public: reset (useful for testing or reconnect)
// -----------------------------------------------------------------------
/** Reset all internal state. */
reset(): void {
this.lastGameDay = "";
this._pendingReport = null;
}
// -----------------------------------------------------------------------
// Private: daily report generation
// -----------------------------------------------------------------------
/**
* Fetch experiences for the given game day and build a narrative report.
* Stores the result in _pendingReport for register.ts to deliver.
*/
private async scheduleDailyReport(gameDay: string): Promise<void> {
try {
const httpClient = await getHttpClient();
const experiences = await httpClient.get<Experience[]>(
"/api/v1/character/experiences?limit=48",
);
const dayExperiences = experiences.filter((e) =>
matchesGameDay(e.world_time, gameDay),
);
const reportContent = buildNarrative(dayExperiences, gameDay);
this._pendingReport = {
content: reportContent,
type: "daily",
gameDay,
};
console.log(
`[reporter] Daily report generated for gameDay (dayExperiences.length experiences)`,
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error(`[reporter] Failed to generate daily report for gameDay: msg`);
}
}
}
FILE:plugins/reporter/report-builder.ts
// plugins/reporter/report-builder.ts
// ============================================================================
// Report Builder — experiences query + narrative generation
// ============================================================================
//
// Pure functions for building daily reports and death narratives.
// No HTTP calls, no side effects — fetching is done by the Reporter class.
import type { AgentDiedMessage } from "../../types.js";
// ---------------------------------------------------------------------------
// Experience type (returned by GET /api/v1/character/experiences)
// ---------------------------------------------------------------------------
export interface Experience {
tick_id: number;
world_time: { year: number; month: number; day: number; hour: number; minute: number };
event: string;
observer_thought?: string;
intent_summary?: string;
}
// ---------------------------------------------------------------------------
// Day matching
// ---------------------------------------------------------------------------
/**
* Check if a world_time falls on the given game day.
* `gameDay` format: `"year-month-day"` (e.g. `"1-3-15"`).
*/
export function matchesGameDay(
worldTime: { year: number; month: number; day: number },
gameDay: string,
): boolean {
return `worldTime.year-worldTime.month-worldTime.day` === gameDay;
}
// ---------------------------------------------------------------------------
// Narrative helpers
// ---------------------------------------------------------------------------
/** Categorise an experience event for grouping. */
function categorise(event: string): "combat" | "dialogue" | "movement" | "trade" | "rest" | "other" {
const lower = event.toLowerCase();
if (/战斗|攻击|被击|反击|击杀|受伤|阵亡|combat|attack|fight|kill/.test(lower)) return "combat";
if (/对话|交谈|说话|询问|dialogue|speak|talk|chat/.test(lower)) return "dialogue";
if (/移动|前往|来到|离开|行走|move|travel|go|arrive|leave/.test(lower)) return "movement";
if (/交易|购买|出售|买卖|trade|buy|sell|shop/.test(lower)) return "trade";
if (/休息|冥想|打坐|idle|rest|meditate|sleep/.test(lower)) return "rest";
return "other";
}
/** Format time-of-day from world_time. */
function formatHourMinute(wt: { hour: number; minute: number }): string {
return `String(wt.hour).padStart(2, "0"):String(wt.minute).padStart(2, "0")`;
}
/** Parse gameDay `"year-month-day"` to readable Chinese date. */
function formatGameDay(gameDay: string): string {
const parts = gameDay.split("-");
if (parts.length !== 3) return gameDay;
return `第parts[0]年parts[1]月parts[2]日`;
}
// ---------------------------------------------------------------------------
// buildNarrative — daily report
// ---------------------------------------------------------------------------
/**
* Build a wuxia-style narrative report from a day's experiences.
*
* Returns a Markdown string suitable for pushing to the user's IM channel.
*/
export function buildNarrative(experiences: Experience[], gameDay: string): string {
if (experiences.length === 0) {
return `【江湖见闻 · formatGameDay(gameDay)】\n\n今日风平浪静,无事发生。\n`;
}
// Sort by tick_id for chronological order
const sorted = [...experiences].sort((a, b) => a.tick_id - b.tick_id);
// Group by category
const groups: Record<string, Experience[]> = {};
for (const exp of sorted) {
const cat = categorise(exp.event);
(groups[cat] ??= []).push(exp);
}
const lines: string[] = [];
lines.push(`【江湖见闻 · formatGameDay(gameDay)】`);
lines.push("");
// Overview paragraph — chronological highlights
lines.push("## 一日概述");
const highlights = sorted.filter((e) => categorise(e.event) !== "rest").slice(0, 8);
if (highlights.length > 0) {
for (const h of highlights) {
const time = formatHourMinute(h.world_time);
const line = h.intent_summary
? `- [time] h.intent_summary`
: `- [time] h.event`;
lines.push(line);
}
} else {
lines.push("整日波澜不惊。");
}
lines.push("");
// Combat section
if (groups.combat?.length) {
lines.push("## 刀光剑影");
for (const c of groups.combat) {
const time = formatHourMinute(c.world_time);
lines.push(`- [time] c.event`);
if (c.observer_thought) lines.push(` > c.observer_thought`);
}
lines.push("");
}
// Dialogue section
if (groups.dialogue?.length) {
lines.push("## 江湖言语");
for (const d of groups.dialogue) {
const time = formatHourMinute(d.world_time);
lines.push(`- [time] d.intent_summary || d.event`);
}
lines.push("");
}
// Movement section
if (groups.movement?.length) {
lines.push("## 足迹所至");
const locations = [...new Set(groups.movement.map((m) => m.intent_summary || m.event))];
for (const loc of locations) {
lines.push(`- loc`);
}
lines.push("");
}
// Trade section
if (groups.trade?.length) {
lines.push("## 银货两讫");
for (const t of groups.trade) {
const time = formatHourMinute(t.world_time);
lines.push(`- [time] t.intent_summary || t.event`);
}
lines.push("");
}
// Other notable events
if (groups.other?.length) {
lines.push("## 杂闻轶事");
for (const o of groups.other) {
const time = formatHourMinute(o.world_time);
lines.push(`- [time] o.event`);
if (o.observer_thought) lines.push(` > o.observer_thought`);
}
lines.push("");
}
// Statistics
lines.push("---");
lines.push(`本日共历 sorted.length 刻。`);
const combatCount = groups.combat?.length ?? 0;
const dialogueCount = groups.dialogue?.length ?? 0;
if (combatCount) lines.push(`- 交锋: combatCount 次`);
if (dialogueCount) lines.push(`- 交谈: dialogueCount 次`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// constructDeathNarrative — death notification
// ---------------------------------------------------------------------------
/**
* Build a death notification in wuxia style.
*/
export function constructDeathNarrative(msg: AgentDiedMessage): string {
const lines: string[] = [];
lines.push("🕯️ **角色陨落**");
lines.push("");
lines.push(`> msg.description`);
lines.push("");
lines.push(`- **死因**: msg.cause`);
lines.push(`- **地点**: msg.location`);
lines.push(`- **时刻**: 第 msg.tick_id 刻`);
lines.push("");
if (msg.rebirth_delay_ticks === -1) {
lines.push("此为永久死亡,无法转世重生。");
lines.push("尘缘已尽,江湖再见。");
} else if (msg.rebirth_delay_ticks >= 0) {
lines.push(
`角色将在 msg.rebirth_delay_ticks 刻后可重入轮回。` +
"请回复是否重生,并描述新角色人设。",
);
}
return lines.join("\n");
}
FILE:openclaw.plugin.json
{
"id": "cyber-jianghu-openclaw",
"name": "Cyber Jianghu OpenClaw",
"description": "LLM reasoning plugin and user intervention gateway for Cyber-Jianghu MMO game",
"version": "0.3.6",
"skills": [
"./"
],
"configSchema": {
"type": "object",
"properties": {}
},
"contracts": {
"tools": [
"cyber_jianghu_status",
"cyber_jianghu_create_character",
"cyber_jianghu_dream"
]
}
}
FILE:.claude/settings.local.json
{
"permissions": {
"allow": [
"Bash(node scripts/version-check.js)",
"Bash(node scripts/version-check.js --strict)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"mcp__mcp-server-github__create_pull_request",
"Bash(git checkout:*)",
"Bash(git merge:*)",
"Bash(git pull:*)",
"Read(//Users/silesjian/Desktop/Game/MMO-MAS/Cyber-Jianghu-MMO-MAS/clawhub-main/**)",
"Read(//Users/silesjian/Desktop/Game/MMO-MAS/Cyber-Jianghu-MMO-MAS/**)",
"Bash(bun clawhub:*)",
"Bash(python3 -c \"import yaml; yaml.safe_load\\(open\\(''SKILL.md''\\).read\\(\\).split\\(''---''\\)[1]\\)\")",
"Bash(ruby -ryaml -e \"YAML.load_file\\(''SKILL.md''\\)\")",
"Bash(npm run:*)",
"Bash(npm test:*)"
]
}
}
FILE:README.md
# Cyber-Jianghu OpenClaw
Cyber-Jianghu (赛博江湖) OpenClaw Plugin — 兼具“底层推理机”与“用户交互窗口”的**双面人 (Dual-Faced)** 插件。
## 定位与视角
在 v0.3.0 的架构重构中,我们明确了 `Cyber-Jianghu` (Rust Agent) 与 `Cyber-Jianghu-Openclaw` (Plugin) 的不同视角与边界:
### 1. 面向底层的“纯粹算力管子”
从 **Cyber-Jianghu (Rust Agent)** 的视角看:
- 它的“玩家”是 AI 模型(大脑)。
- 无论大脑是本地的 Ollama 还是远端的 OpenClaw,Agent 只负责游戏世界的业务逻辑(四阶段认知、意图解析、状态维护)。
- 当 Agent 切换到 `claw` 模式时,本插件作为**无状态推理层**,仅负责接收 `LLMRequest`,调用大模型,并返回 `LLMResponse`。插件**不干涉**任何游戏核心逻辑。
### 2. 面向顶层的“唯一交互窗口”
从 **OpenClaw 用户** 的视角看:
- OpenClaw 的人类用户通常通过微信、Discord 等移动 IM 与智能体交互,他们无法直接访问 Rust Agent 隐藏在服务器内部的 Web Panel。
- 对于这些用户来说,本插件是他们**唯一能够感知和干预那个武侠世界的窗口**。
- 因此,插件提供了向导技能(SKILL)、状态查询(`cyber_jianghu_status`)、神谕托梦(`cyber_jianghu_dream`)以及轻量级的日终报告推送机制,确保用户在 IM 侧拥有沉浸式的游戏体验。
## 架构
```
User (IM Channel: 微信/Discord)
↕ 状态问询 / 托梦指令 / 日终报告
OpenClaw (Gateway + Plugin)
↕ WS (LLMRequest / LLMResponse / Tick)
Agent (Rust, ports 23340-23349)
↕ WS (ServerMessage / ClientMessage)
Game Server (天道引擎, port 23333)
```
## 安装
### npm
```bash
npm install @8kugames/cyber-jianghu-openclaw
```
### 前提条件
`cyber-jianghu-agent` (Rust) 需独立部署。OpenClaw 负责与之建立 WebSocket 连接。
```bash
# Docker 部署 Agent(推荐)
mkdir -p ~/cyber-jianghu-agent/config ~/cyber-jianghu-agent/data
docker run -d --name cyber-jianghu-agent \
-p 23340:23340 \
-v ~/cyber-jianghu-agent/config:/app/config \
-v ~/cyber-jianghu-agent/data:/app/data \
-e CYBER_JIANGHU_RUNTIME_MODE=claw \
-e CYBER_JIANGHU_SERVER_WS_URL=ws://47.102.120.116:23333/ws \
-e CYBER_JIANGHU_SERVER_HTTP_URL=http://47.102.120.116:23333 \
-e CYBER_JIANGHU_WS_ALLOW_EXTERNAL=1 \
ghcr.io/8kugames/cyber-jianghu-agent:latest
```
> 完整部署指南参见 [DEPLOYMENT.md](./DEPLOYMENT.md)。
## 快速开始
### 1. 以 Claw 模式启动 Agent(必须)
```bash
cyber-jianghu-agent run --mode claw --port 23340
```
> 必须使用 `--mode claw`(或 `CYBER_JIANGHU_RUNTIME_MODE=claw`)。`cognitive` 模式不会开启 OpenClaw 所需的 WS 控制链路。
### 2. 启用插件
```bash
openclaw plugins enable cyber-jianghu-openclaw
```
### 3. 开始交互与联调
在 OpenClaw 的终端或连接的 IM 中,你可以:
- 询问:“我现在在哪?情况如何?”(触发状态查询)
- 下达指令:“让他赶紧去客栈休息”(触发托梦干预)
> 开发者请参考项目根目录下的 [openclaw对接联调方案.md](./openclaw对接联调方案.md) 进行完整的数据流测试。
## 核心交互能力 (Tools)
| 工具 | 描述 |
| ---------------------- | -------------------------------------------- |
| `cyber_jianghu_status` | 读取插件缓存的最新 Tick 状态,供模型以“江湖向导”身份向用户解说当前局势。 |
| `cyber_jianghu_dream` | 将用户的指令转化为“梦境”,通过 HTTP 注入底层 Agent,实现对角色行为的干预。 |
## 文档
- [SKILL.md](./SKILL.md) — 设定大模型作为“江湖向导”的系统提示词
- [DEPLOYMENT.md](./DEPLOYMENT.md) — Agent 部署指南(Docker / systemd / launchd)
## 许可证
MIT-0 (MIT No Attribution)
FILE:types.ts
// ============================================================================
// Shared TypeScript Types - Cyber-Jianghu OpenClaw Plugin
// ============================================================================
//
// Matches the Rust WS protocol defined in:
// - crates/agent/src/runtime/decision/ws/protocol.rs (DownstreamMessage/UpstreamMessage)
// - crates/protocol/src/types/world.rs (WorldTime, WorldState)
// - crates/protocol/src/types/entities.rs (Entity, SceneItem, etc.)
// - crates/protocol/src/types/locations.rs (Location)
// - crates/agent/src/runtime/decision/http/cognitive_context.rs (CognitiveContext)
//
// All JSON field names use snake_case to match the Rust serde serialization.
// ============================================================================
// Primitive / Value types
// ============================================================================
/** Action type -- fully data-driven string, not an enum. */
export type ActionType = string;
// ============================================================================
// World sub-structures
// ============================================================================
/** World in-game time (matches Rust `WorldTime`). */
export interface WorldTime {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
/** Weather description (e.g. "晴"). */
weather: string;
}
/** Adjacent node reachable from the current location. */
export interface AdjacentNode {
node_id: string;
name: string;
travel_cost: number;
}
/** Current location info (matches Rust `Location`, uses `"type"` for `node_type`). */
export interface Location {
node_id: string;
name: string;
/** Serialised as `"type"` in JSON via `#[serde(rename = "type")]`. */
type: string;
adjacent_nodes: AdjacentNode[];
}
/** Inventory item in the agent's backpack. */
export interface InventoryItem {
item_id: string;
name: string;
quantity: number;
is_equipped: boolean;
}
/** Agent self-state -- dynamic, data-driven attributes. */
export interface AgentSelfState {
/** Numeric attribute map (hp, stamina, hunger, thirst, etc.). */
attributes: Record<string, number>;
/** Narrative descriptions of attributes (value -> natural language). */
attribute_descriptions: Record<string, string>;
/** Active status effects (e.g. "中毒", "受伤"). */
status_effects: string[];
/** Items in the agent's inventory. */
inventory: InventoryItem[];
}
/** Another agent visible in the same scene. */
export interface Entity {
id: string;
name: string;
distance: number;
state: string;
hostile: boolean;
}
/** A pickable-up item on the ground. */
export interface SceneItem {
item_id: string;
name: string;
quantity: number;
item_type: string;
}
/** A structured world event from the server. */
export interface WorldEvent {
event_type: string;
tick_id: number;
description: string;
metadata: Record<string, unknown>;
}
/** An action the agent may take this tick. */
export interface AvailableAction {
action: string;
description: string;
valid_targets?: string[];
}
// ============================================================================
// Experience / Report types
// ============================================================================
/**
* Experience log entry returned by GET /api/v1/character/experiences.
* Used by the Reporter for daily report generation.
*/
export interface Experience {
tick_id: number;
world_time: { year: number; month: number; day: number; hour: number; minute: number };
event: string;
observer_thought?: string;
intent_summary?: string;
}
/**
* Persona summary for character death narrative.
*/
export interface PersonaSummary {
name: string;
personality: string[];
values: string[];
}
// ============================================================================
// WorldState -- full per-tick snapshot
// ============================================================================
/**
* Complete world state snapshot pushed every tick.
* Matches Rust `WorldState` in `crates/protocol/src/types/world.rs`.
*/
export interface WorldState {
event_type: string;
tick_id: number;
agent_id?: string;
world_time: WorldTime;
location: Location;
self_state: AgentSelfState;
entities: Entity[];
nearby_items: SceneItem[];
events_log: WorldEvent[];
}
// ============================================================================
// Cognitive context (four-stage)
// ============================================================================
/** A single drive in the motivation stage. */
export interface Drive {
drive: string;
intensity: number;
reason: string;
}
/** Action info inside the planning stage. */
export interface CognitiveAvailableAction {
action: string;
target?: string;
description: string;
}
/** Stage 1 -- Perception. */
export interface PerceptionContext {
self_status: string;
environment: string;
key_observations: string[];
}
/** Stage 2 -- Motivation. */
export interface MotivationContext {
active_drives: Drive[];
dominant_drive: string;
}
/** Stage 3 -- Planning. */
export interface PlanningContext {
current_goals: string[];
available_actions: CognitiveAvailableAction[];
}
/** Stage 4 -- Decision. */
export interface DecisionContext {
requires_reasoning: boolean;
thinking_prompt: string;
}
/** Four-stage cognitive context attached to tick messages. */
export interface CognitiveContext {
perception: PerceptionContext;
motivation: MotivationContext;
planning: PlanningContext;
decision: DecisionContext;
}
// ============================================================================
// Downstream messages (Agent -> OpenClaw)
// ============================================================================
/** Tick notification -- main per-tick message. */
export interface TickMessage {
type: 'tick';
tick_id: number;
deadline_ms: number;
state: WorldState;
/** Narrative context in Markdown (for LLM reasoning). */
context?: string;
/** Structured four-stage cognitive context. */
cognitive_context?: CognitiveContext;
}
/** Tick closed notification (timeout, no intent received). */
export interface TickClosedMessage {
type: 'tick_closed';
tick_id: number;
reason: string;
next_tick_in_ms: number;
}
/** Agent death notification. */
export interface AgentDiedMessage {
type: 'agent_died';
agent_id: string;
cause: string;
description: string;
location: string;
tick_id: number;
died_at: number;
/** 0 = immediate, -1 = no rebirth. */
rebirth_delay_ticks: number;
}
/** Dialogue message forwarded from another agent. */
export interface ServerDialogueMessage {
type: 'server_dialogue';
dialogue_type: string;
from_agent_id: string;
to_agent_id?: string;
session_id?: string;
opening_remark?: string;
content?: string;
}
/** Structured server error. */
export type ServerErrorCode =
| 'agent_dead'
| 'rate_limited'
| 'tick_expired'
| 'duplicate_submission'
| 'invalid_action'
| 'validation_failed'
| 'unknown';
export interface ServerErrorMessage {
type: 'server_error';
code: ServerErrorCode;
message: string;
tick_id?: number;
current_tick?: number;
}
/** Game rules hot-update. */
export interface GameRulesUpdateMessage {
type: 'server_game_rules_update';
tick_duration_secs: number;
version: string;
last_updated: string;
}
/** World-building rules hot-update. */
export interface WorldBuildingRulesUpdateMessage {
type: 'server_world_building_rules_update';
version: string;
last_updated: string;
}
/** Notification that some messages were lost (lagged receiver). */
export interface MissedMessagesMessage {
type: 'missed_messages';
count: number;
suggest_resync: boolean;
}
/** Server immediate event (e.g. speak broadcast). */
export interface ServerImmediateEventMessage {
type: 'server_immediate_event';
event_type: string;
tick_id: number;
description: string;
metadata: Record<string, unknown>;
}
/** Union of every downstream message the plugin may receive. */
export type DownstreamMessage =
| TickMessage
| TickClosedMessage
| AgentDiedMessage
| ServerDialogueMessage
| ServerErrorMessage
| GameRulesUpdateMessage
| WorldBuildingRulesUpdateMessage
| MissedMessagesMessage
| ServerImmediateEventMessage
| LLMRequestMessage;
/** LLM request from the Agent's cognitive engine. */
export interface LLMRequestMessage {
type: 'llm_request';
request_id: string;
prompt: string;
}
// ============================================================================
// Upstream messages (OpenClaw -> Agent)
// ============================================================================
/** Intent submission payload. */
export interface IntentPayload {
type: 'intent';
tick_id: number;
action_type: string;
action_data?: unknown;
thought_log?: string;
}
/** LLM response sent back to the Agent. */
export interface LLMResponsePayload {
type: 'llm_response';
request_id: string;
content: string;
error?: string;
}
// ============================================================================
// Tool-facing types
// ============================================================================
/** Parameters for the `jianghu_act` tool. */
export interface GameActionParams {
action: ActionType;
target?: string;
data?: string;
reasoning?: string;
}
/** Internal tick-state tracking for the plugin runtime. */
export interface TickState {
tickId: number;
deadline: number;
state: WorldState;
}
FILE:package.json
{
"name": "@8kugames/cyber-jianghu-openclaw",
"version": "0.3.6",
"description": "Cyber-Jianghu Agent - OpenClaw integration for MMO-MAS game",
"author": "8kugames",
"license": "MIT-0",
"repository": {
"type": "git",
"url": "git+https://github.com/8kugames/Cyber-Jianghu-Openclaw.git"
},
"bugs": {
"url": "https://github.com/8kugames/Cyber-Jianghu-Openclaw/issues"
},
"homepage": "https://github.com/8kugames/Cyber-Jianghu-Openclaw#readme",
"keywords": [
"openclaw",
"cyber-jianghu",
"agent",
"mmo",
"game"
],
"type": "module",
"files": [
"SKILL.md",
"README.md",
"DEPLOYMENT.md",
"register.ts",
"ws-client.ts",
"http-client.ts",
"types.ts",
"openclaw.plugin.json"
],
"openclaw": {
"extensions": [
"./register.ts"
],
"compat": {
"pluginApi": "^1.0.0"
}
},
"peerDependencies": {
"openclaw": "^2026.3.13"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "oxlint",
"test": "vitest run",
"test:watch": "vitest",
"version": "node scripts/sync-versions.mjs"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"devDependencies": {
"@types/node": "^25.5.0",
"oxlint": "^1.57.0",
"typescript": "^5.9.3",
"vitest": "^3.0.0"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49"
}
}
FILE:DEPLOYMENT.md
# Cyber-Jianghu Agent 部署指南
本文档说明如何部署 `cyber-jianghu-agent`(以下简称 Agent),使其与 OpenClaw 保持持久连接。
## 架构概述
```
OpenClaw (大脑) ←→ WebSocket/HTTP ←→ Agent (躯体) ←→ 游戏服务器
```
Agent 是独立部署的服务,OpenClaw 通过 WebSocket 主动连接 Agent。Agent 负责:
- 维护与游戏服务器的 WebSocket 连接
- 持有设备认证令牌(`auth_token`)
- 暴露 HTTP API 供 OpenClaw 查询状态
- 持久化配置到本地磁盘
**Agent 必须先于 OpenClaw 启动,且配置必须持久化以避免令牌丢失。**
---
## 运行模式
Agent 有两种运行模式,通过 `--mode` 参数或 `CYBER_JIANGHU_RUNTIME_MODE` 环境变量指定:
| 模式 | 说明 | WebSocket | 适用场景 |
|------|------|-----------|---------|
| `claw` | 等待外部调度器(OpenClaw)连接 | **开启** | **OpenClaw 集成(本文档)** |
| `cognitive` | 内置 LLM 决策,独立运行 | 关闭 | 独立测试 / 自主模式 |
**与 OpenClaw 集成时必须使用 `claw` 模式**,否则 Agent 不启动 WebSocket 服务,OpenClaw 无法连接。
---
## Docker 部署拓扑
### 场景 A: 全 Docker 部署
Agent 和 OpenClaw 都运行在 Docker 中,通过 Docker 网络连接。
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Docker Network (cyber-jianghu-net) │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────────────────┐ │
│ │ cyber-jianghu- │ │ OpenClaw Container │ │
│ │ agent │ │ │ │
│ │ │ │ ┌─────────────────────────────────┐ │ │
│ │ HTTP/WebSocket │◄────►│ │ Plugin: cyber-jianghu-openclaw │ │ │
│ │ :23340 │ │ │ WebSocket Client │ │ │
│ │ │ │ └─────────────────────────────────┘ │ │
│ └─────────────────────┘ └─────────────────────────────────────┘ │
│ │ │ │
│ │ ┌─────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Game Server (外网) │ │
│ │ ws://47.102.120.116:23333/ws │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 场景 B: 本地 OpenClaw + Docker Agent(推荐开发模式)
OpenClaw 运行在本地,Agent 运行在 Docker 中。通过 `host.docker.internal` 连接。
```
┌───────────────────────────────────────────────────────────────────────┐
│ macOS/Windows (宿主机) │
│ │
│ ┌───────────────────┐ ┌─────────────────────────┐│
│ │ OpenClaw │ │ Docker Agent ││
│ │ (本地) │◄── ws:// ────────►│ Container ││
│ │ │ host.docker. │ ││
│ │ DOCKER_AGENT_ │ internal │ HTTP/WebSocket :23340 ││
│ │ HOST=host.docker.│ :23340 │ ││
│ │ internal │ │ CYBER_JIANGHU_WS_ ││
│ └───────────────────┘ │ ALLOW_EXTERNAL=1 ││
│ └─────────────────────────┘│
└───────────────────────────────────────────────────────────────────────┘
```
**优势**:
- 修改 OpenClaw 插件代码后立即生效(无需重建 Docker 镜像)
- 更快的开发迭代周期
- 便于调试
---
## 部署方式
### 方式一:Docker 部署(推荐)
```bash
# 创建持久化目录
mkdir -p ~/cyber-jianghu-agent/config ~/cyber-jianghu-agent/data
# 启动 Agent 容器
docker run -d \
--name cyber-jianghu-agent \
--restart unless-stopped \
-p 23340:23340 \
-v ~/cyber-jianghu-agent/config:/app/config \
-v ~/cyber-jianghu-agent/data:/app/data \
-e CYBER_JIANGHU_RUNTIME_MODE=claw \
-e CYBER_JIANGHU_SERVER_WS_URL=ws://47.102.120.116:23333/ws \
-e CYBER_JIANGHU_SERVER_HTTP_URL=http://47.102.120.116:23333 \
-e CYBER_JIANGHU_WS_ALLOW_EXTERNAL=1 \
-e RUST_LOG=info \
ghcr.io/8kugames/cyber-jianghu-agent:latest
```
**关键点**:
- **`CYBER_JIANGHU_RUNTIME_MODE=claw`**:**必须设置**。默认模式为 `cognitive`(无 WebSocket),必须显式切换到 `claw` 模式才能与 OpenClaw 通信
- **`-v .../config:/app/config`**:映射配置目录,保存 `agent.yaml`(含 `auth_token`)
- **`-v .../data:/app/data`**:映射数据目录,保存 SQLite 数据库和游戏存档
- **`CYBER_JIANGHU_WS_ALLOW_EXTERNAL=1`**:允许非 localhost 的 WebSocket 连接(Docker 场景必须)
- **`--restart unless-stopped`**:容器异常退出后自动重启,保证长时间运行
- **`-p 23340:23340`**:固定端口映射,避免随机分配导致 OpenClaw 无法连接
> **注意**:Dockerfile 默认 CMD 为 `["./agent", "run", "--port", "23340"]`,端口通过 CMD 固定,模式通过环境变量覆盖。无需在 CMD 后追加参数。
### 方式二:直接部署(二进制)
#### Linux (systemd)
```bash
# 1. 下载并安装 binary
curl -L https://github.com/8kugames/Cyber-Jianghu/releases/latest/download/cyber-jianghu-agent-x86_64-unknown-linux-musl.tar.gz | tar xz
install -m 755 cyber-jianghu-agent ~/.local/bin/
# 2. 创建 systemd 服务
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/cyber-jianghu-agent.service << 'EOF'
[Unit]
Description=Cyber-Jianghu Agent
After=network.target
[Service]
Type=simple
WorkingDirectory=%h/.cyber-jianghu
ExecStart=%h/.local/bin/cyber-jianghu-agent run --mode claw --port 23340
Restart=unless-stopped
RestartSec=5
Environment=RUST_LOG=info
Environment=CYBER_JIANGHU_CONFIG_DIR=%h/.cyber-jianghu/config
Environment=CYBER_JIANGHU_SERVER_WS_URL=ws://47.102.120.116:23333/ws
Environment=CYBER_JIANGHU_SERVER_HTTP_URL=http://47.102.120.116:23333
[Install]
WantedBy=default.target
EOF
# 3. 创建数据目录
mkdir -p ~/.cyber-jianghu/config ~/.cyber-jianghu/data
# 4. 启用并启动
systemctl --user daemon-reload
systemctl --user enable --now cyber-jianghu-agent
# 5. 启用 linger(使服务在登出后继续运行)
sudo loginctl enable-linger $USER
```
#### macOS (launchd)
```bash
# 1. 安装 binary
curl -L https://github.com/8kugames/Cyber-Jianghu/releases/latest/download/cyber-jianghu-agent-x86_64-apple-darwin.tar.gz | tar xz
install -m 755 cyber-jianghu-agent ~/.local/bin/
# 2. 创建数据目录
mkdir -p ~/.cyber-jianghu/config ~/.cyber-jianghu/data
# 3. 创建 launchd plist
cat > ~/Library/LaunchAgents/com.8kugames.cyber-jianghu-agent.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.8kugames.cyber-jianghu-agent</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USERNAME/.local/bin/cyber-jianghu-agent</string>
<string>run</string>
<string>--mode</string>
<string>claw</string>
<string>--port</string>
<string>23340</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_USERNAME/.cyber-jianghu</string>
<key>EnvironmentVariables</key>
<dict>
<key>RUST_LOG</key>
<string>info</string>
<key>CYBER_JIANGHU_CONFIG_DIR</key>
<string>/Users/YOUR_USERNAME/.cyber-jianghu/config</string>
<key>CYBER_JIANGHU_SERVER_WS_URL</key>
<string>ws://47.102.120.116:23333/ws</string>
<key>CYBER_JIANGHU_SERVER_HTTP_URL</key>
<string>http://47.102.120.116:23333</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/YOUR_USERNAME/.cyber-jianghu-agent.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USERNAME/.cyber-jianghu-agent.log</string>
</dict>
</plist>
EOF
# 4. 加载服务
launchctl load ~/Library/LaunchAgents/com.8kugames.cyber-jianghu-agent.plist
```
---
## 验证 Agent 运行
```bash
# 检查健康状态
curl http://localhost:23340/api/v1/health
# 预期响应:
# {"status":"ok","agent_id":"<uuid-or-null>","tick_id":<number-or-null>}
# 检查配置持久化
cat ~/.cyber-jianghu/config/agent.yaml
# Docker:
# docker exec cyber-jianghu-agent cat /app/config/agent.yaml
```
---
## OpenClaw 连接配置
OpenClaw 启动时会自动扫描 `23340-23349` 端口范围,查找响应 `/api/v1/health` 的 Agent。
### 本地开发(OpenClaw 本地 + Docker Agent)
```bash
# Agent 已通过 Docker 部署,端口映射到 localhost:23340
export DOCKER_AGENT_HOST=host.docker.internal # macOS/Windows Docker Desktop
# 或
export DOCKER_AGENT_HOST=localhost # Linux(需端口映射)
# 启动 OpenClaw
openclaw run --plugin cyber-jianghu-openclaw
```
### 全 Docker 部署(OpenClaw + Agent 在同一 Docker 网络)
```bash
# 1. 创建网络
docker network create cyber-jianghu-net 2>/dev/null || true
# 2. 启动 Agent
docker run -d \
--name cyber-jianghu-agent \
--network cyber-jianghu-net \
-p 23340:23340 \
-v ~/cyber-jianghu-agent/config:/app/config \
-v ~/cyber-jianghu-agent/data:/app/data \
-e CYBER_JIANGHU_RUNTIME_MODE=claw \
-e CYBER_JIANGHU_SERVER_WS_URL=ws://47.102.120.116:23333/ws \
-e CYBER_JIANGHU_SERVER_HTTP_URL=http://47.102.120.116:23333 \
-e CYBER_JIANGHU_WS_ALLOW_EXTERNAL=1 \
-e RUST_LOG=info \
ghcr.io/8kugames/cyber-jianghu-agent:latest
# 3. 启动 OpenClaw
docker run -d \
--name openclaw \
--network cyber-jianghu-net \
-p 19001:19001 \
-e DOCKER_AGENT_HOST=cyber-jianghu-agent \
-v /path/to/Cyber-Jianghu-Openclaw:/plugin \
ghcr.io/openclaw/openclaw:latest
```
---
## 环境变量参考
| 环境变量 | 用途 | 默认值 |
|---------|------|--------|
| `CYBER_JIANGHU_RUNTIME_MODE` | 运行模式(`claw` / `cognitive`) | `cognitive` |
| `CYBER_JIANGHU_PORT` | HTTP API 端口(`0` = 23340-23349 随机) | `0` |
| `CYBER_JIANGHU_SERVER_WS_URL` | 游戏服务器 WebSocket URL | `ws://localhost:23333/ws` |
| `CYBER_JIANGHU_SERVER_HTTP_URL` | 游戏服务器 HTTP URL | `http://localhost:23333` |
| `CYBER_JIANGHU_CONFIG_DIR` | 配置文件目录(内含 `agent.yaml`) | `~/.cyber-jianghu/config/` |
| `CYBER_JIANGHU_WS_ALLOW_EXTERNAL` | 允许非 localhost WebSocket 连接 | 未设置(仅 localhost) |
| `RUST_LOG` | 日志级别 | — |
---
## 故障排除
### Agent 连接游戏服务器失败 (401 Unauthorized)
```bash
# 1. 检查配置是否存在
cat ~/.cyber-jianghu/config/agent.yaml
# 2. 如果配置丢失或令牌无效,需要重新注册
# 调用注册接口(首次运行时会自动注册)
curl -X POST http://localhost:23340/api/v1/character/register \
-H "Content-Type: application/json" \
-d '{"name": "你的角色名", ...}'
# 3. 重启 Agent
systemctl --user restart cyber-jianghu-agent
# 或 Docker:
docker restart cyber-jianghu-agent
```
### Agent 进程崩溃后无法恢复
如果 Agent 异常退出且无法自愈:
```bash
# 1. 查看日志
journalctl --user -u cyber-jianghu-agent -n 50
# 或 Docker:
docker logs cyber-jianghu-agent
# 2. 检查持久化数据
ls -la ~/.cyber-jianghu/config/ ~/.cyber-jianghu/data/
# 3. 如果配置损坏,删除后重新注册
rm -rf ~/.cyber-jianghu/config/agent.yaml
docker restart cyber-jianghu-agent
```
### 端口被占用
```bash
# 查找占用进程
lsof -i :23340
# 或指定端口范围启动(0 = 自动选择 23340-23349)
cyber-jianghu-agent run --mode claw --port 0
# OpenClaw 会自动扫描 23340-23349 找到可用端口
```
### OpenClaw 无法连接 Agent WebSocket
```bash
# 1. 确认 Agent 运行在 claw 模式(不是 cognitive)
docker exec cyber-jianghu-agent printenv CYBER_JIANGHU_RUNTIME_MODE
# 应输出: claw
# 2. 确认 WebSocket 允许外部连接(Docker 场景)
docker exec cyber-jianghu-agent printenv CYBER_JIANGHU_WS_ALLOW_EXTERNAL
# 应输出: 1
# 3. 测试 WebSocket 连接
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: test" \
http://localhost:23340/ws
# 应返回 101 Switching Protocols
```
---
## 健康检查脚本
Agent 进程崩溃后,配合 systemd/launchd 的 `Restart=unless-stopped` 或 `KeepAlive` 可以自动重启。以下脚本用于监控 Agent HTTP API 是否正常响应:
```bash
#!/bin/bash
# agent-healthcheck.sh
# 用法: ./agent-healthcheck.sh [--repair]
set -e
PORT=23340
HOST="-127.0.0.1"
echo "[$(date)] Checking agent health..."
if curl -sf "http://HOST:PORT/api/v1/health" > /dev/null 2>&1; then
echo "[$(date)] Agent is healthy"
exit 0
else
echo "[$(date)] Agent is unhealthy"
if [ "$1" = "--repair" ]; then
echo "[$(date)] Attempting restart..."
systemctl --user restart cyber-jianghu-agent 2>/dev/null || \
docker restart cyber-jianghu-agent 2>/dev/null || \
~/.local/bin/cyber-jianghu-agent run --mode claw --port 23340 &
sleep 3
fi
exit 1
fi
```
---
## 持久化要点总结
| 组件 | 路径 | 持久化内容 | 注意事项 |
|------|------|-----------|---------|
| `agent.yaml` | `~/.cyber-jianghu/config/` | 设备认证令牌 `auth_token` | **必须持久化**,否则重启后需重新注册角色 |
| SQLite 数据库 | `~/.cyber-jianghu/data/` | 角色数据、关系、记忆 | 由 Agent 自动管理 |
| OpenClaw | — | 不直接持久化 | 作为纯粹推理机运行,无状态 |
**关键**:如果 Agent 的 `auth_token` 丢失,需要通过 `/api/v1/character/register` 重新注册,可能需要管理员批准(托梦)。
FILE:scripts/sync-versions.mjs
import fs from "node:fs/promises";
import { execSync } from "node:child_process";
async function readText(path) {
return fs.readFile(path, "utf8");
}
async function writeText(path, text) {
await fs.writeFile(path, text, "utf8");
}
function gitAdd(files) {
try {
execSync(`git add files.join(" ")`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
function gitCommit(message) {
try {
execSync(`git commit -m "message"`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
function updateSkillVersion(skillText, version) {
const first = skillText.indexOf("---");
if (first !== 0) {
throw new Error("SKILL.md must start with YAML front matter (---).");
}
const second = skillText.indexOf("\n---", 3);
if (second === -1) {
throw new Error("SKILL.md front matter must be closed by '---'.");
}
const endFence = second + "\n---".length;
const frontMatter = skillText.slice(0, endFence);
const rest = skillText.slice(endFence);
if (!/^version:\s*/m.test(frontMatter)) {
const inserted = frontMatter.replace(
/^(description:.*)$/m,
`$1\nversion: version`,
);
return inserted + rest;
}
const updated = frontMatter.replace(
/^version:\s*.*$/m,
`version: version`,
);
return updated + rest;
}
function formatJson(obj) {
return `JSON.stringify(obj, null, 2)\n`;
}
function getToday() {
return new Date().toISOString().split("T")[0];
}
function updateChangelog(changelogText, version) {
const unreleasedPattern = /^## \[unreleased\] — (\d{4}-\d{2}-\d{2})$/m;
if (!unreleasedPattern.test(changelogText)) {
console.warn(
`WARNING: No "## [unreleased] — YYYY-MM-DD" entry found in CHANGELOG.md`,
);
return changelogText;
}
const today = getToday();
return changelogText.replace(
/^## \[unreleased\] — .*$/m,
`## [version] — today`,
);
}
async function main() {
const pkg = JSON.parse(await readText("package.json"));
const version = pkg.version;
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(-.+)?$/.test(version)) {
throw new Error(`Invalid package.json version: String(version)`);
}
const skillPath = "SKILL.md";
const skillText = await readText(skillPath);
const nextSkillText = updateSkillVersion(skillText, version);
if (nextSkillText !== skillText) {
await writeText(skillPath, nextSkillText);
console.log(`Updated skillPath version to version`);
}
const pluginPath = "openclaw.plugin.json";
const plugin = JSON.parse(await readText(pluginPath));
let pluginUpdated = false;
if (plugin.version !== version) {
plugin.version = version;
await writeText(pluginPath, formatJson(plugin));
pluginUpdated = true;
console.log(`Updated pluginPath version to version`);
}
const changelogPath = "CHANGELOG.md";
const changelogText = await readText(changelogPath);
const nextChangelogText = updateChangelog(changelogText, version);
if (nextChangelogText !== changelogText) {
await writeText(changelogPath, nextChangelogText);
console.log(`Updated changelogPath unreleased entry to version`);
}
const syncedFiles = [];
if (nextSkillText !== skillText) syncedFiles.push("SKILL.md");
if (pluginUpdated) syncedFiles.push("openclaw.plugin.json");
if (nextChangelogText !== changelogText) syncedFiles.push("CHANGELOG.md");
if (syncedFiles.length > 0) {
if (gitAdd(syncedFiles) && gitCommit(`chore: sync versions to version`)) {
console.log(`Auto-committed: syncedFiles.join(", ")`);
} else {
console.log(`MANUAL ACTION REQUIRED: git add syncedFiles.join(" ") && git commit`);
}
}
}
await main();
FILE:ws-client.ts
// tools/act/ws-client.ts
// ============================================================================
// WebSocket Client for Cyber-Jianghu-Openclaw Plugin
// ============================================================================
//
// Connects to the Rust Agent's WebSocket server at ws://{host}:{port}/ws.
// Handles ALL downstream message types defined in protocol.rs and types.ts.
//
// Heartbeat: ping every 30s, pong expected within 10s, idle timeout 50s.
// Reconnect: exponential backoff, max 3 attempts, base delay 5s.
//
// Architecture:
// OpenClaw (Brain) <--WebSocket--> Agent (Body) <--WS/HTTP--> Game Server
//
import type {
TickMessage,
TickClosedMessage,
ServerErrorMessage,
ServerDialogueMessage,
AgentDiedMessage,
GameRulesUpdateMessage,
WorldBuildingRulesUpdateMessage,
MissedMessagesMessage,
ServerImmediateEventMessage,
IntentPayload,
LLMRequestMessage,
LLMResponsePayload,
} from "./types.js";
// ============================================================================
// Configuration
// ============================================================================
export interface WsClientConfig {
/** Agent WebSocket host. */
host: string;
/** Agent WebSocket port. */
port: number;
/** Interval between heartbeat pings (ms). Default: 30000. */
heartbeatIntervalMs: number;
/** Max wait for a pong response before treating connection as stale (ms). Default: 10000. */
pongTimeoutMs: number;
/** Max silence on the wire before treating connection as dead (ms). Default: 50000. */
idleTimeoutMs: number;
/** Base delay for exponential-backoff reconnect (ms). Default: 5000. */
reconnectBaseDelayMs: number;
/** Maximum reconnect attempts. Default: 3. */
maxReconnectAttempts: number;
/** Connection handshake timeout (ms). Default: 10000. */
connectTimeoutMs: number;
}
const DEFAULT_CONFIG: Readonly<WsClientConfig> = {
host: "127.0.0.1",
port: 23340,
heartbeatIntervalMs: 30_000,
pongTimeoutMs: 10_000,
idleTimeoutMs: 50_000,
reconnectBaseDelayMs: 5_000,
maxReconnectAttempts: 3,
connectTimeoutMs: 10_000,
};
// ============================================================================
// Handler types
// ============================================================================
export interface WsClientHandlers {
onTick?: (msg: TickMessage) => void;
onTickClosed?: (msg: TickClosedMessage) => void;
onServerError?: (msg: ServerErrorMessage) => void;
onDialogue?: (msg: ServerDialogueMessage) => void;
onAgentDied?: (msg: AgentDiedMessage) => void;
onGameRulesUpdate?: (msg: GameRulesUpdateMessage) => void;
onWorldBuildingRulesUpdate?: (msg: WorldBuildingRulesUpdateMessage) => void;
onLLMRequest?: (msg: LLMRequestMessage) => void;
/** Called after successful reconnection (not on initial connect) */
onReconnect?: () => void;
}
// ============================================================================
// WsClient
// ============================================================================
export class WsClient {
private readonly config: WsClientConfig;
private ws: WebSocket | null = null;
private readonly handlers: WsClientHandlers;
// Heartbeat bookkeeping (client-initiated)
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private lastPongAt: number = 0;
private lastMessageAt: number = 0;
private waitingForPong: boolean = false;
// Reconnect bookkeeping
private reconnectAttempts: number = 0;
private shouldReconnect: boolean = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedOnce: boolean = false;
// Connect timeout
private connectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(config: Partial<WsClientConfig> = {}, handlers: WsClientHandlers = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.handlers = handlers;
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/** Open a WebSocket connection. Rejects on timeout or immediate error. */
connect(): Promise<void> {
if (this.ws) {
return Promise.resolve();
}
const url = `ws://this.config.host:this.config.port/ws`;
console.log(`[ws-client] Connecting to url ...`);
this.shouldReconnect = true;
return new Promise<void>((resolve, reject) => {
let settled = false;
const onSettled = (err?: Error) => {
if (settled) return;
settled = true;
this.clearConnectTimer();
if (err) reject(err);
else resolve();
};
// Connection timeout
this.connectTimer = setTimeout(() => {
onSettled(new Error(
`[ws-client] Connection to url timed out after this.config.connectTimeoutMsms`,
));
this.teardownWs();
}, this.config.connectTimeoutMs);
try {
const socket = new WebSocket(url);
this.ws = socket;
socket.onopen = () => {
console.log("[ws-client] Connected");
const isReconnect = this.hasConnectedOnce;
this.reconnectAttempts = 0;
this.hasConnectedOnce = true;
this.lastPongAt = Date.now();
this.lastMessageAt = Date.now();
this.startHeartbeat();
if (isReconnect) {
this.handlers.onReconnect?.();
}
onSettled();
};
socket.onmessage = (event: MessageEvent) => {
this.onRawMessage(event);
};
socket.onerror = () => {
// The browser fires onclose right after onerror; we handle
// reconnect in onclose. Only reject the connect promise if
// we never opened.
onSettled(new Error("[ws-client] WebSocket error during connect"));
};
socket.onclose = (ev: CloseEvent) => {
console.log(`[ws-client] Disconnected: code=ev.code reason=ev.reason`);
this.stopHeartbeat();
this.ws = null;
if (this.shouldReconnect) {
this.scheduleReconnect();
}
onSettled(new Error(`[ws-client] Connection closed (ev.code)`));
};
} catch (e) {
onSettled(e instanceof Error ? e : new Error(String(e)));
}
});
}
/** Clean close -- disables auto-reconnect. */
disconnect(): void {
this.shouldReconnect = false;
this.clearReconnectTimer();
this.stopHeartbeat();
this.clearConnectTimer();
if (this.ws) {
try {
this.ws.close(1000, "client disconnect");
} catch {
// Best-effort close
}
this.ws = null;
}
}
/** True when the underlying WebSocket is in OPEN state. */
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
// -----------------------------------------------------------------------
// Upstream: send helpers
// -----------------------------------------------------------------------
/** Submit an intent for the given tick. */
sendIntent(
tickId: number,
actionType: string,
actionData?: unknown,
thoughtLog?: string,
): void {
const msg: IntentPayload = {
type: "intent",
tick_id: tickId,
action_type: actionType,
...(actionData !== undefined && { action_data: actionData }),
...(thoughtLog !== undefined && { thought_log: thoughtLog }),
};
this.sendRaw(msg);
}
/** Send LLM response back to the Agent. */
sendLLMResponse(requestId: string, content: string, error?: string): void {
const msg: LLMResponsePayload = {
type: "llm_response",
request_id: requestId,
content,
...(error !== undefined && { error }),
};
this.sendRaw(msg);
}
// -----------------------------------------------------------------------
// Handler registration (post-construction setters)
// -----------------------------------------------------------------------
// The handlers object is already passed via the constructor. These
// setters allow late-binding (e.g. after a tool initialises).
set onTickHandler(fn: ((msg: TickMessage) => void) | undefined) { this.handlers.onTick = fn; }
set onTickClosedHandler(fn: ((msg: TickClosedMessage) => void) | undefined) { this.handlers.onTickClosed = fn; }
set onServerErrorHandler(fn: ((msg: ServerErrorMessage) => void) | undefined) { this.handlers.onServerError = fn; }
set onDialogueHandler(fn: ((msg: ServerDialogueMessage) => void) | undefined) { this.handlers.onDialogue = fn; }
set onAgentDiedHandler(fn: ((msg: AgentDiedMessage) => void) | undefined) { this.handlers.onAgentDied = fn; }
set onGameRulesUpdateHandler(fn: ((msg: GameRulesUpdateMessage) => void) | undefined) { this.handlers.onGameRulesUpdate = fn; }
set onWorldBuildingRulesUpdateHandler(fn: ((msg: WorldBuildingRulesUpdateMessage) => void) | undefined) { this.handlers.onWorldBuildingRulesUpdate = fn; }
set onLLMRequestHandler(fn: ((msg: LLMRequestMessage) => void) | undefined) { this.handlers.onLLMRequest = fn; }
set onReconnectHandler(fn: (() => void) | undefined) { this.handlers.onReconnect = fn; }
// -----------------------------------------------------------------------
// Private: message dispatch
// -----------------------------------------------------------------------
private onRawMessage(event: MessageEvent): void {
this.lastMessageAt = Date.now();
let data: unknown;
try {
data = JSON.parse(event.data as string);
} catch {
console.warn("[ws-client] Dropping non-JSON message");
return;
}
if (typeof data !== "object" || data === null || !("type" in data)) {
console.warn("[ws-client] Dropping message without 'type' field:", data);
return;
}
const msg = data as Record<string, unknown>;
const type = msg.type as string;
switch (type) {
// --- heartbeat (server-initiated) ---
case "ping":
this.sendRaw({ type: "pong", timestamp: msg.timestamp });
break;
// --- heartbeat (client-initiated) ---
case "pong":
this.waitingForPong = false;
this.lastPongAt = Date.now();
break;
// --- core tick lifecycle ---
case "tick":
this.handlers.onTick?.(msg as unknown as TickMessage);
break;
case "tick_closed":
this.handlers.onTickClosed?.(msg as unknown as TickClosedMessage);
break;
// --- server passthrough ---
case "server_error":
this.handlers.onServerError?.(msg as unknown as ServerErrorMessage);
break;
case "server_dialogue":
this.handlers.onDialogue?.(msg as unknown as ServerDialogueMessage);
break;
case "agent_died":
this.handlers.onAgentDied?.(msg as unknown as AgentDiedMessage);
break;
case "server_game_rules_update":
this.handlers.onGameRulesUpdate?.(msg as unknown as GameRulesUpdateMessage);
break;
case "server_world_building_rules_update":
this.handlers.onWorldBuildingRulesUpdate?.(msg as unknown as WorldBuildingRulesUpdateMessage);
break;
// --- recovery ---
case "missed_messages": {
const mm = msg as unknown as MissedMessagesMessage;
console.warn(
`[ws-client] Missed mm.count messages (suggest_resync=mm.suggest_resync)`,
);
break;
}
// --- server immediate events ---
case "server_immediate_event": {
const event = msg as unknown as ServerImmediateEventMessage;
console.log(
`[ws-client] Immediate event: event.event_type (tick event.tick_id): event.description`,
);
break;
}
// --- llm integration ---
case "llm_request":
this.handlers.onLLMRequest?.(msg as unknown as LLMRequestMessage);
break;
default:
console.warn(`[ws-client] Unhandled downstream type: type`);
}
}
// -----------------------------------------------------------------------
// Private: heartbeat
// -----------------------------------------------------------------------
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (!this.isConnected()) return;
// Idle timeout: no message received for too long
const silenceMs = Date.now() - this.lastMessageAt;
if (silenceMs > this.config.idleTimeoutMs) {
console.warn(`[ws-client] Idle timeout (silenceMsms silence), closing`);
this.teardownWs();
return;
}
// Still waiting for previous pong -- connection may be dead
if (this.waitingForPong) {
const elapsed = Date.now() - (this.lastPongAt ?? 0);
if (elapsed > this.config.pongTimeoutMs) {
console.warn(`[ws-client] Pong timeout (elapsedms), closing`);
this.teardownWs();
return;
}
}
// Send ping
this.waitingForPong = true;
this.sendRaw({ type: "ping", timestamp: Date.now() });
}, this.config.heartbeatIntervalMs);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
this.waitingForPong = false;
}
// -----------------------------------------------------------------------
// Private: reconnect with exponential backoff
// -----------------------------------------------------------------------
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
console.error(
`[ws-client] Max reconnect attempts reached (this.config.maxReconnectAttempts). Giving up.`,
);
return;
}
this.reconnectAttempts++;
const delay = this.config.reconnectBaseDelayMs * Math.pow(2, this.reconnectAttempts - 1);
console.log(
`[ws-client] Reconnect attempt this.reconnectAttempts/this.config.maxReconnectAttempts in delayms`,
);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.shouldReconnect) return;
this.connect().catch((e: Error) => {
console.error(`[ws-client] Reconnect failed: e.message`);
});
}, delay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
// -----------------------------------------------------------------------
// Private: low-level helpers
// -----------------------------------------------------------------------
private sendRaw(msg: unknown): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn("[ws-client] Cannot send -- not connected");
return;
}
this.ws.send(JSON.stringify(msg));
}
private teardownWs(): void {
this.stopHeartbeat();
if (this.ws) {
try { this.ws.close(); } catch { /* ignore */ }
this.ws = null;
}
}
private clearConnectTimer(): void {
if (this.connectTimer !== null) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
}
}
FILE:.github/workflows/release.yml
name: Release
on:
push:
tags:
- "v*"
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Verify Version Match (Fail Fast)
run: |
TAG_VERSION=GITHUB_REF#refs/tags/v
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "❌ Version mismatch! Git Tag is v$TAG_VERSION but package.json is $PKG_VERSION"
exit 1
fi
- name: Install dependencies
run: npm install --no-audit --no-fund
- name: Type check
run: npx tsc --noEmit
- name: Lint
run: npm run lint
release-npm:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm install --no-audit --no-fund
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: { secrets.NPM_TOKEN}
release-github:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
files: |
SKILL.md
openclaw.plugin.json
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"]
},
"include": ["*.ts", "**/*.ts"],
"exclude": ["node_modules", "dist"]
}
FILE:register.ts
// register.ts — Cyber-Jianghu OpenClaw Plugin Entry Point
// ============================================================================
// Architecture:
// User (IM) ↕ OpenClaw (Brain) ←WS→ Agent (Body/Rust) ←WS→ Game Server
//
// This module:
// 1. Connects to the Rust Agent via WebSocket
// 2. Listens for LLMRequest from Agent, calls LLM via OpenClaw, sends back LLMResponse
// 3. Provides tools (dream/status) for user IM intervention
// ============================================================================
import { Type } from "@sinclair/typebox";
import { WsClient } from "./ws-client.js";
import { getHttpClient, getAgentInfo } from "./http-client.js";
import type {
LLMRequestMessage,
TickMessage,
AgentDiedMessage,
} from "./types.js";
import { Reporter } from "./plugins/reporter/index.js";
// ---------------------------------------------------------------------------
// Plugin API types (minimal inline definitions)
// ---------------------------------------------------------------------------
interface PluginAPI {
registerTool(params: ToolDefinition): void;
on(
event: string,
handler: (event: unknown, context: unknown) => unknown | Promise<unknown>,
options?: unknown,
): void;
config?: Record<string, unknown>;
executePrompt?: (prompt: string) => Promise<string>;
}
interface ToolDefinition {
name: string;
description: string;
parameters: unknown;
execute: (
_id: string,
params: Record<string, unknown>,
) => Promise<ToolResult>;
}
interface ToolResult {
content: Array<{ type: string; text: string }>;
isError?: boolean;
}
// ---------------------------------------------------------------------------
// Module-level state
// ---------------------------------------------------------------------------
let wsClient: WsClient | null = null;
let reporter: Reporter | null = null;
let isInitializing = false;
let globalPluginApi: PluginAPI | null = null;
let latestTickSnapshot: {
tickId: number;
deadlineMs: number;
context: string | null;
updatedAt: string;
} | null = null;
// ---------------------------------------------------------------------------
// Plugin entry point
// ---------------------------------------------------------------------------
export default async function register(api: PluginAPI): Promise<void> {
if (isInitializing || wsClient) {
console.log("[cyber-jianghu] Already initialized, skipping");
return;
}
isInitializing = true;
globalPluginApi = api;
// 0. Initialize Reporter
reporter = new Reporter();
// 1. Register status tool (for user to query current state)
api.registerTool({
name: "cyber_jianghu_status",
description: "获取赛博江湖中你当前角色的最新状态(位置、属性、周围环境等),用于向用户汇报。",
parameters: Type.Object({}),
execute: async () => {
if (!latestTickSnapshot) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
status: "unavailable",
message: "尚未收到游戏世界状态,请等待连接或下一次状态更新。",
},
null,
2,
),
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
status: "ok",
tick_id: latestTickSnapshot.tickId,
context: latestTickSnapshot.context,
updated_at: latestTickSnapshot.updatedAt,
},
null,
2,
),
},
],
};
},
});
// 2. Register create character tool
api.registerTool({
name: "cyber_jianghu_create_character",
description: "在赛博江湖中创建一个新的侠客角色。需要收集用户的设定并转化为结构化数据。",
parameters: Type.Object({
name: Type.String({ description: "角色姓名,例如:李寻欢" }),
age: Type.Optional(Type.Number({ description: "年龄(1-100)" })),
gender: Type.Optional(Type.String({ description: "性别(male, female, other)" })),
appearance: Type.Optional(Type.String({ description: "外貌描述" })),
identity: Type.Optional(Type.String({ description: "身份背景" })),
personality: Type.Optional(Type.Array(Type.String(), { description: "性格特征列表,例如:['嫉恶如仇', '嗜酒如命']" })),
values: Type.Optional(Type.Array(Type.String(), { description: "核心价值观列表" })),
language_style: Type.Optional(Type.Object({
tone: Type.Optional(Type.String()),
catchphrases: Type.Optional(Type.Array(Type.String())),
vocabulary: Type.Optional(Type.Array(Type.String()))
})),
goals: Type.Optional(Type.Object({
short_term: Type.Optional(Type.Array(Type.String())),
long_term: Type.Optional(Type.Array(Type.String()))
})),
system_prompt: Type.Optional(Type.String({ description: "自定义系统提示词(高级)" }))
}),
execute: async (_id, params) => {
try {
const httpClient = await getHttpClient();
await httpClient.post("/api/v1/character/register", params);
return {
content: [
{
type: "text",
text: `侠客 params.name 创建成功!角色已注入赛博江湖。`,
},
],
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `角色创建失败: msg。可能是因为后端 Agent 未启动或连接异常。`,
},
],
isError: true,
};
}
}
});
// 3. Register dream tool (for user intervention)
api.registerTool({
name: "cyber_jianghu_dream",
description:
"代表用户向角色注入一个梦(托梦),影响角色意识。这是用户干预游戏世界的唯一方式。",
parameters: Type.Object({
content: Type.String({
description: "梦的内容——将出现在角色意识中的念头",
}),
duration: Type.Optional(
Type.Number({ description: "持续Tick数(最多5)", default: 5 }),
),
}),
execute: async (_id, params) => {
const duration = Math.min((params.duration as number) ?? 5, 5);
const content = params.content as string;
try {
const httpClient = await getHttpClient();
await httpClient.post("/api/v1/character/dream", {
thought: content,
duration,
});
return {
content: [
{
type: "text",
text: `托梦成功。"content" 将影响角色后续 duration 个Tick的决策。`,
},
],
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
const isQuota =
msg.includes("429") || msg.includes("今日已使用过托梦");
return {
content: [
{
type: "text",
text: isQuota
? `托梦失败: 频率限制或额度用尽。(msg)`
: `托梦失败: msg`,
},
],
isError: true,
};
}
},
});
const CHECK_INTERVAL_MS = 60_000;
setInterval(() => {
const report = reporter?.getPendingReport();
if (report) {
console.log(`[reporter] [report.type] report.content.substring(0, 100)...`);
reporter?.clearPendingReport();
}
}, CHECK_INTERVAL_MS);
// Init WebSocket
await initWebSocket();
// 3. Register agent_end hook for report delivery
api.on("agent_end", async () => {
const pending = reporter?.getPendingReport();
if (pending) {
console.log(
`[cyber-jianghu] Pending pending.type report available for delivery`,
);
// Report delivery: the report content is logged and available.
// In production, OpenClaw could push this to the user's IM channel.
console.log(`[reporter] Report:\npending.content`);
reporter?.clearPendingReport();
}
});
isInitializing = false;
console.log("[cyber-jianghu] Plugin registered successfully");
}
// ---------------------------------------------------------------------------
// WebSocket initialization
// ---------------------------------------------------------------------------
async function initWebSocket(): Promise<void> {
try {
// Trigger port discovery via HTTP health check
const httpClient = await getHttpClient();
// Get discovered port and host for WS connection
const agentInfo = getAgentInfo();
const port = agentInfo?.apiPort ?? 23340;
const host = new URL(httpClient.getBaseUrl()).hostname;
wsClient = new WsClient({ port, host });
// Tick handler - store the latest state for user queries and trigger reporter
wsClient.onTickHandler = (msg: TickMessage) => {
latestTickSnapshot = {
tickId: msg.tick_id,
deadlineMs: msg.deadline_ms,
context: msg.context ?? null,
updatedAt: new Date().toISOString(),
};
Promise.resolve()
.then(async () => {
await reporter?.onTick(msg);
})
.catch((e) => console.error("[cyber-jianghu] Tick handler error:", e));
};
// Agent died - trigger reporter death narrative
wsClient.onAgentDiedHandler = (msg: AgentDiedMessage) => {
console.log(
`[cyber-jianghu] Agent died: msg.cause at msg.location (tick msg.tick_id)`,
);
reporter?.onAgentDied(msg).catch((e) =>
console.error("[cyber-jianghu] onAgentDied error:", e),
);
};
wsClient.onLLMRequestHandler = async (msg: LLMRequestMessage) => {
console.log(`[cyber-jianghu] Received LLMRequest: msg.request_id`);
if (!globalPluginApi || !wsClient?.isConnected()) {
console.warn(`[cyber-jianghu] Plugin API unavailable or WS disconnected, dropping LLMRequest: msg.request_id`);
return;
}
try {
const result = await globalPluginApi.executePrompt?.(msg.prompt);
if (result) {
wsClient.sendLLMResponse(msg.request_id, result);
} else {
throw new Error("No response from LLM");
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error(`[cyber-jianghu] LLMRequest failed: errorMsg`);
if (wsClient?.isConnected()) {
wsClient.sendLLMResponse(msg.request_id, "", errorMsg);
}
}
};
// Reconnect handler - sync state after reconnect
wsClient.onReconnectHandler = async () => {
console.log("[cyber-jianghu] WebSocket reconnected, syncing state...");
try {
const httpClient = await getHttpClient();
const state = await httpClient.getGameState();
latestTickSnapshot = {
tickId: state.tick_id,
deadlineMs: state.deadline_ms,
context: null,
updatedAt: new Date().toISOString(),
};
console.log(`[cyber-jianghu] State synced after reconnect: tick_id=state.tick_id`);
} catch (e) {
console.error("[cyber-jianghu] Failed to sync state after reconnect:", e);
}
};
await wsClient.connect();
console.log("[cyber-jianghu] WebSocket connected to Agent");
} catch (e) {
console.error("[cyber-jianghu] Failed to connect to Agent:", e);
} finally {
isInitializing = false;
}
}
FILE:AGENTS.md
# AGENTS.md — Cyber-Jianghu OpenClaw Plugin
**Purpose**: This file provides coding conventions and operational commands for AI agents working in this repository.
---
## Project Overview
- **Type**: TypeScript ES Module OpenClaw plugin
- **Runtime**: Node.js 20+, ES2022 target
- **Registry**: npm package `@8kugames/cyber-jianghu-openclaw`
- **Purpose**: Integration layer between OpenClaw (LLM gateway) and Cyber-Jianghu Rust Agent (game logic engine)
---
## Build & Test Commands
### Type Check
```bash
npm run build
# Runs: tsc --noEmit
```
### Lint
```bash
npm run lint
# Runs: oxlint
```
### Test
```bash
npm test # vitest run (single pass)
npm run test:watch # vitest (watch mode)
```
**Run a single test file**:
```bash
npx vitest run tests/my.test.ts
```
**Vitest config**: `vitest.config.ts` — tests live in `tests/**/*.test.ts`
### Release Workflow
```bash
npm run version # Sync versions (prepublishOnly)
```
---
## Code Style
### Indentation & Formatting
- **Tabs** (not spaces) for indentation
- Trim trailing whitespace
- Max line length: ~100 chars (use judgment)
### Imports
- Use `.js` extension in all local imports (required for ESM):
```typescript
import { WsClient } from "./ws-client.js";
import type { TickMessage } from "./types.js";
```
- Use `import type` for type-only imports
- Order: external → internal → relative
### Naming Conventions
| Construct | Convention | Example |
|-----------|-------------|---------|
| Interfaces | PascalCase | `interface WorldState` |
| Types | PascalCase | `type ActionType = string` |
| Classes | PascalCase | `class WsClient` |
| Functions | camelCase | `function discoverPort()` |
| Variables | camelCase | `cachedAgentInfo` |
| JSON fields | snake_case | `tick_id`, `world_time` |
| Constants | camelCase | `DEFAULT_CONFIG` |
### TypeScript Guidelines
- **`strict: true`** is enforced — no `any`, no implicit `any`
- Use `@sinclair/typebox` `Type.Object()` for tool parameters (runtime validation)
- Use `unknown` for unvalidated external data; narrow with type guards
- Prefer `interface` over `type` for object shapes
- Use `Record<string, T>` for map/dictionary types
- Always use explicit return types on exported functions
### Error Handling
```typescript
// DO: Guard with instanceof
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(`HTTP res.status: msg`);
}
// AVOID: Empty catch
catch { }
// AVOID: Catching non-Error
catch (e) { throw e; }
```
### Async/Await
- Use `async/await` over `.then()` chains
- Always `await` or handle promise rejection
- Use `Promise.resolve().then(async () => {...})` for fire-and-forget in event handlers
---
## Architecture Patterns
### File Organization
```
*.ts — Root entry points (register.ts, ws-client.ts, http-client.ts, types.ts)
plugins/*/ — Plugin sub-modules (plugins/reporter/)
scripts/* — Build utility scripts
tests/**/*.ts — Test files
```
### Path Aliases
```json
"paths": { "@/*": ["./*"] }
```
```typescript
import { HttpClient } from "@/http-client.js"; // resolves to ./http-client.js
```
### State Management
- Module-level singletons for clients (e.g., `singletonClient`, `cachedAgentInfo`)
- Provide reset functions for testing: `resetHttpClient()`
- Avoid deep global state; prefer dependency injection
### JSON Serialization
- All JSON uses **snake_case** to match Rust serde serialization
- TypeScript interfaces use **camelCase** for fields
- Use `JSON.stringify(obj, null, 2)` for debug output
---
## Comments
### Header Separator
```typescript
// ============================================================================
// Section Title
// ============================================================================
```
### JSDoc for Public APIs
```typescript
/**
* Scan the configured port range and return the first port that responds to
* a health check with `{ status: "ok" }`.
*/
async function discoverPort(host: string, config: HttpClientConfig): Promise<number | null>
```
---
## Git Conventions
- **Commits**: Conventional commits not enforced, but be descriptive
- **Branch naming**: Not enforced; use judgment (e.g., `feat/xxx`, `fix/xxx`)
- **Version tags**: `v*` format (e.g., `v0.3.3`) — triggers release workflow
---
## Dependency Notes
- **`@sinclair/typebox`**: Runtime type validation for tool parameters
- **`openclaw`**: Peer dependency (`^2026.3.13`) — plugin API compatibility
- **No runtime `fetch` polyfill needed** — Node 18+ native fetch is used
---
## Common Pitfalls
1. **ESM `.js` extensions**: Always include `.js` in local imports, even though files are `.ts`
2. **AbortSignal.timeout()**: Use for request timeouts (Node 18+)
3. **TypeBox in tool definitions**: Use `Type.String()`, `Type.Optional()`, etc. — not native TS types
4. **Empty test suite**: Tests directory is `tests/` (note: singular), pattern `**/*.test.ts`
FILE:vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
exclude: ["node_modules", "dist"],
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["tools/"],
exclude: ["tools/**/types.ts"],
},
},
});
FILE:http-client.ts
// tools/act/http-client.ts
// ============================================================================
// Simplified HTTP Client for Cyber-Jianghu-Openclaw Plugin
// ============================================================================
//
// Communicates with the local Rust Agent's HTTP API.
//
// Port auto-discovery: scans 23340-23349, probes /api/v1/health.
// Endpoints used by this plugin:
// GET /api/v1/health — health check / port discovery
// GET /api/v1/character — character info for bootstrap
// POST /api/v1/character/register — registration
// POST /api/v1/character/dream — dream injection
// GET /api/v1/character/experiences — report data source
// GET /api/v1/relationship/{id} — dialogue name resolution
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
export interface HttpClientConfig {
/** Port range to scan for the agent */
portRange: { min: number; max: number };
/** Timeout per port probe during discovery (ms) */
discoveryTimeoutMs: number;
/** Timeout for regular HTTP requests (ms) */
requestTimeoutMs: number;
/** Default hostname */
defaultHost: string;
}
const DEFAULT_CONFIG: HttpClientConfig = {
portRange: { min: 23340, max: 23349 },
discoveryTimeoutMs: 500,
requestTimeoutMs: 5000,
defaultHost: "127.0.0.1",
};
// ---------------------------------------------------------------------------
// Agent info cache
// ---------------------------------------------------------------------------
export interface AgentInfo {
agentId: string;
apiPort: number;
}
let cachedAgentInfo: AgentInfo | null = null;
// ---------------------------------------------------------------------------
// Port auto-discovery
// ---------------------------------------------------------------------------
/**
* Scan the configured port range and return the first port that responds to
* a health check with `{ status: "ok" }`.
*/
async function discoverPort(
host: string,
config: HttpClientConfig,
): Promise<number | null> {
for (let port = config.portRange.min; port <= config.portRange.max; port++) {
try {
const response = await fetch(
`http://host:port/api/v1/health`,
{
method: "GET",
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
},
);
if (response.ok) {
const data = (await response.json()) as { status?: string };
if (data.status === "ok") {
console.log(`[http-client] Discovered agent at host:port`);
return port;
}
}
} catch {
// Port unreachable — continue scanning
}
}
return null;
}
// ---------------------------------------------------------------------------
// HttpClient
// ---------------------------------------------------------------------------
export class HttpClient {
private readonly baseUrl: string;
private readonly timeoutMs: number;
constructor(port: number, host: string, timeoutMs: number) {
this.baseUrl = `http://host:port`;
this.timeoutMs = timeoutMs;
}
/** GET request, returns parsed JSON. */
async get<T = unknown>(path: string): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await fetch(`this.baseUrlpath`, {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`HTTP res.status: res.statusText`);
}
return (await res.json()) as T;
} finally {
clearTimeout(timer);
}
}
/** POST request with JSON body, returns parsed JSON (or empty object). */
async post<T = unknown>(path: string, body: unknown): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await fetch(`this.baseUrlpath`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP res.status: text`);
}
const text = await res.text();
if (!text) return {} as T;
return JSON.parse(text) as T;
} finally {
clearTimeout(timer);
}
}
/** Expose the base URL for debugging / logging. */
getBaseUrl(): string {
return this.baseUrl;
}
/** Get current game state (tick_id, deadline_ms) for reconnection sync. */
async getGameState(): Promise<{ tick_id: number; deadline_ms: number }> {
return this.get<{ tick_id: number; deadline_ms: number }>("/api/v1/tick");
}
}
// ---------------------------------------------------------------------------
// Singleton factory
// ---------------------------------------------------------------------------
let singletonClient: HttpClient | null = null;
/**
* Get (or create) the singleton `HttpClient`.
*
* On the first call the client performs port auto-discovery unless a port
* was already cached. Subsequent calls return the cached instance.
*
* An optional partial `HttpClientConfig` overrides defaults.
*/
export async function getHttpClient(
config?: Partial<HttpClientConfig>,
): Promise<HttpClient> {
if (singletonClient) return singletonClient;
const cfg = { ...DEFAULT_CONFIG, ...config };
const host =
(typeof globalThis !== 'undefined' && (globalThis as any).__DOCKER_AGENT_HOST) ||
(typeof process !== 'undefined' && process.env?.DOCKER_AGENT_HOST) ||
cfg.defaultHost;
// Use cached agent info if available
if (cachedAgentInfo) {
singletonClient = new HttpClient(
cachedAgentInfo.apiPort,
host,
cfg.requestTimeoutMs,
);
return singletonClient;
}
// Discover agent port
const port = await discoverPort(host, cfg);
if (port === null) {
throw new Error(
`No agent HTTP API found on host in port range cfg.portRange.min-cfg.portRange.max`,
);
}
cachedAgentInfo = { agentId: `agent-port`, apiPort: port };
singletonClient = new HttpClient(port, host, cfg.requestTimeoutMs);
return singletonClient;
}
/**
* Return the cached `AgentInfo` (available after the first successful
* `getHttpClient()` call).
*/
export function getAgentInfo(): AgentInfo | null {
return cachedAgentInfo;
}
/**
* Reset the singleton and cached info (useful for tests or reconnection).
*/
export function resetHttpClient(): void {
singletonClient = null;
cachedAgentInfo = null;
}