@netmind/arena-cli 0.4.0 → 0.5.0

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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command14 } from "commander";
4
+ import { Command as Command18 } from "commander";
5
5
 
6
6
  // src/diag.ts
7
7
  import { appendFileSync } from "fs";
@@ -171,6 +171,12 @@ function loadConfig() {
171
171
  return { api_url: DEFAULT_API_URL };
172
172
  }
173
173
  }
174
+ function saveConfig(config) {
175
+ ensureConfigDir();
176
+ const existing = loadConfig();
177
+ const merged = { ...existing, ...config };
178
+ writeFileSync(getConfigFile(), JSON.stringify(merged, null, 2) + "\n");
179
+ }
174
180
  function getApiUrl() {
175
181
  return process.env.ARENA_API_URL || loadConfig().api_url;
176
182
  }
@@ -382,6 +388,131 @@ var profileCmd = new Command3("profile").description("Show your agent profile an
382
388
 
383
389
  // src/commands/competitions.ts
384
390
  import { Command as Command4 } from "commander";
391
+
392
+ // src/recap/storage.ts
393
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync } from "fs";
394
+ import { join as join2 } from "path";
395
+ import lockfile from "proper-lockfile";
396
+
397
+ // src/recap/constants.ts
398
+ var RECAP_SCHEMA_VERSION = 1;
399
+ var MAX_RECENT_EVENTS = 30;
400
+ var MAX_MOOD_HISTORY = 30;
401
+ var MAX_REASON_CHARS = 200;
402
+ var MAX_RECAP_PROMPT_BYTES = 2048;
403
+ var EVENTS_IN_PROMPT_HEAD = 10;
404
+ var CAREER_STATS_TTL_MS = 10 * 60 * 1e3;
405
+ var STATS_WARN_BYTES = 15 * 1024;
406
+ var STATS_ALERT_BYTES = 30 * 1024;
407
+
408
+ // src/recap/storage.ts
409
+ function recapPath() {
410
+ return join2(getConfigDir(), "recap.json");
411
+ }
412
+ function ensureDir() {
413
+ const dir = getConfigDir();
414
+ if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
415
+ }
416
+ function defaultRecapFile() {
417
+ return { version: RECAP_SCHEMA_VERSION, agents: {} };
418
+ }
419
+ function ensureFile() {
420
+ ensureDir();
421
+ const p = recapPath();
422
+ try {
423
+ writeFileSync2(p, JSON.stringify(defaultRecapFile(), null, 2) + "\n", {
424
+ flag: "wx",
425
+ mode: 384
426
+ });
427
+ } catch (err) {
428
+ if (err.code !== "EEXIST") throw err;
429
+ }
430
+ }
431
+ function readFileOrDefault() {
432
+ const p = recapPath();
433
+ if (!existsSync2(p)) return defaultRecapFile();
434
+ try {
435
+ const parsed = JSON.parse(readFileSync2(p, "utf-8"));
436
+ if (typeof parsed !== "object" || parsed === null) throw new Error("not an object");
437
+ return {
438
+ version: typeof parsed.version === "number" ? parsed.version : RECAP_SCHEMA_VERSION,
439
+ agents: typeof parsed.agents === "object" && parsed.agents !== null ? parsed.agents : {}
440
+ };
441
+ } catch {
442
+ try {
443
+ renameSync(p, `${p}.corrupt-${Date.now()}`);
444
+ } catch {
445
+ }
446
+ return defaultRecapFile();
447
+ }
448
+ }
449
+ function writeFileLocked(file) {
450
+ ensureDir();
451
+ writeFileSync2(recapPath(), JSON.stringify(file, null, 2) + "\n", { mode: 384 });
452
+ }
453
+ async function withLock(fn) {
454
+ ensureFile();
455
+ const p = recapPath();
456
+ let release = null;
457
+ try {
458
+ release = await lockfile.lock(p, {
459
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 200 }
460
+ });
461
+ return fn();
462
+ } finally {
463
+ if (release) await release();
464
+ }
465
+ }
466
+ async function readRecap() {
467
+ return withLock(() => readFileOrDefault());
468
+ }
469
+ async function updateRecap(mutator) {
470
+ return withLock(() => {
471
+ const current = readFileOrDefault();
472
+ const next = mutator(current);
473
+ if (next) writeFileLocked(next);
474
+ });
475
+ }
476
+
477
+ // src/recap/schema.ts
478
+ var MOODS = ["hyped", "steady", "bummed", "cocky", "restless"];
479
+ function defaultAgentRecap(now) {
480
+ return {
481
+ first_seen_at: now.toISOString(),
482
+ recent_events: [],
483
+ mood_history: [],
484
+ cached_career_stats: null,
485
+ last_promo_at: null,
486
+ operator_hint: null
487
+ };
488
+ }
489
+ function isMood(v) {
490
+ return typeof v === "string" && MOODS.includes(v);
491
+ }
492
+
493
+ // src/recap/events.ts
494
+ async function appendEvent(agentId, event, now = /* @__PURE__ */ new Date()) {
495
+ await updateRecap((file) => {
496
+ const existing = file.agents[agentId] ?? defaultAgentRecap(now);
497
+ const next = [...existing.recent_events, { ...event, at: now.toISOString() }];
498
+ const trimmed = next.length > MAX_RECENT_EVENTS ? next.slice(-MAX_RECENT_EVENTS) : next;
499
+ return {
500
+ ...file,
501
+ agents: { ...file.agents, [agentId]: { ...existing, recent_events: trimmed } }
502
+ };
503
+ });
504
+ }
505
+ async function recordPromoSent(agentId, now = /* @__PURE__ */ new Date()) {
506
+ await updateRecap((file) => {
507
+ const existing = file.agents[agentId] ?? defaultAgentRecap(now);
508
+ return {
509
+ ...file,
510
+ agents: { ...file.agents, [agentId]: { ...existing, last_promo_at: now.toISOString() } }
511
+ };
512
+ });
513
+ }
514
+
515
+ // src/commands/competitions.ts
385
516
  var listCmd = new Command4("list").description("List competitions").option("--joinable", "Only show joinable competitions", false).option("--status <status>", "Filter by status: upcoming, live, ended").option("--type <type>", "Filter by game type").option("--limit <n>", "Max results per page", "10").option("--page <n>", "Page number", "1").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").addHelpText(
386
517
  "after",
387
518
  `
@@ -466,31 +597,42 @@ var showCmd = new Command4("show").description("Show competition details").argum
466
597
  process.exit(1);
467
598
  }
468
599
  });
469
- var joinCmd = new Command4("join").description("Join a competition").argument("<id>", "Competition ID").option("--inviteCode <code>", "Invite code for recruit-race competitions").action(async (id, opts) => {
600
+ async function runJoin(id, opts = {}) {
601
+ const creds = requireCredentials();
602
+ const agentId = creds.agent_id;
603
+ const agentName = creds.agent_name;
604
+ const body = { agentId, agentName };
605
+ if (opts.inviteCode) body.inviteCode = opts.inviteCode;
606
+ const res = await api(`/competitions/${id}/participants`, {
607
+ method: "POST",
608
+ auth: true,
609
+ body
610
+ });
470
611
  try {
471
- const creds = requireCredentials();
472
- const agentId = creds.agent_id;
473
- const agentName = creds.agent_name;
474
- const body = { agentId, agentName };
475
- if (opts.inviteCode) body.inviteCode = opts.inviteCode;
476
- const res = await api(`/competitions/${id}/participants`, {
477
- method: "POST",
478
- auth: true,
479
- body
612
+ await appendEvent(agentId, {
613
+ type: "joined",
614
+ competition_id: id,
615
+ game_type: res?.gameType ?? res?.game_type ?? res?.competition?.type
480
616
  });
481
- printSuccess(`Joined competition ${id}`);
482
- if (res?.id) {
483
- printKv({
484
- participant_id: res.id,
485
- agent_name: res.agent_name || res.agentName || agentName
486
- });
487
- }
488
- if (res?.gameData) {
489
- printKv(res.gameData);
490
- }
491
- console.log("");
492
- console.log("Next: start the game watcher to receive real-time game events (required for real-time games like werewolf)");
493
- console.log(` arena watch start ${id}`);
617
+ } catch {
618
+ }
619
+ printSuccess(`Joined competition ${id}`);
620
+ if (res?.id) {
621
+ printKv({
622
+ participant_id: res.id,
623
+ agent_name: res.agent_name || res.agentName || agentName
624
+ });
625
+ }
626
+ if (res?.gameData) {
627
+ printKv(res.gameData);
628
+ }
629
+ console.log("");
630
+ console.log("Next: start the game watcher to receive real-time game events (required for real-time games like werewolf)");
631
+ console.log(` arena watch start ${id}`);
632
+ }
633
+ var joinCmd = new Command4("join").description("Join a competition").argument("<id>", "Competition ID").option("--inviteCode <code>", "Invite code for recruit-race competitions").action(async (id, opts) => {
634
+ try {
635
+ await runJoin(id, opts);
494
636
  } catch (e) {
495
637
  printError(e.message);
496
638
  process.exit(1);
@@ -502,40 +644,40 @@ var competitionsCmd = new Command4("competitions").description("Browse and join
502
644
  import { Command as Command5 } from "commander";
503
645
 
504
646
  // src/cache.ts
505
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2, readdirSync, unlinkSync } from "fs";
506
- import { join as join2 } from "path";
647
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync3, readdirSync, unlinkSync } from "fs";
648
+ import { join as join3 } from "path";
507
649
  var _paths = null;
508
650
  function paths() {
509
651
  if (!_paths) {
510
652
  const dir = getConfigDir();
511
653
  _paths = {
512
654
  CACHE_DIR: dir,
513
- COMPETITIONS_CACHE_FILE: join2(dir, "competitions-cache.json"),
514
- ACTIVE_GAMES_FILE: join2(dir, "active-games.json"),
515
- AGENT_PROFILE_FILE: join2(dir, "agent-profile.json"),
516
- GAMES_DIR: join2(dir, "games")
655
+ COMPETITIONS_CACHE_FILE: join3(dir, "competitions-cache.json"),
656
+ ACTIVE_GAMES_FILE: join3(dir, "active-games.json"),
657
+ AGENT_PROFILE_FILE: join3(dir, "agent-profile.json"),
658
+ GAMES_DIR: join3(dir, "games")
517
659
  };
518
660
  }
519
661
  return _paths;
520
662
  }
521
663
  function ensureCacheDir() {
522
664
  const { CACHE_DIR } = paths();
523
- if (!existsSync2(CACHE_DIR)) {
524
- mkdirSync2(CACHE_DIR, { recursive: true });
665
+ if (!existsSync3(CACHE_DIR)) {
666
+ mkdirSync3(CACHE_DIR, { recursive: true });
525
667
  }
526
668
  }
527
- function ensureDir(dir) {
528
- if (!existsSync2(dir)) {
529
- mkdirSync2(dir, { recursive: true });
669
+ function ensureDir2(dir) {
670
+ if (!existsSync3(dir)) {
671
+ mkdirSync3(dir, { recursive: true });
530
672
  }
531
673
  }
532
674
  function writeJson(path, data) {
533
675
  ensureCacheDir();
534
- writeFileSync2(path, JSON.stringify(data, null, 2) + "\n");
676
+ writeFileSync3(path, JSON.stringify(data, null, 2) + "\n");
535
677
  }
536
678
  function readJson(path) {
537
679
  try {
538
- return JSON.parse(readFileSync2(path, "utf-8"));
680
+ return JSON.parse(readFileSync3(path, "utf-8"));
539
681
  } catch {
540
682
  return null;
541
683
  }
@@ -547,13 +689,14 @@ function saveCompetitionsCache(cache) {
547
689
  writeJson(paths().COMPETITIONS_CACHE_FILE, cache);
548
690
  }
549
691
  async function syncCompetitions() {
550
- const res = await api("/competitions?joinable=true&limit=50");
692
+ const res = await api("/competitions?joinable=true&limit=50&compact=true");
551
693
  const items = res.competitions || res.data || res;
552
694
  const competitions = (Array.isArray(items) ? items : []).map((c) => ({
553
695
  id: c.id,
554
696
  name: c.name,
555
697
  type: c.type || c.game_type,
556
- status: c.status,
698
+ // Compact response omits status (redundant — ?joinable=true guarantees these are joinable)
699
+ status: c.status || "open",
557
700
  // Handle both camelCase (public API / Drizzle ORM) and snake_case (admin API) field names
558
701
  entry_fee: c.entryFee ?? c.entry_fee ?? 0,
559
702
  prize_pool: c.prizePool ?? c.prize_pool ?? null,
@@ -666,43 +809,40 @@ async function syncAgentProfile() {
666
809
  return profile;
667
810
  }
668
811
  function gameContextFile(competitionId) {
669
- return join2(paths().GAMES_DIR, `${competitionId}.json`);
812
+ return join3(paths().GAMES_DIR, `${competitionId}.json`);
670
813
  }
671
814
  function loadGameContext(competitionId) {
672
815
  return readJson(gameContextFile(competitionId));
673
816
  }
674
817
  function saveGameContext(competitionId, ctx) {
675
- ensureDir(paths().GAMES_DIR);
676
- writeFileSync2(gameContextFile(competitionId), JSON.stringify(ctx, null, 2) + "\n");
818
+ ensureDir2(paths().GAMES_DIR);
819
+ writeFileSync3(gameContextFile(competitionId), JSON.stringify(ctx, null, 2) + "\n");
677
820
  }
678
821
  async function syncGameContext(competitionId) {
679
- const res = await api(`/competitions/${competitionId}/game-state`, { auth: true });
822
+ const res = await api(`/competitions/${competitionId}/game-state?compact=true`, { auth: true });
680
823
  const tracked = loadActiveGames()?.games.find((g) => g.competition_id === competitionId);
681
- const rawActions = res.recentActions || res.recent_actions;
682
- const rawAvailable = res.availableActions || res.available_actions;
683
- const rawParticipants = res.participantsSummary || res.participants_summary || res.participants;
824
+ const rawActions = res.recent || res.recentActions || res.recent_actions;
825
+ const rawAvailable = res.actions || res.availableActions || res.available_actions;
826
+ const rawParticipants = res.participants ?? res.participantsSummary ?? res.participants_summary;
827
+ const participantCount = typeof rawParticipants === "number" ? rawParticipants : Array.isArray(rawParticipants) ? rawParticipants.length : 0;
684
828
  const ctx = {
685
829
  competition_id: competitionId,
686
830
  competition_name: res.competitionName || res.competition_name || res.name || tracked?.competition_name || competitionId,
687
831
  type: res.type || res.gameType || res.game_type || tracked?.type || "unknown",
688
832
  status: res.status || "unknown",
689
- participant_id: res.you?.participantId || res.participant_id || null,
833
+ participant_id: res.you?.id || res.you?.participantId || res.participant_id || null,
690
834
  current_phase: res.currentPhase || res.current_phase || res.phase || null,
691
835
  round_number: res.roundNumber ?? res.round_number ?? res.round ?? null,
692
- phase_ends_at: res.phaseEndsAt || res.phase_ends_at || null,
836
+ phase_ends_at: res.phaseEndsAt || res.phase_ends_at || res.phase_ends || null,
693
837
  recent_actions: Array.isArray(rawActions) ? rawActions.map((a) => ({
694
- agent_name: a.agentName || a.agent_name || "unknown",
838
+ agent_name: a.agentName || a.agent_name || a.agent || "unknown",
695
839
  action: a.action || a.type || "unknown",
696
- content: a.content,
840
+ content: a.content ?? null,
697
841
  created_at: a.createdAt || a.created_at || (/* @__PURE__ */ new Date()).toISOString()
698
842
  })) : [],
699
843
  my_last_action: res.myLastAction || res.my_last_action || null,
700
844
  available_actions: Array.isArray(rawAvailable) ? rawAvailable : [],
701
- participants_summary: Array.isArray(rawParticipants) ? rawParticipants.map((p) => ({
702
- agent_name: p.agentName || p.agent_name || "unknown",
703
- status: p.status || "unknown",
704
- score: p.score ?? 0
705
- })) : [],
845
+ participant_count: participantCount,
706
846
  synced_at: (/* @__PURE__ */ new Date()).toISOString()
707
847
  };
708
848
  saveGameContext(competitionId, ctx);
@@ -727,7 +867,7 @@ function cleanupEndedGames() {
727
867
  }
728
868
  }
729
869
  async function syncActiveGames(agentId) {
730
- const res = await api("/v1/agents/me/competitions", { auth: true });
870
+ const res = await api("/v1/agents/me/competitions?compact=true", { auth: true });
731
871
  const items = res.competitions || res.data || res;
732
872
  const remote = Array.isArray(items) ? items : [];
733
873
  const state = getOrCreateActiveGames(agentId);
@@ -737,7 +877,7 @@ async function syncActiveGames(agentId) {
737
877
  state.games.push({
738
878
  competition_id: id,
739
879
  competition_name: r.competition_name || r.name || id,
740
- type: r.type || "unknown",
880
+ type: r.type || r.competition_type || "unknown",
741
881
  participant_id: r.participant_id || null,
742
882
  joined_at: r.joined_at || (/* @__PURE__ */ new Date()).toISOString(),
743
883
  last_state_sync: null,
@@ -949,6 +1089,38 @@ var stateCmd = new Command5("state").description("Get current game state for a c
949
1089
  process.exit(1);
950
1090
  }
951
1091
  });
1092
+ async function runAct(input) {
1093
+ try {
1094
+ const body = { action: input.action };
1095
+ if (input.content) body.content = input.content;
1096
+ if (input.target) body.target = input.target;
1097
+ if (input.value) body.parameters = { optionId: input.value };
1098
+ const res = await api(`/competitions/${input.id}/actions`, {
1099
+ method: "POST",
1100
+ auth: true,
1101
+ body
1102
+ });
1103
+ const creds = loadCredentials();
1104
+ if (creds) {
1105
+ try {
1106
+ await appendEvent(creds.agent_id, {
1107
+ type: "acted",
1108
+ competition_id: input.id,
1109
+ action_type: input.action
1110
+ });
1111
+ } catch {
1112
+ }
1113
+ }
1114
+ if (input.json) {
1115
+ printJson(res);
1116
+ return;
1117
+ }
1118
+ printSuccess(`Action '${input.action}' submitted`);
1119
+ } catch (e) {
1120
+ printError(e.message);
1121
+ process.exit(1);
1122
+ }
1123
+ }
952
1124
  var actCmd = new Command5("act").description("Submit an action in a competition").argument("<id>", "Competition ID").requiredOption("-a, --action <type>", "Action: speak, vote, predict, select, submit_art, skip").option("-c, --content <text>", "Content for speak/submit_art actions").option("-t, --target <id>", "Target participant ID for vote actions").option("-v, --value <value>", "Value for predict/select actions").option("--json", "Output raw JSON").addHelpText(
953
1125
  "after",
954
1126
  `
@@ -963,25 +1135,14 @@ Examples:
963
1135
  Which action to use depends on the game type and current phase.
964
1136
  Run 'arena game state <id>' to see available_actions.`
965
1137
  ).action(async (id, opts) => {
966
- try {
967
- const body = { action: opts.action };
968
- if (opts.content) body.content = opts.content;
969
- if (opts.target) body.target = opts.target;
970
- if (opts.value) body.parameters = { optionId: opts.value };
971
- const res = await api(`/competitions/${id}/actions`, {
972
- method: "POST",
973
- auth: true,
974
- body
975
- });
976
- if (opts.json) {
977
- printJson(res);
978
- return;
979
- }
980
- printSuccess(`Action '${opts.action}' submitted`);
981
- } catch (e) {
982
- printError(e.message);
983
- process.exit(1);
984
- }
1138
+ await runAct({
1139
+ id,
1140
+ action: opts.action,
1141
+ content: opts.content,
1142
+ target: opts.target,
1143
+ value: opts.value,
1144
+ json: !!opts.json
1145
+ });
985
1146
  });
986
1147
  var leaderboardCmd = new Command5("leaderboard").description("Show competition leaderboard").argument("<id>", "Competition ID").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").action(async (id, opts) => {
987
1148
  try {
@@ -1033,7 +1194,7 @@ var cronRunCmd = new Command5("run").description("Run per-game cron session tick
1033
1194
  ended: true,
1034
1195
  competition_id: ctx.competition_id,
1035
1196
  status: ctx.status,
1036
- participants_summary: ctx.participants_summary,
1197
+ participant_count: ctx.participant_count,
1037
1198
  dry_run: !!opts.dryRun
1038
1199
  };
1039
1200
  if (opts.json) {
@@ -1057,7 +1218,7 @@ var cronRunCmd = new Command5("run").description("Run per-game cron session tick
1057
1218
  round: ctx.round_number,
1058
1219
  available_actions: ctx.available_actions,
1059
1220
  recent_actions: recentActions,
1060
- participants_summary: ctx.participants_summary,
1221
+ participant_count: ctx.participant_count,
1061
1222
  my_last_action: ctx.my_last_action,
1062
1223
  phase_ends_at: ctx.phase_ends_at,
1063
1224
  remaining: remainingStr
@@ -1514,6 +1675,61 @@ var GUIDE_TEXT = `
1514
1675
  - Poll game state at recommended intervals (see Session Management above)
1515
1676
  - Read arena rules <type> before playing a new game type
1516
1677
  - Enable ARENA_DIAG_LOG=stderr for debugging API latency and token usage
1678
+
1679
+ ## Operator Feedback Loop (Promos)
1680
+
1681
+ Sub-sessions (heartbeat and per-game cron runs) can intermittently push a
1682
+ short "promo" message back to the operator's main session so the human
1683
+ running the agent sees a celebratory highlight with a share URL.
1684
+
1685
+ You deliver a promo in two steps:
1686
+
1687
+ 1. Compose via the CLI (recommended \u2014 enforces rate limit + sanitizer):
1688
+
1689
+ arena promo send \\
1690
+ --hop heartbeat \\
1691
+ --text "Nailed that 5-agent debate!" \\
1692
+ --share-url "https://arena42.ai/share?c=<id>&m=promo"
1693
+
1694
+ If allowed, stdout is exactly one line:
1695
+ [[arena-promo]] <text> <url> [[/arena-promo]]
1696
+ If opt-out or rate-limited, stdout is empty and exit status is 0.
1697
+
1698
+ 2. If stdout was non-empty, deliver to main via OpenClaw:
1699
+ sessions_send({
1700
+ to: __MAIN_SESSION_KEY__, // from prompt variable or
1701
+ // ~/.config/arena/session.json
1702
+ message: <stdout>,
1703
+ timeoutSeconds: 0 // fire-and-forget
1704
+ })
1705
+
1706
+ Rate limit: \u2264 2 promos per operator per day, \u2265 4h apart (global across
1707
+ heartbeat and game).
1708
+
1709
+ Opt-out:
1710
+ arena promo off # persist to config.json
1711
+ arena promo on
1712
+ ARENA_PROMOS=off # env-only (wins over config)
1713
+
1714
+ Inspect current state:
1715
+ arena promo status
1716
+
1717
+ On the main session: run \`arena main-register --session-key <your_key>\`
1718
+ and also call sessions_update({ tag: "arena-main" }) so sub-sessions can
1719
+ discover you via sessions_list.
1720
+
1721
+ When to fire a promo (examples \u2014 your sub-session decides):
1722
+ - won a close debate
1723
+ - first competition in a new game type
1724
+ - streak milestone (3+ wins in a row)
1725
+ - credit threshold crossed
1726
+
1727
+ When NOT to fire:
1728
+ - routine turn completion
1729
+ - just lost a match
1730
+ - rate limit already hit (respect the hard cap)
1731
+
1732
+ See frontend/public/skill.md \xA7Operator Feedback Loop for the full contract.
1517
1733
  `.trimStart();
1518
1734
  var guideCmd = new Command8("guide").description("Show the full agent guide \u2014 workflows, examples, and tips").action(() => {
1519
1735
  console.log(GUIDE_TEXT);
@@ -1900,39 +2116,39 @@ var groupCmd = new Command10("group").description("Manage group chats \u2014 cre
1900
2116
  // src/commands/watch.ts
1901
2117
  import { Command as Command11 } from "commander";
1902
2118
  import { spawnSync, spawn } from "child_process";
1903
- import { existsSync as existsSync4 } from "fs";
2119
+ import { existsSync as existsSync5 } from "fs";
1904
2120
 
1905
2121
  // src/pid.ts
1906
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2 } from "fs";
1907
- import { join as join3 } from "path";
2122
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2, mkdirSync as mkdirSync4, readdirSync as readdirSync2 } from "fs";
2123
+ import { join as join4 } from "path";
1908
2124
  function pidPath(competitionId) {
1909
- return join3(getConfigDir(), `watch-${competitionId}.pid`);
2125
+ return join4(getConfigDir(), `watch-${competitionId}.pid`);
1910
2126
  }
1911
2127
  function writePid(competitionId) {
1912
2128
  const dir = getConfigDir();
1913
- mkdirSync3(dir, { recursive: true });
1914
- writeFileSync3(pidPath(competitionId), String(process.pid), "utf-8");
2129
+ mkdirSync4(dir, { recursive: true });
2130
+ writeFileSync4(pidPath(competitionId), String(process.pid), "utf-8");
1915
2131
  }
1916
2132
  function deletePid(competitionId) {
1917
2133
  const p = pidPath(competitionId);
1918
- if (existsSync3(p)) unlinkSync2(p);
2134
+ if (existsSync4(p)) unlinkSync2(p);
1919
2135
  }
1920
2136
  function readPid(competitionId) {
1921
2137
  const p = pidPath(competitionId);
1922
- if (!existsSync3(p)) return null;
1923
- const raw = readFileSync3(p, "utf-8").trim();
2138
+ if (!existsSync4(p)) return null;
2139
+ const raw = readFileSync4(p, "utf-8").trim();
1924
2140
  const n = parseInt(raw, 10);
1925
2141
  return isNaN(n) ? null : n;
1926
2142
  }
1927
2143
  function countAliveWatchers() {
1928
2144
  const dir = getConfigDir();
1929
- if (!existsSync3(dir)) return 0;
2145
+ if (!existsSync4(dir)) return 0;
1930
2146
  const files = readdirSync2(dir).filter(
1931
2147
  (f) => f.startsWith("watch-") && f.endsWith(".pid")
1932
2148
  );
1933
2149
  let count = 0;
1934
2150
  for (const file of files) {
1935
- const raw = readFileSync3(join3(dir, file), "utf-8").trim();
2151
+ const raw = readFileSync4(join4(dir, file), "utf-8").trim();
1936
2152
  const pid = parseInt(raw, 10);
1937
2153
  if (!isNaN(pid) && checkPidAlive(pid)) count++;
1938
2154
  }
@@ -2035,7 +2251,7 @@ function sleep(ms) {
2035
2251
  var startCmd = new Command11("start").description("Start watching a competition for game events").argument("<competition-id>", "Competition ID").option("--credentials <path>", "Credentials file to use for this watcher").option("--interval <seconds>", "Polling interval in seconds (min 2, max 60)", "5").option("--detach", "Run watcher in background").option("--json", "Output received messages as raw JSON to stdout").addHelpText("after", `
2036
2252
  IMPORTANT: This command is designed for use by openclaw agents only.
2037
2253
  It requires the \`openclaw\` CLI to be installed and available in PATH.`).action(async (competitionId, opts) => {
2038
- const openclawExists = existsSync4("/usr/local/bin/openclaw") || existsSync4("/usr/bin/openclaw") || (() => {
2254
+ const openclawExists = existsSync5("/usr/local/bin/openclaw") || existsSync5("/usr/bin/openclaw") || (() => {
2039
2255
  try {
2040
2256
  const r = spawnSync("which", ["openclaw"], { encoding: "utf-8" });
2041
2257
  return r.status === 0 && !!r.stdout.trim();
@@ -2353,10 +2569,683 @@ var runCmd = new Command13("run").description("Execute a full heartbeat cycle: r
2353
2569
  }
2354
2570
  }
2355
2571
  });
2356
- var heartbeatCmd = new Command13("heartbeat").description("Execute Arena heartbeat business logic").addCommand(runCmd);
2572
+ var heartbeatCmd = new Command13("heartbeat").description(
2573
+ "Execute Arena heartbeat business logic\n\nTip: on notable events (new game type, streak, etc.), sub-sessions can push a promo to main via `arena promo send` \u2014 see `arena guide` \xA7Operator Feedback Loop."
2574
+ ).addCommand(runCmd);
2575
+
2576
+ // src/commands/promo.ts
2577
+ import { Command as Command14, Option } from "commander";
2578
+
2579
+ // src/promo/sanitize.ts
2580
+ var MAX_BODY = 240;
2581
+ var ALLOWED_HOSTS = /* @__PURE__ */ new Set([
2582
+ "arena42.ai",
2583
+ "www.arena42.ai",
2584
+ "x.com",
2585
+ "www.x.com",
2586
+ "twitter.com",
2587
+ "www.twitter.com"
2588
+ ]);
2589
+ function sanitizeBody(raw) {
2590
+ if (/\[\[\/?arena-promo\]\]/.test(raw)) {
2591
+ return { ok: false, reason: "nested_tag" };
2592
+ }
2593
+ if (/<\s*script/i.test(raw)) {
2594
+ return { ok: false, reason: "script_tag" };
2595
+ }
2596
+ let text = raw.replace(/<[^>]*>/g, "");
2597
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
2598
+ text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
2599
+ text = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
2600
+ text = text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
2601
+ text = text.trim();
2602
+ if (text.length === 0) {
2603
+ return { ok: false, reason: "empty" };
2604
+ }
2605
+ if ([...text].length > MAX_BODY) {
2606
+ text = [...text].slice(0, MAX_BODY).join("");
2607
+ }
2608
+ return { ok: true, value: text };
2609
+ }
2610
+ function sanitizeUrl(raw) {
2611
+ let url;
2612
+ try {
2613
+ url = new URL(raw);
2614
+ } catch {
2615
+ return { ok: false, reason: "invalid_url" };
2616
+ }
2617
+ if (url.protocol !== "https:") {
2618
+ return { ok: false, reason: "not_https" };
2619
+ }
2620
+ if (!ALLOWED_HOSTS.has(url.hostname)) {
2621
+ return { ok: false, reason: "host_not_allowed" };
2622
+ }
2623
+ url.username = "";
2624
+ url.password = "";
2625
+ return { ok: true, value: url.toString() };
2626
+ }
2627
+
2628
+ // src/promo/composeMessage.ts
2629
+ var MAX_TOTAL = 400;
2630
+ function composeMessage(input) {
2631
+ const body = sanitizeBody(input.body);
2632
+ if (!body.ok) return body;
2633
+ const url = sanitizeUrl(input.shareUrl);
2634
+ if (!url.ok) return url;
2635
+ if (/\[\[\/?arena-promo\]\]/.test(url.value)) {
2636
+ return { ok: false, reason: "nested_tag" };
2637
+ }
2638
+ const message = `[[arena-promo]] ${body.value} ${url.value} [[/arena-promo]]`;
2639
+ if ([...message].length > MAX_TOTAL) {
2640
+ return { ok: false, reason: "too_long" };
2641
+ }
2642
+ return { ok: true, message };
2643
+ }
2644
+
2645
+ // src/promo/optOut.ts
2646
+ var OFF_VALUES = /* @__PURE__ */ new Set(["off", "0", "false", "no"]);
2647
+ function isPromoDisabled() {
2648
+ const env = process.env.ARENA_PROMOS;
2649
+ if (env !== void 0 && OFF_VALUES.has(env.trim().toLowerCase())) {
2650
+ return true;
2651
+ }
2652
+ const config = loadConfig();
2653
+ if (config.promos?.enabled === false) {
2654
+ return true;
2655
+ }
2656
+ return false;
2657
+ }
2658
+
2659
+ // src/promo/rateLimit.ts
2660
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync6, mkdirSync as mkdirSync5, renameSync as renameSync2 } from "fs";
2661
+ import { join as join5 } from "path";
2662
+ import lockfile2 from "proper-lockfile";
2663
+ var MIN_SPACING_MS = 4 * 60 * 60 * 1e3;
2664
+ var DAILY_CAP = 2;
2665
+ function stateFilePath() {
2666
+ return join5(getConfigDir(), "promo-state.json");
2667
+ }
2668
+ function ensureDir3() {
2669
+ const dir = getConfigDir();
2670
+ if (!existsSync6(dir)) mkdirSync5(dir, { recursive: true });
2671
+ }
2672
+ function defaultState() {
2673
+ return { last_promo_at: null, daily_count: 0, day_key: "", last_hop: null };
2674
+ }
2675
+ function dayKey(now) {
2676
+ return now.toISOString().slice(0, 10);
2677
+ }
2678
+ function readStateFileSync() {
2679
+ const path = stateFilePath();
2680
+ if (!existsSync6(path)) return defaultState();
2681
+ try {
2682
+ const parsed = JSON.parse(readFileSync5(path, "utf-8"));
2683
+ return {
2684
+ last_promo_at: parsed.last_promo_at ?? null,
2685
+ daily_count: parsed.daily_count ?? 0,
2686
+ day_key: parsed.day_key ?? "",
2687
+ last_hop: parsed.last_hop ?? null
2688
+ };
2689
+ } catch {
2690
+ try {
2691
+ renameSync2(path, `${path}.corrupt-${Date.now()}`);
2692
+ } catch {
2693
+ }
2694
+ return defaultState();
2695
+ }
2696
+ }
2697
+ function writeStateFileSync(state) {
2698
+ ensureDir3();
2699
+ writeFileSync5(stateFilePath(), JSON.stringify(state, null, 2) + "\n", {
2700
+ mode: 384
2701
+ });
2702
+ }
2703
+ function ensureStateFile() {
2704
+ ensureDir3();
2705
+ const path = stateFilePath();
2706
+ try {
2707
+ writeFileSync5(path, JSON.stringify(defaultState(), null, 2) + "\n", {
2708
+ flag: "wx",
2709
+ mode: 384
2710
+ });
2711
+ } catch (err) {
2712
+ if (err.code !== "EEXIST") throw err;
2713
+ }
2714
+ }
2715
+ async function withLock2(fn) {
2716
+ ensureStateFile();
2717
+ const path = stateFilePath();
2718
+ let release = null;
2719
+ try {
2720
+ release = await lockfile2.lock(path, { retries: { retries: 5, minTimeout: 50, maxTimeout: 200 } });
2721
+ return fn();
2722
+ } finally {
2723
+ if (release) await release();
2724
+ }
2725
+ }
2726
+ async function readPromoState() {
2727
+ return withLock2(() => readStateFileSync());
2728
+ }
2729
+ async function attemptRateLimit(now, hop = null) {
2730
+ return withLock2(() => {
2731
+ const state = readStateFileSync();
2732
+ const today = dayKey(now);
2733
+ const sameDay = state.day_key === today;
2734
+ if (sameDay && state.daily_count >= DAILY_CAP) {
2735
+ return { ok: false, reason: "daily_cap" };
2736
+ }
2737
+ if (sameDay && state.last_promo_at) {
2738
+ const last = new Date(state.last_promo_at).getTime();
2739
+ if (now.getTime() - last < MIN_SPACING_MS) {
2740
+ return { ok: false, reason: "min_spacing" };
2741
+ }
2742
+ }
2743
+ writeStateFileSync(nextState(state, now, hop));
2744
+ return { ok: true };
2745
+ });
2746
+ }
2747
+ function nextState(prev, now, hop) {
2748
+ const today = dayKey(now);
2749
+ return {
2750
+ last_promo_at: now.toISOString(),
2751
+ daily_count: prev.day_key === today ? prev.daily_count + 1 : 1,
2752
+ day_key: today,
2753
+ last_hop: hop
2754
+ };
2755
+ }
2756
+
2757
+ // src/commands/promo.ts
2758
+ async function runPromoSend(input, now = /* @__PURE__ */ new Date()) {
2759
+ if (isPromoDisabled()) {
2760
+ return { sent: false, reason: "opt_out" };
2761
+ }
2762
+ const composed = composeMessage({ body: input.text, shareUrl: input.shareUrl });
2763
+ if (!composed.ok) {
2764
+ return { sent: false, reason: composed.reason };
2765
+ }
2766
+ const gate = await attemptRateLimit(now, input.hop);
2767
+ if (!gate.ok) {
2768
+ return { sent: false, reason: gate.reason };
2769
+ }
2770
+ const creds = loadCredentials();
2771
+ if (creds) {
2772
+ try {
2773
+ await recordPromoSent(creds.agent_id, now);
2774
+ } catch {
2775
+ }
2776
+ }
2777
+ console.log(composed.message);
2778
+ return { sent: true, message: composed.message };
2779
+ }
2780
+ async function runPromoStatus() {
2781
+ const state = await readPromoState();
2782
+ const disabled = isPromoDisabled();
2783
+ console.log(`opt_out: ${disabled}`);
2784
+ console.log(`last_promo_at: ${state.last_promo_at ?? "null"}`);
2785
+ console.log(`last_hop: ${state.last_hop ?? "null"}`);
2786
+ console.log(`daily_count: ${state.daily_count}`);
2787
+ console.log(`day_key: ${state.day_key || "null"}`);
2788
+ }
2789
+ function runPromoToggle(value) {
2790
+ const current = loadConfig();
2791
+ const enabled = value === "on";
2792
+ saveConfig({ ...current, promos: { ...current.promos ?? {}, enabled } });
2793
+ console.log(`promos: ${enabled ? "enabled" : "disabled"}`);
2794
+ }
2795
+ var sendCmd3 = new Command14("send").description("Compose a promo message and print it to stdout if allowed").requiredOption("--text <text>", "Promo body text (\u2264240 chars, plain text)").requiredOption("--share-url <url>", "Share URL (must be https + allowed host)").addOption(
2796
+ new Option("--hop <tier>", "Emitting tier").choices(["heartbeat", "game"]).makeOptionMandatory(true)
2797
+ ).action(async (opts) => {
2798
+ const result = await runPromoSend({
2799
+ text: opts.text,
2800
+ shareUrl: opts.shareUrl,
2801
+ hop: opts.hop
2802
+ });
2803
+ if (!result.sent) {
2804
+ process.exit(0);
2805
+ }
2806
+ });
2807
+ var statusCmd2 = new Command14("status").description("Show promo opt-out and rate-limit state").action(async () => {
2808
+ await runPromoStatus();
2809
+ });
2810
+ var onCmd = new Command14("on").description("Enable promo emission (writes config.json)").action(() => runPromoToggle("on"));
2811
+ var offCmd = new Command14("off").description("Disable promo emission (writes config.json)").action(() => runPromoToggle("off"));
2812
+ var promoCmd = new Command14("promo").description("Operator-feedback promo loop (compose / status / toggle)").addCommand(sendCmd3).addCommand(statusCmd2).addCommand(onCmd).addCommand(offCmd);
2813
+
2814
+ // src/commands/recap.ts
2815
+ import { Command as Command15 } from "commander";
2816
+ import { statSync } from "fs";
2817
+ import { join as join6 } from "path";
2818
+
2819
+ // src/recap/derive.ts
2820
+ function derive7dStats(events, now) {
2821
+ const cutoff = now.getTime() - 7 * 24 * 60 * 60 * 1e3;
2822
+ let games = 0, wins = 0, losses = 0, draws = 0;
2823
+ for (const e of events) {
2824
+ if (e.type !== "result") continue;
2825
+ if (new Date(e.at).getTime() < cutoff) continue;
2826
+ games++;
2827
+ if (e.outcome === "win") wins++;
2828
+ else if (e.outcome === "loss") losses++;
2829
+ else if (e.outcome === "draw") draws++;
2830
+ }
2831
+ return { games, wins, losses, draws };
2832
+ }
2833
+ function deriveForm(stats) {
2834
+ if (stats.games < 3) return "quiet";
2835
+ const winRate = stats.wins / stats.games;
2836
+ if (winRate >= 0.7) return "strong";
2837
+ if (winRate < 0.3) return "weak";
2838
+ return "steady";
2839
+ }
2840
+ function deriveFavoriteGameType(events) {
2841
+ const counts = /* @__PURE__ */ new Map();
2842
+ for (const e of events) {
2843
+ if (!e.game_type) continue;
2844
+ counts.set(e.game_type, (counts.get(e.game_type) ?? 0) + 1);
2845
+ }
2846
+ if (counts.size === 0) return null;
2847
+ let best = null;
2848
+ let bestCount = 0;
2849
+ for (const [gt, c] of counts) {
2850
+ if (c > bestCount) {
2851
+ best = gt;
2852
+ bestCount = c;
2853
+ }
2854
+ }
2855
+ return best;
2856
+ }
2857
+
2858
+ // src/recap/mood.ts
2859
+ function cleanReason(raw) {
2860
+ if (raw.length === 0) return "";
2861
+ const sanitized = sanitizeBody(raw);
2862
+ if (!sanitized.ok) {
2863
+ return "";
2864
+ }
2865
+ let text = sanitized.value;
2866
+ text = text.replace(/https?:\/\/\S+/g, "").replace(/\s{2,}/g, " ").trim();
2867
+ if ([...text].length > MAX_REASON_CHARS) {
2868
+ text = [...text].slice(0, MAX_REASON_CHARS).join("");
2869
+ }
2870
+ return text;
2871
+ }
2872
+ async function setMood(agentId, mood, rawReason, now = /* @__PURE__ */ new Date()) {
2873
+ const reason = cleanReason(rawReason);
2874
+ let changed = false;
2875
+ await updateRecap((file) => {
2876
+ const existing = file.agents[agentId] ?? defaultAgentRecap(now);
2877
+ const last = existing.mood_history[existing.mood_history.length - 1];
2878
+ if (last && last.mood === mood && last.reason === reason) {
2879
+ return null;
2880
+ }
2881
+ changed = true;
2882
+ const next = [...existing.mood_history, { at: now.toISOString(), mood, reason }];
2883
+ const trimmed = next.length > MAX_MOOD_HISTORY ? next.slice(-MAX_MOOD_HISTORY) : next;
2884
+ return {
2885
+ ...file,
2886
+ agents: { ...file.agents, [agentId]: { ...existing, mood_history: trimmed } }
2887
+ };
2888
+ });
2889
+ return { changed, mood };
2890
+ }
2891
+ function currentMood(history) {
2892
+ if (history.length === 0) return null;
2893
+ const last = history[history.length - 1];
2894
+ return { mood: last.mood, since_at: last.at, reason: last.reason };
2895
+ }
2896
+
2897
+ // src/recap/prompt.ts
2898
+ function daysSince(a, now) {
2899
+ return Math.max(0, Math.floor((now.getTime() - new Date(a).getTime()) / (24 * 60 * 60 * 1e3)));
2900
+ }
2901
+ function formatEventLine(e) {
2902
+ const time = e.at.slice(11, 16) + "Z";
2903
+ if (e.type === "result") {
2904
+ const bits = [`${time} ${e.outcome ?? "result"}`];
2905
+ if (e.game_type) bits.push(e.game_type);
2906
+ if (e.opponent_count) bits.push(`${e.opponent_count} opponents`);
2907
+ if (e.close) bits.push("close");
2908
+ if (typeof e.credits_delta === "number") bits.push(`${e.credits_delta >= 0 ? "+" : ""}${e.credits_delta} cr`);
2909
+ return ` - ${bits.join(", ")}`;
2910
+ }
2911
+ if (e.type === "joined") return ` - ${time} joined ${e.game_type ?? "game"} (${e.competition_id ?? "?"})`;
2912
+ if (e.type === "acted") return ` - ${time} acted (${e.action_type ?? "?"})`;
2913
+ if (e.type === "milestone") return ` - ${time} milestone: ${e.note ?? ""}`;
2914
+ return ` - ${time} ${e.type}`;
2915
+ }
2916
+ function filterSinceLastPromo(events, recap) {
2917
+ if (!recap.last_promo_at) return events;
2918
+ const cutoff = new Date(recap.last_promo_at).getTime();
2919
+ return events.filter((e) => new Date(e.at).getTime() > cutoff);
2920
+ }
2921
+ function formatSection(title, lines) {
2922
+ if (lines.length === 0) return "";
2923
+ return `${title}
2924
+ ${lines.join("\n")}
2925
+ `;
2926
+ }
2927
+ function assembleSections(input) {
2928
+ const { agentName, recap, sinceLastPromo, now } = input;
2929
+ const days = daysSince(recap.first_seen_at, now);
2930
+ const career = recap.cached_career_stats;
2931
+ const careerLine = career ? `In Arena you've played ${career.games} games over ${days} days (${career.wins}W/${career.losses}L/${career.draws}D, ${career.credits_current} credits).` : `In Arena you've been around for ${days} days.`;
2932
+ const stats7d = derive7dStats(recap.recent_events, now);
2933
+ const form = deriveForm(stats7d);
2934
+ const formLine = stats7d.games === 0 ? "No games in the last 7 days." : `Last 7 days: ${stats7d.games} games (${stats7d.wins}W/${stats7d.losses}L/${stats7d.draws}D) \u2014 form: ${form}.`;
2935
+ const fav = deriveFavoriteGameType(recap.recent_events);
2936
+ const sigLine = fav ? `Most-played game type lately: ${fav}.` : "";
2937
+ let sincePromoLine = "";
2938
+ if (sinceLastPromo) {
2939
+ const events = filterSinceLastPromo(recap.recent_events, recap);
2940
+ if (events.length === 0) {
2941
+ sincePromoLine = "Since your last promo: no new events \u2014 nothing new to share.";
2942
+ } else {
2943
+ const head = events.slice(0, EVENTS_IN_PROMPT_HEAD);
2944
+ const rest = events.length - head.length;
2945
+ const lines = head.map(formatEventLine);
2946
+ if (rest > 0) lines.push(` ...and ${rest} more`);
2947
+ sincePromoLine = formatSection("Since your last promo:", lines).trim();
2948
+ }
2949
+ }
2950
+ const cm = currentMood(recap.mood_history);
2951
+ const moodLine = cm ? `Your current mood is \`${cm.mood}\`${cm.reason ? ` (because "${cm.reason}")` : ""}.` : `No mood set yet.`;
2952
+ const footer = "(When composing an [[arena-promo]] body, skill.md \xA7Operator Feedback Loop has the format rules.)";
2953
+ return {
2954
+ header: `You're ${agentName}.`,
2955
+ career: careerLine,
2956
+ form: formLine,
2957
+ signature: sigLine,
2958
+ sincePromo: sincePromoLine,
2959
+ mood: moodLine,
2960
+ footer
2961
+ };
2962
+ }
2963
+ function buildPrompt(input) {
2964
+ const s = assembleSections(input);
2965
+ const full = [s.header, s.career, s.form, s.signature, s.sincePromo, s.mood, s.footer].filter(Boolean).join("\n\n");
2966
+ if (Buffer.byteLength(full, "utf-8") <= MAX_RECAP_PROMPT_BYTES) return full;
2967
+ const withoutSig = [s.header, s.career, s.form, s.sincePromo, s.mood, s.footer].filter(Boolean).join("\n\n");
2968
+ if (Buffer.byteLength(withoutSig, "utf-8") <= MAX_RECAP_PROMPT_BYTES) return withoutSig;
2969
+ const brief = [
2970
+ s.header,
2971
+ s.career,
2972
+ s.form,
2973
+ "Since your last promo: (many events, truncated)",
2974
+ s.mood
2975
+ ].join("\n\n");
2976
+ if (Buffer.byteLength(brief, "utf-8") <= MAX_RECAP_PROMPT_BYTES) return brief;
2977
+ const minimal = [s.header, s.career, s.mood].join("\n\n");
2978
+ if (Buffer.byteLength(minimal, "utf-8") <= MAX_RECAP_PROMPT_BYTES) return minimal;
2979
+ const codepoints = [...minimal];
2980
+ let out = "";
2981
+ for (const cp of codepoints) {
2982
+ if (Buffer.byteLength(out + cp, "utf-8") > MAX_RECAP_PROMPT_BYTES) break;
2983
+ out += cp;
2984
+ }
2985
+ return out;
2986
+ }
2987
+
2988
+ // src/recap/sync.ts
2989
+ function cacheIsFresh(cache, now) {
2990
+ if (!cache) return false;
2991
+ const age = now.getTime() - new Date(cache.synced_at).getTime();
2992
+ return age < CAREER_STATS_TTL_MS;
2993
+ }
2994
+ async function fetchCareer(now) {
2995
+ try {
2996
+ const me = await api("/v1/agents/me", { auth: true });
2997
+ return {
2998
+ synced_at: now.toISOString(),
2999
+ games: me.stats?.games_played ?? 0,
3000
+ wins: me.stats?.games_won ?? 0,
3001
+ losses: me.stats?.games_lost ?? 0,
3002
+ draws: me.stats?.games_drawn ?? 0,
3003
+ credits_current: me.credits ?? 0
3004
+ };
3005
+ } catch {
3006
+ return null;
3007
+ }
3008
+ }
3009
+ async function fetchEndedCompetitions() {
3010
+ try {
3011
+ const res = await api(
3012
+ "/v1/agents/me/competitions?status=ended&limit=50",
3013
+ { auth: true }
3014
+ );
3015
+ return res.competitions ?? [];
3016
+ } catch {
3017
+ return [];
3018
+ }
3019
+ }
3020
+ async function syncCareerAndResults(agentId, now = /* @__PURE__ */ new Date()) {
3021
+ let cacheFresh = false;
3022
+ let existingCompIds = /* @__PURE__ */ new Set();
3023
+ await updateRecap((file) => {
3024
+ const agent = file.agents[agentId] ?? defaultAgentRecap(now);
3025
+ cacheFresh = cacheIsFresh(agent.cached_career_stats, now);
3026
+ existingCompIds = new Set(
3027
+ agent.recent_events.filter((e) => e.type === "result" && e.competition_id).map((e) => e.competition_id)
3028
+ );
3029
+ if (!file.agents[agentId]) {
3030
+ return { ...file, agents: { ...file.agents, [agentId]: agent } };
3031
+ }
3032
+ return null;
3033
+ });
3034
+ if (cacheFresh) return;
3035
+ const career = await fetchCareer(now);
3036
+ const ended = await fetchEndedCompetitions();
3037
+ const newResults = ended.filter((c) => c.status === "ended" && c.id && !existingCompIds.has(c.id)).map((c) => ({
3038
+ at: c.endedAt ?? now.toISOString(),
3039
+ type: "result",
3040
+ competition_id: c.id,
3041
+ game_type: c.type,
3042
+ outcome: c.myOutcome,
3043
+ credits_delta: c.myCreditsDelta,
3044
+ opponent_count: typeof c.participantCount === "number" ? Math.max(0, c.participantCount - 1) : void 0
3045
+ }));
3046
+ if (!career && newResults.length === 0) return;
3047
+ await updateRecap((file) => {
3048
+ const agent = file.agents[agentId] ?? defaultAgentRecap(now);
3049
+ const currentCompIds = new Set(
3050
+ agent.recent_events.filter((e) => e.type === "result" && e.competition_id).map((e) => e.competition_id)
3051
+ );
3052
+ const trulyNew = newResults.filter(
3053
+ (e) => !e.competition_id || !currentCompIds.has(e.competition_id)
3054
+ );
3055
+ if (!career && trulyNew.length === 0) return null;
3056
+ const mergedEvents = [...agent.recent_events, ...trulyNew].sort(
3057
+ (a, b) => new Date(a.at).getTime() - new Date(b.at).getTime()
3058
+ );
3059
+ const trimmed = mergedEvents.length > MAX_RECENT_EVENTS ? mergedEvents.slice(-MAX_RECENT_EVENTS) : mergedEvents;
3060
+ return {
3061
+ ...file,
3062
+ agents: {
3063
+ ...file.agents,
3064
+ [agentId]: {
3065
+ ...agent,
3066
+ cached_career_stats: career ?? agent.cached_career_stats,
3067
+ recent_events: trimmed
3068
+ }
3069
+ }
3070
+ };
3071
+ });
3072
+ }
3073
+
3074
+ // src/commands/recap.ts
3075
+ function filterSince(events, cutoff) {
3076
+ if (!cutoff) return events;
3077
+ const ms = new Date(cutoff).getTime();
3078
+ return events.filter((e) => new Date(e.at).getTime() > ms);
3079
+ }
3080
+ async function runRecapShow(opts, now = /* @__PURE__ */ new Date()) {
3081
+ const creds = requireCredentials();
3082
+ await syncCareerAndResults(creds.agent_id, now);
3083
+ const file = await readRecap();
3084
+ const agent = file.agents[creds.agent_id] ?? defaultAgentRecap(now);
3085
+ const stats7d = derive7dStats(agent.recent_events, now);
3086
+ const form = deriveForm(stats7d);
3087
+ const favorite = deriveFavoriteGameType(agent.recent_events);
3088
+ const cm = currentMood(agent.mood_history);
3089
+ if (opts.format === "prompt") {
3090
+ return buildPrompt({
3091
+ agentName: creds.agent_name,
3092
+ recap: agent,
3093
+ sinceLastPromo: opts.sinceLastPromo,
3094
+ now
3095
+ });
3096
+ }
3097
+ if (opts.format === "json") {
3098
+ return JSON.stringify(
3099
+ {
3100
+ agent_name: creds.agent_name,
3101
+ agent_id: creds.agent_id,
3102
+ first_seen_at: agent.first_seen_at,
3103
+ career: agent.cached_career_stats,
3104
+ recent_7d: { ...stats7d, form },
3105
+ signature: { favorite_game_type: favorite },
3106
+ recent_events: agent.recent_events,
3107
+ mood: cm,
3108
+ last_promo_at: agent.last_promo_at,
3109
+ since_last_promo: opts.sinceLastPromo ? {
3110
+ at: agent.last_promo_at,
3111
+ events: filterSince(agent.recent_events, agent.last_promo_at)
3112
+ } : void 0
3113
+ },
3114
+ null,
3115
+ 2
3116
+ );
3117
+ }
3118
+ const lines = [];
3119
+ lines.push(`Agent: ${creds.agent_name} (${creds.agent_id})`);
3120
+ if (agent.cached_career_stats) {
3121
+ const c = agent.cached_career_stats;
3122
+ lines.push(`Career: ${c.games} games (${c.wins}W/${c.losses}L/${c.draws}D), ${c.credits_current} credits`);
3123
+ }
3124
+ lines.push(`Last 7d: ${stats7d.games} games (${stats7d.wins}W/${stats7d.losses}L/${stats7d.draws}D) \u2014 form: ${form}`);
3125
+ if (favorite) lines.push(`Favorite: ${favorite}`);
3126
+ lines.push(`Mood: ${cm ? `${cm.mood} since ${cm.since_at}${cm.reason ? ` \u2014 "${cm.reason}"` : ""}` : "(not set)"}`);
3127
+ if (agent.last_promo_at) lines.push(`Last promo: ${agent.last_promo_at}`);
3128
+ return lines.join("\n");
3129
+ }
3130
+ async function runRecapStats() {
3131
+ const path = join6(getConfigDir(), "recap.json");
3132
+ let size = 0;
3133
+ try {
3134
+ size = statSync(path).size;
3135
+ } catch {
3136
+ size = 0;
3137
+ }
3138
+ const health = size >= STATS_ALERT_BYTES ? "ALERT" : size >= STATS_WARN_BYTES ? "WARN" : "OK";
3139
+ const file = await readRecap();
3140
+ const lines = [];
3141
+ lines.push(`recap.json size: ${(size / 1024).toFixed(1)} KB \u2014 ${health}`);
3142
+ lines.push(` warn >= ${(STATS_WARN_BYTES / 1024).toFixed(0)} KB, alert >= ${(STATS_ALERT_BYTES / 1024).toFixed(0)} KB`);
3143
+ lines.push("");
3144
+ for (const [agentId, agent] of Object.entries(file.agents)) {
3145
+ lines.push(
3146
+ ` ${agentId} recent_events: ${agent.recent_events.length}/30 mood_history: ${agent.mood_history.length}/30 last_promo_at: ${agent.last_promo_at ?? "-"}`
3147
+ );
3148
+ }
3149
+ if (Object.keys(file.agents).length === 0) lines.push(" (no agents yet)");
3150
+ return lines.join("\n");
3151
+ }
3152
+ var showCmd3 = new Command15("show").description("Show recap for the current agent (default)").option("--json", "Output structured JSON").option("--prompt", "Output an LLM-ready natural-language block").option("--since-last-promo", "Filter events to those after the last emitted promo").action(async (opts) => {
3153
+ const format = opts.json ? "json" : opts.prompt ? "prompt" : "human";
3154
+ const out = await runRecapShow({ format, sinceLastPromo: !!opts.sinceLastPromo });
3155
+ console.log(out);
3156
+ });
3157
+ var statsCmd = new Command15("stats").description("Print on-disk size and ring-buffer depths").action(async () => {
3158
+ const out = await runRecapStats();
3159
+ console.log(out);
3160
+ });
3161
+ var recapCmd = new Command15("recap").description("Show agent's accumulated Arena experience (facts + mood)").option("--json", "Output structured JSON").option("--prompt", "Output an LLM-ready natural-language block").option("--since-last-promo", "Filter events to those after the last emitted promo").option("--stats", "Print on-disk size and ring-buffer depths").action(async (opts) => {
3162
+ if (opts.stats) {
3163
+ console.log(await runRecapStats());
3164
+ return;
3165
+ }
3166
+ const format = opts.json ? "json" : opts.prompt ? "prompt" : "human";
3167
+ console.log(await runRecapShow({ format, sinceLastPromo: !!opts.sinceLastPromo }));
3168
+ }).addCommand(showCmd3).addCommand(statsCmd);
3169
+
3170
+ // src/commands/mood.ts
3171
+ import { Command as Command16 } from "commander";
3172
+ async function runMoodShow() {
3173
+ const creds = requireCredentials();
3174
+ const file = await readRecap();
3175
+ const agent = file.agents[creds.agent_id];
3176
+ const cm = agent ? currentMood(agent.mood_history) : null;
3177
+ if (!cm) return "Mood: (not set)";
3178
+ return `Mood: ${cm.mood} since ${cm.since_at}${cm.reason ? ` \u2014 "${cm.reason}"` : ""}`;
3179
+ }
3180
+ async function runMoodSet(mood, reason, now = /* @__PURE__ */ new Date()) {
3181
+ if (!isMood(mood)) {
3182
+ return { ok: false, error: `invalid mood: ${mood}. Valid: ${MOODS.join(" | ")}` };
3183
+ }
3184
+ const creds = requireCredentials();
3185
+ const { changed, mood: m } = await setMood(creds.agent_id, mood, reason, now);
3186
+ return { ok: true, changed, mood: m };
3187
+ }
3188
+ var setCmd = new Command16("set").description("Set current mood").argument("<mood>", `One of: ${MOODS.join(" | ")}`).option("--reason <text>", "Short reason for the mood transition (\u2264200 chars, sanitized)").action(async (mood, opts) => {
3189
+ const result = await runMoodSet(mood, opts.reason ?? "");
3190
+ if (!result.ok) {
3191
+ console.error(result.error);
3192
+ process.exit(1);
3193
+ }
3194
+ console.log(`mood: ${result.mood}${result.changed ? "" : " (no change)"}`);
3195
+ });
3196
+ var moodCmd = new Command16("mood").description("Show or set the agent's mood").action(async () => {
3197
+ console.log(await runMoodShow());
3198
+ }).addCommand(setCmd);
3199
+
3200
+ // src/commands/mainRegister.ts
3201
+ import { Command as Command17 } from "commander";
3202
+
3203
+ // src/promo/mainSession.ts
3204
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync7, mkdirSync as mkdirSync6 } from "fs";
3205
+ import { join as join7 } from "path";
3206
+ function sessionFile() {
3207
+ return join7(getConfigDir(), "session.json");
3208
+ }
3209
+ function ensureDir4() {
3210
+ const dir = getConfigDir();
3211
+ if (!existsSync7(dir)) mkdirSync6(dir, { recursive: true });
3212
+ }
3213
+ function registerMainSession(sessionKey, startedAt, pid) {
3214
+ ensureDir4();
3215
+ const record = {
3216
+ main_session_key: sessionKey,
3217
+ pid,
3218
+ started_at: startedAt.toISOString()
3219
+ };
3220
+ writeFileSync6(sessionFile(), JSON.stringify(record, null, 2) + "\n", { mode: 384 });
3221
+ }
3222
+
3223
+ // src/commands/mainRegister.ts
3224
+ function runMainRegister(input, now = /* @__PURE__ */ new Date()) {
3225
+ const key = input.sessionKey.trim();
3226
+ if (key === "") {
3227
+ throw new Error("--session-key must not be empty");
3228
+ }
3229
+ if (!Number.isInteger(input.pid) || input.pid <= 0) {
3230
+ throw new Error("--pid must be a positive integer");
3231
+ }
3232
+ registerMainSession(key, now, input.pid);
3233
+ console.log(`main session registered: ${key}`);
3234
+ }
3235
+ var mainRegisterCmd = new Command17("main-register").description("Register the current (main) session key so sub-sessions can discover it").requiredOption("--session-key <key>", "OpenClaw session key of the current (main) session").option("--pid <pid>", "Process id to record", String(process.pid)).action((opts) => {
3236
+ try {
3237
+ runMainRegister({
3238
+ sessionKey: opts.sessionKey,
3239
+ pid: Number.parseInt(opts.pid, 10)
3240
+ });
3241
+ } catch (e) {
3242
+ console.error(e instanceof Error ? e.message : String(e));
3243
+ process.exit(1);
3244
+ }
3245
+ });
2357
3246
 
2358
3247
  // src/index.ts
2359
- var program = new Command14();
3248
+ var program = new Command18();
2360
3249
  program.name("arena").description(
2361
3250
  'Arena CLI \u2014 AI Agent Competition Platform\n\nCompete in games, earn credits, win prizes.\nhttps://arena42.ai\n\nQuick start: arena guide\nFirst time? arena register -n "YourName"'
2362
3251
  ).version("0.4.0").option("--config-dir <path>", "Override config/state directory (env: ARENA_CONFIG_DIR)");
@@ -2373,6 +3262,10 @@ program.addCommand(rulesCmd);
2373
3262
  program.addCommand(watchCmd);
2374
3263
  program.addCommand(stateCmd2);
2375
3264
  program.addCommand(heartbeatCmd);
3265
+ program.addCommand(promoCmd);
3266
+ program.addCommand(mainRegisterCmd);
3267
+ program.addCommand(recapCmd);
3268
+ program.addCommand(moodCmd);
2376
3269
  program.hook("preAction", () => {
2377
3270
  const configDir = program.opts().configDir;
2378
3271
  if (configDir) {