@dynamicagents/plugins 0.11.1 → 0.13.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 (134) hide show
  1. package/README.md +59 -79
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +73 -31
  7. package/dist/claude-code/config.js +0 -2
  8. package/dist/claude-code/credentials.d.ts +21 -9
  9. package/dist/claude-code/credentials.js +43 -9
  10. package/dist/claude-code/egress.d.ts +15 -14
  11. package/dist/claude-code/egress.js +51 -14
  12. package/dist/claude-code/events.d.ts +153 -9
  13. package/dist/claude-code/events.js +289 -58
  14. package/dist/claude-code/github.d.ts +39 -0
  15. package/dist/claude-code/github.js +82 -0
  16. package/dist/claude-code/index.d.ts +24 -80
  17. package/dist/claude-code/index.js +19 -234
  18. package/dist/claude-code/model.d.ts +152 -0
  19. package/dist/claude-code/model.js +342 -0
  20. package/dist/claude-code/run.d.ts +283 -92
  21. package/dist/claude-code/run.js +620 -114
  22. package/dist/claude-code/session.d.ts +131 -0
  23. package/dist/claude-code/session.js +285 -0
  24. package/dist/claude-code/spec.d.ts +46 -0
  25. package/dist/claude-code/spec.js +83 -0
  26. package/dist/claude-code/verified.d.ts +15 -0
  27. package/dist/claude-code/verified.js +15 -0
  28. package/dist/computer/context.d.ts +19 -0
  29. package/dist/computer/context.js +222 -0
  30. package/dist/computer/file-lock.d.ts +5 -0
  31. package/dist/computer/file-lock.js +40 -0
  32. package/dist/computer/gate.d.ts +12 -12
  33. package/dist/computer/gate.js +12 -12
  34. package/dist/computer/index.d.ts +57 -255
  35. package/dist/computer/index.js +98 -1039
  36. package/dist/computer/paths.d.ts +47 -22
  37. package/dist/computer/paths.js +64 -49
  38. package/dist/computer/proxy.d.ts +45 -0
  39. package/dist/computer/proxy.js +234 -0
  40. package/dist/computer/render.d.ts +27 -38
  41. package/dist/computer/render.js +32 -65
  42. package/dist/computer/tools-exec.d.ts +3 -0
  43. package/dist/computer/tools-exec.js +268 -0
  44. package/dist/computer/tools-file.d.ts +12 -0
  45. package/dist/computer/tools-file.js +64 -0
  46. package/dist/computer/tools-grep.d.ts +3 -0
  47. package/dist/computer/tools-grep.js +129 -0
  48. package/dist/repo/checkout.d.ts +35 -6
  49. package/dist/repo/checkout.js +64 -6
  50. package/dist/repo/context.d.ts +135 -0
  51. package/dist/repo/context.js +582 -0
  52. package/dist/repo/index.d.ts +79 -57
  53. package/dist/repo/index.js +118 -1112
  54. package/dist/repo/tools-checks.d.ts +3 -0
  55. package/dist/repo/tools-checks.js +112 -0
  56. package/dist/repo/tools-clone.d.ts +4 -0
  57. package/dist/repo/tools-clone.js +192 -0
  58. package/dist/repo/tools-pr.d.ts +14 -0
  59. package/dist/repo/tools-pr.js +196 -0
  60. package/dist/repo/tools-review.d.ts +12 -0
  61. package/dist/repo/tools-review.js +393 -0
  62. package/dist/repo/tools-worktree.d.ts +4 -0
  63. package/dist/repo/tools-worktree.js +305 -0
  64. package/dist/repo/tools-worktrees.d.ts +12 -0
  65. package/dist/repo/tools-worktrees.js +48 -0
  66. package/dist/repo/url.d.ts +2 -2
  67. package/dist/repo/url.js +7 -7
  68. package/dist/scratch/index.d.ts +4 -4
  69. package/dist/scratch/index.js +12 -11
  70. package/dist/{computer → workspace}/advisory.d.ts +33 -20
  71. package/dist/{computer → workspace}/advisory.js +32 -7
  72. package/dist/workspace/ca-trust.d.ts +95 -0
  73. package/dist/workspace/ca-trust.js +312 -0
  74. package/dist/workspace/container-deps.d.ts +54 -0
  75. package/dist/workspace/container-deps.js +224 -0
  76. package/dist/workspace/container-fault.d.ts +38 -0
  77. package/dist/workspace/container-fault.js +73 -0
  78. package/dist/workspace/exec.d.ts +94 -0
  79. package/dist/workspace/exec.js +153 -0
  80. package/dist/workspace/format.d.ts +36 -0
  81. package/dist/workspace/format.js +57 -0
  82. package/dist/workspace/git-host.d.ts +79 -0
  83. package/dist/workspace/git-host.js +226 -0
  84. package/dist/workspace/git-identity.d.ts +57 -0
  85. package/dist/workspace/git-identity.js +147 -0
  86. package/dist/workspace/index.d.ts +43 -47
  87. package/dist/workspace/index.js +52 -112
  88. package/dist/workspace/install-job.d.ts +121 -0
  89. package/dist/workspace/install-job.js +866 -0
  90. package/dist/{computer → workspace}/install.d.ts +15 -19
  91. package/dist/{computer → workspace}/install.js +8 -11
  92. package/dist/workspace/object.d.ts +502 -0
  93. package/dist/workspace/object.js +1427 -0
  94. package/dist/workspace/open.d.ts +95 -0
  95. package/dist/workspace/open.js +63 -0
  96. package/dist/workspace/read.d.ts +16 -0
  97. package/dist/workspace/read.js +22 -0
  98. package/dist/workspace/shell.d.ts +45 -0
  99. package/dist/workspace/shell.js +80 -0
  100. package/dist/workspace/sync.d.ts +111 -0
  101. package/dist/workspace/sync.js +124 -0
  102. package/dist/workspace/wake.d.ts +19 -0
  103. package/dist/workspace/wake.js +10 -0
  104. package/package.json +28 -42
  105. package/dist/arc-agi/analysis.d.ts +0 -336
  106. package/dist/arc-agi/analysis.js +0 -829
  107. package/dist/arc-agi/client.d.ts +0 -82
  108. package/dist/arc-agi/client.js +0 -113
  109. package/dist/arc-agi/game-tools.d.ts +0 -25
  110. package/dist/arc-agi/game-tools.js +0 -26
  111. package/dist/arc-agi/index.d.ts +0 -42
  112. package/dist/arc-agi/index.js +0 -175
  113. package/dist/arc-agi/main-agent.d.ts +0 -27
  114. package/dist/arc-agi/main-agent.js +0 -65
  115. package/dist/arc-agi/recipe.d.ts +0 -51
  116. package/dist/arc-agi/recipe.js +0 -100
  117. package/dist/arc-agi/scorecard.d.ts +0 -118
  118. package/dist/arc-agi/scorecard.js +0 -163
  119. package/dist/arc-agi/soul.d.ts +0 -40
  120. package/dist/arc-agi/soul.js +0 -90
  121. package/dist/arc-agi/store.d.ts +0 -154
  122. package/dist/arc-agi/store.js +0 -216
  123. package/dist/arc-agi/tools.d.ts +0 -36
  124. package/dist/arc-agi/tools.js +0 -576
  125. package/dist/arc-agi/types.d.ts +0 -212
  126. package/dist/arc-agi/types.js +0 -19
  127. package/dist/claude-code/recipe.d.ts +0 -53
  128. package/dist/claude-code/recipe.js +0 -100
  129. package/dist/computer/read.d.ts +0 -137
  130. package/dist/computer/read.js +0 -203
  131. package/dist/recall/index.d.ts +0 -99
  132. package/dist/recall/index.js +0 -171
  133. package/dist/triage/index.d.ts +0 -355
  134. package/dist/triage/index.js +0 -302
@@ -1,829 +0,0 @@
1
- /**
2
- * Pure grid-analysis helpers for ARC-AGI-3 frames. No I/O — every function is a
3
- * deterministic transform of a 64×64 grid (color values 0–15), unit-tested in
4
- * isolation. The tool family calls these to render compact observations for the
5
- * model instead of dumping raw grids into context.
6
- *
7
- * Two rendering registers, deliberately kept apart:
8
- *
9
- * - **Prose** (diffs, shapes, histogram) names its colors: `blue->white` carries
10
- * structure a bare `9->0` hides, and no tool input ever takes a color, so the
11
- * index is never needed as an argument.
12
- * - **Grids** ({@link renderGrid}, {@link renderRegion}) stay one character per
13
- * cell and carry a {@link renderLegend} instead. One char per cell is the only
14
- * reason a 64×64 board is legible at all; a named cell would be unreadable and
15
- * cost ~50× the tokens.
16
- */
17
- /**
18
- * The ARC-AGI-3 16-color palette, index → name.
19
- *
20
- * Source: `agents/templates/multimodal.py` in `arcprize/ARC-AGI-3-Agents` (ARC
21
- * Prize's own agent template), which maps these indices to RGBA for multimodal
22
- * play. Taken from there rather than assumed, because **ARC-AGI-3's palette is
23
- * unrelated to ARC-AGI-1's**: here 0 is *white* and 1–5 are a greyscale ramp,
24
- * where the familiar ARC-AGI-1 mapping would say 0=black, 1=blue, 2=red. Note
25
- * that same repo carries a second, contradictory `COLOR_PALETTE` in
26
- * `langgraph_thinking/vision.py` — that one is incomplete and does not match the
27
- * engine; do not use it.
28
- *
29
- * The pairs matter to a player: 6/7 and 9/10 and 2/3 are related shades, which
30
- * the names convey and the indices do not.
31
- */
32
- export const COLOR_NAMES = [
33
- "white", // 0 #FFFFFF
34
- "off-white", // 1 #CCCCCC
35
- "neutral-light", // 2 #999999
36
- "neutral", // 3 #666666
37
- "off-black", // 4 #333333
38
- "black", // 5 #000000
39
- "magenta", // 6 #E53AA3
40
- "magenta-light", // 7 #FF7BCC
41
- "red", // 8 #F93C31
42
- "blue", // 9 #1E93FF
43
- "blue-light", // 10 #88D8F1
44
- "yellow", // 11 #FFDC00
45
- "orange", // 12 #FF851B
46
- "maroon", // 13 #921231
47
- "green", // 14 #4FCC30
48
- "purple" // 15 #A356D6
49
- ];
50
- /** Color 0 (white) — treated as background by {@link locateComponents}. */
51
- export const BACKGROUND_COLOR = 0;
52
- /**
53
- * Name of a color index. An out-of-range value renders as `color <n>` rather
54
- * than throwing: a malformed frame should degrade the wording, not fail the turn.
55
- */
56
- export function colorName(color) {
57
- return COLOR_NAMES[color] ?? `color ${color}`;
58
- }
59
- /** The single character a color occupies in a grid render. */
60
- function colorChar(color) {
61
- return color >= 0 && color < 16 ? color.toString(16) : "?";
62
- }
63
- /** The current board: the LAST grid of a frame response's grid array. */
64
- export function lastGrid(frame) {
65
- return frame.length === 0 ? [] : frame[frame.length - 1];
66
- }
67
- /**
68
- * Bare hex rows, one char per cell, no labels — the round-trippable **storage**
69
- * form, paired with {@link parseGrid}. Deliberately distinct from
70
- * {@link renderGrid}, which adds a legend, rulers and row collapsing and so
71
- * cannot be parsed back.
72
- */
73
- export function serializeGrid(grid) {
74
- return grid.map((row) => row.map(colorChar).join("")).join("\n");
75
- }
76
- /** Inverse of {@link serializeGrid}. */
77
- export function parseGrid(hex) {
78
- if (hex === "")
79
- return [];
80
- return hex
81
- .split("\n")
82
- .map((line) => [...line].map((ch) => parseInt(ch, 16) || 0));
83
- }
84
- /** Cell-by-cell diff of two grids. `cap` bounds the returned cell list. */
85
- export function diffGrids(a, b, cap = 12) {
86
- if (a === null)
87
- return { changed: -1, cells: [], box: null };
88
- let changed = 0;
89
- const cells = [];
90
- let top = Infinity;
91
- let left = Infinity;
92
- let bottom = -Infinity;
93
- let right = -Infinity;
94
- for (let r = 0; r < b.length; r++) {
95
- const rowB = b[r];
96
- const rowA = a[r] ?? [];
97
- for (let c = 0; c < rowB.length; c++) {
98
- if (rowA[c] !== rowB[c]) {
99
- changed++;
100
- // The box spans every change, so it stays informative even when the
101
- // cell list is capped away to nothing.
102
- if (r < top)
103
- top = r;
104
- if (r > bottom)
105
- bottom = r;
106
- if (c < left)
107
- left = c;
108
- if (c > right)
109
- right = c;
110
- if (cells.length < cap) {
111
- cells.push({ row: r, col: c, from: rowA[c] ?? -1, to: rowB[c] });
112
- }
113
- }
114
- }
115
- }
116
- return {
117
- changed,
118
- cells,
119
- box: changed === 0 ? null : { top, left, bottom, right }
120
- };
121
- }
122
- /** `rows 3-5, cols 10-12` — or a single row/col rendered without the range. */
123
- export function describeBox(box) {
124
- const rows = box.top === box.bottom ? `row ${box.top}` : `rows ${box.top}-${box.bottom}`;
125
- const cols = box.left === box.right
126
- ? `col ${box.left}`
127
- : `cols ${box.left}-${box.right}`;
128
- return `${rows}, ${cols}`;
129
- }
130
- /** `(3,10) blue->white` — one changed cell, colors named. */
131
- export function describeCell(cell) {
132
- const from = cell.from < 0 ? "off-grid" : colorName(cell.from);
133
- return `(${cell.row},${cell.col}) ${from}->${colorName(cell.to)}`;
134
- }
135
- /** Count of each color present, descending by count. */
136
- export function colorHistogram(grid) {
137
- const counts = new Map();
138
- for (const row of grid) {
139
- for (const c of row)
140
- counts.set(c, (counts.get(c) ?? 0) + 1);
141
- }
142
- return [...counts.entries()]
143
- .map(([color, count]) => ({ color, count }))
144
- .sort((x, y) => y.count - x.count);
145
- }
146
- /**
147
- * Every 4-connected same-color region, with its bounding box, largest first.
148
- * Skips {@link BACKGROUND_COLOR}.
149
- *
150
- * The bounding boxes are the point: a rollup of counts alone tells the model
151
- * *that* there are three shapes and never *where* they are, which leaves dumping
152
- * the whole 64×64 grid as the only way to locate anything. The flood fill has to
153
- * visit every cell regardless, so tracking min/max row/col is free.
154
- */
155
- export function locateComponents(grid) {
156
- const rows = grid.length;
157
- const cols = rows === 0 ? 0 : grid[0].length;
158
- const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
159
- const found = [];
160
- for (let r = 0; r < rows; r++) {
161
- for (let c = 0; c < cols; c++) {
162
- const color = grid[r][c];
163
- if (color === BACKGROUND_COLOR || seen[r][c])
164
- continue;
165
- // Flood-fill this component (iterative, 4-connectivity).
166
- let size = 0;
167
- let top = r;
168
- let left = c;
169
- let bottom = r;
170
- let right = c;
171
- const stack = [[r, c]];
172
- seen[r][c] = true;
173
- while (stack.length > 0) {
174
- const [cr, cc] = stack.pop();
175
- size++;
176
- if (cr < top)
177
- top = cr;
178
- if (cr > bottom)
179
- bottom = cr;
180
- if (cc < left)
181
- left = cc;
182
- if (cc > right)
183
- right = cc;
184
- const neighbors = [
185
- [cr - 1, cc],
186
- [cr + 1, cc],
187
- [cr, cc - 1],
188
- [cr, cc + 1]
189
- ];
190
- for (const [nr, nc] of neighbors) {
191
- if (nr >= 0 &&
192
- nr < rows &&
193
- nc >= 0 &&
194
- nc < cols &&
195
- !seen[nr][nc] &&
196
- grid[nr][nc] === color) {
197
- seen[nr][nc] = true;
198
- stack.push([nr, nc]);
199
- }
200
- }
201
- }
202
- found.push({ color, size, top, left, bottom, right });
203
- }
204
- }
205
- return found.sort((x, y) => y.size - x.size);
206
- }
207
- /**
208
- * Summarize components per color: how many, and the largest. Derived from
209
- * {@link locateComponents} so only one flood fill ever runs.
210
- */
211
- export function connectedComponents(grid) {
212
- const perColor = new Map();
213
- for (const comp of locateComponents(grid)) {
214
- const entry = perColor.get(comp.color) ?? { components: 0, largest: 0 };
215
- entry.components++;
216
- entry.largest = Math.max(entry.largest, comp.size);
217
- perColor.set(comp.color, entry);
218
- }
219
- return [...perColor.entries()]
220
- .map(([color, v]) => ({
221
- color,
222
- components: v.components,
223
- largest: v.largest
224
- }))
225
- .sort((x, y) => y.components - x.components);
226
- }
227
- /**
228
- * How much of its bounding box a region actually occupies, 0–1.
229
- *
230
- * Boxes are well formed by construction wherever this is used: every caller
231
- * passes either a {@link Component} from {@link locateComponents} or a
232
- * {@link unionBox} of those, and a flood fill cannot produce `bottom < top`. So
233
- * `area` is always at least 1, and the zero-area branch below is unreachable —
234
- * kept only so a malformed box degrades to 0 instead of dividing by zero.
235
- *
236
- * The result is deliberately **not** clamped into 0–1. An out-of-range value here
237
- * could only mean a caller broke that invariant, and the way to learn that is a
238
- * region classified absurdly, not a number quietly corrected into looking sane.
239
- */
240
- export function fillRatio(box, size) {
241
- const area = (box.bottom - box.top + 1) * (box.right - box.left + 1);
242
- return area === 0 ? 0 : size / area;
243
- }
244
- /**
245
- * The rectangle enclosing several boxes. Takes a **non-empty** list — the reduce
246
- * has no seed, since there is no identity box to start from, so `[]` throws. Its
247
- * one caller filters components for a color it is already holding one of, and so
248
- * cannot pass an empty list.
249
- */
250
- function unionBox(boxes) {
251
- return boxes.reduce((a, b) => ({
252
- top: Math.min(a.top, b.top),
253
- left: Math.min(a.left, b.left),
254
- bottom: Math.max(a.bottom, b.bottom),
255
- right: Math.max(a.right, b.right)
256
- }));
257
- }
258
- /** Runs of `color` in one row, as inclusive `[first, last]` column pairs. */
259
- function rowRuns(row, color) {
260
- const runs = [];
261
- let start = -1;
262
- for (let c = 0; c <= row.length; c++) {
263
- const hit = c < row.length && row[c] === color;
264
- if (hit && start < 0)
265
- start = c;
266
- else if (!hit && start >= 0) {
267
- runs.push([start, c - 1]);
268
- start = -1;
269
- }
270
- }
271
- return runs;
272
- }
273
- /**
274
- * Where one color sits, row band by row band — the view a bounding box cannot
275
- * give for a maze, a frame, or any other structure that is mostly holes.
276
- *
277
- * Keyed by **color rather than component** on purpose. A wall that a doorway
278
- * splits into three components is one thing to a player, and three boxes describe
279
- * it worse than one span list does; the flood fill's notion of connectedness is an
280
- * implementation detail here, not something the model should have to reassemble.
281
- *
282
- * Rows whose runs are identical collapse into one band, the same trick
283
- * {@link renderGrid} uses — and for the same reason, since ARC boards are banded.
284
- * On a real 64×64 maze it turns 64 rows into ~20 bands.
285
- */
286
- export function colorSpans(grid, color) {
287
- const perRow = grid.map((row) => rowRuns(row, color));
288
- const keys = perRow.map((runs) => runs.map(([a, b]) => `${a}:${b}`).join(" "));
289
- const bands = [];
290
- for (let r = 0; r < perRow.length;) {
291
- let end = r;
292
- while (end + 1 < perRow.length && keys[end + 1] === keys[r])
293
- end++;
294
- if (perRow[r].length > 0) {
295
- bands.push({
296
- top: r,
297
- bottom: end,
298
- cols: perRow[r].slice(0, MAX_SPAN_RUNS),
299
- hidden: Math.max(0, perRow[r].length - MAX_SPAN_RUNS)
300
- });
301
- }
302
- r = end + 1;
303
- }
304
- return bands;
305
- }
306
- /**
307
- * Runs named per band, one line each. Both caps exist to bound the render on a
308
- * board this encoding suits badly — a checkerboard is 32 runs in every one of 64
309
- * rows — and both announce themselves, so a clipped view is never mistaken for
310
- * the whole of one.
311
- */
312
- export function renderSpans(bands) {
313
- const lines = bands.slice(0, MAX_SPAN_BANDS).map((band) => {
314
- const rows = band.top === band.bottom
315
- ? `row ${band.top}`
316
- : `rows ${band.top}-${band.bottom}`;
317
- const cols = band.cols
318
- .map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`))
319
- .join(", ");
320
- const more = band.hidden === 0 ? "" : `, +${band.hidden} more run(s)`;
321
- return ` ${rows}: cols ${cols}${more}`;
322
- });
323
- if (bands.length > MAX_SPAN_BANDS) {
324
- lines.push(` +${bands.length - MAX_SPAN_BANDS} more row band(s)`);
325
- }
326
- return lines.join("\n");
327
- }
328
- /** Column runs named per band before {@link renderSpans} stops. */
329
- const MAX_SPAN_RUNS = 8;
330
- /**
331
- * Row bands named per color before {@link renderSpans} stops. Set from the real
332
- * `ls20` board, whose floor needs 25: a cap that clips a terrain layer three
333
- * quarters of the way down the board hides exactly the part a play reaches last.
334
- */
335
- const MAX_SPAN_BANDS = 28;
336
- /**
337
- * A region this big whose box it fills less than {@link SPRAWL_MAX_FILL} of is
338
- * described by spans instead. Both gates matter: fill alone would spend lines on
339
- * every small hollow glyph (a 3×3 donut fills 8/9 of its box, a 6×6 outline just
340
- * over half), and size alone would spend them on solid blocks that a box already
341
- * describes exactly.
342
- */
343
- const SPRAWL_MIN_CELLS = 64;
344
- const SPRAWL_MAX_FILL = 0.7;
345
- /** How many colors {@link renderShapes} will describe by spans. */
346
- const SPRAWL_COLORS = 3;
347
- /**
348
- * Named regions with their positions, largest first — the locational view.
349
- *
350
- * A bounding box describes a region only when the region roughly fills it. That
351
- * holds for the movable pieces and fails for exactly the two things a route
352
- * depends on: on a real `ls20` board the model was told `off-black: rows 0-63,
353
- * cols 0-63 (2129 cells)` for the whole maze and `neutral: rows 5-54, cols 9-58
354
- * (1354 cells)` for the whole floor, both a shade over half-filled. The first says
355
- * nothing; the second is worse, asserting a 50×50 open arena of which 46% is wall.
356
- * Blind to both, the play read its route off the boxes and walked into walls, then
357
- * spent a dozen turns rebuilding the maze through 11×11 `region` peepholes.
358
- *
359
- * So a region that sprawls is rendered as {@link colorSpans} instead — every row
360
- * band and the columns it occupies, which is the same fact at a resolution that
361
- * can be acted on. Sprawl is judged per color and the whole color is then spanned
362
- * at once, so a wall and the doorway splitting it read as one terrain layer rather
363
- * than as two boxes that overlap everything.
364
- *
365
- * `cap` bounds the boxed list; anything beyond it is reported as a count so the
366
- * model knows the view is truncated rather than complete.
367
- */
368
- export function renderShapes(grid, cap = 20) {
369
- const comps = locateComponents(grid);
370
- if (comps.length === 0)
371
- return "no shapes (board is entirely white).";
372
- // Judged on the largest component of each color, walking largest-first: a color
373
- // whose biggest region is a sprawl is a terrain layer whatever its offcuts do.
374
- const sprawling = new Set();
375
- const judged = new Set();
376
- for (const s of comps) {
377
- if (judged.has(s.color))
378
- continue;
379
- judged.add(s.color);
380
- if (sprawling.size >= SPRAWL_COLORS)
381
- break;
382
- if (s.size >= SPRAWL_MIN_CELLS && fillRatio(s, s.size) < SPRAWL_MAX_FILL) {
383
- sprawling.add(s.color);
384
- }
385
- }
386
- const lines = [];
387
- const spanned = new Set();
388
- let boxed = 0;
389
- for (const s of comps) {
390
- if (sprawling.has(s.color)) {
391
- if (spanned.has(s.color))
392
- continue;
393
- spanned.add(s.color);
394
- const parts = comps.filter((c) => c.color === s.color);
395
- const cells = parts.reduce((n, c) => n + c.size, 0);
396
- const box = unionBox(parts);
397
- const pct = Math.round(fillRatio(box, cells) * 100);
398
- lines.push(`${colorName(s.color)}: ${cells} cells in ${parts.length} region(s), ` +
399
- `${pct}% of ${describeBox(box)} — too sparse for a box, so by row:`);
400
- lines.push(renderSpans(colorSpans(grid, s.color)));
401
- continue;
402
- }
403
- if (boxed >= cap)
404
- continue;
405
- boxed++;
406
- lines.push(`${colorName(s.color)}: ${describeBox(s)} (${s.size} cell${s.size === 1 ? "" : "s"})`);
407
- }
408
- const rest = comps.filter((c) => !sprawling.has(c.color)).length - boxed;
409
- if (rest > 0)
410
- lines.push(`+${rest} more shape(s), smaller than these.`);
411
- return lines.join("\n");
412
- }
413
- /**
414
- * Above this many changed shapes, a frame is a repaint (a level change, a
415
- * transform) rather than a board where things moved, and naming twenty shapes
416
- * that all "appeared" says less than the cell diff does. {@link renderShapeDelta}
417
- * declines, and its caller falls back.
418
- */
419
- export const MAX_SHAPE_CHANGES = 10;
420
- /** Manhattan distance between two boxes' top-left corners. */
421
- const originDistance = (a, b) => Math.abs(a.top - b.top) + Math.abs(a.left - b.left);
422
- /**
423
- * Pair each `from` with its nearest unclaimed `to`, closest pair first — a greedy
424
- * assignment, which is right here because the alternative (a full optimal
425
- * matching) buys nothing: within one group every candidate is the same color and
426
- * the same size, and one action moves things by a few cells, so the nearest
427
- * candidate is the same object in all but contrived cases.
428
- */
429
- function pairByProximity(from, to) {
430
- const candidates = [];
431
- for (const [a, fromShape] of from.entries()) {
432
- for (const [b, toShape] of to.entries()) {
433
- candidates.push({ a, b, d: originDistance(fromShape, toShape) });
434
- }
435
- }
436
- candidates.sort((x, y) => x.d - y.d);
437
- const claimedFrom = new Set();
438
- const claimedTo = new Set();
439
- const pairs = [];
440
- for (const { a, b } of candidates) {
441
- if (claimedFrom.has(a) || claimedTo.has(b))
442
- continue;
443
- claimedFrom.add(a);
444
- claimedTo.add(b);
445
- pairs.push([from[a], to[b]]);
446
- }
447
- return {
448
- pairs,
449
- unpairedFrom: from.filter((_, i) => !claimedFrom.has(i)),
450
- unpairedTo: to.filter((_, i) => !claimedTo.has(i))
451
- };
452
- }
453
- /** Group shapes under a key, preserving each group's input order. */
454
- function groupShapes(shapes, key) {
455
- const groups = new Map();
456
- for (const shape of shapes) {
457
- const k = key(shape);
458
- const group = groups.get(k);
459
- if (group)
460
- group.push(shape);
461
- else
462
- groups.set(k, [shape]);
463
- }
464
- return groups;
465
- }
466
- /**
467
- * Match the shapes of two frames: what moved, what changed size, what came and
468
- * went. Null when there is no earlier frame to compare against.
469
- *
470
- * Three passes, and the order is the whole design. Color **and** cell count
471
- * first, because a rigid object that travelled keeps both — that pass is what
472
- * finds a move, and its pairs are never reconsidered. Color alone second, over
473
- * what is left, which is what a bar losing cells or a trail growing looks like.
474
- * **Footprint** alone last, over what neither pass claimed, which is a shape that
475
- * changed color without moving — see {@link matchRepaints}.
476
- */
477
- export function diffShapes(before, after) {
478
- if (before === null)
479
- return null;
480
- return matchShapes(locateComponents(before), locateComponents(after));
481
- }
482
- /** {@link diffShapes} over already-located components. Exported for tests. */
483
- export function matchShapes(before, after) {
484
- const moved = [];
485
- const other = [];
486
- const leftFrom = [];
487
- const leftTo = [];
488
- const rigid = (s) => `${s.color}:${s.size}`;
489
- const beforeGroups = groupShapes(before, rigid);
490
- const afterGroups = groupShapes(after, rigid);
491
- for (const key of new Set([...beforeGroups.keys(), ...afterGroups.keys()])) {
492
- const result = pairByProximity(beforeGroups.get(key) ?? [], afterGroups.get(key) ?? []);
493
- for (const [from, to] of result.pairs) {
494
- // Same color, same size, same corner: either it did not move, or it turned
495
- // in place. Both are silence here — a turn shows up in the cell diff, which
496
- // is what the caller falls back to when this delta explains nothing.
497
- if (from.top === to.top && from.left === to.left)
498
- continue;
499
- moved.push({
500
- kind: "moved",
501
- from,
502
- to,
503
- dRow: to.top - from.top,
504
- dCol: to.left - from.left
505
- });
506
- }
507
- leftFrom.push(...result.unpairedFrom);
508
- leftTo.push(...result.unpairedTo);
509
- }
510
- const byColor = (s) => String(s.color);
511
- const colorBefore = groupShapes(leftFrom, byColor);
512
- const colorAfter = groupShapes(leftTo, byColor);
513
- const gone = [];
514
- const appeared = [];
515
- for (const key of new Set([...colorBefore.keys(), ...colorAfter.keys()])) {
516
- const result = pairByProximity(colorBefore.get(key) ?? [], colorAfter.get(key) ?? []);
517
- for (const [from, to] of result.pairs) {
518
- other.push({ kind: "resized", from, to });
519
- }
520
- gone.push(...result.unpairedFrom);
521
- appeared.push(...result.unpairedTo);
522
- }
523
- other.push(...matchRepaints(gone, appeared));
524
- moved.sort((x, y) => y.from.size - x.from.size);
525
- other.sort((x, y) => shapeOf(y).size - shapeOf(x).size);
526
- return { moved, other };
527
- }
528
- /**
529
- * Pair off the shapes neither earlier pass claimed, by **footprint**: one that
530
- * vanished and one that arrived on the same box with the same cell count are one
531
- * shape repainted, not two events.
532
- *
533
- * A click game is the case this exists for. Nothing on such a board ever travels,
534
- * so both earlier passes — which pair within a color — leave every changed block
535
- * as a `gone` and an `appeared` at the same coordinates, and a whole play reads as
536
- * things vanishing and other things materializing where they stood. The logged
537
- * `ft09` play was told `blue 6×6 at rows 36-41, cols 36-41 is gone; red 6×6
538
- * appeared at rows 36-41, cols 36-41` twenty times and had to work out for itself,
539
- * across several turns, that a click toggles a block's color.
540
- *
541
- * Box and size together are strong evidence and not proof — two different shapes
542
- * can share a bounding box and a cell count without sharing a cell — but the
543
- * claim made from them is only that those cells changed color, which is true
544
- * either way. Colors necessarily differ: same-color leftovers were already paired
545
- * by {@link pairByProximity}, which leaves one side of each color empty.
546
- */
547
- function matchRepaints(gone, appeared) {
548
- const footprint = (s) => `${s.top},${s.left},${s.bottom},${s.right}:${s.size}`;
549
- const unclaimed = groupShapes(gone, footprint);
550
- const changes = [];
551
- for (const shape of appeared) {
552
- const from = unclaimed.get(footprint(shape))?.shift();
553
- changes.push(from === undefined
554
- ? { kind: "appeared", shape }
555
- : { kind: "recolored", from, to: shape });
556
- }
557
- for (const leftover of unclaimed.values()) {
558
- for (const shape of leftover)
559
- changes.push({ kind: "gone", shape });
560
- }
561
- return changes;
562
- }
563
- /** The component a change is about, whichever field carries it. */
564
- function shapeOf(change) {
565
- return change.kind === "appeared" || change.kind === "gone"
566
- ? change.shape
567
- : change.from;
568
- }
569
- /** `orange 2×5` — a shape's color and how much board it spans. */
570
- function shapeLabel(s) {
571
- return `${colorName(s.color)} ${s.bottom - s.top + 1}×${s.right - s.left + 1}`;
572
- }
573
- /** `down 5, left 10` — a displacement in board directions, zero terms dropped. */
574
- export function describeShift(dRow, dCol) {
575
- const parts = [];
576
- if (dRow !== 0)
577
- parts.push(`${dRow > 0 ? "down" : "up"} ${Math.abs(dRow)}`);
578
- if (dCol !== 0)
579
- parts.push(`${dCol > 0 ? "right" : "left"} ${Math.abs(dCol)}`);
580
- return parts.length === 0 ? "no shift" : parts.join(", ");
581
- }
582
- /** Destination of a move, spelling out only the axis that changed. */
583
- function describeDestination(change) {
584
- if (change.dCol === 0)
585
- return `rows ${change.to.top}-${change.to.bottom}`;
586
- if (change.dRow === 0)
587
- return `cols ${change.to.left}-${change.to.right}`;
588
- return describeBox(change.to);
589
- }
590
- /**
591
- * One action's effect as movement: `nothing moved` when the board's objects
592
- * stayed put, whatever a counter did alongside them.
593
- *
594
- * That phrase is spent carefully, because the soul teaches it as the signal for
595
- * *blocked or refused*: it leads a step only when everything else the delta holds
596
- * is a {@link ShapeChange} of kind `resized`, which is a counter or a bar doing
597
- * its own bookkeeping. A step that repainted a block, or made one appear, changed
598
- * the board — no object travelled, and saying so as `nothing moved` told a logged
599
- * `ft09` play that twenty clicks which each toggled a 36-cell block had been
600
- * refused.
601
- *
602
- * Null means *this view has nothing to say* — either no shape can be paired
603
- * across the frames, or so many changed that they are a repaint
604
- * ({@link MAX_SHAPE_CHANGES}). Both leave the cell diff as the better witness, so
605
- * the caller renders that instead rather than printing a confident "nothing
606
- * moved" over a board that changed completely.
607
- */
608
- export function renderShapeDelta(delta, cap = 6) {
609
- const total = delta.moved.length + delta.other.length;
610
- if (total === 0 || total > MAX_SHAPE_CHANGES)
611
- return null;
612
- const lines = [];
613
- for (const change of delta.moved.slice(0, cap)) {
614
- lines.push(`${shapeLabel(change.from)} ${describeBox(change.from)} → ` +
615
- `${describeDestination(change)} (${describeShift(change.dRow, change.dCol)})`);
616
- }
617
- if (delta.moved.length === 0) {
618
- lines.push(delta.other.every((change) => change.kind === "resized")
619
- ? "nothing moved"
620
- : "no shape travelled, but the board changed");
621
- }
622
- const room = Math.max(0, cap - lines.length);
623
- for (const change of delta.other.slice(0, room)) {
624
- switch (change.kind) {
625
- case "resized":
626
- lines.push(`${colorName(change.from.color)} ${describeBox(change.from)} → ` +
627
- `${describeBox(change.to)} (${change.from.size}→${change.to.size} cells)`);
628
- break;
629
- case "recolored":
630
- lines.push(`${shapeLabel(change.from)} at ${describeBox(change.from)} ` +
631
- `turned ${colorName(change.to.color)}`);
632
- break;
633
- case "appeared":
634
- lines.push(`${shapeLabel(change.shape)} appeared at ${describeBox(change.shape)}`);
635
- break;
636
- case "gone":
637
- lines.push(`${shapeLabel(change.shape)} at ${describeBox(change.shape)} is gone`);
638
- break;
639
- }
640
- }
641
- const shown = Math.min(delta.moved.length, cap) + Math.min(delta.other.length, room);
642
- if (total > shown)
643
- lines.push(`+${total - shown} more change(s)`);
644
- return lines.join("; ");
645
- }
646
- /**
647
- * A shape's identity within a batch: what it is, and where it sits right now.
648
- * The box is passed apart from the color and cell count because a move's
649
- * destination carries only a box — same color, same size, by construction.
650
- */
651
- const travelKey = (shape, at) => `${shape.color}:${shape.size}:${at.top},${at.left}`;
652
- /**
653
- * Fold one step's moves into a batch's running totals.
654
- *
655
- * Identity is *position*, not appearance. Boards carrying two identical shapes
656
- * are the ordinary case, not the contrived one — a pair of blocks, a player and
657
- * its twin — and summing them under one color-and-size key reports two shapes
658
- * that passed each other as one shape that never moved, which is the single
659
- * worst thing this line could say.
660
- *
661
- * Each entry is re-keyed to where its shape landed, which is exactly where the
662
- * next step's diff will find it: the frames chain, step N's board being step
663
- * N+1's starting board, so a position is a stable handle for as long as the
664
- * shape keeps its color and size. Sources are cleared before any destination is
665
- * written, so a shape sliding into the cell another just vacated inherits its
666
- * own history rather than the vacater's.
667
- */
668
- export function trackTravel(travels, moved) {
669
- const started = moved.map((move) => {
670
- const key = travelKey(move.from, move.from);
671
- const travel = travels.get(key) ?? {
672
- shape: move.from,
673
- dRow: 0,
674
- dCol: 0,
675
- moves: 0
676
- };
677
- travels.delete(key);
678
- return { move, travel };
679
- });
680
- for (const { move, travel } of started) {
681
- travel.dRow += move.dRow;
682
- travel.dCol += move.dCol;
683
- travel.moves++;
684
- travels.set(travelKey(move.from, move.to), travel);
685
- }
686
- }
687
- /**
688
- * A shape's net travel over a batch, counted in **moves** rather than cells.
689
- *
690
- * Cells are the misleading unit for a sequence: two presses that each slide a
691
- * selector five cells are not "ten cells down", they are two moves of five, and a
692
- * model reading the former will place itself ten cells from where it is. So the
693
- * per-move stride is stated whenever the total divides by the moves that produced
694
- * it — observed from the steps, never assumed.
695
- */
696
- export function renderTravel(travel) {
697
- const label = shapeLabel(travel.shape);
698
- if (travel.dRow === 0 && travel.dCol === 0) {
699
- return `${label} is back where it started after ${travel.moves} move(s)`;
700
- }
701
- const shift = describeShift(travel.dRow, travel.dCol);
702
- if (travel.moves <= 1)
703
- return `${label} ${shift}`;
704
- const even = travel.dRow % travel.moves === 0 && travel.dCol % travel.moves === 0;
705
- const stride = even
706
- ? `, ${describeShift(travel.dRow / travel.moves, travel.dCol / travel.moves)} per move`
707
- : "";
708
- return `${label} ${shift} over ${travel.moves} moves${stride}`;
709
- }
710
- /** `0=white 9=blue b=yellow` — maps grid characters to names, present colors only. */
711
- export function renderLegend(grid) {
712
- const present = colorHistogram(grid).sort((a, b) => a.color - b.color);
713
- return ("colors: " +
714
- present.map((h) => `${colorChar(h.color)}=${colorName(h.color)}`).join(" "));
715
- }
716
- /**
717
- * A column ruler for a labeled grid: a line labelling every `step`-th column and
718
- * a tick line beneath it, both indented past the row-label gutter.
719
- *
720
- * Labels are **absolute** column numbers — `firstCol` is where the drawn area
721
- * starts, so a clipped window rules to the same coordinates the rest of the
722
- * output uses. `step` narrows for a window, where every-tenth might not land at
723
- * all.
724
- */
725
- function columnRuler(cols, gutter, firstCol = 0, step = 10) {
726
- let marks = "";
727
- let ticks = "";
728
- for (let c = 0; c < cols; c++) {
729
- const label = String(firstCol + c);
730
- // A label that would run off the end is dropped rather than widening the
731
- // ruler past the body it rules.
732
- if ((firstCol + c) % step === 0 && c + label.length <= cols) {
733
- marks += label;
734
- ticks += "|";
735
- // The multi-char label has already consumed the next columns' slots.
736
- c += label.length - 1;
737
- for (let i = 1; i < label.length; i++)
738
- ticks += " ";
739
- }
740
- else {
741
- marks += " ";
742
- ticks += " ";
743
- }
744
- }
745
- // A window too narrow to contain a single mark gets no ruler at all, rather
746
- // than two blank lines the caller would have to recognize as empty.
747
- if (marks.trim() === "")
748
- return [];
749
- const pad = " ".repeat(gutter);
750
- return [(pad + marks).trimEnd(), (pad + ticks).trimEnd()];
751
- }
752
- /** Ruler spacing for {@link renderRegion}, whose windows are at most 41 wide. */
753
- const REGION_RULER_STEP = 5;
754
- /**
755
- * The full board: a legend, a column ruler, then one labeled line per row —
756
- * with **runs of identical rows collapsed** into a single `12-31 |` band.
757
- *
758
- * Labels exist because a model cannot reliably count to column 37 across 64
759
- * undelimited characters; miscounting there costs a wrong click and two more
760
- * turns. Collapsing exploits how banded ARC boards are without run-length
761
- * encoding *within* a row, which would destroy the 2-D structure that this view
762
- * exists to convey.
763
- */
764
- export function renderGrid(grid) {
765
- if (grid.length === 0)
766
- return "empty grid.";
767
- const rendered = grid.map((row) => row.map(colorChar).join(""));
768
- const cols = grid[0].length;
769
- // Gutter fits the widest row label, e.g. "62-63 | ".
770
- const widest = String(grid.length - 1).length;
771
- const gutter = widest * 2 + 1 + 3;
772
- const lines = [renderLegend(grid), ...columnRuler(cols, gutter)];
773
- for (let r = 0; r < rendered.length;) {
774
- let end = r;
775
- while (end + 1 < rendered.length && rendered[end + 1] === rendered[r])
776
- end++;
777
- const label = end === r ? String(r) : `${r}-${end}`;
778
- lines.push(`${label.padStart(gutter - 3)} | ${rendered[r]}`);
779
- r = end + 1;
780
- }
781
- return lines.join("\n");
782
- }
783
- /**
784
- * A localized view: a `radius`-cell square around (centerRow, centerCol), with
785
- * **absolute** row and column labels. The labels are what make the view usable —
786
- * an unlabeled block cannot be mapped back to a coordinate to click.
787
- *
788
- * The window is **clipped** on every side, never padded, which is what makes the
789
- * header's `cols` anchor true: character `i` of a body line is column
790
- * `firstCol + i`. Padding off-grid columns with spaces instead would shift that
791
- * mapping by up to `radius` near the left edge.
792
- *
793
- * The header used to be the only thing to count from, on the reasoning that a
794
- * window this narrow does not need a ruler. Logs disagree: asked to place a cell
795
- * in an 11-wide window, the model counted the characters by hand and got them
796
- * wrong — "cols 34-38, 5 chars wait that's 10... hmm" — then reasoned on the wrong
797
- * columns. So the window is ruled too, at {@link REGION_RULER_STEP} rather than
798
- * {@link renderGrid}'s tens, which a window this size can miss entirely.
799
- */
800
- export function renderRegion(grid, centerRow, centerCol, radius = 5) {
801
- if (grid.length === 0)
802
- return "empty grid.";
803
- const firstRow = Math.max(0, centerRow - radius);
804
- const lastRow = Math.min(grid.length - 1, centerRow + radius);
805
- const firstCol = Math.max(0, centerCol - radius);
806
- const gutter = String(lastRow).length;
807
- const window = [];
808
- const body = [];
809
- // Widest extent actually drawn, so the header says where the window ends as
810
- // well as where it starts. Each row is clipped on its own in case a frame
811
- // arrives ragged: that shortens a line, it never shifts one.
812
- let lastCol = firstCol;
813
- for (let r = firstRow; r <= lastRow; r++) {
814
- const end = Math.min(grid[r].length - 1, centerCol + radius);
815
- const cells = grid[r].slice(firstCol, end + 1);
816
- window.push(cells);
817
- body.push(`${String(r).padStart(gutter)} | ${cells.map(colorChar).join("")}`);
818
- lastCol = Math.max(lastCol, end);
819
- }
820
- return [
821
- renderLegend(window),
822
- `rows ${firstRow}-${lastRow}, cols ${firstCol}-${lastCol} ` +
823
- `(centered on row ${centerRow}, col ${centerCol})`,
824
- // `gutter + 3` is the body's `<label> | ` prefix, so the ruler sits over the
825
- // cells rather than over the labels.
826
- ...columnRuler(lastCol - firstCol + 1, gutter + 3, firstCol, REGION_RULER_STEP),
827
- ...body
828
- ].join("\n");
829
- }