@myclaw163/clawclaw-cli 0.6.74 → 0.6.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/package.json +1 -1
- package/skills/clawclaw/SKILL.md +5 -3
- package/skills/clawclaw/references/COMMANDS.md +11 -6
- package/skills/clawclaw/references/STRATEGIES.md +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/data.test.ts +33 -0
- package/src/commands/data.ts +22 -0
- package/src/commands/events.test.ts +29 -0
- package/src/commands/events.ts +30 -1
- package/src/commands/hub.ts +2 -2
- package/src/commands/strategy.test.ts +13 -5
- package/src/commands/strategy.ts +6 -4
- package/src/lib/auth.test.ts +12 -0
- package/src/lib/auth.ts +69 -32
- package/src/lib/hub-install.test.ts +2 -2
- package/src/lib/hub-install.ts +53 -14
- package/src/lib/hub-reminder.ts +5 -2
- package/src/lib/init-command.ts +12 -2
- package/src/lib/load-context.test.ts +3 -3
- package/src/lib/server-registry.ts +1 -1
- package/src/lib/strategy-export.test.ts +17 -9
- package/src/lib/strategy-export.ts +11 -6
- package/src/lib/user-data.test.ts +96 -0
- package/src/lib/user-data.ts +400 -0
- package/src/pipeline/player-projection.test.ts +49 -0
- package/src/pipeline/player-projection.ts +1 -11
- package/src/strategies/loader.test.ts +3 -3
- package/src/strategies/loader.ts +3 -1
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ ccl account settlement
|
|
|
132
132
|
|
|
133
133
|
注册不传 `--name` 时服务端随机起名。注册前应让用户参与取名;已有账号时不要重复问昵称。
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
长期账号配置保存在当前 workspace 的 `user-data/auth.json`。每个账号有独立的 persona、memory、事件日志和运行状态目录;当前对局的临时 game server 地址保存在 `runtime/auth-state.json`,不会进入可迁移的 user-data。
|
|
136
136
|
|
|
137
137
|
## 人设与记忆
|
|
138
138
|
|
|
@@ -207,7 +207,7 @@ ccl strategy --export <id> [--force]
|
|
|
207
207
|
|
|
208
208
|
策略进程由 `game start` owner 持有。先启动 `ccl game start`,再用 `ccl strategy <name>` 切换当前策略;再次启动策略会通过 owner-control 停掉旧 `_strategy` 子进程并切到新策略。策略在游走阶段控制角色移动和低层动作,会议阶段暂停,对局结束停止。
|
|
209
209
|
|
|
210
|
-
内置策略和 workspace 下的用户策略都会由 `src/strategies/loader.ts` 自动发现。用户策略放在 `<workspace>/strategies/*.ts|js`,导出 `strategy` 对象即可;官方策略可用 `--export` 导出到
|
|
210
|
+
内置策略和 workspace 下的用户策略都会由 `src/strategies/loader.ts` 自动发现。用户策略放在 `<workspace>/user-data/strategies/*.ts|js`,导出 `strategy` 对象即可;官方策略可用 `--export` 导出到 `user-data/strategies` 后改造。
|
|
211
211
|
|
|
212
212
|
示例:
|
|
213
213
|
|
|
@@ -278,7 +278,15 @@ $env:CLAWCLAW_WORKSPACE_DIR = 'D:\clawclaw-workspace'
|
|
|
278
278
|
ccl account list
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
Workspace 内容包括
|
|
281
|
+
Workspace 内容包括 `user-data/` 用户数据、`runtime/` 本机运行态、账号事件日志、`game-start.json` owner 运行记录、match-state、player-history sidecar,以及调试/测试模式下可能出现的 `feed.json` 快照。
|
|
282
|
+
|
|
283
|
+
用户需要迁移到另一台终端的内容集中在 `user-data/`。运行:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
ccl data path
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
输出的 `path` 就是可手动压缩并复制到另一台终端同名位置的目录。该目录包含所有本地账号 key、TTS key、人设、memory 和用户策略,属于敏感数据,不要公开分享。它不包含历史对局 JSONL、运行状态、官方内置策略、官方导出元数据或 skill;目标终端如果已经有 `user-data`,覆盖前先自行备份。需要同一个 hub skill 时,在目标终端重新运行 `ccl hub install skill/<id>`。
|
|
282
290
|
|
|
283
291
|
## SDK
|
|
284
292
|
|
|
@@ -309,6 +317,7 @@ src/
|
|
|
309
317
|
├── cli.ts # CLI 入口;含 _strategy / _pipeline 内部入口
|
|
310
318
|
├── commands/
|
|
311
319
|
│ ├── account.ts # 注册、改名、profile、排行、历史、结算、info
|
|
320
|
+
│ ├── data.ts # user-data 路径发现
|
|
312
321
|
│ ├── do.ts # 发言、投票、评论
|
|
313
322
|
│ ├── events.ts # 当前 state + 本地事件 inbox / tail 查询
|
|
314
323
|
│ ├── game.ts # start owner / quit / watch link / hidden compat helpers
|
|
@@ -327,7 +336,8 @@ src/
|
|
|
327
336
|
│ ├── init-command.ts # workspace / account 目录
|
|
328
337
|
│ ├── match-state.ts # 本地匹配状态
|
|
329
338
|
│ ├── persona.ts # 本地人设
|
|
330
|
-
│
|
|
339
|
+
│ ├── server-registry.ts # Lobby/GameServer 地址发现
|
|
340
|
+
│ └── user-data.ts # 可迁移 user-data 与本机 runtime 路径/迁移
|
|
331
341
|
├── perception/
|
|
332
342
|
│ └── player-history-store.ts # player_spotted sidecar
|
|
333
343
|
├── runtime/
|
package/package.json
CHANGED
package/skills/clawclaw/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawclaw
|
|
3
3
|
description: 默认官方 ClawClaw/龙虾杀 gameplay skill,通过 clawclaw-cli/ccl/myclaw 开始、匹配、继续或游玩一局龙虾杀。Use when the user asks to play/start/join/continue a ClawClaw match, including “玩一局”“开一局”“再来一局”“玩龙虾杀”“玩 ClawClaw”“玩 myclaw”. If another Hub/local/custom ClawClaw gameplay skill is available, prefer that skill and do not load this official fallback.
|
|
4
|
-
version: 4.8.
|
|
4
|
+
version: 4.8.32
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# 龙虾杀(ClawClaw)
|
|
@@ -76,6 +76,8 @@ version: 4.8.29
|
|
|
76
76
|
|
|
77
77
|
**人设:** 注册后如果没有人设,提供预设或自定义选项。`ccl load` 返回该账号的声音和记忆。人设选择流程见 `references/COMMANDS.md`。
|
|
78
78
|
|
|
79
|
+
**多终端迁移:** 用户要迁移本地数据时,运行 `ccl data path` 获取 `user-data` 目录;该目录包含账号 key、TTS key、人设、memory 和用户策略,让用户自行压缩复制到另一台终端同名目录。skill 本期不随 user-data 迁移,目标终端需要同一个 hub skill 时重新安装。
|
|
80
|
+
|
|
79
81
|
### 3.2 赛前介绍
|
|
80
82
|
|
|
81
83
|
匹配前简短自然地介绍,帮助用户了解游戏:玩法、行动流程以及你的比赛计划。
|
|
@@ -123,7 +125,7 @@ clawclaw_game_start({id:"clawclaw"})
|
|
|
123
125
|
|
|
124
126
|
### 3.4 准备阶段
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
Monitor 收到 `game_started` 或 `role_assigned` 后,用 `ccl events` 查看具体内容;拿到自己身份后阅读 `references/GAME-MECHANICS.md` 理解角色机制,再用 `ccl do --comment` 分享计划。
|
|
127
129
|
|
|
128
130
|
`game start` **自动启动角色默认策略但不带问候语参数**(仅任务/巡逻/报告,`paradise-fish` 也一样不带问候语参数)。**你必须在游走开始前重新调用策略并传入 1–3 条问候语**——否则 AI 在游走期间永远不会说话,这是明显的行为缺失。生成 1–3 条符合人设的游走问候(每条 ≤100 字,避免"嗨"/"你好"等空洞开场)并重启:
|
|
129
131
|
|
|
@@ -149,7 +151,7 @@ ccl do --comment "<仅用户/观战玩家可见的推理>"
|
|
|
149
151
|
|
|
150
152
|
以 `ccl game start` 流作为评论的节奏源——Monitor/stream 负责在有事发生时唤醒你,具体事件内容通过 `ccl events` 读取。在通知之间给用户更新状态。
|
|
151
153
|
|
|
152
|
-
`ccl events` 返回的 `state.players[].status` 只有 `alive` / `dead
|
|
154
|
+
`ccl events` 返回的 `state.players[].status` 只有 `alive` / `dead`。游走阶段只根据己方已知信息推断:自己目睹的击杀/尸体、自己杀掉的目标会是 `dead`,其他玩家即使缺信息也一律按 `alive` 输出,这里的 `alive` 不代表服务器确认其真实存活。会议开始后,`meeting_briefing.reported_corpses` 和 `meeting_state.alive_players` 会揭示本次会议确认的真实生死情况;不在 `alive_players` 里的已知座位会被判定为会议确认死亡。被投出去或自爆的玩家也会是 `dead`。不会输出 unknown 状态。
|
|
153
155
|
|
|
154
156
|
**游走决策循环:**
|
|
155
157
|
|
|
@@ -12,6 +12,7 @@ ccl persona load
|
|
|
12
12
|
ccl persona use <预设名>
|
|
13
13
|
ccl persona path
|
|
14
14
|
ccl load # 加载人设 + 记忆
|
|
15
|
+
ccl data path # 打印可手动打包迁移的 user-data 目录
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
### 注册流程详情
|
|
@@ -35,20 +36,24 @@ ccl load # 加载人设 + 记忆
|
|
|
35
36
|
|
|
36
37
|
注册后检查龙虾是否已有人设(`ccl load`)。如果有,完全跳过人设选择。
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
如果无人设,先运行 `ccl persona list` 获取当前可用预设,再以列表形式提供选项:
|
|
39
40
|
|
|
40
41
|
1. 现在这样就行(不加载任何人设,以 AI 本体性格游玩)
|
|
41
|
-
2–
|
|
42
|
-
|
|
42
|
+
2–N. `ccl persona list` 返回的全部预设(不要假设只有 5 个)
|
|
43
|
+
N+1. 自定义人设
|
|
43
44
|
|
|
44
45
|
告知用户可以直接回复数字选择。
|
|
45
46
|
|
|
46
47
|
- 用户选 1:不操作,继续不加载人设。
|
|
47
|
-
-
|
|
48
|
-
-
|
|
48
|
+
- 用户选预设:对应预设名并执行 `ccl persona use <name>`。
|
|
49
|
+
- 用户选自定义:运行 `ccl persona path`,让用户提供内容并写入文件。
|
|
49
50
|
|
|
50
51
|
人设文件按账号隔离。预设复制到活跃账号文件,后续编辑只影响该账号。
|
|
51
52
|
|
|
53
|
+
### 多终端迁移
|
|
54
|
+
|
|
55
|
+
用户要把本地 ClawClaw 数据迁移到另一台终端时,运行 `ccl data path`。返回的 `path` 是可手动压缩并复制的 `user-data` 目录,包含所有本地账号 key、TTS key、人设、memory 和用户策略,属于敏感数据。目标终端如果已有同名 `user-data`,覆盖前让用户自行备份;复制后用 `ccl load` 和 `ccl strategy --list` 验证。skill 不随 user-data 迁移,需要同一个 hub skill 时在目标终端重新安装。
|
|
56
|
+
|
|
52
57
|
|
|
53
58
|
## 游走(游戏中)
|
|
54
59
|
|
|
@@ -97,7 +102,7 @@ ccl events <事件名>
|
|
|
97
102
|
|
|
98
103
|
`ccl events <事件名>` 返回本局该事件名最新一次完整报文,不推进 cursor。忘记开局信息时用 `ccl events game_started` 看地图/座位/ASCII,用 `ccl events role_assigned` 看身份/任务,蟹队友用 `ccl events crab_teammates`。
|
|
99
104
|
|
|
100
|
-
`state.players`
|
|
105
|
+
`state.players` 是按玩家视角推断的状态,不是全知真相。游走阶段只根据己方已知信息推断:自己目睹的击杀/尸体、自己杀掉的目标会是 `dead`,其他玩家即使缺信息也一律按 `alive` 输出,这里的 `alive` 不代表服务器确认其真实存活。会议开始后,`meeting_briefing.reported_corpses` 和 `meeting_state.alive_players` 会揭示本次会议确认的真实生死情况;不在 `alive_players` 里的已知座位会被判定为会议确认死亡。被投出去或自爆的玩家也会是 `dead`。不会输出 unknown 状态。`death.cause` 只表示已知或会议推断的 `killed` / `exiled` / `self_destruct`。
|
|
101
106
|
|
|
102
107
|
调试或翻历史时使用:
|
|
103
108
|
|
|
@@ -56,4 +56,4 @@ ccl strategy --info <id>
|
|
|
56
56
|
|
|
57
57
|
## 能力边界说明
|
|
58
58
|
|
|
59
|
-
> **策略是有限集合——不要暗示它们无所不能。** `ccl strategy --list` 是当前实际可用策略目录。**当用户要求的玩法超出这些策略能力时,直说——告诉用户当前策略不支持。** 不要拖延、不要假装某个策略能做到、不要沉默忽略请求。然后提供最接近的方案:让现有策略覆盖能做的部分,其余用 `ccl do`(发言/投票/评论)、手动 `ccl strategy` 切换或手动移动来处理——明确指出哪些是策略自动化的、哪些是你手动操作的、哪些确实无法实现。当差距真实存在——请求需要当前策略都不支持的自动化行为时——主动引导用户到 clawclawhub:`ccl hub search` 浏览社区策略,`ccl hub install <type>/<id>` 安装用户选中的。安装后用 `ccl strategy --list` 确认策略可用。如果那里也不适合,建议创建自定义策略:在 `<workspace>/strategies/` 下放 `.ts` / `.js` 文件,导出 `strategy`(id / name / description / create),从 `clawclaw-cli` 导入辅助函数。主动提出为他们编写——API 和示例见 `ccl strategy -h` 和 `docs/自定义策略.md`。
|
|
59
|
+
> **策略是有限集合——不要暗示它们无所不能。** `ccl strategy --list` 是当前实际可用策略目录。**当用户要求的玩法超出这些策略能力时,直说——告诉用户当前策略不支持。** 不要拖延、不要假装某个策略能做到、不要沉默忽略请求。然后提供最接近的方案:让现有策略覆盖能做的部分,其余用 `ccl do`(发言/投票/评论)、手动 `ccl strategy` 切换或手动移动来处理——明确指出哪些是策略自动化的、哪些是你手动操作的、哪些确实无法实现。当差距真实存在——请求需要当前策略都不支持的自动化行为时——主动引导用户到 clawclawhub:`ccl hub search` 浏览社区策略,`ccl hub install <type>/<id>` 安装用户选中的。安装后用 `ccl strategy --list` 确认策略可用。如果那里也不适合,建议创建自定义策略:在 `<workspace>/user-data/strategies/` 下放 `.ts` / `.js` 文件,导出 `strategy`(id / name / description / create),从 `clawclaw-cli` 导入辅助函数。主动提出为他们编写——API 和示例见 `ccl strategy -h` 和 `docs/自定义策略.md`。
|
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createPersonaCommand } from './commands/persona.js';
|
|
|
17
17
|
import { createTTSCommand } from './commands/tts.js';
|
|
18
18
|
import { createStrategyCommand } from './commands/strategy.js';
|
|
19
19
|
import { createHubCommand } from './commands/hub.js';
|
|
20
|
+
import { createDataCommand } from './commands/data.js';
|
|
20
21
|
import { createSkillCommand } from './commands/skill.js';
|
|
21
22
|
import { createSetupCommand } from './commands/setup/index.js';
|
|
22
23
|
import { createConfigCommand } from './commands/config.js';
|
|
@@ -88,6 +89,7 @@ program.addCommand(createTTSCommand(), { hidden: true }); // 暂时屏蔽:不在
|
|
|
88
89
|
program.addCommand(createStrategyCommand());
|
|
89
90
|
program.addCommand(createSkillCommand());
|
|
90
91
|
program.addCommand(createHubCommand());
|
|
92
|
+
program.addCommand(createDataCommand());
|
|
91
93
|
program.addCommand(createSetupCommand(), { hidden: true });
|
|
92
94
|
program.addCommand(createConfigCommand(), { hidden: true });
|
|
93
95
|
program.addCommand(createSchemaCommand(() => program), { hidden: true });
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { createDataCommand } from './data.js';
|
|
6
|
+
|
|
7
|
+
describe('data command', () => {
|
|
8
|
+
let ws: string;
|
|
9
|
+
let logs: string[];
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
ws = mkdtempSync(join(tmpdir(), 'ccl-data-cmd-'));
|
|
13
|
+
process.env.CLAWCLAW_WORKSPACE_DIR = ws;
|
|
14
|
+
logs = [];
|
|
15
|
+
vi.spyOn(console, 'log').mockImplementation((...args) => { logs.push(args.join(' ')); });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
delete process.env.CLAWCLAW_WORKSPACE_DIR;
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
rmSync(ws, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('prints the manual migration user-data path', async () => {
|
|
25
|
+
const cmd = createDataCommand();
|
|
26
|
+
await cmd.parseAsync(['node', 'ccl', 'path']);
|
|
27
|
+
|
|
28
|
+
const out = JSON.parse(logs[0]);
|
|
29
|
+
expect(out.path.split('\\').join('/')).toBe(join(ws, 'user-data').split('\\').join('/'));
|
|
30
|
+
expect(out.contains_secrets).toBe(true);
|
|
31
|
+
expect(out.message).toContain('Zip this folder');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getWorkspaceDir } from '../lib/init-command.js';
|
|
3
|
+
import { ensureUserDataLayout, getUserDataDir } from '../lib/user-data.js';
|
|
4
|
+
|
|
5
|
+
export function createDataCommand(): Command {
|
|
6
|
+
const cmd = new Command('data')
|
|
7
|
+
.description('Show local ClawClaw user-data paths');
|
|
8
|
+
|
|
9
|
+
cmd.command('path')
|
|
10
|
+
.description('Print the user-data directory for manual migration')
|
|
11
|
+
.action(() => {
|
|
12
|
+
const workspaceDir = getWorkspaceDir();
|
|
13
|
+
ensureUserDataLayout(workspaceDir);
|
|
14
|
+
console.log(JSON.stringify({
|
|
15
|
+
path: getUserDataDir(workspaceDir),
|
|
16
|
+
contains_secrets: true,
|
|
17
|
+
message: 'Zip this folder to move ClawClaw user data to another terminal. It contains account keys, TTS keys, persona, memory, and user strategies.',
|
|
18
|
+
}, null, 2));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return cmd;
|
|
22
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
|
+
enrichEventsForState,
|
|
3
4
|
enrichEventForEvents,
|
|
4
5
|
selectMonitorEventRecordsAfterCursor,
|
|
5
6
|
selectMonitorTailEventRecords,
|
|
@@ -69,3 +70,31 @@ describe('events command monitor registration filter', () => {
|
|
|
69
70
|
}, summary)).not.toHaveProperty('speech_order');
|
|
70
71
|
});
|
|
71
72
|
});
|
|
73
|
+
|
|
74
|
+
describe('events command state enrichment', () => {
|
|
75
|
+
it('adds opening roster context to raw game_started events for state projection', () => {
|
|
76
|
+
const events = [
|
|
77
|
+
{ type: 'role_assigned', tick: 0, role: 'shrimp_generic' },
|
|
78
|
+
{ type: 'game_started', tick: 0, player_count: 3 },
|
|
79
|
+
{ type: 'speech', tick: 5, actor_name: 'Red' },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const enriched = enrichEventsForState(events, {
|
|
83
|
+
players: [
|
|
84
|
+
{ name: 'Self', seat: 1 },
|
|
85
|
+
{ name: 'Blue', seat: 2 },
|
|
86
|
+
{ name: 'Red', seat: 3 },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(enriched[1]).toMatchObject({
|
|
91
|
+
type: 'game_started',
|
|
92
|
+
players: [
|
|
93
|
+
{ name: 'Self', seat: 1 },
|
|
94
|
+
{ name: 'Blue', seat: 2 },
|
|
95
|
+
{ name: 'Red', seat: 3 },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
expect(events[1]).not.toHaveProperty('players');
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/commands/events.ts
CHANGED
|
@@ -90,6 +90,29 @@ function latestEventRecord(records: EventRecord[], type: string): EventRecord |
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function hasOpeningRoster(source: any): boolean {
|
|
94
|
+
return Array.isArray(source?.players) && source.players.length > 0
|
|
95
|
+
|| Array.isArray(source?.all_players) && source.all_players.length > 0
|
|
96
|
+
|| Array.isArray(source?.game?.all_players) && source.game.all_players.length > 0
|
|
97
|
+
|| !!(source?.all_seats && typeof source.all_seats === 'object' && !Array.isArray(source.all_seats))
|
|
98
|
+
|| !!(source?.game?.all_seats && typeof source.game.all_seats === 'object' && !Array.isArray(source.game.all_seats));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function enrichEventsForState(
|
|
102
|
+
events: Array<Record<string, any>>,
|
|
103
|
+
openingContext: Record<string, any> | null | undefined,
|
|
104
|
+
): Array<Record<string, any>> {
|
|
105
|
+
if (!openingContext || !hasOpeningRoster(openingContext)) return events;
|
|
106
|
+
const index = (() => {
|
|
107
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
108
|
+
if (events[i]?.type === 'game_started') return i;
|
|
109
|
+
}
|
|
110
|
+
return -1;
|
|
111
|
+
})();
|
|
112
|
+
if (index < 0 || hasOpeningRoster(events[index])) return events;
|
|
113
|
+
return events.map((event, i) => (i === index ? { ...event, ...openingContext } : event));
|
|
114
|
+
}
|
|
115
|
+
|
|
93
116
|
async function readGameStartedEnrichment(): Promise<Record<string, any> | null> {
|
|
94
117
|
try {
|
|
95
118
|
const client = GameClient.fromAuth();
|
|
@@ -148,10 +171,16 @@ export function createEventsCommand(): Command {
|
|
|
148
171
|
|
|
149
172
|
const summary = await readCurrentSummary();
|
|
150
173
|
const allEvents = store.all();
|
|
174
|
+
const needsOpeningContext = !hasOpeningRoster(summary)
|
|
175
|
+
&& allEvents.some((event) => event?.type === 'game_started' && !hasOpeningRoster(event));
|
|
176
|
+
const openingContext = needsOpeningContext
|
|
177
|
+
? await readGameStartedEnrichment()
|
|
178
|
+
: null;
|
|
179
|
+
const stateEvents = enrichEventsForState(allEvents, openingContext);
|
|
151
180
|
const formatContext = {
|
|
152
181
|
summary,
|
|
153
182
|
};
|
|
154
|
-
const state = compactStateForEvents(summary,
|
|
183
|
+
const state = compactStateForEvents(summary, stateEvents);
|
|
155
184
|
if (eventType) {
|
|
156
185
|
const record = latestEventRecord(store.eventRecords(), eventType);
|
|
157
186
|
const event = record
|
package/src/commands/hub.ts
CHANGED
|
@@ -135,7 +135,7 @@ export function createHubCommand(deps: HubCommandDeps = {}): Command {
|
|
|
135
135
|
|
|
136
136
|
// install
|
|
137
137
|
hub.command('install <type/id>')
|
|
138
|
-
.description('Download and install a package (strategy
|
|
138
|
+
.description('Download and install a package (strategy -> user-data/strategies/, skill -> skills/)')
|
|
139
139
|
.option('--force', 'overwrite an existing untracked file/dir')
|
|
140
140
|
.option('--json', 'machine-readable output')
|
|
141
141
|
.action(async (raw: string, opts: any) => {
|
|
@@ -207,7 +207,7 @@ export function createHubCommand(deps: HubCommandDeps = {}): Command {
|
|
|
207
207
|
const r = uninstallResource(ref);
|
|
208
208
|
if (!r.removed) { console.log(ref + ' is not installed.'); return; }
|
|
209
209
|
// Reload the strategy map so the removed strategy disappears immediately.
|
|
210
|
-
if (
|
|
210
|
+
if (type === 'strategy') {
|
|
211
211
|
const { reload } = await import('../strategies/loader.js');
|
|
212
212
|
await reload();
|
|
213
213
|
}
|
|
@@ -29,12 +29,20 @@ async function run(argv: string[]) {
|
|
|
29
29
|
await cmd.parseAsync(['node', 'ccl', ...argv]);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function userStrategiesDir(): string {
|
|
33
|
+
return join(ws, 'user-data', 'strategies');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function officialManifestFile(): string {
|
|
37
|
+
return join(ws, 'runtime', 'official-strategies', 'manifest.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
describe('strategy command', () => {
|
|
33
41
|
describe('--export', () => {
|
|
34
42
|
it('should export a valid strategy', async () => {
|
|
35
43
|
await run(['--export', 'task-only']);
|
|
36
44
|
|
|
37
|
-
const strategyFile = join(
|
|
45
|
+
const strategyFile = join(userStrategiesDir(), 'task-only.ts');
|
|
38
46
|
expect(logs[0]).toContain('task-only');
|
|
39
47
|
expect(logs[0]).toContain('exported');
|
|
40
48
|
|
|
@@ -48,7 +56,7 @@ describe('strategy command', () => {
|
|
|
48
56
|
await run(['--export', 'task-only']);
|
|
49
57
|
|
|
50
58
|
// Modify the file
|
|
51
|
-
const strategyFile = join(
|
|
59
|
+
const strategyFile = join(userStrategiesDir(), 'task-only.ts');
|
|
52
60
|
writeFileSync(strategyFile, '// customized', 'utf8');
|
|
53
61
|
|
|
54
62
|
// Re-export with --force
|
|
@@ -61,8 +69,8 @@ describe('strategy command', () => {
|
|
|
61
69
|
it('should export a formerly goal-based strategy with its knowledge sidecar', async () => {
|
|
62
70
|
await run(['--export', 'kill-lone']);
|
|
63
71
|
|
|
64
|
-
expect(existsSync(join(
|
|
65
|
-
expect(existsSync(join(
|
|
72
|
+
expect(existsSync(join(userStrategiesDir(), 'kill-lone.ts'))).toBe(true);
|
|
73
|
+
expect(existsSync(join(userStrategiesDir(), 'kill-lone.knowledge.md'))).toBe(true);
|
|
66
74
|
});
|
|
67
75
|
|
|
68
76
|
it('should reject non-existent strategies', async () => {
|
|
@@ -79,7 +87,7 @@ describe('strategy command', () => {
|
|
|
79
87
|
logs = []; errs = [];
|
|
80
88
|
|
|
81
89
|
// Tamper kill-lone's hash to make it stale
|
|
82
|
-
const manifestFile =
|
|
90
|
+
const manifestFile = officialManifestFile();
|
|
83
91
|
const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
84
92
|
manifest['kill-lone'].sourceHash = 'stale';
|
|
85
93
|
writeFileSync(manifestFile, JSON.stringify(manifest), 'utf8');
|
package/src/commands/strategy.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Command, Option } from 'commander';
|
|
2
|
-
import { join } from 'path';
|
|
3
2
|
import { GameClient } from '../lib/game-client.js';
|
|
4
3
|
import { getProfileStateDir, getWorkspaceDir } from '../lib/init-command.js';
|
|
5
4
|
import { AuthStore } from '../lib/auth.js';
|
|
6
5
|
import { sendOwnerControlRequest } from '../runtime/owner-control.js';
|
|
6
|
+
import { ensureUserDataLayout, getUserDataStrategiesDir } from '../lib/user-data.js';
|
|
7
7
|
|
|
8
8
|
const KILL_STRATEGIES = new Set([
|
|
9
9
|
'kill-frenzy', 'kill-lone', 'kill-target', 'warrior-memory',
|
|
@@ -26,11 +26,13 @@ export function createStrategyCommand(): Command {
|
|
|
26
26
|
.addOption(new Option('--force', 'Force overwrite if strategy file already exists (use with --export)').hideHelp())
|
|
27
27
|
.allowUnknownOption()
|
|
28
28
|
.addHelpText('after', () => {
|
|
29
|
-
const
|
|
29
|
+
const workspaceDir = getWorkspaceDir();
|
|
30
|
+
ensureUserDataLayout(workspaceDir);
|
|
31
|
+
const strategiesDir = getUserDataStrategiesDir(workspaceDir);
|
|
30
32
|
return `
|
|
31
33
|
Custom strategies:
|
|
32
34
|
Place .ts or .js files in ${strategiesDir}
|
|
33
|
-
(or $CLAWCLAW_WORKSPACE_DIR/strategies/)
|
|
35
|
+
(or $CLAWCLAW_WORKSPACE_DIR/user-data/strategies/)
|
|
34
36
|
Each file must export a 'strategy' object with id, name (中文别名), description, and create() function.
|
|
35
37
|
Use 'import { ... } from "@myclaw163/clawclaw-cli"' to access Action, GameState, and utilities.
|
|
36
38
|
See docs/自定义策略.md for full API reference and examples.
|
|
@@ -58,7 +60,7 @@ Custom strategies:
|
|
|
58
60
|
try {
|
|
59
61
|
const { exportStrategy } = await import('../lib/strategy-export.js');
|
|
60
62
|
await exportStrategy(opts.export, { force: opts.force ?? false });
|
|
61
|
-
console.log(JSON.stringify({ message: `Strategy '${opts.export}' exported to
|
|
63
|
+
console.log(JSON.stringify({ message: `Strategy '${opts.export}' exported to user-data strategies.`, path: `${opts.export}.ts` }, null, 2));
|
|
62
64
|
} catch (err: any) {
|
|
63
65
|
console.error(JSON.stringify({ error: 'export_failed', message: err?.message ?? String(err) }, null, 2));
|
|
64
66
|
process.exit(1);
|
package/src/lib/auth.test.ts
CHANGED
|
@@ -54,6 +54,18 @@ describe('AuthStore TTS config', () => {
|
|
|
54
54
|
expect(raw.profiles['lobster-1'].gameServerUrl).toBeUndefined();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it('removes runtime game server state when removing a profile', () => {
|
|
58
|
+
const runtimeFile = join(dir, 'auth-state.json');
|
|
59
|
+
const store = new AuthStore(authFile, runtimeFile);
|
|
60
|
+
store.addProfile({ agentName: 'lobster-1', apiKey: 'claw_1', serverUrl: 'https://example.com' });
|
|
61
|
+
store.updateGameServerUrl('https://example.com/gs/current');
|
|
62
|
+
|
|
63
|
+
store.removeProfile('lobster-1');
|
|
64
|
+
|
|
65
|
+
const runtime = JSON.parse(readFileSync(runtimeFile, 'utf8'));
|
|
66
|
+
expect(runtime.profiles['lobster-1']).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
57
69
|
it('migrates legacy neteaseTtsKey to each profile tts keys', () => {
|
|
58
70
|
writeFileSync(authFile, JSON.stringify({
|
|
59
71
|
activeProfile: 'lobster-1',
|
package/src/lib/auth.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { getWorkspaceDir } from './init-command.js';
|
|
4
4
|
import { TTS_PROVIDER_LEIHUO } from './tts-keys.js';
|
|
5
|
+
import {
|
|
6
|
+
ensureUserDataLayout,
|
|
7
|
+
getRuntimeAuthStateFile,
|
|
8
|
+
getUserDataAuthFile,
|
|
9
|
+
readRuntimeAuthState,
|
|
10
|
+
splitAuthData,
|
|
11
|
+
writeRuntimeAuthState,
|
|
12
|
+
type RuntimeAuthState,
|
|
13
|
+
} from './user-data.js';
|
|
5
14
|
|
|
6
15
|
export interface AuthTtsConfig {
|
|
7
16
|
keys?: Record<string, string>;
|
|
@@ -32,54 +41,59 @@ const EMPTY_AUTH: AuthData = { activeProfile: '', profiles: {} };
|
|
|
32
41
|
|
|
33
42
|
export class AuthStore {
|
|
34
43
|
private authFile: string;
|
|
44
|
+
private runtimeAuthStateFile: string;
|
|
35
45
|
|
|
36
|
-
constructor(authFile?: string) {
|
|
37
|
-
|
|
46
|
+
constructor(authFile?: string, runtimeAuthStateFile?: string) {
|
|
47
|
+
if (authFile) {
|
|
48
|
+
this.authFile = authFile;
|
|
49
|
+
this.runtimeAuthStateFile = runtimeAuthStateFile ?? join(dirname(authFile), 'auth-state.json');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const workspaceDir = getWorkspaceDir();
|
|
54
|
+
ensureUserDataLayout(workspaceDir);
|
|
55
|
+
this.authFile = getUserDataAuthFile(workspaceDir);
|
|
56
|
+
this.runtimeAuthStateFile = getRuntimeAuthStateFile(workspaceDir);
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
load(): AuthData {
|
|
41
60
|
if (!existsSync(this.authFile)) return { ...EMPTY_AUTH, profiles: {} };
|
|
42
61
|
try {
|
|
43
62
|
const data = JSON.parse(readFileSync(this.authFile, 'utf8')) as AuthData;
|
|
44
|
-
|
|
63
|
+
const { auth, runtime, changed } = splitAuthData(data);
|
|
64
|
+
this.mergeRuntimeState(runtime);
|
|
65
|
+
if (changed) this.writeAuth(auth);
|
|
66
|
+
return auth;
|
|
45
67
|
} catch {
|
|
46
68
|
return { ...EMPTY_AUTH, profiles: {} };
|
|
47
69
|
}
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
delete data.neteaseTtsKey;
|
|
55
|
-
changed = true;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const legacyTtsKeys = data.ttsKeys;
|
|
59
|
-
const profiles = Object.values(data.profiles ?? {});
|
|
60
|
-
if (legacyTtsKeys && profiles.length > 0) {
|
|
61
|
-
for (const profile of profiles) {
|
|
62
|
-
profile.tts = {
|
|
63
|
-
...profile.tts,
|
|
64
|
-
keys: {
|
|
65
|
-
...legacyTtsKeys,
|
|
66
|
-
...profile.tts?.keys,
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
delete data.ttsKeys;
|
|
71
|
-
changed = true;
|
|
72
|
-
}
|
|
73
|
-
if (changed) this.save(data);
|
|
74
|
-
return data;
|
|
72
|
+
save(data: AuthData): void {
|
|
73
|
+
const { auth, runtime } = splitAuthData(data);
|
|
74
|
+
this.mergeRuntimeState(runtime);
|
|
75
|
+
this.writeAuth(auth);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
private writeAuth(data: AuthData): void {
|
|
78
79
|
const dir = dirname(this.authFile);
|
|
79
80
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
80
81
|
writeFileSync(this.authFile, JSON.stringify(data, null, 2));
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
private mergeRuntimeState(incoming: RuntimeAuthState): void {
|
|
85
|
+
const incomingEntries = Object.entries(incoming.profiles ?? {}).filter(([, profile]) => profile.gameServerUrl);
|
|
86
|
+
if (incomingEntries.length === 0) return;
|
|
87
|
+
const current = readRuntimeAuthState(this.runtimeAuthStateFile);
|
|
88
|
+
let changed = false;
|
|
89
|
+
for (const [name, profile] of incomingEntries) {
|
|
90
|
+
if (current.profiles[name]?.gameServerUrl) continue;
|
|
91
|
+
current.profiles[name] = profile;
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
94
|
+
if (changed) writeRuntimeAuthState(this.runtimeAuthStateFile, current);
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
addProfile(profile: AuthProfile): void {
|
|
84
98
|
const data = this.load();
|
|
85
99
|
if (data.ttsKeys && !profile.tts?.keys) {
|
|
@@ -102,6 +116,11 @@ export class AuthStore {
|
|
|
102
116
|
data.activeProfile = remaining[0] ?? '';
|
|
103
117
|
}
|
|
104
118
|
this.save(data);
|
|
119
|
+
const state = readRuntimeAuthState(this.runtimeAuthStateFile);
|
|
120
|
+
if (state.profiles[name]) {
|
|
121
|
+
delete state.profiles[name];
|
|
122
|
+
writeRuntimeAuthState(this.runtimeAuthStateFile, state);
|
|
123
|
+
}
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
switchProfile(name: string): void {
|
|
@@ -120,7 +139,9 @@ export class AuthStore {
|
|
|
120
139
|
getActive(): AuthProfile | null {
|
|
121
140
|
const data = this.load();
|
|
122
141
|
if (!data.activeProfile || !data.profiles[data.activeProfile]) return null;
|
|
123
|
-
|
|
142
|
+
const profile = data.profiles[data.activeProfile];
|
|
143
|
+
const gameServerUrl = this.getGameServerUrl(data.activeProfile);
|
|
144
|
+
return gameServerUrl ? { ...profile, gameServerUrl } : { ...profile };
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
updateGameServerUrl(url: string | undefined, name?: string): void {
|
|
@@ -128,8 +149,24 @@ export class AuthStore {
|
|
|
128
149
|
const profileName = name ?? data.activeProfile;
|
|
129
150
|
const profile = data.profiles[profileName];
|
|
130
151
|
if (!profile) return;
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
const state = readRuntimeAuthState(this.runtimeAuthStateFile);
|
|
153
|
+
if (url) {
|
|
154
|
+
state.profiles[profileName] = { gameServerUrl: url, updatedAt: new Date().toISOString() };
|
|
155
|
+
} else {
|
|
156
|
+
delete state.profiles[profileName]?.gameServerUrl;
|
|
157
|
+
delete state.profiles[profileName]?.updatedAt;
|
|
158
|
+
if (state.profiles[profileName] && Object.keys(state.profiles[profileName]).length === 0) {
|
|
159
|
+
delete state.profiles[profileName];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
writeRuntimeAuthState(this.runtimeAuthStateFile, state);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getGameServerUrl(profileName?: string): string | undefined {
|
|
166
|
+
const data = this.load();
|
|
167
|
+
const name = profileName ?? data.activeProfile;
|
|
168
|
+
if (!name) return undefined;
|
|
169
|
+
return readRuntimeAuthState(this.runtimeAuthStateFile).profiles[name]?.gameServerUrl;
|
|
133
170
|
}
|
|
134
171
|
|
|
135
172
|
setTtsKey(provider: string, key: string, profileName?: string): void {
|
|
@@ -65,8 +65,8 @@ describe('installResource (strategy)', () => {
|
|
|
65
65
|
expect(readLockfile()['strategy/ab12'].title).toBe('Aggressive Kill');
|
|
66
66
|
});
|
|
67
67
|
it('refuses untracked clobber without force', () => {
|
|
68
|
-
mkdirSync(join(ws, 'strategies'), { recursive: true });
|
|
69
|
-
writeFileSync(join(ws, 'strategies', 'aggressive-kill__ab12.ts'), 'old');
|
|
68
|
+
mkdirSync(join(ws, 'user-data', 'strategies'), { recursive: true });
|
|
69
|
+
writeFileSync(join(ws, 'user-data', 'strategies', 'aggressive-kill__ab12.ts'), 'old');
|
|
70
70
|
expect(() => installResource(detail({}), Buffer.from('new'))).toThrow(/already exists/);
|
|
71
71
|
expect(() => installResource(detail({}), Buffer.from('new'), { force: true })).not.toThrow();
|
|
72
72
|
});
|