cozyclay 3.1.0 → 3.1.2

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -0,0 +1,243 @@
1
+ // Measured, not judged (output.observations.life): how mechanical a take reads. Five cheap readings
2
+ // over the baked frames and the keys the agent wrote, one linear pass each, warnings only (never a
3
+ // QA group, never qa.ok):
4
+ // sync outside walks, the frame each body part (CHAINS) starts and stops moving (its activity,
5
+ // below, past START of its usual level after a hold); the starts (stops) of several parts
6
+ // close together are one transition, synchronous where most of them go within +-1 frame.
7
+ // A body does not do that: the pelvis or a hand leads and the trunk, head and arms follow
8
+ // a few frames later. score = the share of transitions that are synchronous.
9
+ // keys the gaps between the agent's key frames (per channel and all channels as one): their
10
+ // coefficient of variation (cv, 0 = an even grid) and the share of keys on a half second.
11
+ // Keys on a beat move every pose at one tempo; keys on the extremes do not.
12
+ // jerk the third difference of each CHAINS joint's position (m/s^3): its 95th percentile on
13
+ // the frames the body moves, and spike frames, where a joint goes past SPIKE times its level
14
+ // (and JERK_FLOOR): the acceleration snaps there, a seam between two keys' curves (a landing
15
+ // too); warned past twice that.
16
+ // twin outside walks (the hips over WALK_SPEED) and jumps (no foot still), in WINDOW-frame windows: the correlation of
17
+ // the left and right limb's bend speeds (elbow and shoulder; knee and hip). Over TWIN both
18
+ // sides do the same thing on the same frames. score = the share of windows that twin.
19
+ // dead runs of a second or more with no joint moving over DEAD m a frame outside a still goal:
20
+ // a statue (idle false, or a pose held where the idle layer stays off).
21
+ // Activity is a part's own motion: the turn (deg/frame) of its local rotation, and for the hips
22
+ // their world turn or travel (cm/frame), so a hand carried by its arm does not count as moving.
23
+ // Why: compiled takes pass QA and still read mechanical (every part starts and stops on one frame,
24
+ // keys every 12 frames, both arms twinned), and nothing in the answer said so.
25
+ import { sub, len } from "../compiler/math.js";
26
+
27
+ const CHAINS = ["Hips", "Spine", "Spine2", "Head", "LeftArm", "RightArm", "LeftForeArm", "RightForeArm", "LeftHand", "RightHand",
28
+ "LeftUpLeg", "RightUpLeg", "LeftLeg", "RightLeg", "LeftFoot", "RightFoot"];
29
+ /** A part moves above MOVE deg (cm) a frame; holds are HOLD frames or more under it; START of a part's usual level starts it. */
30
+ const MOVE = 0.25, HOLD = 3, START = 0.2;
31
+ /** sync: events GAP frames or less apart are one transition; synchronous when SYNC_MIN parts and SYNC_SHARE of its parts go within +-1 frame. */
32
+ const SYNC_MIN = 4, SYNC_SHARE = 0.6, GAP = 3;
33
+ const GRID_CV = 0.15, GRID_KEYS = 4, HALF_SHARE = 0.7;
34
+ const SPIKE = 4, JERK_FLOOR = 20;
35
+ const WALK_SPEED = 0.25, FOOT_STILL = 0.005, WINDOW = 48, TWIN = 0.9, TWIN_SWEEP = 15;
36
+ const DEAD = 1e-4;
37
+ const r2 = x => Math.round(x * 100) / 100;
38
+
39
+ export const LIFE_UNITS = "life (warnings, not QA): [share or s, worst f0, f1]; keys [gap cv, even channels, half-second share]; jerk [p95 m/s^3, joint, spikes]";
40
+
41
+ const p95 = xs => { const s = [...xs].sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor(0.95 * s.length))] : 0; };
42
+ const cvOf = gaps => { const m = gaps.reduce((a, b) => a + b, 0) / gaps.length, v = gaps.reduce((a, g) => a + (g - m) ** 2, 0) / gaps.length; return m > 0 ? Math.sqrt(v) / m : 0; };
43
+ function pearson(a, b) {
44
+ const n = a.length, ma = a.reduce((s, x) => s + x, 0) / n, mb = b.reduce((s, x) => s + x, 0) / n;
45
+ let ab = 0, aa = 0, bb = 0;
46
+ for (let i = 0; i < n; i++) { ab += (a[i] - ma) * (b[i] - mb); aa += (a[i] - ma) ** 2; bb += (b[i] - mb) ** 2; }
47
+ return aa > 0 && bb > 0 ? ab / Math.sqrt(aa * bb) : 0;
48
+ }
49
+
50
+ /** Per CHAINS part, activity[f] (from f-1): its local turn in deg; the hips the larger of their world turn and travel in cm. */
51
+ function activities(sk, F) {
52
+ const I = sk.index, n = F.length, DEG = 180 / Math.PI;
53
+ return CHAINS.map(j => {
54
+ const b = I[j], p = j === "Hips" ? -1 : sk.parent[b], act = new Float64Array(n);
55
+ let L = new Float64Array(9), prev = new Float64Array(9);
56
+ for (let f = 0; f < n; f++) {
57
+ const { R, P } = F[f].s, B = R[b], A = p < 0 ? null : R[p];
58
+ // L = A^T B (the local rotation), written out: a pass over every frame of every part must not allocate
59
+ for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) L[r * 3 + c] = A ? A[0][r] * B[0][c] + A[1][r] * B[1][c] + A[2][r] * B[2][c] : B[r][c];
60
+ if (f) {
61
+ let tr = 0;
62
+ for (let i = 0; i < 9; i++) tr += L[i] * prev[i];
63
+ const turn = Math.acos(Math.max(-1, Math.min(1, (tr - 1) / 2))) * DEG, Q = F[f - 1].s.P[b];
64
+ act[f] = p < 0 ? Math.max(turn, Math.hypot(P[b][0] - Q[0], P[b][1] - Q[1], P[b][2] - Q[2]) * 100) : turn;
65
+ }
66
+ [L, prev] = [prev, L];
67
+ }
68
+ return act;
69
+ });
70
+ }
71
+
72
+ /** Moves between holds: [a, b] runs of frames where some part moves, gaps under HOLD frames joined. */
73
+ function moves(acts, n) {
74
+ const runs = [];
75
+ let a = -1, quiet = 0;
76
+ for (let f = 1; f < n; f++) {
77
+ if (acts.some(act => act[f] > MOVE)) { if (a < 0) a = f; quiet = 0; }
78
+ else if (a >= 0 && ++quiet >= HOLD) { runs.push([a, f - quiet]); a = -1; quiet = 0; }
79
+ }
80
+ if (a >= 0) runs.push([a, n - 1 - quiet]);
81
+ return runs.filter(([x, y]) => y - x >= 3);
82
+ }
83
+
84
+ /**
85
+ * Starts and stops outside walks: a part starts where its activity rises past its threshold
86
+ * (MOVE, or START of its usual level: the 90th percentile of its moving frames) after HOLD frames
87
+ * under it, and stops where it falls under it for HOLD frames. Events of several parts that follow
88
+ * one another within GAP frames are one transition; it is synchronous when, of the m parts in it
89
+ * (SYNC_MIN or more), max(SYNC_MIN, SYNC_SHARE * m) go within one 3-frame window.
90
+ */
91
+ function syncOf(acts, walking, n) {
92
+ const events = [[], []];
93
+ acts.forEach((act, j) => {
94
+ const level = [...act].filter(v => v > MOVE).sort((a, b) => a - b), thr = Math.max(MOVE, START * (level[Math.floor(0.9 * level.length)] ?? 0));
95
+ const on = f => act[f] > thr;
96
+ for (let f = 1; f < n; f++) {
97
+ if (walking[f]) continue;
98
+ if (on(f) && !on(f - 1)) { let q = 1; while (q <= HOLD && f - q >= 0 && !on(f - q)) q++; if (q > HOLD || f - q < 0) events[0].push([f, j]); }
99
+ if (!on(f) && on(f - 1)) { let q = 0; while (q < HOLD && f + q < n && !on(f + q)) q++; if (q >= HOLD && f + q < n) events[1].push([f - 1, j]); }
100
+ }
101
+ });
102
+ let total = 0, synced = 0, worst = null;
103
+ for (const list of events) {
104
+ list.sort((a, b) => a[0] - b[0]);
105
+ for (let i = 0; i < list.length;) {
106
+ let k = i + 1;
107
+ while (k < list.length && list[k][0] - list[k - 1][0] <= GAP) k++;
108
+ const group = list.slice(i, k), m = new Set(group.map(e => e[1])).size;
109
+ i = k;
110
+ if (m < SYNC_MIN) continue;
111
+ let best = 0, at = group[0][0];
112
+ for (let x = 0, y = 0; y < group.length; y++) {
113
+ while (group[y][0] - group[x][0] > 2) x++;
114
+ const c = new Set(group.slice(x, y + 1).map(e => e[1])).size;
115
+ if (c > best) { best = c; at = group[x][0]; }
116
+ }
117
+ total++;
118
+ if (best >= Math.max(SYNC_MIN, Math.ceil(SYNC_SHARE * m))) synced++;
119
+ if (!worst || best / m > worst.share || (best / m === worst.share && best > worst.k)) worst = { share: best / m, k: best, of: m, at: [at, at + 2] };
120
+ }
121
+ }
122
+ return { score: total ? r2(synced / total) : 0, total, worst };
123
+ }
124
+
125
+ /** The agent's keys: cv of all key gaps, the channels keyed on an even grid, the share on half seconds. */
126
+ function keysOf(keys, fps) {
127
+ const per = new Map(), all = new Set();
128
+ for (const k of keys ?? []) {
129
+ if (!Number.isFinite(k?.f)) continue;
130
+ all.add(k.f);
131
+ for (const ch of Object.keys(k.set ?? {})) (per.get(ch) ?? per.set(ch, new Set()).get(ch)).add(k.f);
132
+ }
133
+ const gaps = fs => { const s = [...fs].sort((a, b) => a - b); return s.slice(1).map((f, i) => f - s[i]); };
134
+ const eligible = [...per].filter(([, fs]) => fs.size >= GRID_KEYS), grid = eligible.filter(([, fs]) => cvOf(gaps(fs)) < GRID_CV).map(([ch]) => ch);
135
+ const inner = [...all].filter(f => f > 0), half = Math.round(fps / 2);
136
+ return { cv: all.size >= GRID_KEYS ? r2(cvOf(gaps(all))) : null, grid, eligible: eligible.length,
137
+ halfSec: inner.length >= GRID_KEYS ? r2(inner.filter(f => f % half === 0).length / inner.length) : null };
138
+ }
139
+
140
+ function jerkOf(sk, F, moving, fps) {
141
+ const k3 = fps ** 3;
142
+ let top = [0, null], worst = null;
143
+ const spikes = new Set();
144
+ for (const j of CHAINS) {
145
+ const X = F.map(fr => fr.s.P[sk.index[j]]), J = new Float64Array(F.length), on = [];
146
+ for (let f = 3; f < F.length; f++) {
147
+ const d = i => X[f][i] - 3 * X[f - 1][i] + 3 * X[f - 2][i] - X[f - 3][i];
148
+ J[f] = Math.hypot(d(0), d(1), d(2)) * k3;
149
+ if (moving[f]) on.push(J[f]);
150
+ }
151
+ const level = p95(on);
152
+ if (level > top[0]) top = [r2(level), j];
153
+ for (let f = 3; f < F.length; f++) {
154
+ if (J[f] <= Math.max(JERK_FLOOR, SPIKE * level)) continue;
155
+ spikes.add(f);
156
+ const ratio = J[f] / Math.max(level, 1e-9);
157
+ if (!worst || ratio > worst.ratio) worst = { ratio, f, j };
158
+ }
159
+ }
160
+ return { p95: top, spikes: spikes.size, worst };
161
+ }
162
+
163
+ /** Bend angles (deg) per frame of one side's limb: [elbow, shoulder] or [knee, hip]. */
164
+ function bends(sk, s, S, arm) {
165
+ const I = sk.index, P = s.P, up = sub(P[I.Spine2], P[I.Hips]);
166
+ const ang = (a, b) => Math.acos(Math.max(-1, Math.min(1, (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) / Math.max(1e-9, len(a) * len(b))))) * 180 / Math.PI;
167
+ const [r, m, e] = arm ? [`${S}Arm`, `${S}ForeArm`, `${S}Hand`] : [`${S}UpLeg`, `${S}Leg`, `${S}Foot`];
168
+ const u = sub(P[I[m]], P[I[r]]);
169
+ return [ang(u, sub(P[I[e]], P[I[m]])), ang(u, arm ? up : [-up[0], -up[1], -up[2]])];
170
+ }
171
+
172
+ function twinOf(sk, F, walking) {
173
+ const res = {};
174
+ for (const [name, arm] of [["arms", true], ["legs", false]]) {
175
+ const L = F.map(({ s }) => bends(sk, s, "Left", arm)), R = F.map(({ s }) => bends(sk, s, "Right", arm));
176
+ let windows = 0, twins = 0, worst = null;
177
+ for (let a = 1; a + WINDOW <= F.length; a += WINDOW / 4) {
178
+ if (walking.slice(a, a + WINDOW).some(Boolean)) continue;
179
+ const dl = [], dr = [];
180
+ for (let f = a; f < a + WINDOW; f++) for (let i = 0; i < 2; i++) { dl.push(L[f][i] - L[f - 1][i]); dr.push(R[f][i] - R[f - 1][i]); }
181
+ const sweep = d => d.reduce((x, v) => x + Math.abs(v), 0);
182
+ if (sweep(dl) < TWIN_SWEEP || sweep(dr) < TWIN_SWEEP) continue;
183
+ const c = pearson(dl, dr);
184
+ windows++;
185
+ if (c > TWIN) twins++;
186
+ if (!worst || c > worst.c) worst = { c, at: [a, a + WINDOW - 1] };
187
+ }
188
+ res[name] = { score: windows ? r2(twins / windows) : 0, windows, worst };
189
+ }
190
+ return res;
191
+ }
192
+
193
+ function deadOf(sk, F, spec, fps) {
194
+ const still = new Uint8Array(F.length);
195
+ for (const g of spec.goals ?? []) {
196
+ const at = Array.isArray(g?.at) ? g.at : null;
197
+ if (g?.still !== undefined && at?.length) for (let f = Math.max(0, Math.round(at[0])); f <= Math.min(F.length - 1, Math.round(at[at.length - 1])); f++) still[f] = 1;
198
+ }
199
+ let best = null, frames = 0;
200
+ for (let f = 1, a = -1; f <= F.length; f++) {
201
+ const dead = f < F.length && !still[f] && sk.names.every((_, j) => len(sub(F[f].s.P[j], F[f - 1].s.P[j])) < DEAD);
202
+ if (dead) { if (a < 0) a = f; continue; }
203
+ if (a >= 0 && f - a >= fps) { frames += f - a; if (!best || f - a > best[1] - best[0] + 1) best = [a, f - 1]; }
204
+ a = -1;
205
+ }
206
+ return { frames, best };
207
+ }
208
+
209
+ /**
210
+ * observeLife(sk, out, keys) -> observations.life; keys are the spec keys as the agent wrote them
211
+ * (a skill's keys are the compiler's). Frames are absolute (out.f0 added). flags names the metrics
212
+ * past their mark, worst first; warn is what to change for the worst (one line: the receipt budget, report.js).
213
+ */
214
+ export function observeLife(sk, out, keys) {
215
+ const F = out.frames, n = F.length, fps = out.fps ?? 24, f0 = out.f0 ?? 0, spec = out.ctx.spec, I = sk.index, warnings = [];
216
+ const acts = activities(sk, F), runs = moves(acts, n), moving = new Uint8Array(n);
217
+ for (const [a, b] of runs) moving.fill(1, a, b + 1);
218
+ // a walk: the hips travel over WALK_SPEED, widened by half a window so its first and last steps count
219
+ const walking = new Uint8Array(n), pad = WINDOW / 2;
220
+ for (let f = 1; f < n; f++) {
221
+ const P = F[f].s.P[I.Hips], Q = F[f - 1].s.P[I.Hips];
222
+ if (Math.hypot(P[0] - Q[0], P[2] - Q[2]) * fps > WALK_SPEED) walking.fill(1, Math.max(0, f - pad), Math.min(n, f + pad));
223
+ }
224
+ // twins leave out walks and jumps too: no foot still (a flip, a hop takes both legs together)
225
+ const free = walking.slice();
226
+ for (let f = 1; f < n; f++) if (["LeftFoot", "RightFoot"].every(j => len(sub(F[f].s.P[I[j]], F[f - 1].s.P[I[j]])) > FOOT_STILL)) free.fill(1, Math.max(0, f - pad), Math.min(n, f + pad));
227
+ const sync = syncOf(acts, walking, n), k = keysOf(keys, fps), jerk = jerkOf(sk, F, moving, fps), twin = twinOf(sk, F, free), dead = deadOf(sk, F, spec, fps);
228
+ const span = ([a, b]) => `f${a + f0}-${b + f0}`;
229
+ // [weight, metric, advice]: the answer keeps the worst advice and names every metric it flags
230
+ if (sync.total >= 2 && sync.score >= 0.5) warnings.push([sync.score, "sync", `${sync.worst.k} of ${sync.worst.of} parts start or stop together ${span(sync.worst.at)}: lead with pelvis or a hand, spine/head/arms 2-4 frames later`]);
231
+ const gridKeys = (k.cv !== null && k.cv < GRID_CV) || (k.eligible && k.grid.length / k.eligible >= 0.5) || (k.halfSec ?? 0) >= HALF_SHARE;
232
+ if (gridKeys) warnings.push([0.9, "keys", `keys on a grid: key the extremes (contact, peak, turn), vary the gaps`]);
233
+ if (jerk.worst && jerk.worst.ratio > 2 * SPIKE) warnings.push([0.6, "jerk", `${jerk.worst.j} jerks ${Math.round(jerk.worst.ratio)}x at f${jerk.worst.f + f0}: a key snaps; ease or move it`]);
234
+ for (const [name, t] of Object.entries(twin)) if (t.windows >= 2 && t.score >= 0.4) warnings.push([t.score, name, `${name} mirror ${span(t.worst.at)}: ${name === "legs" ? "weight onto one foot, not straight down" : "offset one side 2-4 frames or pose it apart"}`]);
235
+ if (dead.best) warnings.push([0.7, "dead", `dead still ${span(dead.best)}${spec.idle === false ? " (idle false)" : ""}: let idle run or key a settle`]);
236
+ warnings.sort((a, b) => b[0] - a[0]);
237
+ const at = (score, w) => (w ? [score, w[0] + f0, w[1] + f0] : [score]);
238
+ return {
239
+ sync: at(sync.score, sync.worst?.at), keys: [k.cv, k.grid.length, k.halfSec], jerk: [...jerk.p95, jerk.spikes],
240
+ arms: at(twin.arms.score, twin.arms.worst?.at), legs: at(twin.legs.score, twin.legs.worst?.at), dead: at(r2(dead.frames / fps), dead.best),
241
+ ...(warnings.length ? { flags: warnings.map(w => w[1]), warn: warnings[0][2] } : {}),
242
+ };
243
+ }
@@ -36,7 +36,7 @@ function graspPart(out, ref) {
36
36
  return out.ctx.sockets?.get(name)?.owner ?? a.object;
37
37
  }
38
38
 
39
- const active = (out, f, when) => (Array.isArray(when) ? f + (out.f0 ?? 0) >= when[0] && f + (out.f0 ?? 0) <= when[1] : when ? (out.frames[f].c[when] ?? 0) > 0.99 : true);
39
+ export const active = (out, f, when) => (Array.isArray(when) ? f + (out.f0 ?? 0) >= when[0] && f + (out.f0 ?? 0) <= when[1] : when ? (out.frames[f].c[when] ?? 0) > 0.99 : true);
40
40
 
41
41
  function alongDir(out, f, along) {
42
42
  if (along === "up") return [0, 1, 0];
@@ -44,19 +44,25 @@ function alongDir(out, f, along) {
44
44
  return norm(along);
45
45
  }
46
46
 
47
+ /** How far a checks.contacts entry k's point is from its target at frame f (indexes out.frames), m; null when a point is unknown there. */
48
+ export function contactGap(out, f, k) {
49
+ const a = pointOf(out, f, k.point), b = pointOf(out, f, k.target);
50
+ if (!a || !b) return null;
51
+ // on a grasp's anchor the fingers close on the held part wherever the grasp turned them round it: they are on it
52
+ // when they are on its surface (as goals' near on an object), not on one point of it (session 7f15b4ae: 0.4 cm
53
+ // on the cup, read as 5.3 cm from the anchor)
54
+ // and a hand that holds a thing resting in its palm (seat.js) has its fingers on that thing's surface
55
+ const held = graspPart(out, k.target) ?? seatedIn(out, k.point, f + (out.f0 ?? 0));
56
+ return held ? refDistance(out, f, k.point, `object:${held}`) ?? len(sub(a, b)) : len(sub(a, b));
57
+ }
58
+
47
59
  export function specChecks(out, checks = {}) {
48
60
  const issues = [], n = out.frames.length, sk = out.ctx.sk, I = sk.index;
49
61
  const push = (kind, msg, f, value, limit) => issues.push({ kind, msg, frame: f, value: +value.toFixed(4), limit });
50
62
  for (let f = 0; f < n; f++) {
51
63
  for (const k of checks.contacts ?? []) if (active(out, f, k.when)) {
52
- const a = pointOf(out, f, k.point), b = pointOf(out, f, k.target);
53
- if (!a || !b) continue;
54
- // on a grasp's anchor the fingers close on the held part wherever the grasp turned them round it: they are on it
55
- // when they are on its surface (as goals' near on an object), not on one point of it (session 7f15b4ae: 0.4 cm
56
- // on the cup, read as 5.3 cm from the anchor)
57
- // and a hand that holds a thing resting in its palm (seat.js) has its fingers on that thing's surface
58
- const held = graspPart(out, k.target) ?? seatedIn(out, k.point, f + (out.f0 ?? 0)), e = held ? refDistance(out, f, k.point, `object:${held}`) ?? len(sub(a, b)) : len(sub(a, b));
59
- if (e > (k.tol ?? 0.01)) push("CONTACT", `${k.name}: ${(e * 100).toFixed(1)} cm apart`, f, e, k.tol ?? 0.01);
64
+ const e = contactGap(out, f, k);
65
+ if (e !== null && e > (k.tol ?? 0.01)) push("CONTACT", `${k.name}: ${(e * 100).toFixed(1)} cm apart`, f, e, k.tol ?? 0.01);
60
66
  }
61
67
  for (const k of checks.orient ?? []) if (active(out, f, k.when)) {
62
68
  const a = pointOf(out, f, k.from), b = pointOf(out, f, k.to);
@@ -0,0 +1,50 @@
1
+ // C6. Is one compile's QA better than another's: the one rule repair (repair.js) keeps a change by and the hint check
2
+ // (hint-check.js) offers a patch by. Better means all of
3
+ // less the summed severity (severity.js) drops
4
+ // goals no more goals fail, and every goal that held still holds
5
+ // problems no more issue groups than before
6
+ // trade what got worse (a new group, or a group whose severity rose) costs at most half of what got better
7
+ // Logs: a hint patch applied as sent made the take worse 49% of the time, and repair moved the root and broke the arms;
8
+ // a lower sum alone let a change through that traded one failure for several new ones.
9
+ import { groupKey } from "./severity.js";
10
+
11
+ const groupsOf = qa => qa?.groups ?? [];
12
+ const sevOf = qa => groupsOf(qa).reduce((sum, g) => sum + (g.severity ?? 0), 0);
13
+ const heldOf = qa => (qa?.goals ?? []).filter(g => g.ok).map(g => g.name);
14
+ const failedOf = qa => (qa?.goals ?? []).filter(g => !g.ok).length;
15
+ const TRADE = 0.5;
16
+ const r2 = x => Math.round(x * 100) / 100;
17
+
18
+ /** The group severities by key (severity.js groupKey). */
19
+ function byKey(qa) {
20
+ const m = new Map();
21
+ for (const g of groupsOf(qa)) m.set(groupKey(g), (m.get(groupKey(g)) ?? 0) + (g.severity ?? 0));
22
+ return m;
23
+ }
24
+
25
+ /**
26
+ * compareQa(next, base) -> { better, why, sev: [base, next], problems: [base, next], worse: [{ key, severity: [a, b] }] }
27
+ * why: the first rule that failed ("" when better); worse: the groups that got worse, worst first (at most 3).
28
+ */
29
+ export function compareQa(next, base) {
30
+ const a = byKey(base), b = byKey(next);
31
+ let rise = 0, drop = 0;
32
+ const worse = [];
33
+ for (const [k, v] of b) { const was = a.get(k) ?? 0; if (v > was + 1e-9) { rise += v - was; worse.push({ key: k, severity: [r2(was), r2(v)] }); } else drop += was - v; }
34
+ for (const [k, v] of a) if (!b.has(k)) drop += v;
35
+ worse.sort((x, y) => y.severity[1] - y.severity[0] - (x.severity[1] - x.severity[0]));
36
+ const sev = [r2(sevOf(base)), r2(sevOf(next))], problems = [groupsOf(base).length, groupsOf(next).length];
37
+ const lost = heldOf(base).filter(n => !heldOf(next).includes(n));
38
+ const why = !(sevOf(next) < sevOf(base) - 1e-9) ? `summed severity ${sev[0]} -> ${sev[1]}`
39
+ : failedOf(next) > failedOf(base) || lost.length ? `goal ${lost.length ? `"${lost[0]}" no longer holds` : "failures rose"}`
40
+ : problems[1] > problems[0] ? `problems ${problems[0]} -> ${problems[1]}`
41
+ : rise > TRADE * drop + 1e-9 ? `${worse[0]?.key} got worse (${worse[0]?.severity.join(" -> ")})` : "";
42
+ return { better: !why, why, sev, problems, worse: worse.slice(0, 3) };
43
+ }
44
+
45
+ /** next is no worse than base: no more problems (at as many, the summed severity does not rise), no more goals fail, none that held is lost. */
46
+ export const noWorse = (next, base) => (groupsOf(next).length < groupsOf(base).length || (groupsOf(next).length === groupsOf(base).length && sevOf(next) <= sevOf(base) + 1e-9))
47
+ && failedOf(next) <= failedOf(base) && heldOf(base).every(n => heldOf(next).includes(n));
48
+
49
+ /** Severity of the group with key `key` in qa (0 when it is gone). */
50
+ export const severityIn = (qa, key) => groupsOf(qa).filter(g => groupKey(g) === key).reduce((s, g) => s + (g.severity ?? 0), 0);
@@ -5,9 +5,13 @@
5
5
  // anchor:<name> a spec anchor, resolved as the compiler resolves it
6
6
  // landmark:mouth the measured face landmark
7
7
  // person:<id>.<Bone> another character's joint where its installed take has it (people.js)
8
- // object:<id>[.center|.top|.base] a body or a group (object-pose.js objectBoxesAt): the centre,
9
- // top-face centre or bottom-face centre of the AABB of its
10
- // part boxes' corners (default center)
8
+ // object:<id>[.center|.top|.base|.rim] a body or a group (object-pose.js objectBoxesAt): the centre,
9
+ // top-face centre or bottom-face centre of the world AABB of its
10
+ // part boxes' corners (default center); .rim is its opening (a cup's,
11
+ // a can's): the top face of its highest part in the part's own axes,
12
+ // so it tilts with it, and measured against another point the edge of
13
+ // that face nearest the point (where a lip meets a cup). A tilted cup's
14
+ // .top is the box's highest corner height over its middle, not its rim
11
15
  // Frames are absolute; a frame outside the compiled window (out.f0 ..) answers null.
12
16
  import { REF_KINDS } from "./schema/fields.js";
13
17
  import { resolveBone } from "./bones.js";
@@ -20,14 +24,14 @@ import { add, apply, sub, len } from "./compiler/math.js";
20
24
  import { sdBox } from "./qa/geometry.js";
21
25
  import { personJoint, peopleList } from "./people.js";
22
26
 
23
- const SYNTAX = { joint: "joint:<Bone>", bone: "bone:<Bone>", prop: "prop:<prop>.<point>", anchor: "anchor:<name>", landmark: "landmark:mouth", object: "object:<id>[.center|.top|.base]", person: "person:<id>.<Bone>" };
27
+ const SYNTAX = { joint: "joint:<Bone>", bone: "bone:<Bone>", prop: "prop:<prop>.<point>", anchor: "anchor:<name>", landmark: "landmark:mouth", object: "object:<id>[.center|.top|.base|.rim]", person: "person:<id>.<Bone>" };
24
28
 
25
29
  function parse(ref) {
26
30
  const m = /^([a-z]+):(.+)$/s.exec(String(ref));
27
31
  if (!m) return null;
28
32
  const [, kind, rest] = m;
29
33
  if (kind === "prop") { const [name, point] = rest.split("."); return { kind, name, point: point ?? "" }; }
30
- if (kind === "object") { const o = /^(.+)\.(center|top|base)$/s.exec(rest); return { kind, name: o ? o[1] : rest, point: o ? o[2] : null }; }
34
+ if (kind === "object") { const o = /^(.+)\.(center|top|base|rim)$/s.exec(rest); return { kind, name: o ? o[1] : rest, point: o ? o[2] : null }; }
31
35
  if (kind === "person") { const dot = rest.lastIndexOf("."); return { kind, name: dot > 0 ? rest.slice(0, dot) : rest, point: dot > 0 ? rest.slice(dot + 1) : "" }; }
32
36
  return { kind, name: rest, point: null };
33
37
  }
@@ -42,6 +46,18 @@ function boxesPoint(boxes, which) {
42
46
  return which === "top" ? [c[0], hi[1], c[2]] : which === "base" ? [c[0], lo[1], c[2]] : c;
43
47
  }
44
48
 
49
+ /** The opening of a body or group's boxes: { o, n, r }, the top face of the highest part in its own axes (centre, axis, the smaller half width). */
50
+ export function openingOf(boxes) {
51
+ const tops = (boxes ?? []).map(b => ({ o: add(b.pos, apply(b.R, [0, 2 * b.half[1], 0])), n: [b.R[0][1], b.R[1][1], b.R[2][1]], r: Math.min(b.half[0], b.half[2]) }));
52
+ return tops.length ? tops.reduce((p, q) => (q.o[1] > p.o[1] ? q : p)) : null;
53
+ }
54
+
55
+ /** The point of an opening's edge nearest p (its centre when p is on the axis). */
56
+ function rimNear(op, p) {
57
+ const v = sub(p, op.o), along = v[0] * op.n[0] + v[1] * op.n[1] + v[2] * op.n[2], across = sub(v, op.n.map(x => x * along)), d = len(across);
58
+ return d > 1e-6 ? add(op.o, across.map(x => (x * op.r) / d)) : op.o;
59
+ }
60
+
45
61
  /** World point [x, y, z] of `ref` at absolute frame f of compile result out, or null. */
46
62
  export function refPoint(out, f, ref) {
47
63
  const fr = out.frames?.[f - (out.f0 ?? 0)], r = parse(ref);
@@ -52,7 +68,7 @@ export function refPoint(out, f, ref) {
52
68
  case "prop": { const p = diag.props?.[r.name]?.[r.point]; return Array.isArray(p) ? p.slice() : null; }
53
69
  case "anchor": return out.ctx.spec.anchors?.[r.name] ? resolveAnchor(out.ctx, r.name, s, { ...diag, frame: f }, c) : null;
54
70
  case "landmark": return sk.landmarks?.[r.name] ? bonePoint(s, sk.index.Head, sk.landmarks[r.name]) : null;
55
- case "object": { const boxes = objectBoxesAt(out, r.name, f); return boxes ? boxesPoint(boxes, r.point) : null; }
71
+ case "object": { const boxes = objectBoxesAt(out, r.name, f); return boxes ? (r.point === "rim" ? openingOf(boxes).o.slice() : boxesPoint(boxes, r.point)) : null; }
56
72
  case "person": return personJoint(out.ctx.scene, r.name, r.point, f);
57
73
  default: return null;
58
74
  }
@@ -71,25 +87,33 @@ export function refDistance(out, f, a, b) {
71
87
  const p = refPoint(out, f, surf[0]), boxes = objectBoxesAt(out, surf[1].name, f);
72
88
  return p && boxes ? Math.max(0, Math.min(...boxes.map(box => sdBox(p, box)))) : null;
73
89
  }
90
+ // an opening against another point: the edge of it nearest that point
91
+ const rim = [a, b].map(ref => { const r = parse(ref); return r?.kind === "object" && r.point === "rim" ? r : null; });
92
+ if (rim[0] || rim[1]) {
93
+ const [own, other] = rim[0] ? [rim[0], b] : [rim[1], a], p = refPoint(out, f, other), op = openingOf(objectBoxesAt(out, own.name, f));
94
+ return p && op ? len(sub(p, rimNear(op, p))) : null;
95
+ }
74
96
  const p = refPoint(out, f, a), q = refPoint(out, f, b);
75
97
  return p && q ? len(sub(p, q)) : null;
76
98
  }
77
99
 
78
- /**
79
- * The prefixed refs a bare name could mean in this spec, rig and scene (`; did you mean
80
- * "object:empty-2"?`), or "". A field that takes more than one ref kind stays strict.
81
- */
82
- function bareHint(spec, { sk, scene }, ref, kinds) {
83
- if (typeof ref !== "string" || !ref) return "";
84
- const id = ref.replace(/\.(center|top|base)$/, ""), [prop, point] = ref.split(".");
100
+ /** The prefixed refs a bare name could mean in this spec, rig and scene (lenient.js takes the one when there is one). */
101
+ export function bareRefs(spec, { sk, scene }, ref, kinds = REF_KINDS) {
102
+ if (typeof ref !== "string" || !ref) return [];
103
+ const id = ref.replace(/\.(center|top|base|rim)$/, ""), [prop, point] = ref.split(".");
85
104
  const bone = sk && (kinds.includes("joint") || kinds.includes("bone")) ? resolveBone(sk, ref).bone : null;
86
- const known = [
105
+ return [
87
106
  kinds.includes("object") && (scene?.objects?.[id] || partsOf(scene, id).length) && `object:${ref}`,
88
107
  bone && `${kinds.includes("joint") ? "joint" : "bone"}:${bone}`,
89
108
  kinds.includes("anchor") && spec.anchors?.[ref] && `anchor:${ref}`,
90
109
  kinds.includes("prop") && spec.props?.[prop] && (point === "origin" || spec.props[prop].points?.[point]) && `prop:${ref}`,
91
110
  kinds.includes("landmark") && ref === "mouth" && "landmark:mouth",
92
111
  ].filter(Boolean);
112
+ }
113
+
114
+ /** `; did you mean "object:empty-2"?` for a bare name (bareRefs), or "". A field that takes more than one ref kind stays strict. */
115
+ function bareHint(spec, ctx, ref, kinds) {
116
+ const known = bareRefs(spec, ctx, ref, kinds);
93
117
  return known.length ? `; did you mean ${known.map(k => `"${k}"`).join(" or ")}?` : "";
94
118
  }
95
119
 
@@ -0,0 +1,132 @@
1
+ // The stop rule of the compile-repair loop (motion.compile): a short note in the answer when more
2
+ // compiles stopped paying. It never refuses a compile; the agent reads output.stuck and the prompt
3
+ // tells it to obey. The agent's own logs set the numbers: a compile is ~0.3 s but a repair step is
4
+ // ~1,250 output tokens of model time, a turn compiles median 8 / p90 17 times, a failure that survives
5
+ // two retries unchanged survives a third about a third of the time, and about a fifth of the
6
+ // compiles after a take first passes QA failed QA again (re-tweaking what already passed).
7
+ // polish an earlier compile of this character in this turn passed QA: say so, stop unless asked
8
+ // cap REPAIR_CAP compiles after the first: report what still fails on the best take
9
+ // stuck the same failing groups (or more) for STUCK_AFTER compiles in a row, neither fewer nor
10
+ // DELTA_STEP less severe: stop patching them (or re-applying their hints), start over,
11
+ // take another approach, or report what the best take still fails
12
+ // History is per character and agent turn (context.turnId); without a turn (MCP, tests) per character,
13
+ // with the cap counted along the base chain (a whole spec starts it again). In a turn the cap counts agent
14
+ // steps: the compiles of one tool call (context.callId; a script's nested ones share the script's) are one
15
+ // repair step, as a script that compares variants is one try; a compile with no call id is a step of its own.
16
+ // The 60 bench sessions: compiles counted one by one read 10-23 at the agent's 6-12th compiling call. The stuck streak
17
+ // counts the same steps: of the runs of 3 script compiles whose problems and issues did not drop (the SSE frames), 21 of
18
+ // 37 merged (48 of 71 base) lay inside one script, which compared variants; each step's last compile carries it on. A whole spec sent after a
19
+ // stuck note is the fresh start the note asked for, so the streak starts again too.
20
+ // A note never leaves the take empty: the agent obeys it by reporting, and a turn whose compiles all ran
21
+ // install: "ifClean" (or false) unclean ended with no take (5 of 30 bench runs). So the line keeps its best
22
+ // compile per placement (`at`, the last PLACES of them: a take plays in the character's placement frame,
23
+ // take.js, so it only meets the scene where it was baked) with its take, and a note on a compile that did
24
+ // not install itself answers `install`: the best take here, when the character carries no take or one the
25
+ // watch put on that this one beats. The caller installs it (no compile: the take is the one baked then).
26
+ // A take the agent installed (install: true, or an ifClean that installed) is never replaced for the rest
27
+ // of the line, nor one the character had before it (replay of merged r3 S8: the agent's own take was
28
+ // swapped for an earlier draft, so its report no longer named the character's take).
29
+ import { groupKey } from "./qa/severity.js";
30
+
31
+ const STUCK_AFTER = 3;
32
+ const REPAIR_CAP = 10;
33
+ const DELTA_STEP = 0.05;
34
+ const KEPT = 32;
35
+ // lines whose best takes are kept (the newest): a take is ~0.25 MB a 240-frame Y Bot take (rotations, 65 bones), so
36
+ // with PLACES each at most about 3 MB here; an older line keeps its notes but no take (its turn is over)
37
+ const TAKES = 4;
38
+ // placements per line whose best take is kept (S8 r1 moved the body 0.78 -> 0.88 -> 0.94 -> 0.88 m)
39
+ const PLACES = 3;
40
+ const SAY_CODES = 2;
41
+
42
+ const severity = groups => groups.reduce((s, g) => s + (Number.isFinite(g.severity) ? g.severity : 0), 0);
43
+ const superset = (a, b) => [...b].every(k => a.has(k));
44
+ const better = (a, b) => (a.ok !== b.ok ? a.ok : a.n !== b.n ? a.n < b.n : a.sev < b.sev);
45
+ const problems = n => `${n} problem${n === 1 ? "" : "s"}`;
46
+ const cut = s => (s.length > 40 ? `${s.slice(0, 39)}~` : s);
47
+ const named = c => `${c.specId} (${c.ok ? "QA passes" : problems(c.n)})`;
48
+ // a take plays in the character's placement frame: one the watch put on says so (a move misaligns it)
49
+ const POS = "bound to this position (recompile after a move)";
50
+
51
+ export function createRepairWatch() {
52
+ const lines = new Map();
53
+ const lineOf = key => {
54
+ let line = lines.get(key);
55
+ if (line) { lines.delete(key); lines.set(key, line); return line; }
56
+ // picks: the best compile per placement; held: the take the watch put on; mine: the agent's own last install
57
+ line = { compiles: 0, steps: 0, call: null, last: null, before: null, passed: null, best: null, picks: new Map(), held: null, mine: null, noted: false, depth: new Map() };
58
+ lines.set(key, line);
59
+ if (lines.size > KEPT) lines.delete(lines.keys().next().value);
60
+ return line;
61
+ };
62
+ /**
63
+ * One compile's note. qa is the full QA (every group); base the specId it patched, absent for a whole
64
+ * spec; at the placement key of the compile (character pose and scene), take its baked take (kept while
65
+ * it is the best at `at`), installed whether this compile put its own take on the character, current the
66
+ * specId of the take the character carries now (after this compile).
67
+ * -> { say?, install?: { specId, take } } : install is the best take the caller puts on the character.
68
+ * callId the agent's tool call the compile ran in, or null.
69
+ * @param {{ characterId: string, turnId?: string | null, callId?: string | null, specId: string, base?: string, qa: { ok: boolean, groups: object[] },
70
+ * at?: string, take?: unknown, installed?: boolean, current?: string | null }} c
71
+ */
72
+ function note({ characterId, turnId = null, callId = null, specId, base, qa, at = "", take, installed = false, current = null }) {
73
+ const line = lineOf(`${characterId}\u0000${turnId ?? ""}`), groups = qa.groups ?? [];
74
+ const now = { specId, at, ok: Boolean(qa.ok), fails: new Set(groups.map(groupKey)), n: groups.length, sev: severity(groups), streak: 1 };
75
+ // core: the failures the whole streak shares (its first compile's; the later ones hold them all)
76
+ now.core = now.fails;
77
+ // a step: one agent call (its compiles share callId) or one compile; the streak compares with the last compile of the
78
+ // step before, so the variants one script compares are one try, not three
79
+ const step = !callId || callId !== line.call;
80
+ if (step) line.before = line.last;
81
+ const prev = line.before, fresh = base === undefined && line.noted;
82
+ if (prev && !fresh && now.fails.size && prev.fails.size && superset(now.fails, prev.fails) && now.n >= prev.n && now.sev > prev.sev * (1 - DELTA_STEP))
83
+ Object.assign(now, { streak: prev.streak + 1, core: prev.core });
84
+ line.compiles += 1;
85
+ if (step) line.steps += 1;
86
+ line.call = callId;
87
+ const depth = base === undefined ? 0 : (line.depth.get(base) ?? 0) + 1;
88
+ line.depth.set(specId, depth);
89
+ const repairs = turnId ? line.steps - 1 : depth, passed = line.passed, best = line.best;
90
+ line.last = now;
91
+ if (now.ok && !line.passed) line.passed = specId;
92
+ if (!best || better(now, best)) line.best = now;
93
+ // the best take at this placement (each of the last PLACES keeps its own), and the agent's own install
94
+ let pick = line.picks.get(at);
95
+ if (!pick || better(now, pick)) {
96
+ pick = { ...now, take };
97
+ let older = lines.size - TAKES;
98
+ for (const other of lines.values()) if (older-- > 0) for (const p of other.picks.values()) p.take = undefined;
99
+ }
100
+ line.picks.delete(at);
101
+ line.picks.set(at, pick);
102
+ if (line.picks.size > PLACES) line.picks.delete(line.picks.keys().next().value);
103
+ if (installed) line.mine = now;
104
+ const past = c => (best && best.specId !== c.specId && better(best, c) ? `; best so far ${named(best)}` : "");
105
+ const kind = passed && passed !== specId ? "polish" : repairs >= REPAIR_CAP && !now.ok ? "cap" : now.streak >= STUCK_AFTER ? "stuck" : null;
106
+ line.noted = Boolean(kind) && !passed;
107
+ if (!kind) return {};
108
+ // the take to leave on the character: this compile's, or the agent's own, when it carries it; else fill an empty
109
+ // take, or upgrade the one the watch put on, with the best here; a take from before this line stays
110
+ const held = line.held, mine = line.mine && line.mine.specId === current, watched = held && held.specId === current;
111
+ const keeps = !installed && !mine && watched && held.at === at && !better(pick, held);
112
+ const fills = !installed && !mine && !keeps && (current === null || watched) && pick.take !== undefined;
113
+ const install = fills ? { specId: pick.specId, take: pick.take } : undefined;
114
+ if (install) line.held = pick;
115
+ // what the character carries after the note, in one sentence; then what to do (a cap note at most ~180 characters)
116
+ const status = installed ? `This take is installed${past(now)}.` : install ? `Installed the best take here, ${named(pick)}, ${POS}.`
117
+ : mine ? `Your installed take ${named(line.mine)} stays${past(line.mine)}.` : keeps ? `${named(held)} stays installed, ${POS}.`
118
+ : current !== null && pick.take !== undefined ? `The earlier take stays; to replace it, install the best take here, ${named(pick)}.`
119
+ : `Install the best take here, ${named(pick)}.`;
120
+ const asked = "stop unless the user asked for a specific change";
121
+ let say;
122
+ if (kind === "polish") say = !install ? `${passed} already passed QA: ${asked}.` : install.specId === passed ? `${passed} passed QA and is now installed, ${POS}: ${asked}.`
123
+ : `${passed} passed QA at another position; the best take here, ${named(pick)}, is installed, ${POS}. Report its problems; ${asked}.`;
124
+ else if (kind === "cap") say = `${repairs} repair ${turnId && callId ? "calls" : "compiles"}. ${status} Report its problems; stop unless asked for more.`;
125
+ else {
126
+ const codes = [...now.core], names = codes.slice(0, SAY_CODES).map(cut).join(", ") + (codes.length > SAY_CODES ? ` +${codes.length - SAY_CODES}` : "");
127
+ say = `${names} unchanged over ${now.streak} ${turnId && callId ? "calls" : "compiles"}. Stop patching it or re-applying its hint: start afresh or try another approach. ${status} If neither helps, report its problems.`;
128
+ }
129
+ return { say, ...(install ? { install } : {}) };
130
+ }
131
+ return { note };
132
+ }