@hemansubedi/aether-ai 1.0.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.
Files changed (90) hide show
  1. package/.gitattributes +3 -0
  2. package/.github/workflows/live-stats.yml +42 -0
  3. package/.github/workflows/publish.yml +34 -0
  4. package/.github/workflows/update-preview.yml +41 -0
  5. package/INSTALL.md +59 -0
  6. package/LICENSE +21 -0
  7. package/README.md +397 -0
  8. package/assets/aether-arena.svg +72 -0
  9. package/assets/aether-banner.svg +62 -0
  10. package/assets/aether-router.svg +129 -0
  11. package/dist/agent.js +125 -0
  12. package/dist/arena.js +486 -0
  13. package/dist/checkpoint.js +105 -0
  14. package/dist/client.js +95 -0
  15. package/dist/combos.js +176 -0
  16. package/dist/commands.js +483 -0
  17. package/dist/config.js +104 -0
  18. package/dist/cost.js +176 -0
  19. package/dist/git.js +52 -0
  20. package/dist/health.js +81 -0
  21. package/dist/index.js +272 -0
  22. package/dist/keys.js +128 -0
  23. package/dist/memory.js +98 -0
  24. package/dist/modes.js +68 -0
  25. package/dist/providers/index.js +32 -0
  26. package/dist/providers/ollama.js +206 -0
  27. package/dist/providers/openai-compat.js +181 -0
  28. package/dist/providers/openrouter.js +189 -0
  29. package/dist/providers/registry.js +211 -0
  30. package/dist/router-engine.js +200 -0
  31. package/dist/router.js +171 -0
  32. package/dist/server.js +210 -0
  33. package/dist/session.js +97 -0
  34. package/dist/settings.js +97 -0
  35. package/dist/skills.js +100 -0
  36. package/dist/tokensaver.js +50 -0
  37. package/dist/tools/filesystem.js +243 -0
  38. package/dist/tools/git.js +53 -0
  39. package/dist/tools/glob.js +175 -0
  40. package/dist/tools/grep.js +193 -0
  41. package/dist/tools/registry.js +39 -0
  42. package/dist/tools/vision.js +140 -0
  43. package/dist/tools/websearch.js +118 -0
  44. package/dist/tui.js +562 -0
  45. package/dist/types.js +8 -0
  46. package/docs/preview.txt +51 -0
  47. package/docs/screenshots.md +110 -0
  48. package/docs/stats.md +5 -0
  49. package/install.ps1 +170 -0
  50. package/install.sh +196 -0
  51. package/package.json +34 -0
  52. package/scripts/generate-stats-card.ts +62 -0
  53. package/scripts/patch_index.ps1 +17 -0
  54. package/scripts/release.sh +7 -0
  55. package/src/agent.ts +146 -0
  56. package/src/arena.ts +584 -0
  57. package/src/checkpoint.ts +111 -0
  58. package/src/client.ts +172 -0
  59. package/src/combos.ts +199 -0
  60. package/src/commands.ts +973 -0
  61. package/src/config.ts +122 -0
  62. package/src/cost.ts +206 -0
  63. package/src/git.ts +68 -0
  64. package/src/health.ts +90 -0
  65. package/src/index.ts +281 -0
  66. package/src/keys.ts +135 -0
  67. package/src/memory.ts +101 -0
  68. package/src/modes.ts +84 -0
  69. package/src/providers/index.ts +59 -0
  70. package/src/providers/ollama.ts +222 -0
  71. package/src/providers/openai-compat.ts +188 -0
  72. package/src/providers/openrouter.ts +198 -0
  73. package/src/providers/registry.ts +223 -0
  74. package/src/router-engine.ts +214 -0
  75. package/src/router.ts +195 -0
  76. package/src/server.ts +242 -0
  77. package/src/session.ts +111 -0
  78. package/src/settings.ts +125 -0
  79. package/src/skills.ts +106 -0
  80. package/src/tokensaver.ts +57 -0
  81. package/src/tools/filesystem.ts +258 -0
  82. package/src/tools/git.ts +53 -0
  83. package/src/tools/glob.ts +180 -0
  84. package/src/tools/grep.ts +192 -0
  85. package/src/tools/registry.ts +54 -0
  86. package/src/tools/vision.ts +152 -0
  87. package/src/tools/websearch.ts +130 -0
  88. package/src/tui.ts +664 -0
  89. package/src/types.ts +77 -0
  90. package/tsconfig.json +16 -0
package/src/arena.ts ADDED
@@ -0,0 +1,584 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import type { ChatChunk, Message } from "./types.js";
5
+
6
+ const ARENA_FILE = path.join(os.homedir(), ".aether", "arena.json");
7
+ const ARENA_DIR = path.dirname(ARENA_FILE);
8
+ const LEADERBOARD_JSON = path.join(ARENA_DIR, "leaderboard.json");
9
+ const LEADERBOARD_MD = path.join(ARENA_DIR, "leaderboard.md");
10
+
11
+ export interface ArenaResult {
12
+ modelId: string;
13
+ text: string;
14
+ toolCalls: any[];
15
+ usage?: { input_tokens: number; output_tokens: number };
16
+ error?: string;
17
+ elapsedMs: number;
18
+ }
19
+
20
+ export interface RankingEntry {
21
+ modelId: string;
22
+ elo: number;
23
+ wins: number;
24
+ losses: number;
25
+ }
26
+
27
+ export interface TournamentSummary {
28
+ prompt: string;
29
+ matches: number;
30
+ rankings: RankingEntry[];
31
+ judge: string;
32
+ results: ArenaResult[];
33
+ }
34
+
35
+ export interface JudgeScore {
36
+ overall: number;
37
+ correctness: number;
38
+ clarity: number;
39
+ completeness: number;
40
+ helpfulness: number;
41
+ }
42
+
43
+ export interface JudgeVerdict {
44
+ scores: { modelA: JudgeScore; modelB: JudgeScore };
45
+ winner: "modelA" | "modelB" | "tie";
46
+ reasoning: string;
47
+ }
48
+
49
+ export interface HeadToHead {
50
+ winsA: number;
51
+ winsB: number;
52
+ ties: number;
53
+ total: number;
54
+ }
55
+
56
+ const K_FACTOR = 32;
57
+
58
+ export class Arena {
59
+ readonly router: any;
60
+ elo = new Map<string, number>();
61
+ wins = new Map<string, number>();
62
+ losses = new Map<string, number>();
63
+ headToHead = new Map<string, { winsA: number; winsB: number; ties: number; total: number }>();
64
+
65
+ constructor(router: any) {
66
+ this.router = router;
67
+ this.load();
68
+ }
69
+
70
+ private ensure(modelId: string): void {
71
+ if (!this.elo.has(modelId)) this.elo.set(modelId, 1200);
72
+ if (!this.wins.has(modelId)) this.wins.set(modelId, 0);
73
+ if (!this.losses.has(modelId)) this.losses.set(modelId, 0);
74
+ }
75
+
76
+ /** Run the same prompt through N models in parallel, streaming each result. */
77
+ async compare(
78
+ userMessage: string,
79
+ history: Message[],
80
+ modelIds: string[],
81
+ onResult: (modelId: string, chunk: ChatChunk) => void
82
+ ): Promise<ArenaResult[]> {
83
+ const settled = await Promise.all(
84
+ modelIds.map((modelId) => this.runOne(modelId, userMessage, history, onResult))
85
+ );
86
+ return settled.filter((r): r is ArenaResult => r !== null);
87
+ }
88
+
89
+ private async runOne(
90
+ modelId: string,
91
+ userMessage: string,
92
+ history: Message[],
93
+ onResult: (modelId: string, chunk: ChatChunk) => void
94
+ ): Promise<ArenaResult | null> {
95
+ const start = Date.now();
96
+ let text = "";
97
+ const toolCalls: any[] = [];
98
+ let usage: ArenaResult["usage"];
99
+ let error: string | undefined;
100
+ try {
101
+ const provider = await this.router.getModelProvider(modelId);
102
+ const messages: Message[] = [...history, { role: "user", content: userMessage }];
103
+ for await (const chunk of provider.chat(messages, [], { temperature: 0.7, maxTokens: 4096 })) {
104
+ onResult(modelId, chunk);
105
+ if (chunk.type === "text" && chunk.text) text += chunk.text;
106
+ if (chunk.type === "tool_call" && chunk.tool_call) toolCalls.push(chunk.tool_call);
107
+ if (chunk.type === "done") usage = chunk.usage;
108
+ if (chunk.type === "error" && chunk.error) error = chunk.error;
109
+ }
110
+ } catch (err) {
111
+ error = (err as Error).message;
112
+ }
113
+ return {
114
+ modelId,
115
+ text,
116
+ toolCalls,
117
+ usage,
118
+ error,
119
+ elapsedMs: Date.now() - start,
120
+ };
121
+ }
122
+
123
+ /** Collect full responses from each model for blind voting. */
124
+ async vote(
125
+ userMessage: string,
126
+ history: Message[],
127
+ modelIds: string[]
128
+ ): Promise<{ modelId: string; response: string }[]> {
129
+ const results = await this.compare(userMessage, history, modelIds, () => {});
130
+ return results.map((r) => ({ modelId: r.modelId, response: r.error ? `[error] ${r.error}` : r.text }));
131
+ }
132
+
133
+ recordMatch(winner: string, loser: string): void {
134
+ this.ensure(winner);
135
+ this.ensure(loser);
136
+ const eW = this.elo.get(winner)!;
137
+ const eL = this.elo.get(loser)!;
138
+ const expectedW = 1 / (1 + Math.pow(10, (eL - eW) / 400));
139
+ const expectedL = 1 - expectedW;
140
+ this.elo.set(winner, Math.round(eW + K_FACTOR * (1 - expectedW)));
141
+ this.elo.set(loser, Math.round(eL + K_FACTOR * (0 - expectedL)));
142
+ this.wins.set(winner, (this.wins.get(winner) ?? 0) + 1);
143
+ this.losses.set(loser, (this.losses.get(loser) ?? 0) + 1);
144
+ this.save();
145
+ }
146
+
147
+ getRankings(): RankingEntry[] {
148
+ const out: RankingEntry[] = [];
149
+ for (const modelId of this.elo.keys()) {
150
+ this.ensure(modelId);
151
+ out.push({
152
+ modelId,
153
+ elo: this.elo.get(modelId)!,
154
+ wins: this.wins.get(modelId)!,
155
+ losses: this.losses.get(modelId)!,
156
+ });
157
+ }
158
+ return out.sort((a, b) => b.elo - a.elo);
159
+ }
160
+
161
+ /** Render a side-by-side comparison string for display. */
162
+ render(results: ArenaResult[]): string {
163
+ if (results.length === 0) return "(no results)";
164
+
165
+ const cols = results.map((r) => ({
166
+ modelId: r.modelId,
167
+ text: r.error ? `⚠ ${r.text}` : r.text,
168
+ error: r.error,
169
+ elapsedMs: r.elapsedMs,
170
+ }));
171
+
172
+ const width = Math.max(20, ...cols.map((c) => c.modelId.length + 4));
173
+
174
+ const truncate = (s: string, max: number): string => {
175
+ if (s.length <= max) return s;
176
+ return s.slice(0, max - 1) + "\u2026";
177
+ };
178
+
179
+ const header = cols
180
+ .map((c) => truncate(c.modelId, width))
181
+ .join(" \u2502 ");
182
+ const sep = cols.map(() => "-".repeat(width)).join("-+-");
183
+
184
+ const maxLen = Math.max(...cols.map((c) => c.text.split("\n").length));
185
+ const body: string[] = [];
186
+ for (let i = 0; i < maxLen; i++) {
187
+ const row = cols
188
+ .map((c) => {
189
+ const src = c.text.split("\n")[i] ?? "";
190
+ return truncate(src, width).padEnd(width);
191
+ })
192
+ .join(" \u2502 ");
193
+ body.push(row);
194
+ }
195
+
196
+ const footer = cols
197
+ .map((c) => `${truncate(c.modelId, width)}: ${(c.elapsedMs / 1000).toFixed(2)}s`)
198
+ .join(" \u2502 ");
199
+
200
+ const lines: string[] = [header, sep, ...body, "", footer];
201
+ return lines.join("\n");
202
+ }
203
+
204
+ private splitColumns(results: ArenaResult[], width: number): string[] {
205
+ const maxLen = Math.max(...results.map((r) => r.text.split("\n").length));
206
+ const lines: string[] = [];
207
+ for (let i = 0; i < maxLen; i++) {
208
+ const cols = results.map((r) => {
209
+ const src = r.text.split("\n")[i] ?? "";
210
+ return (r.error ? `> ${src}` : src).padEnd(width);
211
+ });
212
+ lines.push(cols.join(" | "));
213
+ }
214
+ return lines;
215
+ }
216
+
217
+ private load(): void {
218
+ try {
219
+ if (!fs.existsSync(ARENA_FILE)) return;
220
+ const raw = fs.readFileSync(ARENA_FILE, "utf8");
221
+ const obj = JSON.parse(raw);
222
+ if (obj && typeof obj === "object") {
223
+ if (obj.elo && typeof obj.elo === "object") {
224
+ for (const [k, v] of Object.entries(obj.elo)) this.elo.set(k, Number(v) || 1200);
225
+ }
226
+ if (obj.wins && typeof obj.wins === "object") {
227
+ for (const [k, v] of Object.entries(obj.wins)) this.wins.set(k, Number(v) || 0);
228
+ }
229
+ if (obj.losses && typeof obj.losses === "object") {
230
+ for (const [k, v] of Object.entries(obj.losses)) this.losses.set(k, Number(v) || 0);
231
+ }
232
+ if (obj.headToHead && typeof obj.headToHead === "object") {
233
+ for (const [k, v] of Object.entries(obj.headToHead)) {
234
+ const h = v as any;
235
+ this.headToHead.set(k, {
236
+ winsA: Number(h.winsA) || 0,
237
+ winsB: Number(h.winsB) || 0,
238
+ ties: Number(h.ties) || 0,
239
+ total: Number(h.total) || 0,
240
+ });
241
+ }
242
+ }
243
+ }
244
+ } catch {
245
+ // ignore malformed arena file
246
+ }
247
+ }
248
+
249
+ private save(): void {
250
+ try {
251
+ if (!fs.existsSync(ARENA_DIR)) fs.mkdirSync(ARENA_DIR, { recursive: true });
252
+ const tmp = ARENA_FILE + ".tmp";
253
+ fs.writeFileSync(
254
+ tmp,
255
+ JSON.stringify(
256
+ {
257
+ elo: Object.fromEntries(this.elo),
258
+ wins: Object.fromEntries(this.wins),
259
+ losses: Object.fromEntries(this.losses),
260
+ headToHead: Object.fromEntries(this.headToHead),
261
+ },
262
+ null,
263
+ 2
264
+ ),
265
+ "utf8"
266
+ );
267
+ fs.renameSync(tmp, ARENA_FILE);
268
+ } catch {
269
+ // best-effort persistence
270
+ }
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Head-to-head
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /** Track direct confrontations between two specific models. */
278
+ h2h(modelA: string, modelB: string): HeadToHead {
279
+ const key = this.h2hKey(modelA, modelB);
280
+ const raw = this.headToHead.get(key);
281
+ if (raw) return { ...raw };
282
+ return { winsA: 0, winsB: 0, ties: 0, total: 0 };
283
+ }
284
+
285
+ private h2hKey(modelA: string, modelB: string): string {
286
+ return `${modelA}::${modelB}`;
287
+ }
288
+
289
+ private recordH2H(modelA: string, modelB: string, winner: "A" | "B" | "tie"): void {
290
+ const key = this.h2hKey(modelA, modelB);
291
+ const cur = this.headToHead.get(key) ?? { winsA: 0, winsB: 0, ties: 0, total: 0 };
292
+ cur.total += 1;
293
+ if (winner === "A") cur.winsA += 1;
294
+ else if (winner === "B") cur.winsB += 1;
295
+ else cur.ties += 1;
296
+ this.headToHead.set(key, cur);
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Leaderboard export
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /** Export all elo, wins, losses, and rankings as a JSON string. */
304
+ exportJSON(): string {
305
+ const rankings = this.getRankings();
306
+ const payload = {
307
+ generatedAt: Date.now(),
308
+ rankings: rankings.map((r) => ({
309
+ modelId: r.modelId,
310
+ elo: r.elo,
311
+ wins: r.wins,
312
+ losses: r.losses,
313
+ winRate: r.wins + r.losses > 0 ? r.wins / (r.wins + r.losses) : 0,
314
+ })),
315
+ elo: Object.fromEntries(this.elo),
316
+ wins: Object.fromEntries(this.wins),
317
+ losses: Object.fromEntries(this.losses),
318
+ headToHead: Object.fromEntries(this.headToHead),
319
+ };
320
+ return JSON.stringify(payload, null, 2);
321
+ }
322
+
323
+ /** Export the leaderboard as a markdown table. */
324
+ exportMarkdown(): string {
325
+ const rankings = this.getRankings();
326
+ if (rankings.length === 0) {
327
+ return "| Rank | Model | Elo | Wins | Losses | Winrate |\n|------|-------|-----|------|--------|--------|\n";
328
+ }
329
+ const lines: string[] = [];
330
+ lines.push("| Rank | Model | Elo | Wins | Losses | Winrate |");
331
+ lines.push("|------|-------|-----|------|--------|--------|");
332
+ rankings.forEach((r, i) => {
333
+ const total = r.wins + r.losses;
334
+ const winrate = total > 0 ? (r.wins / total) * 100 : 0;
335
+ lines.push(`| ${i + 1} | ${r.modelId} | ${r.elo} | ${r.wins} | ${r.losses} | ${winrate.toFixed(1)}% |`);
336
+ });
337
+ return lines.join("\n");
338
+ }
339
+
340
+ /** Write the leaderboard to disk in the requested format. */
341
+ saveLeaderboard(format: "json" | "md"): void {
342
+ try {
343
+ if (!fs.existsSync(ARENA_DIR)) fs.mkdirSync(ARENA_DIR, { recursive: true });
344
+ const target = format === "json" ? LEADERBOARD_JSON : LEADERBOARD_MD;
345
+ const content = format === "json" ? this.exportJSON() : this.exportMarkdown();
346
+ fs.writeFileSync(target, content, "utf8");
347
+ } catch {
348
+ // best-effort persistence
349
+ }
350
+ }
351
+
352
+ /** Read the leaderboard back from disk. Returns null if missing/invalid. */
353
+ static loadLeaderboard(): {
354
+ rankings: RankingEntry[];
355
+ elo: Record<string, number>;
356
+ wins: Record<string, number>;
357
+ losses: Record<string, number>;
358
+ } | null {
359
+ try {
360
+ if (!fs.existsSync(LEADERBOARD_JSON)) return null;
361
+ const raw = fs.readFileSync(LEADERBOARD_JSON, "utf8");
362
+ const obj = JSON.parse(raw);
363
+ if (!obj || typeof obj !== "object") return null;
364
+ const rankings: RankingEntry[] = Array.isArray(obj.rankings)
365
+ ? obj.rankings.map((r: any) => ({
366
+ modelId: String(r.modelId),
367
+ elo: Number(r.elo) || 0,
368
+ wins: Number(r.wins) || 0,
369
+ losses: Number(r.losses) || 0,
370
+ }))
371
+ : [];
372
+ return {
373
+ rankings,
374
+ elo: (obj.elo && typeof obj.elo === "object") ? obj.elo : {},
375
+ wins: (obj.wins && typeof obj.wins === "object") ? obj.wins : {},
376
+ losses: (obj.losses && typeof obj.losses === "object") ? obj.losses : {},
377
+ };
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Judge
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Ask a judge model to score two responses on a 0-10 scale across criteria.
389
+ * Returns structured JSON; falls back to a tie if the judge fails.
390
+ */
391
+ private async judgeMatch(
392
+ prompt: string,
393
+ responseA: string,
394
+ responseB: string,
395
+ modelA: string,
396
+ modelB: string,
397
+ judgeModel: string
398
+ ): Promise<JudgeVerdict> {
399
+ const judgePrompt = [
400
+ "You are an impartial judge for an AI model arena.",
401
+ "Score the two responses below on a 0-10 scale for each criterion.",
402
+ "Criteria: correctness, clarity, completeness, helpfulness.",
403
+ "Return ONLY valid JSON, no markdown fences, no prose.",
404
+ "Schema:",
405
+ "{",
406
+ ' "scores": {',
407
+ ' "modelA": { "overall": number, "correctness": number, "clarity": number, "completeness": number, "helpfulness": number },',
408
+ ' "modelB": { "overall": number, "correctness": number, "clarity": number, "completeness": number, "helpfulness": number }',
409
+ " },",
410
+ ' "winner": "modelA" | "modelB" | "tie",',
411
+ ' "reasoning": "string"',
412
+ "}",
413
+ "",
414
+ `Prompt: ${prompt}`,
415
+ "",
416
+ `Model A (${modelA}):`,
417
+ responseA,
418
+ "",
419
+ `Model B (${modelB}):`,
420
+ responseB,
421
+ ].join("\n");
422
+
423
+ const fallback: JudgeVerdict = {
424
+ scores: {
425
+ modelA: { overall: 5, correctness: 5, clarity: 5, completeness: 5, helpfulness: 5 },
426
+ modelB: { overall: 5, correctness: 5, clarity: 5, completeness: 5, helpfulness: 5 },
427
+ },
428
+ winner: "tie",
429
+ reasoning: "Judge unavailable; declared a tie.",
430
+ };
431
+
432
+ try {
433
+ const provider = await this.router.getModelProvider(judgeModel);
434
+ const messages: Message[] = [{ role: "user", content: judgePrompt }];
435
+ let text = "";
436
+ for await (const chunk of provider.chat(messages, [], { temperature: 0, maxTokens: 1024 })) {
437
+ if (chunk.type === "text" && chunk.text) text += chunk.text;
438
+ if (chunk.type === "error") throw new Error(chunk.error ?? "judge error");
439
+ }
440
+ return this.parseJudgeResponse(text, modelA, modelB);
441
+ } catch {
442
+ return fallback;
443
+ }
444
+ }
445
+
446
+ private parseJudgeResponse(raw: string, modelA: string, modelB: string): JudgeVerdict {
447
+ let cleaned = (raw ?? "").trim();
448
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
449
+ const start = cleaned.indexOf("{");
450
+ const end = cleaned.lastIndexOf("}");
451
+ if (start === -1 || end === -1 || end <= start) {
452
+ throw new Error("Judge returned no JSON object");
453
+ }
454
+ const jsonText = cleaned.slice(start, end + 1);
455
+ const parsed = JSON.parse(jsonText);
456
+ if (!parsed || typeof parsed !== "object" || !parsed.scores) {
457
+ throw new Error("Judge JSON missing scores");
458
+ }
459
+ const normalize = (s: any): JudgeScore => ({
460
+ overall: this.clampScore(s?.overall),
461
+ correctness: this.clampScore(s?.correctness),
462
+ clarity: this.clampScore(s?.clarity),
463
+ completeness: this.clampScore(s?.completeness),
464
+ helpfulness: this.clampScore(s?.helpfulness),
465
+ });
466
+ const winnerRaw = String(parsed.winner ?? "tie").toLowerCase();
467
+ let winner: JudgeVerdict["winner"];
468
+ if (winnerRaw === "modela" || winnerRaw === "model_a" || winnerRaw === "a") winner = "modelA";
469
+ else if (winnerRaw === "modelb" || winnerRaw === "model_b" || winnerRaw === "b") winner = "modelB";
470
+ else winner = "tie";
471
+
472
+ return {
473
+ scores: {
474
+ modelA: normalize(parsed.scores.modelA),
475
+ modelB: normalize(parsed.scores.modelB),
476
+ },
477
+ winner,
478
+ reasoning: String(parsed.reasoning ?? ""),
479
+ };
480
+ }
481
+
482
+ private clampScore(v: unknown): number {
483
+ const n = Number(v);
484
+ if (!Number.isFinite(n)) return 5;
485
+ return Math.max(0, Math.min(10, n));
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Tournament
490
+ // ---------------------------------------------------------------------------
491
+
492
+ /**
493
+ * Run a round-robin tournament: every model plays against every other model
494
+ * on the same prompt. A judge model scores each pair of responses and the
495
+ * winner is recorded via recordMatch. Returns ArenaResult[] plus a summary.
496
+ */
497
+ async tournament(
498
+ userMessage: string,
499
+ history: Message[],
500
+ modelIds: string[],
501
+ onResult?: (modelId: string, chunk: ChatChunk) => void
502
+ ): Promise<{ results: ArenaResult[]; summary: TournamentSummary }> {
503
+ const unique = Array.from(new Set(modelIds.filter((m) => m && m.trim())));
504
+ if (unique.length < 2) {
505
+ throw new Error("Tournament requires at least 2 distinct models");
506
+ }
507
+
508
+ const judgeModel = this.pickJudge(unique);
509
+
510
+ // 1. Collect full responses for every model once.
511
+ const responses = await this.compare(userMessage, history, unique, onResult ?? (() => {}));
512
+ const byModel = new Map<string, ArenaResult>();
513
+ for (const r of responses) byModel.set(r.modelId, r);
514
+
515
+ // 2. Round-robin: every pair plays once.
516
+ const results: ArenaResult[] = [];
517
+ const pairScores = new Map<string, number>(); // modelId -> accumulated judge score
518
+ for (const m of unique) pairScores.set(m, 0);
519
+
520
+ for (let i = 0; i < unique.length; i++) {
521
+ for (let j = i + 1; j < unique.length; j++) {
522
+ const a = unique[i];
523
+ const b = unique[j];
524
+ const ra = byModel.get(a)?.text ?? "";
525
+ const rb = byModel.get(b)?.text ?? "";
526
+ const verdict = await this.judgeMatch(userMessage, ra, rb, a, b, judgeModel);
527
+
528
+ const scoreA = this.average(verdict.scores.modelA);
529
+ const scoreB = this.average(verdict.scores.modelB);
530
+ pairScores.set(a, (pairScores.get(a) ?? 0) + scoreA);
531
+ pairScores.set(b, (pairScores.get(b) ?? 0) + scoreB);
532
+
533
+ if (verdict.winner === "modelA") {
534
+ this.recordMatch(a, b);
535
+ this.recordH2H(a, b, "A");
536
+ } else if (verdict.winner === "modelB") {
537
+ this.recordMatch(b, a);
538
+ this.recordH2H(a, b, "B");
539
+ } else {
540
+ this.recordH2H(a, b, "tie");
541
+ }
542
+ results.push(...responses.filter((r) => r.modelId === a || r.modelId === b));
543
+ }
544
+ }
545
+
546
+ const rankings = this.getRankings().map((r) => ({
547
+ ...r,
548
+ elo: r.elo,
549
+ }));
550
+ // Sort by accumulated judge score, falling back to elo.
551
+ const sorted = [...rankings].sort((x, y) => {
552
+ const d = (pairScores.get(y.modelId) ?? 0) - (pairScores.get(x.modelId) ?? 0);
553
+ if (d !== 0) return d;
554
+ return y.elo - x.elo;
555
+ });
556
+
557
+ const summary: TournamentSummary = {
558
+ prompt: userMessage,
559
+ matches: unique.length * (unique.length - 1) / 2,
560
+ rankings: sorted,
561
+ judge: judgeModel,
562
+ results,
563
+ };
564
+ return { results, summary };
565
+ }
566
+
567
+ private average(score: JudgeScore): number {
568
+ return (score.overall + score.correctness + score.clarity + score.completeness + score.helpfulness) / 5;
569
+ }
570
+
571
+ private pickJudge(modelIds: string[]): string {
572
+ // Strongest available = highest current elo, otherwise first model.
573
+ let best = modelIds[0];
574
+ let bestElo = -Infinity;
575
+ for (const m of modelIds) {
576
+ const e = this.elo.get(m) ?? 1200;
577
+ if (e > bestElo) {
578
+ bestElo = e;
579
+ best = m;
580
+ }
581
+ }
582
+ return best;
583
+ }
584
+ }
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+
5
+ export interface CheckpointEntry {
6
+ label: string;
7
+ timestamp: number;
8
+ files: { path: string; content: string }[];
9
+ }
10
+
11
+ export class Checkpoint {
12
+ private dir: string;
13
+ private index: Map<string, CheckpointEntry>;
14
+
15
+ constructor(dir?: string) {
16
+ this.dir = dir ?? path.join(os.homedir(), ".aether", "checkpoints");
17
+ this.index = new Map();
18
+ this.loadAll();
19
+ }
20
+
21
+ private get filePath(): { path: string; entries: CheckpointEntry[] } {
22
+ return { path: path.join(this.dir, "index.json"), entries: [] };
23
+ }
24
+
25
+ private loadAll(): void {
26
+ try {
27
+ if (!fs.existsSync(this.dir)) return;
28
+ const indexFile = path.join(this.dir, "index.json");
29
+ if (!fs.existsSync(indexFile)) return;
30
+ const raw = fs.readFileSync(indexFile, "utf8");
31
+ const arr = JSON.parse(raw);
32
+ if (Array.isArray(arr)) {
33
+ for (const e of arr) this.index.set(e.label, e);
34
+ }
35
+ } catch {
36
+ // ignore
37
+ }
38
+ }
39
+
40
+ private saveIndex(): void {
41
+ try {
42
+ fs.mkdirSync(this.dir, { recursive: true });
43
+ const arr = Array.from(this.index.values());
44
+ const tmp = path.join(this.dir, "index.json.tmp");
45
+ fs.writeFileSync(tmp, JSON.stringify(arr, null, 2), "utf8");
46
+ fs.renameSync(tmp, path.join(this.dir, "index.json"));
47
+ } catch {
48
+ // best effort
49
+ }
50
+ }
51
+
52
+ save(filePaths: string[], label: string): CheckpointEntry {
53
+ const files: { path: string; content: string }[] = [];
54
+ for (const fp of filePaths) {
55
+ try {
56
+ if (fs.existsSync(fp)) {
57
+ files.push({ path: fp, content: fs.readFileSync(fp, "utf8") });
58
+ }
59
+ } catch {
60
+ // skip unreadable files
61
+ }
62
+ }
63
+ const entry: CheckpointEntry = { label, timestamp: Date.now(), files };
64
+ this.index.set(label, entry);
65
+ this.saveIndex();
66
+ return entry;
67
+ }
68
+
69
+ restore(label: string): string {
70
+ const entry = this.index.get(label);
71
+ if (!entry) return `ERROR: no checkpoint named "${label}"`;
72
+ let restored = 0;
73
+ for (const f of entry.files) {
74
+ try {
75
+ const dir = path.dirname(f.path);
76
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
77
+ fs.writeFileSync(f.path, f.content, "utf8");
78
+ restored++;
79
+ } catch (err) {
80
+ return `ERROR restoring ${f.path}: ${(err as Error).message}`;
81
+ }
82
+ }
83
+ return `Restored ${restored} file(s) from checkpoint "${label}".`;
84
+ }
85
+
86
+ list(): { label: string; timestamp: number; fileCount: number }[] {
87
+ return Array.from(this.index.values())
88
+ .map((e) => ({ label: e.label, timestamp: e.timestamp, fileCount: e.files.length }))
89
+ .sort((a, b) => b.timestamp - a.timestamp);
90
+ }
91
+
92
+ latest(): CheckpointEntry | undefined {
93
+ let latest: CheckpointEntry | undefined;
94
+ for (const e of this.index.values()) {
95
+ if (!latest || e.timestamp > latest.timestamp) latest = e;
96
+ }
97
+ return latest;
98
+ }
99
+
100
+ autoCheckpoint(rootDir: string, filePath: string): void {
101
+ const abs = path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath);
102
+ const label = `auto-${Date.now()}`;
103
+ this.save([abs], label);
104
+ }
105
+
106
+ private static instance_: Checkpoint | null = null;
107
+ static instance(): Checkpoint {
108
+ if (!Checkpoint.instance_) Checkpoint.instance_ = new Checkpoint();
109
+ return Checkpoint.instance_;
110
+ }
111
+ }