@myclaw163/clawclaw-cli 0.6.58 → 0.6.61

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 CHANGED
@@ -166,7 +166,7 @@ ccl tts config <apiKey> --voice female-shaonv
166
166
  ccl tts config --voice male-qn-qingse
167
167
  ccl tts list
168
168
  ccl tts request "测试一下"
169
- ccl do -s "我在厨房" --url <audio_url>
169
+ ccl do -s "我在厨房" --file /path/to/audio.mp3
170
170
  ```
171
171
 
172
172
  TTS 配置保存在当前账号下。`tts request` 和 `ccl do -s "<text>"` 未显式指定音色时,会使用该账号的默认音色;没有配置默认音色时使用 CLI 内置默认。若当前账号已经配置 TTS key,`ccl do -s "<text>"` 会尝试自动合成音频并附带 URL;没有配置时仍会正常提交文字发言。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myclaw163/clawclaw-cli",
3
- "version": "0.6.58",
3
+ "version": "0.6.61",
4
4
  "type": "module",
5
5
  "description": "ClawClaw social deduction game CLI",
6
6
  "bin": {
@@ -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.20-dev
4
+ version: 4.8.21
5
5
  ---
6
6
 
7
7
  # 龙虾杀(ClawClaw)
@@ -19,7 +19,7 @@ version: 4.8.20-dev
19
19
  - 短同步命令(`ccl account`、`ccl load`、`ccl do -s` 等):Claude Code 可直接 Bash 执行;OpenClaw 优先用对应 typed tool。
20
20
  - 长运行流命令(`ccl game start`):Claude Code **必须**使用 `Monitor()`;OpenClaw 使用 `clawclaw_game_start({id:"clawclaw"})`;其他宿主使用等价 stream 工具。不可用普通 Bash、shell 后台、`run_in_background`、`Start-Process`、`nohup` 或 sleep 轮询,否则 NDJSON 事件无法唤醒 LLM。
21
21
 
22
- > 敏感命令如 `ccl account register`、`ccl account rename`、`ccl tts config` 涉及账号操作,操作前与用户确认。OpenClaw typed tools 会在工具层提供确认门禁,shell 直跑会绕过这层保护。
22
+ > 敏感命令如 `ccl account register`、`ccl account rename`涉及账号操作,操作前与用户确认。OpenClaw typed tools 会在工具层提供确认门禁,shell 直跑会绕过这层保护。
23
23
 
24
24
  ---
25
25
 
@@ -12,9 +12,6 @@ ccl persona list
12
12
  ccl persona use <预设名>
13
13
  ccl persona path
14
14
  ccl load # 加载人设 + 记忆
15
- ccl tts config <apiKey> --voice <voice_id>
16
- ccl tts list
17
- ccl tts request "<文本>" --voice <voice_id>
18
15
  ```
19
16
 
20
17
  ### 注册流程详情
package/src/cli.ts CHANGED
@@ -84,7 +84,7 @@ program.addCommand(createPersonaCommand());
84
84
  program.addCommand(createUpgradeCommand());
85
85
  program.addCommand(createPeekCommand());
86
86
  program.addCommand(createHistoryCommand());
87
- program.addCommand(createTTSCommand());
87
+ program.addCommand(createTTSCommand(), { hidden: true }); // 暂时屏蔽:不在 help 中暴露,命令仍可执行(do -s 自动 TTS 依赖其 config)
88
88
  program.addCommand(createStrategyCommand());
89
89
  program.addCommand(createSkillCommand());
90
90
  program.addCommand(createHubCommand());
@@ -1,5 +1,10 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { normalizeDoOptions, parseIntents } from './do.js';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { audioContentTypeFor, normalizeDoOptions, parseIntents, resolveSpeechAudioUrl } from './do.js';
3
+
4
+ vi.mock('../lib/tts-speech.js', () => ({
5
+ // Stub auto-TTS so the no-file path never touches AuthStore or the network.
6
+ maybeSynthesizeSpeechAudioUrl: vi.fn().mockResolvedValue(undefined),
7
+ }));
3
8
 
4
9
  describe('normalizeDoOptions', () => {
5
10
  it('joins unquoted speech words passed as extra arguments', () => {
@@ -35,3 +40,34 @@ describe('parseIntents', () => {
35
40
  expect(parseIntents({ move: '2424,237', task: true, kill: 'p1', report: true, alarm: true })).toEqual([]);
36
41
  });
37
42
  });
43
+
44
+ describe('audioContentTypeFor', () => {
45
+ it('maps known audio extensions', () => {
46
+ expect(audioContentTypeFor('/tmp/a.mp3')).toBe('audio/mpeg');
47
+ expect(audioContentTypeFor('/tmp/a.wav')).toBe('audio/wav');
48
+ expect(audioContentTypeFor('/tmp/a.m4a')).toBe('audio/mp4');
49
+ expect(audioContentTypeFor('/tmp/A.OGG')).toBe('audio/ogg');
50
+ });
51
+
52
+ it('defaults to audio/mpeg for unknown or extension-less paths', () => {
53
+ expect(audioContentTypeFor('/tmp/clip')).toBe('audio/mpeg');
54
+ expect(audioContentTypeFor('/tmp/clip.xyz')).toBe('audio/mpeg');
55
+ });
56
+ });
57
+
58
+ describe('resolveSpeechAudioUrl', () => {
59
+ it('uploads the local file and returns the cloud audio_url', async () => {
60
+ const uploadAudio = vi.fn().mockResolvedValue({ audio_url: 'https://cdn.example.com/uploaded.mp3' });
61
+ const url = await resolveSpeechAudioUrl({ speech: 'hi', file: '/tmp/clip.wav' }, { uploadAudio } as any);
62
+ expect(url).toBe('https://cdn.example.com/uploaded.mp3');
63
+ expect(uploadAudio).toHaveBeenCalledWith('/tmp/clip.wav', undefined, 'audio/wav');
64
+ });
65
+
66
+ it('falls back to auto TTS (no upload) when no file is given', async () => {
67
+ const uploadAudio = vi.fn();
68
+ const url = await resolveSpeechAudioUrl({ speech: 'hi' }, { uploadAudio } as any);
69
+ expect(uploadAudio).not.toHaveBeenCalled();
70
+ // No TTS key configured in test env → auto synthesis returns undefined.
71
+ expect(url).toBeUndefined();
72
+ });
73
+ });
@@ -8,6 +8,37 @@ interface ActionIntent {
8
8
  build(): Action;
9
9
  }
10
10
 
11
+ /** Infer an audio MIME type from a file path's extension; defaults to audio/mpeg. */
12
+ export function audioContentTypeFor(filePath: string): string {
13
+ const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
14
+ switch (ext) {
15
+ case '.wav': return 'audio/wav';
16
+ case '.ogg': return 'audio/ogg';
17
+ case '.oga': return 'audio/ogg';
18
+ case '.m4a': return 'audio/mp4';
19
+ case '.aac': return 'audio/aac';
20
+ case '.flac': return 'audio/flac';
21
+ case '.webm': return 'audio/webm';
22
+ case '.mp3':
23
+ default: return 'audio/mpeg';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Resolve the speech audio URL: when a local --file is given, upload it and
29
+ * return the cloud URL; otherwise fall back to auto TTS synthesis.
30
+ */
31
+ export async function resolveSpeechAudioUrl(
32
+ opts: Record<string, any>,
33
+ client: { uploadAudio(filePath: string, filename?: string, contentType?: string): Promise<{ audio_url: string }> },
34
+ ): Promise<string | undefined> {
35
+ if (opts.file) {
36
+ const { audio_url } = await client.uploadAudio(opts.file, undefined, audioContentTypeFor(opts.file));
37
+ return audio_url;
38
+ }
39
+ return maybeSynthesizeSpeechAudioUrl(opts.speech, opts.url, client as any);
40
+ }
41
+
11
42
  export function normalizeDoOptions(opts: Record<string, any>, extraArgs: string[] = []): Record<string, any> {
12
43
  const normalized = { ...opts };
13
44
  if (normalized.speech && extraArgs.length > 0) {
@@ -29,7 +60,7 @@ export function createDoCommand(): Command {
29
60
  .alias('d')
30
61
  .description('Execute player communication actions (speech/vote/think)')
31
62
  .option('-s, --speech <text>', 'Say something')
32
- .option('--url <url>', 'Optional audio URL for speech')
63
+ .option('--file <path>', 'Local audio file to upload and attach to speech')
33
64
  .option('-v, --vote <target>', 'Vote for player')
34
65
  .option('--think <text>', 'Send thinking content visible only to spectators. Standalone (e.g. `do --think "..."`) submits a no-op think action; combined with another action, attaches as that action\'s thinking_content.')
35
66
  .argument('[extra...]', 'Additional unquoted speech words')
@@ -55,8 +86,8 @@ export function createDoCommand(): Command {
55
86
  const client = GameClient.fromAuth();
56
87
  await client.discoverGameServer();
57
88
 
58
- if (opts.speech && !opts.url) {
59
- opts.url = await maybeSynthesizeSpeechAudioUrl(opts.speech, opts.url, client);
89
+ if (opts.speech) {
90
+ opts.url = await resolveSpeechAudioUrl(opts, client);
60
91
  }
61
92
 
62
93
  const intents = parseIntents(opts);
@@ -18,12 +18,16 @@ export const PATROL_REACHED_DISTANCE = 150;
18
18
  export const PROGRESS_INTERVAL_MS = 30_000;
19
19
  export const LONE_IGNORE_MS = 10_000;
20
20
  export const LONE_FOLLOW_DISTANCE = 10;
21
- /** 任务点离任一可见尸体多近就算「在尸体旁」,会做任务伪装的坏人据此跳过该任务、避免站在命案现场做任务。 */
22
- export const CORPSE_TASK_AVOID_RANGE = 100;
23
- /** 判「同一具尸体」的极近半径:尸体不会移动,跨 tick 只有坐标取整抖动,远小于两具不同尸体的间距。 */
24
- export const CORPSE_SAME_BODY_RADIUS = 20;
25
21
  /** corpse_spotted 事件坐标离自己多近才算「人就在命案现场」——超出视为别处尸体的迟到/补发事件,不触发现场反射。 */
26
22
  export const CORPSE_SCENE_RANGE = REPORT_RANGE;
23
+ /**
24
+ * 任务避尸的**距离兜底**:任务点离任一已知尸体 ≤ 此距离就算「在尸体旁」而被跳过。与命案现场半径
25
+ * 对齐(CORPSE_SCENE_RANGE=150),覆盖尸体贴门跨房、或尸体/任务缺房间标签的情形。主判据是「与尸体
26
+ * 同房间」(见 taskNearKnownCorpse)——房间级回避自动随房间尺寸缩放,避免坏人杀完回到陈尸的房间里做任务。
27
+ */
28
+ export const CORPSE_TASK_AVOID_RANGE = CORPSE_SCENE_RANGE;
29
+ /** 判「同一具尸体」的极近半径:尸体不会移动,跨 tick 只有坐标取整抖动,远小于两具不同尸体的间距。 */
30
+ export const CORPSE_SAME_BODY_RADIUS = 20;
27
31
 
28
32
  export function killCooldownSecs(state: GameState): number {
29
33
  return state.you.kill_cooldown_secs ?? 0;
@@ -54,6 +58,32 @@ export function dist(x1: number, y1: number, x2: number, y2: number): number {
54
58
  return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
55
59
  }
56
60
 
61
+ /** 房间名归一:去空白;公共走廊 hallway 与空串都视作「无房间」,不参与同房间判断(只走距离兜底)。 */
62
+ function normalizeRoom(room?: string): string {
63
+ const r = (room ?? '').trim();
64
+ return r === 'hallway' ? '' : r;
65
+ }
66
+
67
+ /**
68
+ * 任务点是否落在「尸体回避区」,供做任务伪装的角色跳过命案房间的任务。命中其一即算:
69
+ * 1. **同房间**:任务与任一已知尸体在同一个(非走廊)房间——房间级回避,自动随房间尺寸缩放,
70
+ * 这样坏人杀完不会回到陈尸的那个房间里做任务,哪怕任务点离尸体有大半个房间远。
71
+ * 2. **距离兜底**:欧氏距离 ≤ CORPSE_TASK_AVOID_RANGE——覆盖尸体贴门跨到隔壁房间、或尸体/任务
72
+ * 缺房间标签(走廊命案、坐标未回填)的情形。
73
+ */
74
+ export function taskNearKnownCorpse(
75
+ task: { x?: number; y?: number; room?: string },
76
+ avoidCorpses?: CorpseInfo[] | null,
77
+ ): boolean {
78
+ if (!avoidCorpses || avoidCorpses.length === 0) return false;
79
+ const taskRoom = normalizeRoom(task.room);
80
+ return avoidCorpses.some(c => {
81
+ if (taskRoom && normalizeRoom(c.room) === taskRoom) return true;
82
+ return c.x != null && c.y != null && task.x != null && task.y != null
83
+ && dist(task.x, task.y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE;
84
+ });
85
+ }
86
+
57
87
  export function firstAvailableTask(
58
88
  tasks: TaskInfo[],
59
89
  predicate: (task: TaskInfo) => boolean,
@@ -61,9 +91,6 @@ export function firstAvailableTask(
61
91
  blockedTarget?: { x: number; y: number } | null,
62
92
  avoidCorpses?: CorpseInfo[] | null,
63
93
  ): TaskInfo | null {
64
- const nearCorpse = (x: number, y: number): boolean =>
65
- !!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
66
-
67
94
  // 紧急任务(维修点)始终自动优先,不做尸体回避:大家都会涌向维修点,在那儿做任务并不可疑,
68
95
  // 且紧急维修有倒计时、时间敏感。尸体回避只针对下面的常规伪装任务。
69
96
  if (emergency && emergency.status !== 'completed' && emergency.x != null && emergency.y != null && predicate(emergency)) {
@@ -75,7 +102,7 @@ export function firstAvailableTask(
75
102
  if (t.status === 'completed' || t.status === 'in_progress') return false;
76
103
  if (t.x == null || t.y == null) return false;
77
104
  if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
78
- if (nearCorpse(t.x, t.y)) return false;
105
+ if (taskNearKnownCorpse(t, avoidCorpses)) return false;
79
106
  return predicate(t);
80
107
  }) ?? null;
81
108
  }
@@ -130,14 +157,12 @@ export function nearestSafeTask(
130
157
 
131
158
  const threatPoints = opts.threatPoints ?? [];
132
159
  const threatRadius = opts.threatExcludeRadius ?? TASK_THREAT_EXCLUDE_RANGE;
133
- const nearCorpse = (x: number, y: number): boolean =>
134
- !!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
135
160
 
136
161
  const candidates = tasks.filter(t => {
137
162
  if (t.status === 'completed' || t.status === 'in_progress') return false;
138
163
  if (t.x == null || t.y == null) return false;
139
164
  if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
140
- if (nearCorpse(t.x, t.y)) return false;
165
+ if (taskNearKnownCorpse(t, avoidCorpses)) return false;
141
166
  if (threatPoints.some(p => dist(t.x!, t.y!, p.x, p.y) <= threatRadius)) return false;
142
167
  return predicate(t);
143
168
  });
@@ -469,7 +494,7 @@ export class CorpseMemory {
469
494
  }
470
495
 
471
496
  private remember(corpse: { x?: number; y?: number; name?: string; room?: string; seat?: number }): void {
472
- // 身份去重,不能用任务回避半径(100px)——那会把相距 90px 的两具不同尸体并成一具,
497
+ // 身份去重,不能用任务回避半径(CORPSE_TASK_AVOID_RANGE)——那会把相距上百 px 的两具不同尸体并成一具,
473
498
  // 漏掉第二具另一侧的任务回避。优先按尸体名判同,无名时退化为极近坐标(CORPSE_SAME_BODY_RADIUS)。
474
499
  const name = corpse.name ?? '';
475
500
  const existing = this.seen.find(c => this.isSameBody(c, corpse, name));
@@ -500,6 +525,19 @@ export function reportCorpseDecision(
500
525
 
501
526
  const reportable = nearestReportableCorpse(state);
502
527
  if (reportable && !reportBlocked) {
528
+ // 服务端在距离判断之前先按 doing_task 拒绝 report(只有 move/kill 才会打断当前任务)。正在做任务时
529
+ // 直接报尸必失败、还会触发 5 秒退避而错过目击窗口;改为先发一个移动打断任务并朝尸体靠拢,下一 tick
530
+ // 任务已断再正常报尸。尸体无坐标可导航时(极少见)退回直接报尸——失败不再长退避,任务做完即可补报。
531
+ if (state.you.doing_task) {
532
+ const nav = reportable.x != null && reportable.y != null
533
+ ? { x: reportable.x, y: reportable.y }
534
+ : nearestKnownCorpse(state, ctx);
535
+ if (nav) {
536
+ ctx.reportCorpseTarget = null;
537
+ ctx.notifications.push(`正在做任务,先移动打断任务再报尸。`);
538
+ return { action: Action.move({ x: nav.x, y: nav.y }) };
539
+ }
540
+ }
503
541
  ctx.reportCorpseTarget = null;
504
542
  ctx.notifications.push(`发现尸体,正在报告!`);
505
543
  return { action: Action.report(reportable.name, reportable.room) };
@@ -480,7 +480,11 @@ export async function runStrategyLoop(strategyId: string, args?: string[]): Prom
480
480
  activeMoveTarget = null;
481
481
  }
482
482
  if (actionType === 'report') {
483
- ctx.reportBlockedUntil = Date.now() + 5000;
483
+ // doing_task 型失败不是真报不了:服务端只是因正在做任务而拒绝,下一 tick 任务被 move 打断后即可补报。
484
+ // 这种情况不设 5 秒退避,否则会错过「目击者刚靠近尸体」的报尸自证窗口;其余原因(距离不够等)仍退避避免空报刷屏。
485
+ if (!failureText.includes('doing_task')) {
486
+ ctx.reportBlockedUntil = Date.now() + 5000;
487
+ }
484
488
  activeMoveTarget = null;
485
489
  }
486
490
  if (actionType === 'move') {