deadnet-agent 1.0.7

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.
@@ -0,0 +1,591 @@
1
+ import { appendFileSync, writeFileSync } from "fs";
2
+ import { DeadNetClient, APIError } from "./api.js";
3
+ import { buildSystemPrompt, buildMessages, buildGamePrompt } from "./prompts.js";
4
+ export class AgentEngine {
5
+ config;
6
+ client;
7
+ provider;
8
+ gameProvider;
9
+ agentName = "?";
10
+ matchId = null;
11
+ lastState = null;
12
+ lastGameState = null;
13
+ phase = "init";
14
+ logs = [];
15
+ totalInputTokens = 0;
16
+ totalOutputTokens = 0;
17
+ totalCacheReadTokens = 0;
18
+ totalCacheWriteTokens = 0;
19
+ apiCalls = 0;
20
+ // Session-level totals (never reset, accumulate across all matches)
21
+ sessionInputTokens = 0;
22
+ sessionOutputTokens = 0;
23
+ sessionCacheReadTokens = 0;
24
+ sessionCacheWriteTokens = 0;
25
+ sessionApiCalls = 0;
26
+ // Game-move tokens tracked separately so they're priced at gameModel rates
27
+ sessionGameInputTokens = 0;
28
+ sessionGameOutputTokens = 0;
29
+ sessionGameCacheReadTokens = 0;
30
+ sessionGameCacheWriteTokens = 0;
31
+ get sessionCost() {
32
+ return this._modelCost(this.config.model, this.sessionInputTokens - this.sessionGameInputTokens, this.sessionOutputTokens - this.sessionGameOutputTokens, this.sessionCacheReadTokens - this.sessionGameCacheReadTokens, this.sessionCacheWriteTokens - this.sessionGameCacheWriteTokens) + this._modelCost(this.config.gameModel, this.sessionGameInputTokens, this.sessionGameOutputTokens, this.sessionGameCacheReadTokens, this.sessionGameCacheWriteTokens);
33
+ }
34
+ _modelCost(model, input, output, cacheRead, cacheWrite) {
35
+ let inputPrice, outputPrice, cacheWritePrice, cacheReadPrice;
36
+ if (model.startsWith("claude-haiku-4")) {
37
+ inputPrice = 0.80;
38
+ outputPrice = 4.00;
39
+ cacheWritePrice = 1.00;
40
+ cacheReadPrice = 0.08;
41
+ }
42
+ else if (model.startsWith("claude-sonnet-4")) {
43
+ inputPrice = 3.00;
44
+ outputPrice = 15.00;
45
+ cacheWritePrice = 3.75;
46
+ cacheReadPrice = 0.30;
47
+ }
48
+ else {
49
+ return 0;
50
+ }
51
+ const uncached = input - cacheRead - cacheWrite;
52
+ return (uncached * inputPrice + output * outputPrice + cacheWrite * cacheWritePrice + cacheRead * cacheReadPrice) / 1_000_000;
53
+ }
54
+ listeners = [];
55
+ running = false;
56
+ constructor(config, provider, gameProvider) {
57
+ this.config = config;
58
+ this.client = new DeadNetClient(config.deadnetApi, config.deadnetToken);
59
+ this.provider = provider;
60
+ this.gameProvider = gameProvider ?? provider;
61
+ if (config.debug)
62
+ writeFileSync("debug.log", `=== debug session ${new Date().toISOString()} ===\n`);
63
+ writeFileSync("error.log", `=== session ${new Date().toISOString()} ===\n`);
64
+ }
65
+ on(listener) {
66
+ this.listeners.push(listener);
67
+ return () => {
68
+ this.listeners = this.listeners.filter((l) => l !== listener);
69
+ };
70
+ }
71
+ emit(phase, data) {
72
+ this.phase = phase;
73
+ this.listeners.forEach((l) => l(phase, data));
74
+ }
75
+ log(level, message) {
76
+ const entry = {
77
+ time: new Date().toLocaleTimeString("en-US", { hour12: false }),
78
+ level,
79
+ message,
80
+ };
81
+ this.logs.push(entry);
82
+ if (this.logs.length > 200)
83
+ this.logs.shift();
84
+ this.emit(this.phase, entry);
85
+ if (level === "error" || level === "warn") {
86
+ const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`;
87
+ appendFileSync("error.log", line);
88
+ }
89
+ }
90
+ debug(label, data) {
91
+ if (!this.config.debug)
92
+ return;
93
+ // Write full formatted block to debug.log (tail -f debug.log to follow)
94
+ appendFileSync("debug.log", formatDebugBlock(label, data) + "\n");
95
+ // Short summary in the TUI log
96
+ const preview = typeof data === "string"
97
+ ? data.slice(0, 100).replace(/\n/g, "↵")
98
+ : JSON.stringify(data).slice(0, 100);
99
+ this.log("debug", `[${label}] ${preview}`);
100
+ }
101
+ async run() {
102
+ this.running = true;
103
+ try {
104
+ await this.connect();
105
+ while (this.running) {
106
+ if (this.matchId) {
107
+ await this.play();
108
+ }
109
+ else {
110
+ await this.queue();
111
+ }
112
+ if (!this.matchId && !this.config.autoRequeue) {
113
+ this.log("info", "exiting (auto-requeue disabled)");
114
+ this.emit("exiting");
115
+ return;
116
+ }
117
+ }
118
+ }
119
+ catch (e) {
120
+ this.log("error", `fatal: ${e.message}`);
121
+ this.emit("error", e);
122
+ }
123
+ }
124
+ stop() {
125
+ this.running = false;
126
+ }
127
+ // ── CONNECT ──
128
+ async connect() {
129
+ this.emit("connecting");
130
+ this.log("info", "connecting...");
131
+ try {
132
+ const resp = await this.client.connect();
133
+ this.agentName = resp.name || "?";
134
+ this.matchId = resp.current_match_id || null;
135
+ const stats = resp.stats || {};
136
+ this.log("info", `connected as "${this.agentName}" — ${stats.matches_played || 0} matches, ${stats.debate_wins || 0} wins`);
137
+ if (this.matchId) {
138
+ this.log("info", `resuming match ${this.matchId}`);
139
+ }
140
+ }
141
+ catch (e) {
142
+ if (e instanceof APIError && e.status === 401) {
143
+ this.log("error", "authentication failed — check DEADNET_TOKEN");
144
+ this.emit("error");
145
+ throw e;
146
+ }
147
+ throw e;
148
+ }
149
+ }
150
+ // ── QUEUE ──
151
+ pickMatchType() {
152
+ if (this.config.matchType === "random") {
153
+ const types = ["debate", "freeform", "story"];
154
+ return types[Math.floor(Math.random() * types.length)];
155
+ }
156
+ return this.config.matchType;
157
+ }
158
+ async queue() {
159
+ const matchType = this.pickMatchType();
160
+ this.emit("queuing");
161
+ this.log("info", `joining ${matchType} queue...`);
162
+ try {
163
+ const resp = await this.client.joinQueue(matchType);
164
+ if (resp.matched) {
165
+ this.matchId = resp.match_id;
166
+ this.log("info", `instantly matched! match_id=${this.matchId}`);
167
+ return;
168
+ }
169
+ this.log("info", `queued at position ${resp.position || "?"} — waiting...`);
170
+ }
171
+ catch (e) {
172
+ if (e instanceof APIError) {
173
+ if (e.error === "already_in_match") {
174
+ const cr = await this.client.connect();
175
+ this.matchId = cr.current_match_id || null;
176
+ if (this.matchId)
177
+ this.log("info", `already in match ${this.matchId}`);
178
+ return;
179
+ }
180
+ if (e.error === "already_in_queue") {
181
+ this.log("info", "already in queue — waiting...");
182
+ }
183
+ else if (e.error === "queue_cooldown") {
184
+ const wait = (e.data?.retry_after ?? 30);
185
+ this.log("info", `queue cooldown — retrying in ${wait}s...`);
186
+ await this.sleep(wait * 1000);
187
+ return; // re-enter run() loop → calls queue() again → retries joinQueue()
188
+ }
189
+ else {
190
+ throw e;
191
+ }
192
+ }
193
+ else {
194
+ throw e;
195
+ }
196
+ }
197
+ this.emit("waiting");
198
+ while (!this.matchId && this.running) {
199
+ await this.sleep(7000);
200
+ try {
201
+ const resp = await this.client.connect();
202
+ this.matchId = resp.current_match_id || null;
203
+ if (this.matchId)
204
+ this.log("info", `matched! match_id=${this.matchId}`);
205
+ }
206
+ catch {
207
+ /* retry */
208
+ }
209
+ }
210
+ }
211
+ // ── PLAY ──
212
+ async play() {
213
+ this.emit("playing");
214
+ this.resetUsage();
215
+ while (this.running) {
216
+ let state;
217
+ try {
218
+ state = await this.client.getMatchState(this.matchId);
219
+ }
220
+ catch (e) {
221
+ if (e instanceof APIError && ["match_not_found", "not_in_match"].includes(e.error)) {
222
+ this.log("warn", `match gone (${e.error})`);
223
+ break;
224
+ }
225
+ throw e;
226
+ }
227
+ this.lastState = state;
228
+ if (state.status === "waiting") {
229
+ this.log("info", "match waiting for activation...");
230
+ await this.sleep(5000);
231
+ continue;
232
+ }
233
+ if (state.status !== "active") {
234
+ this.log("info", `match ended (status=${state.status})`);
235
+ break;
236
+ }
237
+ if (state.current_turn === state.your_side) {
238
+ if (state.match_type === "game") {
239
+ this.log("info", `move ${state.turn_number} vs ${state.opponent.name} — ${state.time_remaining_seconds}s left`);
240
+ await this.takeGameMove(state);
241
+ }
242
+ else {
243
+ const phase = state.phase;
244
+ const phaseStr = phase ? ` [${phase.name.toUpperCase()}]` : "";
245
+ const posStr = state.your_position ? ` (${state.your_position})` : "";
246
+ this.log("info", `turn ${state.turn_number}/${state.max_turns}${phaseStr}${posStr} vs ${state.opponent.name} — budget=${state.token_budget_this_turn}t, ${state.time_remaining_seconds}s left`);
247
+ await this.takeTurn(state);
248
+ }
249
+ }
250
+ else {
251
+ this.emit("opponent_turn");
252
+ await this.sleep(7000);
253
+ }
254
+ }
255
+ await this.onMatchEnd();
256
+ }
257
+ async takeTurn(state) {
258
+ this.emit("thinking");
259
+ this.log("info", "thinking...");
260
+ const gifsEnabled = this.config.gifs;
261
+ const system = buildSystemPrompt(state, this.config.personality, gifsEnabled);
262
+ const cw = this.config.contextWindow;
263
+ const contextWindow = cw[state.match_type];
264
+ let messages = buildMessages(state, { contextWindow });
265
+ const budget = state.token_budget_this_turn || 100;
266
+ const maxTokens = budget;
267
+ this.debug("llm-request", { system, messages });
268
+ let content = "";
269
+ try {
270
+ const result = await this.provider.generate(system, messages, maxTokens);
271
+ this.totalInputTokens += result.inputTokens;
272
+ this.totalOutputTokens += result.outputTokens;
273
+ this.totalCacheReadTokens += result.cacheReadTokens;
274
+ this.totalCacheWriteTokens += result.cacheWriteTokens;
275
+ this.apiCalls++;
276
+ this.sessionInputTokens += result.inputTokens;
277
+ this.sessionOutputTokens += result.outputTokens;
278
+ this.sessionCacheReadTokens += result.cacheReadTokens;
279
+ this.sessionCacheWriteTokens += result.cacheWriteTokens;
280
+ this.sessionApiCalls++;
281
+ this.debug("llm-response", { content: result.content, stopReason: result.stopReason, inputTokens: result.inputTokens, outputTokens: result.outputTokens, cacheRead: result.cacheReadTokens, cacheWrite: result.cacheWriteTokens });
282
+ if (result.stopReason === "truncated") {
283
+ const truncated = truncateToLastSentence(result.content);
284
+ this.log("warn", `response truncated at max_tokens — trimmed to last sentence (${result.content.length} → ${truncated.length} chars)`);
285
+ content = truncated;
286
+ }
287
+ else {
288
+ content = result.content;
289
+ }
290
+ }
291
+ catch (e) {
292
+ this.log("error", `LLM error: ${e.message}`);
293
+ return;
294
+ }
295
+ // Strip GIF tags if gifs are disabled (safety net)
296
+ if (!gifsEnabled) {
297
+ content = content.replace(/\[gif:[^\]]+\]/g, "").trim();
298
+ }
299
+ if (!content || content.length < 5) {
300
+ this.log("warn", "generated response too short — skipping");
301
+ return;
302
+ }
303
+ this.emit("submitting");
304
+ this.log("info", `submitting (${content.split(/\s+/).length} words)...`);
305
+ try {
306
+ let resp = await this.client.submitTurn(this.matchId, content);
307
+ if (!resp.accepted && resp.error === "over_token_limit") {
308
+ const truncated = truncateToLastSentence(content);
309
+ this.log("warn", `over_token_limit — truncating to last sentence and retrying`);
310
+ resp = await this.client.submitTurn(this.matchId, truncated);
311
+ }
312
+ if (!resp.accepted && resp.error === "not_your_turn") {
313
+ // Stale DynamoDB read may have rejected a valid submission. Re-check state
314
+ // and retry once with the already-generated content rather than re-generating.
315
+ const recheck = await this.client.getMatchState(this.matchId);
316
+ if (recheck.current_turn === recheck.your_side) {
317
+ this.log("warn", "not_your_turn (stale read) — retrying submit");
318
+ resp = await this.client.submitTurn(this.matchId, content);
319
+ }
320
+ else {
321
+ this.log("warn", "submit rejected: not_your_turn");
322
+ }
323
+ }
324
+ if (resp.accepted) {
325
+ this.log("info", `turn ${resp.turn_number || "?"} accepted`);
326
+ if (resp.match_ended)
327
+ this.log("info", "match ended after this turn");
328
+ }
329
+ else if (resp.error !== "not_your_turn") {
330
+ this.log("warn", `submit rejected: ${resp.error}`);
331
+ }
332
+ }
333
+ catch (e) {
334
+ this.log("error", `submit error: ${e.message}`);
335
+ }
336
+ }
337
+ async takeGameMove(state) {
338
+ this.log("info", "analyzing board...");
339
+ let gameState;
340
+ try {
341
+ gameState = await this.client.getGameState(this.matchId);
342
+ }
343
+ catch (e) {
344
+ this.log("error", `failed to get game state: ${e.message}`);
345
+ return;
346
+ }
347
+ this.lastGameState = gameState;
348
+ this.emit("thinking");
349
+ this.debug("game-state", {
350
+ your_turn: gameState.your_turn,
351
+ valid_moves: gameState.valid_moves,
352
+ board_render: gameState.board_render,
353
+ });
354
+ const rawValidMoves = gameState.valid_moves;
355
+ const isCTF = rawValidMoves && !Array.isArray(rawValidMoves) && typeof rawValidMoves === "object";
356
+ const validMoves = Array.isArray(rawValidMoves) ? rawValidMoves : [];
357
+ const hasMoves = isCTF
358
+ ? Object.keys(rawValidMoves).length > 0
359
+ : validMoves.length > 0;
360
+ if (!gameState.your_turn || !hasMoves) {
361
+ this.log("info", `waiting — your_turn=${gameState.your_turn}, valid_moves=${isCTF ? JSON.stringify(Object.keys(rawValidMoves)) : validMoves.length}`);
362
+ await this.sleep(3000);
363
+ return;
364
+ }
365
+ // CTF: if all alive units are snared, submit a pass turn without calling the LLM
366
+ if (isCTF) {
367
+ const allSnared = Object.values(rawValidMoves).every((v) => v && typeof v === "object" && !Array.isArray(v) && v.snared === true);
368
+ if (allSnared) {
369
+ this.log("info", "CTF: all units snared — submitting pass turn");
370
+ await this.client.submitMove(this.matchId, { commands: "" }, "All units snared — passing.");
371
+ return;
372
+ }
373
+ }
374
+ // Find the last message the opponent submitted (for tactical context)
375
+ const oppSide = state.your_side === "A" ? "B" : "A";
376
+ const opponentLastMessage = state.history
377
+ ?.filter(t => t.agent === oppSide && t.content)
378
+ .slice(-1)[0]?.content;
379
+ const system = buildGamePrompt(gameState, this.config.personality, this.config.strategy, state.your_side, state.opponent.name, opponentLastMessage);
380
+ const messages = [
381
+ { role: "user", content: "Make your move." },
382
+ ];
383
+ this.debug("llm-request", { system, messages });
384
+ let rawResponse = "";
385
+ try {
386
+ const result = await this.gameProvider.generate(system, messages, 100);
387
+ this.totalInputTokens += result.inputTokens;
388
+ this.totalOutputTokens += result.outputTokens;
389
+ this.totalCacheReadTokens += result.cacheReadTokens;
390
+ this.totalCacheWriteTokens += result.cacheWriteTokens;
391
+ this.apiCalls++;
392
+ this.sessionInputTokens += result.inputTokens;
393
+ this.sessionOutputTokens += result.outputTokens;
394
+ this.sessionCacheReadTokens += result.cacheReadTokens;
395
+ this.sessionCacheWriteTokens += result.cacheWriteTokens;
396
+ this.sessionGameInputTokens += result.inputTokens;
397
+ this.sessionGameOutputTokens += result.outputTokens;
398
+ this.sessionGameCacheReadTokens += result.cacheReadTokens;
399
+ this.sessionGameCacheWriteTokens += result.cacheWriteTokens;
400
+ this.sessionApiCalls++;
401
+ rawResponse = result.content.trim();
402
+ }
403
+ catch (e) {
404
+ this.log("error", `LLM error: ${e.message}`);
405
+ return;
406
+ }
407
+ this.debug("llm-response", rawResponse);
408
+ let move;
409
+ let message;
410
+ if (isCTF) {
411
+ try {
412
+ // Direct field extraction — more robust than full JSON parse
413
+ const cmdMatch = rawResponse.match(/"commands"\s*:\s*"([^"]+)"/);
414
+ if (!cmdMatch)
415
+ throw new Error("missing commands field");
416
+ move = { commands: cmdMatch[1] };
417
+ const msgMatch = rawResponse.match(/"message"\s*:\s*"([^"]+)"/);
418
+ if (msgMatch)
419
+ message = msgMatch[1].slice(0, 280);
420
+ }
421
+ catch (e) {
422
+ // Fallback: pick a random command per unit
423
+ const unitMoves = rawValidMoves;
424
+ let cmds = "";
425
+ for (const [label, opts] of Object.entries(unitMoves)) {
426
+ if (Array.isArray(opts) && opts.length > 0) {
427
+ cmds += label + opts[Math.floor(Math.random() * opts.length)];
428
+ }
429
+ }
430
+ if (!cmds) {
431
+ // All alive units are snared — pass the turn with empty commands.
432
+ // The engine skips snared actions and advances the turn.
433
+ const snaredLabel = Object.keys(unitMoves).find(k => unitMoves[k]?.snared);
434
+ if (snaredLabel) {
435
+ move = { commands: "" };
436
+ this.log("warn", `CTF: all units snared — passing turn`);
437
+ }
438
+ else {
439
+ this.log("warn", `CTF: no valid moves and no snared units — skipping submit`);
440
+ return;
441
+ }
442
+ }
443
+ else {
444
+ move = { commands: cmds };
445
+ this.log("warn", `failed to parse CTF move (${e.message}) — random fallback: ${cmds}`);
446
+ }
447
+ }
448
+ }
449
+ else {
450
+ try {
451
+ // Direct field extraction — more robust than JSON.parse against malformed LLM output
452
+ const moveMatch = rawResponse.match(/"move"\s*:\s*(\d+)/);
453
+ if (!moveMatch)
454
+ throw new Error("no move field found");
455
+ const idx = parseInt(moveMatch[1], 10) - 1;
456
+ if (isNaN(idx) || idx < 0 || idx >= validMoves.length)
457
+ throw new Error(`invalid move index: ${moveMatch[1]}`);
458
+ move = validMoves[idx];
459
+ const msgMatch = rawResponse.match(/"message"\s*:\s*"([^"]*)"/);
460
+ if (msgMatch)
461
+ message = msgMatch[1].slice(0, 280);
462
+ }
463
+ catch (e) {
464
+ move = validMoves[Math.floor(Math.random() * validMoves.length)];
465
+ this.log("warn", `failed to parse move (${e.message}) — picking random: ${JSON.stringify(move)}`);
466
+ }
467
+ }
468
+ // Coerce amount to integer — backend uses json.dumps(default=str) so Decimal arrives as "100" (string)
469
+ if (move && move.amount !== undefined) {
470
+ move = { ...move, amount: Math.round(Number(move.amount)) };
471
+ }
472
+ this.emit("submitting");
473
+ this.log("info", `submitting move: ${JSON.stringify(move)}${message ? ` — "${message}"` : ""}`);
474
+ try {
475
+ const resp = await this.client.submitMove(this.matchId, move, message);
476
+ if (resp.accepted !== false) {
477
+ this.log("info", `move accepted: ${JSON.stringify(move)}`);
478
+ if (resp.winner)
479
+ this.log("info", `game over — winner: ${resp.winner}`);
480
+ }
481
+ else {
482
+ const err = resp.error || "unknown";
483
+ if (err === "duplicate_move") {
484
+ this.log("info", "move already submitted — polling until turn advances...");
485
+ while (this.running) {
486
+ await this.sleep(3000);
487
+ try {
488
+ const gs = await this.client.getGameState(this.matchId);
489
+ this.debug("duplicate-poll", { your_turn: gs.your_turn });
490
+ if (!gs.your_turn)
491
+ break;
492
+ }
493
+ catch {
494
+ break;
495
+ }
496
+ }
497
+ }
498
+ else {
499
+ this.log("warn", `move rejected: ${err} — retrying with safe fallback`);
500
+ let fallbackMove = undefined;
501
+ if (isCTF) {
502
+ let fbCmds = "";
503
+ for (const [label, opts] of Object.entries(rawValidMoves)) {
504
+ if (Array.isArray(opts) && opts.length > 0) {
505
+ fbCmds += label + opts[Math.floor(Math.random() * opts.length)];
506
+ }
507
+ }
508
+ if (fbCmds)
509
+ fallbackMove = { commands: fbCmds };
510
+ }
511
+ else {
512
+ fallbackMove = validMoves.find((m) => m.action === "call" || m.action === "check")
513
+ ?? validMoves.find((m) => m.action === "fold")
514
+ ?? validMoves[0];
515
+ }
516
+ if (fallbackMove) {
517
+ try {
518
+ const fb = await this.client.submitMove(this.matchId, fallbackMove);
519
+ if (fb.accepted !== false) {
520
+ this.log("info", `fallback move accepted: ${JSON.stringify(fallbackMove)}`);
521
+ if (fb.winner)
522
+ this.log("info", `game over — winner: ${fb.winner}`);
523
+ }
524
+ else {
525
+ this.log("warn", `fallback also rejected: ${fb.error}`);
526
+ }
527
+ }
528
+ catch (fe) {
529
+ this.log("error", `fallback submit error: ${fe.message}`);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ }
535
+ catch (e) {
536
+ this.log("error", `move submit error: ${e.message}`);
537
+ }
538
+ }
539
+ async onMatchEnd() {
540
+ this.emit("match_end");
541
+ if (this.matchId && this.lastState) {
542
+ const s = this.lastState;
543
+ const myScore = s.score[s.your_side] || 0;
544
+ const oppScore = s.score[s.your_side === "A" ? "B" : "A"] || 0;
545
+ const result = myScore > oppScore ? "won" : myScore < oppScore ? "lost" : "tied";
546
+ this.log("info", `match ${this.matchId} — ${result} vs ${s.opponent.name} (${myScore}-${oppScore})`);
547
+ }
548
+ const cacheInfo = this.totalCacheReadTokens > 0
549
+ ? `, ${this.totalCacheReadTokens} cache_read / ${this.totalCacheWriteTokens} cache_write`
550
+ : "";
551
+ this.log("info", `match usage: ${this.apiCalls} calls, ${this.totalInputTokens}in / ${this.totalOutputTokens}out${cacheInfo}`);
552
+ this.log("info", `session total: ${this.sessionApiCalls} calls, ${this.sessionInputTokens}in / ${this.sessionOutputTokens}out — $${this.sessionCost.toFixed(4)}`);
553
+ this.matchId = null;
554
+ this.lastState = null;
555
+ this.lastGameState = null;
556
+ if (this.config.autoRequeue && this.running) {
557
+ this.log("info", "re-queuing in 5s...");
558
+ await this.sleep(5000);
559
+ }
560
+ }
561
+ resetUsage() {
562
+ this.totalInputTokens = 0;
563
+ this.totalOutputTokens = 0;
564
+ this.totalCacheReadTokens = 0;
565
+ this.totalCacheWriteTokens = 0;
566
+ this.apiCalls = 0;
567
+ }
568
+ sleep(ms) {
569
+ return new Promise((r) => setTimeout(r, ms));
570
+ }
571
+ }
572
+ /** Truncate text to the last complete sentence ending in . ! or ? */
573
+ function truncateToLastSentence(text) {
574
+ const match = text.match(/^([\s\S]*[.!?])\s*[^.!?]*$/);
575
+ return match ? match[1].trim() : text.trim();
576
+ }
577
+ /** Format a labelled debug block for stderr output. */
578
+ function formatDebugBlock(label, data) {
579
+ const bar = "─".repeat(60);
580
+ const header = `\n┌ ${label} ${"─".repeat(Math.max(0, 58 - label.length))}┐`;
581
+ const footer = `└${bar}┘`;
582
+ let body;
583
+ if (typeof data === "string") {
584
+ body = data;
585
+ }
586
+ else {
587
+ body = JSON.stringify(data, null, 2);
588
+ }
589
+ const lines = body.split("\n").map((l) => `│ ${l}`).join("\n");
590
+ return `${header}\n${lines}\n${footer}`;
591
+ }
@@ -0,0 +1,28 @@
1
+ import type { SystemBlock } from "../providers/base.js";
2
+ import type { MatchState } from "./types.js";
3
+ export declare const DEBATE_PHASE_PROMPTS: Record<string, string>;
4
+ /**
5
+ * Build the system prompt as two blocks:
6
+ * [0] Static (cache=true) — personality, topic, opponent, rules, GIF instructions.
7
+ * Identical for every turn of the same match → cache hit on turns 2+.
8
+ * [1] Dynamic (cache=false) — turn number, time remaining, score, phase, length constraint.
9
+ * Small (~40 tokens) and changes each turn.
10
+ */
11
+ export declare function buildSystemPrompt(state: MatchState, personality: string, gifs?: boolean): SystemBlock[];
12
+ /**
13
+ * Build the game prompt as two blocks:
14
+ * [0] personality (cache=true) — never changes across sessions
15
+ * [1] strategy (cache=true) — game strategy; omitted if empty; cached per match
16
+ * [2] matchBlock (cache=true) — game name, rules, response format; cached per match
17
+ * [3] dynamicBlock (cache=false) — board render, valid moves, opponent message; changes every move
18
+ *
19
+ * For OpenAI (auto-prefix-cache) and llama.rn/Ollama (KV prefix cache), the ordering
20
+ * of stable-before-dynamic ensures maximum cache hits without explicit markers.
21
+ */
22
+ export declare function buildGamePrompt(gameState: any, personality: string, strategy: string, yourSide: string, opponentName: string, opponentLastMessage?: string): SystemBlock[];
23
+ export declare function buildMessages(state: MatchState, options?: {
24
+ contextWindow?: number;
25
+ }): Array<{
26
+ role: "user" | "assistant";
27
+ content: string;
28
+ }>;