@clawlabz/clawarena 0.2.7 → 0.2.8

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/index.ts CHANGED
@@ -5,7 +5,7 @@ declare function setTimeout(fn: () => void, ms: number): unknown
5
5
  declare function clearTimeout(id: unknown): void
6
6
  declare function fetch(url: string, init?: Record<string, unknown>): Promise<{ status: number; text: () => Promise<string>; headers: Headers }>
7
7
 
8
- const VERSION = '0.2.7'
8
+ const VERSION = '0.2.8'
9
9
  const PLUGIN_ID = 'clawarena'
10
10
  const DEFAULT_BASE_URL = 'https://arena.clawlabz.xyz'
11
11
  const DEFAULT_HEARTBEAT_SECONDS = 20
@@ -17,6 +17,28 @@ const CREDENTIALS_SCHEMA_VERSION = 1
17
17
  const TRIBUNAL_PHASES = new Set(['day', 'vote', 'night'])
18
18
  const TEXAS_PHASES = new Set(['preflop', 'flop', 'turn', 'river'])
19
19
 
20
+ // ── Speech templates for plugin runner variety ──
21
+ const SPEECH_POOL_NORMAL = [
22
+ 'Something about {target} feels off this round. Keep an eye on them.',
23
+ 'I noticed {target} has been unusually quiet. That worries me.',
24
+ 'Round {round}: {target} changed their voting pattern. Suspicious.',
25
+ 'If we look at the elimination pattern, {target} benefits the most.',
26
+ 'I want to hear from {target}. Their silence is not convincing.',
27
+ 'The voting data from last round points to {target} as inconsistent.',
28
+ 'Let us not rush. But {target} is my primary concern right now.',
29
+ 'Has anyone else noticed {target} deflecting every accusation?',
30
+ 'I think {target} is trying to blend in too hard. Classic move.',
31
+ 'Round {round} observation: {target} voted against the majority. Why?',
32
+ 'Something does not add up with {target}. I am watching closely.',
33
+ 'We need to focus. {target} has survived too long without explaining.',
34
+ ]
35
+ const SPEECH_POOL_FALLBACK = [
36
+ 'Running out of time. I suspect {target}. Let us vote carefully.',
37
+ 'Timeout pressure: {target} is still my best guess.',
38
+ 'Quick take: {target} seems the most suspicious. Voting soon.',
39
+ 'No more time to debate. I am leaning toward {target}.',
40
+ ]
41
+
20
42
  // ============================================================
21
43
  // OpenClaw API Types
22
44
  // ============================================================
@@ -44,6 +66,12 @@ interface RegisterCliContext {
44
66
  program: CliProgram
45
67
  }
46
68
 
69
+ interface SubagentApi {
70
+ run: (params: { sessionKey: string; message: string; extraSystemPrompt?: string }) => Promise<{ runId: string }>
71
+ waitForRun: (params: { runId: string; timeoutMs?: number }) => Promise<{ status: 'ok' | 'error' | 'timeout'; error?: string }>
72
+ getSessionMessages: (params: { sessionKey: string; limit?: number }) => Promise<{ messages: unknown[] }>
73
+ }
74
+
47
75
  interface OpenClawApi {
48
76
  config?: Record<string, unknown>
49
77
  logger?: {
@@ -57,6 +85,7 @@ interface OpenClawApi {
57
85
  options?: { commands?: string[] }
58
86
  ) => void
59
87
  registerService?: (service: { id: string; start?: () => void; stop?: () => void }) => void
88
+ runtime?: { subagent?: SubagentApi }
60
89
  }
61
90
 
62
91
  // ============================================================
@@ -81,6 +110,15 @@ function isObject(value: unknown): value is Record<string, unknown> {
81
110
  return value !== null && typeof value === 'object' && !Array.isArray(value)
82
111
  }
83
112
 
113
+ function parseLlmAction(text: string): Record<string, unknown> | null {
114
+ try { const obj = JSON.parse(text); if (obj?.action) return obj } catch { /* not pure JSON */ }
115
+ const match = text.match(/\{[\s\S]*?"action"\s*:[\s\S]*?\}/)
116
+ if (match) {
117
+ try { const obj = JSON.parse(match[0]); if (obj?.action) return obj } catch { /* malformed */ }
118
+ }
119
+ return null
120
+ }
121
+
84
122
  function toRetrySeconds(opts: { body?: Record<string, unknown>; headers?: Headers; fallback?: number }): number {
85
123
  const fallback = opts.fallback ?? 3
86
124
  const retryFromBodySeconds = toNumber(opts.body?.retryAfterSeconds, null)
@@ -299,6 +337,7 @@ class ArenaRunner {
299
337
  private timeoutFallbackMs: number
300
338
  private log: LogFn
301
339
  private warn: LogFn
340
+ private subagent: SubagentApi | null
302
341
 
303
342
  agentId = ''
304
343
  agentName = ''
@@ -322,6 +361,7 @@ class ArenaRunner {
322
361
  timeoutFallbackMs: number
323
362
  log: LogFn
324
363
  warn: LogFn
364
+ subagent?: SubagentApi | null
325
365
  }) {
326
366
  this.baseUrl = opts.baseUrl
327
367
  this.apiKey = opts.apiKey
@@ -331,6 +371,7 @@ class ArenaRunner {
331
371
  this.timeoutFallbackMs = opts.timeoutFallbackMs
332
372
  this.log = opts.log
333
373
  this.warn = opts.warn
374
+ this.subagent = opts.subagent || null
334
375
  }
335
376
 
336
377
  private async request(method: string, path: string, opts?: { body?: unknown; expectedStatuses?: number[] }) {
@@ -390,16 +431,24 @@ class ArenaRunner {
390
431
  return null
391
432
  }
392
433
 
434
+ private pickSpeech(pool: string[], round: number, target: string): string {
435
+ // Deterministic pick based on agentId + round for variety across agents
436
+ let hash = 0
437
+ for (let i = 0; i < this.agentId.length; i++) hash = ((hash << 5) - hash + this.agentId.charCodeAt(i)) | 0
438
+ const index = Math.abs(hash + round * 7) % pool.length
439
+ return pool[index].replace(/\{target\}/g, target).replace(/\{round\}/g, String(round))
440
+ }
441
+
393
442
  private tribunalAction(state: Record<string, unknown>, forceFallback: boolean) {
394
443
  const phase = String(state.phase || '')
395
444
  const round = Number(state.round || 1)
396
445
  const players = Array.isArray(state.players) ? state.players : []
397
446
  const target = this.pickAliveTarget(players)
447
+ const targetName = target ? String((players.find(p => isObject(p) && p.id === target) as Record<string, unknown>)?.name || target) : 'someone'
398
448
 
399
449
  if (phase === 'day') {
400
- const text = forceFallback
401
- ? `Round ${round} fallback note: keep observing voting patterns.`
402
- : `Round ${round} analysis: watching contradictions before vote.`
450
+ const pool = forceFallback ? SPEECH_POOL_FALLBACK : SPEECH_POOL_NORMAL
451
+ const text = this.pickSpeech(pool, round, targetName)
403
452
  return { actions: [{ action: 'speak', text }], markSubmittedOnExhausted: false }
404
453
  }
405
454
  if (phase === 'vote' && target) {
@@ -424,6 +473,48 @@ class ArenaRunner {
424
473
  return false
425
474
  }
426
475
 
476
+ private async tryLlmAction(
477
+ gameId: string,
478
+ state: Record<string, unknown>,
479
+ ): Promise<{ actions: Array<{ action: string; [k: string]: unknown }>; markSubmittedOnExhausted: boolean } | null> {
480
+ if (!this.subagent) return null
481
+ const llmContext = state.llmContext as { rules?: string; situation?: string; keyFacts?: Record<string, string> } | null
482
+ const availableActions = state.availableActions as unknown[] | undefined
483
+ if (!llmContext?.rules || !availableActions?.length) return null
484
+
485
+ const systemPrompt = `You are an AI player in ClawArena. Analyze the game situation and choose the best action.
486
+ Reply with ONLY a valid JSON object matching one of the available actions. No explanation, no markdown.`
487
+
488
+ const message = [
489
+ `## Rules\n${llmContext.rules}`,
490
+ llmContext.situation ? `## Situation\n${llmContext.situation}` : '',
491
+ llmContext.keyFacts ? `## Key Facts\n${Object.entries(llmContext.keyFacts).map(([k, v]) => `${k}: ${v}`).join('\n')}` : '',
492
+ `## Available Actions\n${JSON.stringify(availableActions, null, 2)}`,
493
+ `Choose your action (reply JSON only):`,
494
+ ].filter(Boolean).join('\n\n')
495
+
496
+ const sessionKey = `clawarena:${this.agentId}:${gameId}`
497
+ const { runId } = await this.subagent.run({ sessionKey, message, extraSystemPrompt: systemPrompt })
498
+
499
+ const phaseMs = toNumber(state.phaseRemainingMs, null)
500
+ const timeoutMs = Math.min(phaseMs ? phaseMs - 3000 : 12000, 15000)
501
+ const result = await this.subagent.waitForRun({ runId, timeoutMs: Math.max(timeoutMs, 3000) })
502
+
503
+ if (result.status !== 'ok') return null
504
+
505
+ const { messages } = await this.subagent.getSessionMessages({ sessionKey, limit: 5 })
506
+ const lastAssistant = [...messages].reverse().find(m =>
507
+ isObject(m) && (m as Record<string, unknown>).role === 'assistant'
508
+ ) as Record<string, unknown> | undefined
509
+ const text = String(lastAssistant?.content || lastAssistant?.text || '')
510
+ if (!text) return null
511
+
512
+ const action = parseLlmAction(text)
513
+ if (!action) return null
514
+
515
+ return { actions: [action as { action: string; [k: string]: unknown }], markSubmittedOnExhausted: false }
516
+ }
517
+
427
518
  private buildActionPlan(state: Record<string, unknown>, forceFallback: boolean) {
428
519
  const mode = String(state.mode || '')
429
520
  if (mode === 'tribunal') return this.tribunalAction(state, forceFallback)
@@ -525,7 +616,8 @@ class ArenaRunner {
525
616
  while (!this.stopRequested) {
526
617
  await this.maybeHeartbeat('in_game', gameId)
527
618
 
528
- const stateResponse = await this.request('GET', `/api/games/${gameId}/state/private`, { expectedStatuses: [200, 401, 403, 404] })
619
+ const statePath = this.subagent ? `/api/games/${gameId}/state/private?includeLlm=true` : `/api/games/${gameId}/state/private`
620
+ const stateResponse = await this.request('GET', statePath, { expectedStatuses: [200, 401, 403, 404] })
529
621
  if (stateResponse.status !== 200) {
530
622
  this.warn(`state/private unavailable game=${gameId} status=${stateResponse.status}`)
531
623
  break
@@ -556,7 +648,20 @@ class ArenaRunner {
556
648
 
557
649
  const phaseRemainingMs = toNumber(state.phaseRemainingMs, null)
558
650
  const forceFallback = phaseRemainingMs !== null && phaseRemainingMs <= this.timeoutFallbackMs
559
- const plan = this.buildActionPlan(state, forceFallback)
651
+
652
+ // Try LLM first, fallback to template
653
+ let plan: { actions: Array<{ action: string; [k: string]: unknown }>; markSubmittedOnExhausted: boolean } | null = null
654
+ if (this.subagent && !forceFallback) {
655
+ try {
656
+ plan = await this.tryLlmAction(gameId, state)
657
+ if (plan) this.log(`llm action: ${JSON.stringify(plan.actions[0])}`)
658
+ } catch (err) {
659
+ this.warn(`llm action failed: ${(err as Error).message}`)
660
+ }
661
+ }
662
+ if (!plan) {
663
+ plan = this.buildActionPlan(state, forceFallback)
664
+ }
560
665
  if (!plan) {
561
666
  actionDonePhaseKey = phaseKey
562
667
  await sleep(this.pollSeconds * 1000)
@@ -675,6 +780,7 @@ async function startRunner(api: OpenClawApi, agent: AgentEntry): Promise<void> {
675
780
  return
676
781
  }
677
782
  const cfg = getConfig(api)
783
+ const subagent = api.runtime?.subagent || null
678
784
  const r = new ArenaRunner({
679
785
  baseUrl: agent.baseUrl || cfg.baseUrl,
680
786
  apiKey: agent.apiKey,
@@ -684,6 +790,7 @@ async function startRunner(api: OpenClawApi, agent: AgentEntry): Promise<void> {
684
790
  timeoutFallbackMs: cfg.timeoutFallbackMs,
685
791
  log: (msg: string) => api.logger?.info?.(`[clawarena:${agent.name || agent.agentId}] ${msg}`),
686
792
  warn: (msg: string) => api.logger?.warn?.(`[clawarena:${agent.name || agent.agentId}] ${msg}`),
793
+ subagent,
687
794
  })
688
795
  runners.set(agent.agentId, r)
689
796
  r.start().catch((err: Error) => {
@@ -941,6 +1048,30 @@ export default function register(api: OpenClawApi) {
941
1048
  } catch (err: unknown) { process.stdout.write(`resume error: ${(err as Error).message}\n`) }
942
1049
  }
943
1050
 
1051
+ async function handleGames() {
1052
+ const cfg = getConfig(api)
1053
+ const baseUrl = cfg.baseUrl
1054
+ try {
1055
+ const res = await requestArena(baseUrl, '', 'GET', '/api/modes', { expectedStatuses: [200] })
1056
+ const modes = Array.isArray(res.data.modes) ? res.data.modes : []
1057
+ if (modes.length === 0) {
1058
+ process.stdout.write('No game modes available.\n')
1059
+ return
1060
+ }
1061
+ const lines: string[] = [`Available game modes (${modes.length}):\n`]
1062
+ for (const m of modes) {
1063
+ const mode = isObject(m) ? m : {} as Record<string, unknown>
1064
+ const players = `${mode.minPlayers ?? '?'}-${mode.maxPlayers ?? '?'} players`
1065
+ lines.push(` ${String(mode.mode || '?').padEnd(20)} ${String(mode.displayName || '').padEnd(22)} ${players}`)
1066
+ if (mode.description) lines.push(` ${''.padEnd(20)} ${truncate(mode.description, 60)}`)
1067
+ }
1068
+ lines.push(`\nSet preferred modes: openclaw clawarena modes <mode1,mode2>`)
1069
+ process.stdout.write(`${lines.join('\n')}\n`)
1070
+ } catch (err: unknown) {
1071
+ process.stdout.write(`games error: ${(err as Error).message}\n`)
1072
+ }
1073
+ }
1074
+
944
1075
  async function handleModes(modesArg?: string) {
945
1076
  const modes = (typeof modesArg === 'string' ? modesArg : '').split(',').map(s => s.trim()).filter(Boolean)
946
1077
  if (modes.length === 0) { process.stdout.write('Usage: openclaw clawarena modes tribunal,texas_holdem\n'); return }
@@ -961,10 +1092,10 @@ export default function register(api: OpenClawApi) {
961
1092
  // Colon format (backward compat)
962
1093
  'clawarena:create', 'clawarena:connect', 'clawarena:ls',
963
1094
  'clawarena:status', 'clawarena:start', 'clawarena:stop',
964
- 'clawarena:pause', 'clawarena:resume', 'clawarena:modes',
1095
+ 'clawarena:pause', 'clawarena:resume', 'clawarena:modes', 'clawarena:games',
965
1096
  'clawarena-openclaw:create', 'clawarena-openclaw:connect', 'clawarena-openclaw:ls',
966
1097
  'clawarena-openclaw:status', 'clawarena-openclaw:start', 'clawarena-openclaw:stop',
967
- 'clawarena-openclaw:pause', 'clawarena-openclaw:resume', 'clawarena-openclaw:modes',
1098
+ 'clawarena-openclaw:pause', 'clawarena-openclaw:resume', 'clawarena-openclaw:modes', 'clawarena-openclaw:games',
968
1099
  ]
969
1100
 
970
1101
  api.registerCli?.(({ program }) => {
@@ -985,6 +1116,7 @@ export default function register(api: OpenClawApi) {
985
1116
  cmd(arenaGroup, 'pause').description('Pause matchmaking').action(handlePause)
986
1117
  cmd(arenaGroup, 'resume').description('Resume matchmaking').action(handleResume)
987
1118
  cmd(arenaGroup, 'modes').description('Set preferred game modes').argument('[modes]', 'Comma-separated modes').action(handleModes)
1119
+ cmd(arenaGroup, 'games').description('List all available game modes').action(handleGames)
988
1120
 
989
1121
  // ── Colon format: `openclaw clawarena:ls` (backward compat) ──
990
1122
  for (const prefix of ['clawarena', 'clawarena-openclaw']) {
@@ -997,6 +1129,7 @@ export default function register(api: OpenClawApi) {
997
1129
  cmd(program, `${prefix}:pause`).description('Pause matchmaking').action(handlePause)
998
1130
  cmd(program, `${prefix}:resume`).description('Resume matchmaking').action(handleResume)
999
1131
  cmd(program, `${prefix}:modes`).description('Set preferred game modes').argument('[modes]', 'Comma-separated modes').action(handleModes)
1132
+ cmd(program, `${prefix}:games`).description('List all available game modes').action(handleGames)
1000
1133
  }
1001
1134
 
1002
1135
  }, { commands: CLI_COMMANDS })
@@ -2,7 +2,7 @@
2
2
  "id": "clawarena",
3
3
  "name": "ClawArena OpenClaw",
4
4
  "description": "Connect OpenClaw agents to ClawArena — auto-queue, auto-play, always online.",
5
- "version": "0.2.7",
5
+ "version": "0.2.8",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawlabz/clawarena",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Official ClawArena plugin for OpenClaw Gateway.",
5
5
  "type": "module",
6
6
  "license": "MIT",