@trolleroof/tui 0.2.4 → 0.2.6

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 (91) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/README.md +32 -8
  4. package/dist/index.js +286 -19
  5. package/overlay/dist/app.js +74 -0
  6. package/overlay/dist/index.html +1589 -0
  7. package/overlay-release.json +2 -2
  8. package/package.json +28 -4
  9. package/scripts/install-adapters.sh +25 -14
  10. package/scripts/install-adapters.test.sh +12 -2
  11. package/scripts/materialize-agents-plugin.sh +8 -6
  12. package/scripts/npm-pack.test.ts +51 -0
  13. package/scripts/overlay.ts +1 -1
  14. package/src/analytics/events.test.ts +77 -0
  15. package/src/analytics/events.ts +85 -0
  16. package/src/analytics/posthog.test.ts +85 -0
  17. package/src/analytics/posthog.ts +88 -0
  18. package/src/auth-pages.ts +108 -0
  19. package/src/auth.test.ts +255 -0
  20. package/src/auth.ts +511 -0
  21. package/src/core/game.ts +35 -0
  22. package/src/core/key.ts +12 -0
  23. package/src/core/rng.ts +41 -0
  24. package/src/data/bc-export.test.ts +153 -0
  25. package/src/data/bc-export.ts +125 -0
  26. package/src/data/trajectory.ts +103 -0
  27. package/src/games/connect4/connect4.ts +81 -0
  28. package/src/games/connect4/engine.test.ts +77 -0
  29. package/src/games/connect4/engine.ts +89 -0
  30. package/src/games/g2048/engine.test.ts +138 -0
  31. package/src/games/g2048/engine.ts +109 -0
  32. package/src/games/g2048/game2048.ts +104 -0
  33. package/src/games/minesweeper/engine.test.ts +83 -0
  34. package/src/games/minesweeper/engine.ts +112 -0
  35. package/src/games/minesweeper/minesweeper.ts +107 -0
  36. package/src/games/registry.test.ts +16 -0
  37. package/src/games/registry.ts +130 -0
  38. package/src/games/snake/engine.test.ts +89 -0
  39. package/src/games/snake/engine.ts +95 -0
  40. package/src/games/snake/snake.ts +77 -0
  41. package/src/games/sokoban/engine.test.ts +49 -0
  42. package/src/games/sokoban/engine.ts +152 -0
  43. package/src/games/sokoban/sokoban.ts +103 -0
  44. package/src/games/sudoku/engine.test.ts +88 -0
  45. package/src/games/sudoku/engine.ts +99 -0
  46. package/src/games/sudoku/sudoku.ts +100 -0
  47. package/src/games/tetris/engine.test.ts +378 -0
  48. package/src/games/tetris/engine.ts +236 -0
  49. package/src/games/tetris/tetris.ts +220 -0
  50. package/src/index.ts +82 -0
  51. package/src/recording/chunks.ts +93 -0
  52. package/src/recording/config.test.ts +31 -0
  53. package/src/recording/config.ts +37 -0
  54. package/src/recording/contracts.test.ts +71 -0
  55. package/src/recording/gameplay.test.ts +70 -0
  56. package/src/recording/gameplay.ts +18 -0
  57. package/src/recording/http-transport.test.ts +197 -0
  58. package/src/recording/identity.test.ts +26 -0
  59. package/src/recording/identity.ts +38 -0
  60. package/src/recording/ids.ts +27 -0
  61. package/src/recording/permissions.ts +10 -0
  62. package/src/recording/recorder.test.ts +388 -0
  63. package/src/recording/recorder.ts +464 -0
  64. package/src/recording/store.test.ts +231 -0
  65. package/src/recording/store.ts +305 -0
  66. package/src/recording/terminal-grid.test.ts +88 -0
  67. package/src/recording/terminal-grid.ts +248 -0
  68. package/src/recording/trace-recorder.test.ts +287 -0
  69. package/src/recording/trace-recorder.ts +560 -0
  70. package/src/recording/transport.ts +170 -0
  71. package/src/recording/types.test.ts +18 -0
  72. package/src/recording/types.ts +326 -0
  73. package/src/recording/uploader.test.ts +193 -0
  74. package/src/recording/uploader.ts +103 -0
  75. package/src/results/completion.ts +100 -0
  76. package/src/results/http-result-transport.test.ts +75 -0
  77. package/src/results/local-score.test.ts +32 -0
  78. package/src/results/local-score.ts +39 -0
  79. package/src/results/results.test.ts +383 -0
  80. package/src/results/store.ts +149 -0
  81. package/src/results/transport.ts +79 -0
  82. package/src/results/types.ts +20 -0
  83. package/src/results/uploader.ts +84 -0
  84. package/src/sync/config.test.ts +108 -0
  85. package/src/sync/config.ts +115 -0
  86. package/src/sync/coordinator.test.ts +423 -0
  87. package/src/sync/coordinator.ts +277 -0
  88. package/src/sync/game-start-queue.test.ts +93 -0
  89. package/src/sync/game-start-queue.ts +45 -0
  90. package/src/sync/identity-client.test.ts +34 -0
  91. package/src/sync/identity-client.ts +43 -0
@@ -0,0 +1,305 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ renameSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { opendir } from "node:fs/promises";
11
+ import { basename, dirname, join } from "node:path";
12
+ import type {
13
+ GameEvent,
14
+ PersistedChunk,
15
+ RecordedEvent,
16
+ RecordedEventChunk,
17
+ } from "./types.js";
18
+ import { ensurePrivateDirectory, ensurePrivateFile } from "./permissions.js";
19
+
20
+ const CHUNK_SEQUENCE_WIDTH = 12;
21
+
22
+ export interface CompletedGame {
23
+ gameId: string;
24
+ events: RecordedEvent[];
25
+ }
26
+
27
+ export type RecoveryChunkIndex = Map<string, PersistedChunk[]>;
28
+ export type ReadonlyRecoveryChunkIndex = ReadonlyMap<string, readonly PersistedChunk[]>;
29
+
30
+ export interface BatchedStoreOptions {
31
+ batchSize?: number;
32
+ yieldControl?: () => Promise<void>;
33
+ signal?: AbortSignal;
34
+ }
35
+
36
+ export class RecordingStore {
37
+ constructor(readonly root: string) {
38
+ ensurePrivateDirectory(root);
39
+ for (const directory of [
40
+ "active",
41
+ "pending",
42
+ "failed",
43
+ "completed",
44
+ "completed/games",
45
+ "completed/chunks",
46
+ ]) {
47
+ ensurePrivateDirectory(join(root, directory));
48
+ }
49
+ }
50
+
51
+ activePath(gameId: string): string {
52
+ return join(this.root, "active", `${gameId}.jsonl`);
53
+ }
54
+
55
+ appendEvent(gameId: string, event: RecordedEvent): void {
56
+ const path = this.activePath(gameId);
57
+ appendFileSync(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
58
+ ensurePrivateFile(path);
59
+ }
60
+
61
+ readActiveEvents(gameId: string): GameEvent[] {
62
+ return this.readActiveRecordedEvents(gameId) as GameEvent[];
63
+ }
64
+
65
+ readActiveRecordedEvents(gameId: string): RecordedEvent[] {
66
+ const path = this.activePath(gameId);
67
+ if (!existsSync(path)) return [];
68
+ ensurePrivateFile(path);
69
+ const lines = readFileSync(path, "utf8")
70
+ .split("\n")
71
+ .filter(Boolean);
72
+ return lines.flatMap((line, index) => {
73
+ try {
74
+ return [JSON.parse(line) as RecordedEvent];
75
+ } catch (error) {
76
+ if (index === lines.length - 1) return [];
77
+ throw error;
78
+ }
79
+ });
80
+ }
81
+
82
+ listActiveGameIds(): string[] {
83
+ return readdirSync(join(this.root, "active"))
84
+ .filter((name) => name.endsWith(".jsonl"))
85
+ .map((name) => name.slice(0, -".jsonl".length))
86
+ .sort();
87
+ }
88
+
89
+ persistChunk(chunk: RecordedEventChunk): PersistedChunk {
90
+ const bytes = JSON.stringify(chunk);
91
+ const directory = join(this.root, "pending", chunk.game_id);
92
+ ensurePrivateDirectory(directory);
93
+ const name = `${String(chunk.chunk_sequence).padStart(CHUNK_SEQUENCE_WIDTH, "0")}-${chunk.chunk_id}.json`;
94
+ const path = join(directory, name);
95
+ if (existsSync(path)) {
96
+ ensurePrivateFile(path);
97
+ const existing = readFileSync(path, "utf8");
98
+ if (existing !== bytes) throw new Error(`chunk ${chunk.chunk_id} has conflicting bytes`);
99
+ return { chunk, path, bytes: existing };
100
+ }
101
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
102
+ writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 });
103
+ renameSync(temporary, path);
104
+ ensurePrivateFile(path);
105
+ return { chunk, path, bytes };
106
+ }
107
+
108
+ listPending(): PersistedChunk[] {
109
+ return this.listChunks(join(this.root, "pending"));
110
+ }
111
+
112
+ listFailed(): PersistedChunk[] {
113
+ return this.listChunks(join(this.root, "failed"));
114
+ }
115
+
116
+ listCompletedChunks(): PersistedChunk[] {
117
+ return this.listChunks(join(this.root, "completed", "chunks"));
118
+ }
119
+
120
+ async listRecoveryChunksBatched(
121
+ options: BatchedStoreOptions = {},
122
+ ): Promise<PersistedChunk[]> {
123
+ const chunks: PersistedChunk[] = [];
124
+ for await (const persisted of this.iterateRecoveryChunksBatched(options)) {
125
+ chunks.push(persisted);
126
+ }
127
+ return this.sortChunks(chunks);
128
+ }
129
+
130
+ async buildRecoveryChunkIndex(
131
+ options: BatchedStoreOptions = {},
132
+ ): Promise<RecoveryChunkIndex> {
133
+ const chunksByGame: RecoveryChunkIndex = new Map();
134
+ for await (const persisted of this.iterateRecoveryChunksBatched(options)) {
135
+ const chunks = chunksByGame.get(persisted.chunk.game_id) ?? [];
136
+ chunks.push(persisted);
137
+ chunksByGame.set(persisted.chunk.game_id, chunks);
138
+ }
139
+ return chunksByGame;
140
+ }
141
+
142
+ async *iterateRecoveryChunksBatched(
143
+ options: BatchedStoreOptions = {},
144
+ ): AsyncGenerator<PersistedChunk> {
145
+ const batchSize = Math.max(1, options.batchSize ?? 16);
146
+ const yieldControl = options.yieldControl ?? (() =>
147
+ new Promise<void>((resolve) => setTimeout(resolve, 0))
148
+ );
149
+ let processed = 0;
150
+ for (const base of [
151
+ join(this.root, "pending"),
152
+ join(this.root, "failed"),
153
+ join(this.root, "completed", "chunks"),
154
+ ]) {
155
+ if (options.signal?.aborted || !existsSync(base)) return;
156
+ const games = await opendir(base);
157
+ for await (const game of games) {
158
+ if (options.signal?.aborted) return;
159
+ if (!game.isDirectory()) continue;
160
+ const directory = join(base, game.name);
161
+ ensurePrivateDirectory(directory);
162
+ const files = await opendir(directory);
163
+ for await (const file of files) {
164
+ if (options.signal?.aborted) return;
165
+ if (!file.isFile() || !file.name.endsWith(".json")) continue;
166
+ if (processed > 0 && processed % batchSize === 0) {
167
+ await yieldControl();
168
+ if (options.signal?.aborted) return;
169
+ }
170
+ yield this.readChunk(join(directory, file.name));
171
+ processed++;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ async *iterateActiveGameIdsBatched(
178
+ options: BatchedStoreOptions = {},
179
+ ): AsyncGenerator<string> {
180
+ const directory = join(this.root, "active");
181
+ const batchSize = Math.max(1, options.batchSize ?? 16);
182
+ const yieldControl = options.yieldControl ?? (() =>
183
+ new Promise<void>((resolve) => setTimeout(resolve, 0))
184
+ );
185
+ let processed = 0;
186
+ const files = await opendir(directory);
187
+ for await (const file of files) {
188
+ if (options.signal?.aborted) return;
189
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
190
+ if (processed > 0 && processed % batchSize === 0) {
191
+ await yieldControl();
192
+ if (options.signal?.aborted) return;
193
+ }
194
+ processed++;
195
+ yield file.name.slice(0, -".jsonl".length);
196
+ }
197
+ }
198
+
199
+ listCompletedGames(): CompletedGame[] {
200
+ return this.listCompletedGameIds().map((gameId) => this.readCompletedGame(gameId));
201
+ }
202
+
203
+ listCompletedGameIds(): string[] {
204
+ ensurePrivateDirectory(join(this.root, "completed"));
205
+ const directory = join(this.root, "completed", "games");
206
+ ensurePrivateDirectory(directory);
207
+ return readdirSync(directory)
208
+ .filter((name) => name.endsWith(".jsonl"))
209
+ .sort()
210
+ .map((name) => name.slice(0, -".jsonl".length));
211
+ }
212
+
213
+ readCompletedGame(gameId: string): CompletedGame {
214
+ const path = join(this.root, "completed", "games", `${gameId}.jsonl`);
215
+ ensurePrivateFile(path);
216
+ const events = readFileSync(path, "utf8")
217
+ .split("\n")
218
+ .filter(Boolean)
219
+ .map((line) => JSON.parse(line) as RecordedEvent);
220
+ return { gameId, events };
221
+ }
222
+
223
+ async *iterateCompletedGamesBatched(
224
+ options: BatchedStoreOptions = {},
225
+ ): AsyncGenerator<CompletedGame> {
226
+ const directory = join(this.root, "completed", "games");
227
+ ensurePrivateDirectory(directory);
228
+ const batchSize = Math.max(1, options.batchSize ?? 16);
229
+ const yieldControl = options.yieldControl ?? (() =>
230
+ new Promise<void>((resolve) => setTimeout(resolve, 0))
231
+ );
232
+ let processed = 0;
233
+ const files = await opendir(directory);
234
+ for await (const file of files) {
235
+ if (options.signal?.aborted) return;
236
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
237
+ if (processed > 0 && processed % batchSize === 0) {
238
+ await yieldControl();
239
+ if (options.signal?.aborted) return;
240
+ }
241
+ const gameId = file.name.slice(0, -".jsonl".length);
242
+ yield this.readCompletedGame(gameId);
243
+ processed++;
244
+ }
245
+ }
246
+
247
+ acknowledge(persisted: PersistedChunk): string {
248
+ return this.moveChunk(persisted, join(this.root, "completed", "chunks"));
249
+ }
250
+
251
+ markFailed(persisted: PersistedChunk, reason: string): string {
252
+ const destination = this.moveChunk(persisted, join(this.root, "failed"));
253
+ writeFileSync(`${destination}.error.txt`, `${reason}\n`, { mode: 0o600 });
254
+ ensurePrivateFile(`${destination}.error.txt`);
255
+ return destination;
256
+ }
257
+
258
+ finalizeGame(gameId: string): string | null {
259
+ const source = this.activePath(gameId);
260
+ if (!existsSync(source)) return null;
261
+ const destination = join(this.root, "completed", "games", `${gameId}.jsonl`);
262
+ renameSync(source, destination);
263
+ ensurePrivateFile(destination);
264
+ return destination;
265
+ }
266
+
267
+ private moveChunk(persisted: PersistedChunk, base: string): string {
268
+ const destination = join(base, persisted.chunk.game_id, basename(persisted.path));
269
+ ensurePrivateDirectory(dirname(destination));
270
+ renameSync(persisted.path, destination);
271
+ ensurePrivateFile(destination);
272
+ return destination;
273
+ }
274
+
275
+ private listChunks(base: string): PersistedChunk[] {
276
+ return this.sortChunks(this.chunkPaths(base).map((path) => this.readChunk(path)));
277
+ }
278
+
279
+ private chunkPaths(base: string): string[] {
280
+ const paths: string[] = [];
281
+ if (!existsSync(base)) return paths;
282
+ for (const game of readdirSync(base, { withFileTypes: true })) {
283
+ if (!game.isDirectory()) continue;
284
+ const directory = join(base, game.name);
285
+ ensurePrivateDirectory(directory);
286
+ for (const name of readdirSync(directory).filter((entry) => entry.endsWith(".json")).sort()) {
287
+ paths.push(join(directory, name));
288
+ }
289
+ }
290
+ return paths;
291
+ }
292
+
293
+ protected readChunk(path: string): PersistedChunk {
294
+ ensurePrivateFile(path);
295
+ const bytes = readFileSync(path, "utf8");
296
+ return { chunk: JSON.parse(bytes) as RecordedEventChunk, path, bytes };
297
+ }
298
+
299
+ private sortChunks(chunks: PersistedChunk[]): PersistedChunk[] {
300
+ return chunks.sort((a, b) =>
301
+ a.chunk.game_id.localeCompare(b.chunk.game_id) ||
302
+ a.chunk.chunk_sequence - b.chunk.chunk_sequence,
303
+ );
304
+ }
305
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ encodeTerminalObservation,
4
+ parseTerminalGrid,
5
+ } from "./terminal-grid.js";
6
+
7
+ describe("terminal grid capture", () => {
8
+ it("preserves glyphs, 256-color styling, attributes, and viewport dimensions", () => {
9
+ const grid = parseTerminalGrid([
10
+ "\x1b[1;48;5;45;38;5;22m T \x1b[0mX",
11
+ ], { width: 5, height: 2 });
12
+
13
+ expect(grid.width).toBe(5);
14
+ expect(grid.height).toBe(2);
15
+ expect(grid.cells.slice(0, 5)).toEqual([
16
+ { glyph: " ", foreground: 22, background: 45, attributes: 1, width: 1 },
17
+ { glyph: "T", foreground: 22, background: 45, attributes: 1, width: 1 },
18
+ { glyph: " ", foreground: 22, background: 45, attributes: 1, width: 1 },
19
+ { glyph: "X", foreground: null, background: null, attributes: 0, width: 1 },
20
+ { glyph: " ", foreground: null, background: null, attributes: 0, width: 1 },
21
+ ]);
22
+ });
23
+
24
+ it("emits a sparse full frame followed by a causally linked delta", () => {
25
+ const first = parseTerminalGrid([
26
+ "\x1b[48;5;45m T \x1b[0m",
27
+ ], { width: 4, height: 2 });
28
+ const full = encodeTerminalObservation({
29
+ observationId: "obs_1",
30
+ grid: first,
31
+ captureTiming: {
32
+ generated_time_us: 100,
33
+ delivered_time_us: 125,
34
+ dropped_observations: 0,
35
+ },
36
+ });
37
+ expect(full.representation).toBe("terminal_grid_v1");
38
+ if (full.representation !== "terminal_grid_v1") throw new Error("expected full frame");
39
+ expect(full.cells).toHaveLength(3);
40
+ expect(full.width).toBe(4);
41
+ expect(full.height).toBe(2);
42
+ expect(full.state_hash).toMatch(/^sha256:/);
43
+ expect(full.capture_timing).toEqual({
44
+ generated_time_us: 100,
45
+ delivered_time_us: 125,
46
+ dropped_observations: 0,
47
+ });
48
+
49
+ const second = parseTerminalGrid([
50
+ "\x1b[48;5;45m I \x1b[0m",
51
+ ], { width: 4, height: 2 });
52
+ const delta = encodeTerminalObservation({
53
+ observationId: "obs_2",
54
+ grid: second,
55
+ previous: { observationId: "obs_1", grid: first },
56
+ causedByEventId: "evt_control",
57
+ });
58
+ expect(delta).toMatchObject({
59
+ observation_id: "obs_2",
60
+ representation: "terminal_grid_delta_v1",
61
+ base_observation_id: "obs_1",
62
+ caused_by_event_id: "evt_control",
63
+ changed_cells: [{
64
+ x: 1,
65
+ y: 0,
66
+ cell: {
67
+ glyph: "I",
68
+ foreground: null,
69
+ background: 45,
70
+ attributes: 0,
71
+ width: 1,
72
+ },
73
+ }],
74
+ });
75
+ });
76
+
77
+ it("records an empty delta when an accepted control does not change the screen", () => {
78
+ const grid = parseTerminalGrid(["same"], { width: 4, height: 1 });
79
+ const delta = encodeTerminalObservation({
80
+ observationId: "obs_2",
81
+ grid,
82
+ previous: { observationId: "obs_1", grid },
83
+ causedByEventId: "evt_noop",
84
+ });
85
+ if (delta.representation !== "terminal_grid_delta_v1") throw new Error("expected delta");
86
+ expect(delta.changed_cells).toEqual([]);
87
+ });
88
+ });
@@ -0,0 +1,248 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ TerminalCell,
4
+ TerminalColor,
5
+ TerminalObservation,
6
+ ObservationCaptureTiming,
7
+ } from "./types.js";
8
+
9
+ const BOLD = 1;
10
+ const DIM = 2;
11
+ const ITALIC = 4;
12
+ const UNDERLINE = 8;
13
+ const INVERSE = 16;
14
+ const GRAPHEME_SEGMENTER = typeof Intl.Segmenter === "function"
15
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
16
+ : null;
17
+
18
+ const DEFAULT_CELL: TerminalCell = {
19
+ glyph: " ",
20
+ foreground: null,
21
+ background: null,
22
+ attributes: 0,
23
+ width: 1,
24
+ };
25
+
26
+ interface Style {
27
+ foreground: TerminalColor;
28
+ background: TerminalColor;
29
+ attributes: number;
30
+ }
31
+
32
+ export interface TerminalViewport {
33
+ width: number;
34
+ height: number;
35
+ }
36
+
37
+ export interface TerminalGrid {
38
+ width: number;
39
+ height: number;
40
+ cells: TerminalCell[];
41
+ }
42
+
43
+ interface EncodeObservationInput {
44
+ observationId: string;
45
+ grid: TerminalGrid;
46
+ previous?: { observationId: string; grid: TerminalGrid };
47
+ causedByEventId?: string;
48
+ captureTiming?: ObservationCaptureTiming;
49
+ }
50
+
51
+ function cloneDefaultCell(): TerminalCell {
52
+ return { ...DEFAULT_CELL };
53
+ }
54
+
55
+ function basicColor(code: number, background: boolean): number | null {
56
+ const base = background ? 40 : 30;
57
+ const bright = background ? 100 : 90;
58
+ if (code >= base && code <= base + 7) return code - base;
59
+ if (code >= bright && code <= bright + 7) return code - bright + 8;
60
+ return null;
61
+ }
62
+
63
+ function rgbColor(red: number, green: number, blue: number): string {
64
+ return `#${[red, green, blue].map((value) =>
65
+ Math.max(0, Math.min(255, value)).toString(16).padStart(2, "0")
66
+ ).join("")}`;
67
+ }
68
+
69
+ function applySgr(style: Style, parameters: string): void {
70
+ const codes = parameters === "" ? [0] : parameters.split(";").map(Number);
71
+ for (let index = 0; index < codes.length; index++) {
72
+ const code = Number.isFinite(codes[index]) ? codes[index]! : 0;
73
+ if (code === 0) {
74
+ style.foreground = null;
75
+ style.background = null;
76
+ style.attributes = 0;
77
+ } else if (code === 1) style.attributes |= BOLD;
78
+ else if (code === 2) style.attributes |= DIM;
79
+ else if (code === 3) style.attributes |= ITALIC;
80
+ else if (code === 4) style.attributes |= UNDERLINE;
81
+ else if (code === 7) style.attributes |= INVERSE;
82
+ else if (code === 22) style.attributes &= ~(BOLD | DIM);
83
+ else if (code === 23) style.attributes &= ~ITALIC;
84
+ else if (code === 24) style.attributes &= ~UNDERLINE;
85
+ else if (code === 27) style.attributes &= ~INVERSE;
86
+ else if (code === 39) style.foreground = null;
87
+ else if (code === 49) style.background = null;
88
+ else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
89
+ style.foreground = basicColor(code, false);
90
+ } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
91
+ style.background = basicColor(code, true);
92
+ } else if ((code === 38 || code === 48) && codes[index + 1] === 5) {
93
+ const color = codes[index + 2];
94
+ if (Number.isInteger(color)) {
95
+ if (code === 38) style.foreground = color!;
96
+ else style.background = color!;
97
+ }
98
+ index += 2;
99
+ } else if ((code === 38 || code === 48) && codes[index + 1] === 2) {
100
+ const [red, green, blue] = codes.slice(index + 2, index + 5);
101
+ if ([red, green, blue].every(Number.isInteger)) {
102
+ const color = rgbColor(red!, green!, blue!);
103
+ if (code === 38) style.foreground = color;
104
+ else style.background = color;
105
+ }
106
+ index += 4;
107
+ }
108
+ }
109
+ }
110
+
111
+ function cellWidth(glyph: string): number {
112
+ const codePoint = glyph.codePointAt(0) ?? 0;
113
+ if (codePoint === 0) return 0;
114
+ if (
115
+ codePoint >= 0x1100 && (
116
+ codePoint <= 0x115f ||
117
+ codePoint === 0x2329 || codePoint === 0x232a ||
118
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
119
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
120
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
121
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
122
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
123
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
124
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff)
125
+ )
126
+ ) return 2;
127
+ return 1;
128
+ }
129
+
130
+ function graphemes(value: string): string[] {
131
+ if (GRAPHEME_SEGMENTER) {
132
+ return Array.from(GRAPHEME_SEGMENTER.segment(value), ({ segment }) => segment);
133
+ }
134
+ return Array.from(value);
135
+ }
136
+
137
+ function sameCell(left: TerminalCell, right: TerminalCell): boolean {
138
+ return left.glyph === right.glyph &&
139
+ left.foreground === right.foreground &&
140
+ left.background === right.background &&
141
+ left.attributes === right.attributes &&
142
+ left.width === right.width;
143
+ }
144
+
145
+ function isDefaultCell(cell: TerminalCell): boolean {
146
+ return sameCell(cell, DEFAULT_CELL);
147
+ }
148
+
149
+ function gridHash(grid: TerminalGrid): string {
150
+ return `sha256:${createHash("sha256").update(JSON.stringify(grid)).digest("hex")}`;
151
+ }
152
+
153
+ export function parseTerminalGrid(
154
+ lines: readonly string[],
155
+ viewport: TerminalViewport,
156
+ ): TerminalGrid {
157
+ if (!Number.isInteger(viewport.width) || viewport.width <= 0) {
158
+ throw new Error("terminal viewport width must be a positive integer");
159
+ }
160
+ if (!Number.isInteger(viewport.height) || viewport.height <= 0) {
161
+ throw new Error("terminal viewport height must be a positive integer");
162
+ }
163
+ const cells = Array.from(
164
+ { length: viewport.width * viewport.height },
165
+ cloneDefaultCell,
166
+ );
167
+ const style: Style = { foreground: null, background: null, attributes: 0 };
168
+
169
+ for (let y = 0; y < Math.min(lines.length, viewport.height); y++) {
170
+ const line = lines[y]!;
171
+ let x = 0;
172
+ let cursor = 0;
173
+ while (cursor < line.length && x < viewport.width) {
174
+ if (line[cursor] === "\x1b" && line[cursor + 1] === "[") {
175
+ const match = /^\x1b\[([0-9;?]*)([@-~])/.exec(line.slice(cursor));
176
+ if (match) {
177
+ if (match[2] === "m") applySgr(style, match[1]!);
178
+ cursor += match[0].length;
179
+ continue;
180
+ }
181
+ }
182
+ const nextEscape = line.indexOf("\x1b", cursor);
183
+ const plain = line.slice(cursor, nextEscape === -1 ? line.length : nextEscape);
184
+ for (const glyph of graphemes(plain)) {
185
+ if (x >= viewport.width) break;
186
+ const width = cellWidth(glyph);
187
+ cells[y * viewport.width + x] = { glyph, ...style, width };
188
+ if (width === 2 && x + 1 < viewport.width) {
189
+ cells[y * viewport.width + x + 1] = {
190
+ glyph: "",
191
+ ...style,
192
+ width: 0,
193
+ };
194
+ }
195
+ x += Math.max(1, width);
196
+ }
197
+ cursor += plain.length || 1;
198
+ }
199
+ }
200
+ return { width: viewport.width, height: viewport.height, cells };
201
+ }
202
+
203
+ export function encodeTerminalObservation(
204
+ input: EncodeObservationInput,
205
+ ): TerminalObservation {
206
+ const common = {
207
+ observation_id: input.observationId,
208
+ width: input.grid.width,
209
+ height: input.grid.height,
210
+ state_hash: gridHash(input.grid),
211
+ ...(input.causedByEventId
212
+ ? { caused_by_event_id: input.causedByEventId }
213
+ : {}),
214
+ ...(input.captureTiming ? { capture_timing: { ...input.captureTiming } } : {}),
215
+ };
216
+ if (
217
+ input.previous &&
218
+ input.previous.grid.width === input.grid.width &&
219
+ input.previous.grid.height === input.grid.height
220
+ ) {
221
+ const changed = input.grid.cells.flatMap((cell, index) => {
222
+ if (sameCell(cell, input.previous!.grid.cells[index]!)) return [];
223
+ return [{
224
+ x: index % input.grid.width,
225
+ y: Math.floor(index / input.grid.width),
226
+ cell: isDefaultCell(cell) ? null : { ...cell },
227
+ }];
228
+ });
229
+ return {
230
+ ...common,
231
+ representation: "terminal_grid_delta_v1",
232
+ base_observation_id: input.previous.observationId,
233
+ changed_cells: changed,
234
+ };
235
+ }
236
+ return {
237
+ ...common,
238
+ representation: "terminal_grid_v1",
239
+ cells: input.grid.cells.flatMap((cell, index) => {
240
+ if (isDefaultCell(cell)) return [];
241
+ return [{
242
+ x: index % input.grid.width,
243
+ y: Math.floor(index / input.grid.width),
244
+ ...cell,
245
+ }];
246
+ }),
247
+ };
248
+ }