cozyclay 3.1.4 → 3.1.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 (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/INSTALL_FOR_AGENTS.md +3 -0
  3. package/README.md +5 -0
  4. package/bin/agent/agent-failure-codes.mjs +31 -0
  5. package/bin/agent/agent-routes.mjs +5 -10
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/cozyclay.mjs +16 -64
  8. package/bin/studio-routes.mjs +69 -0
  9. package/bin/update-check.mjs +53 -5
  10. package/bin/update-install.mjs +136 -0
  11. package/bin/update-routes.mjs +80 -0
  12. package/dist/ai-camera-control/index.html +1 -1
  13. package/dist/app/index.html +5 -6
  14. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  15. package/dist/assets/app-BLS1Zaru.js +5144 -0
  16. package/dist/assets/app-CEP7jzXB.css +1 -0
  17. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  18. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  19. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  20. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  21. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/greybox-to-video/index.html +3 -3
  24. package/dist/index.html +533 -264
  25. package/dist/media/cozyclay-mark.png +0 -0
  26. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  27. package/dist/media/step-move-poster.jpg +0 -0
  28. package/dist/media/step-shoot-poster.jpg +0 -0
  29. package/dist/media/step-stage-poster.jpg +0 -0
  30. package/dist/media/truck-greybox-poster.jpg +0 -0
  31. package/dist/media/truck-seedance-poster.jpg +0 -0
  32. package/dist/previs-software/index.html +4 -4
  33. package/dist/privacy/index.html +46 -19
  34. package/dist/seedance-camera-control/index.html +3 -3
  35. package/dist/sitemap.xml +6 -6
  36. package/dist/workflow/index.html +3 -4
  37. package/mcp/project-camera.mjs +43 -0
  38. package/package.json +2 -1
  39. package/src/App.jsx +59 -70
  40. package/src/analytics-controls.js +17 -0
  41. package/src/analytics-friction.js +124 -0
  42. package/src/analytics-strict.js +139 -0
  43. package/src/analytics-values.js +49 -0
  44. package/src/analytics.js +37 -133
  45. package/src/asset-pane.jsx +4 -5
  46. package/src/click-friction.js +101 -0
  47. package/src/command-bus-telemetry.js +155 -0
  48. package/src/command-bus.js +10 -8
  49. package/src/commands/motion3.js +7 -2
  50. package/src/controls.jsx +3 -2
  51. package/src/crash-telemetry.js +45 -0
  52. package/src/domains/cast.js +2 -11
  53. package/src/domains/motion.js +49 -38
  54. package/src/domains/objects.js +3 -0
  55. package/src/domains/scene-undo.js +27 -0
  56. package/src/domains/scenes.js +1 -1
  57. package/src/error-boundary.jsx +2 -0
  58. package/src/export-attempt.js +100 -0
  59. package/src/export-workspace.js +12 -1
  60. package/src/first-success-guide.jsx +11 -7
  61. package/src/guide-telemetry.js +60 -0
  62. package/src/hierarchy-panel.jsx +2 -1
  63. package/src/live-control.js +2 -9
  64. package/src/locale-zh/domains.js +1 -0
  65. package/src/main.jsx +5 -0
  66. package/src/motion/timeline.jsx +9 -5
  67. package/src/motion-draft-telemetry.js +62 -0
  68. package/src/motion3/compiler/channels.js +7 -5
  69. package/src/motion3/compiler/compile.js +6 -2
  70. package/src/motion3/compiler/finger-close.js +84 -7
  71. package/src/motion3/compiler/head-lead.js +70 -0
  72. package/src/motion3/compiler/head-steady.js +1 -1
  73. package/src/motion3/compiler/limbs.js +10 -3
  74. package/src/motion3/compiler/pose.js +1 -1
  75. package/src/motion3/compiler/validate.js +5 -3
  76. package/src/motion3/pipeline.js +4 -0
  77. package/src/motion3/qa/explain.js +2 -1
  78. package/src/motion3/qa/finger-checks.js +80 -0
  79. package/src/motion3/qa/gait-observe.js +88 -0
  80. package/src/motion3/qa/grasp-checks.js +3 -0
  81. package/src/motion3/qa/hints-grasp.js +8 -0
  82. package/src/motion3/qa/index.js +2 -1
  83. package/src/motion3/qa/motion-life.js +26 -3
  84. package/src/motion3/schema/topics.js +1 -1
  85. package/src/playback-telemetry.js +107 -0
  86. package/src/post-turn-edits.js +62 -0
  87. package/src/search-friction.js +49 -0
  88. package/src/semantic-edit.js +19 -6
  89. package/src/shell/BoardSlot.jsx +3 -3
  90. package/src/shell/BottomDock.jsx +1 -1
  91. package/src/shell/DetailsSlot.jsx +1 -1
  92. package/src/shell/MenuBar.jsx +13 -13
  93. package/src/shell/StudioShell.jsx +7 -1
  94. package/src/shell/TopBar.jsx +2 -0
  95. package/src/shell/ViewportToolbar.jsx +2 -2
  96. package/src/shortcut-friction.js +122 -0
  97. package/src/stage-canvas-rigs.jsx +3 -4
  98. package/src/studio-telemetry.js +97 -0
  99. package/src/thrash-friction.js +90 -0
  100. package/src/update/UpdateChip.jsx +86 -0
  101. package/src/update/UpdateDialog.jsx +147 -0
  102. package/src/update/update-client.js +188 -0
  103. package/src/update/update-telemetry.js +78 -0
  104. package/src/update/update.css +94 -0
  105. package/src/use-webgl-available.js +35 -0
  106. package/src/workflow/AgentPanel.jsx +29 -30
  107. package/src/workflow/CozySceneNode.jsx +1 -1
  108. package/src/workflow/LoginPanel.jsx +15 -5
  109. package/src/workflow/agent-client.js +10 -91
  110. package/src/workflow/agent-failure-codes.js +47 -0
  111. package/src/workflow/agent-turn-telemetry.js +222 -0
  112. package/src/workflow/connect-telemetry.js +86 -0
  113. package/src/workflow/prompt-telemetry.js +105 -0
  114. package/tools/dev/pages/privacy.html +44 -17
  115. package/tools/loc-baseline.json +10 -10
  116. package/tools/run-tests.mjs +8 -8
  117. package/tools/verify-package-tarball.mjs +29 -1
  118. package/dist/assets/app-BdRlMf4J.css +0 -1
  119. package/dist/assets/app-KXdgBC-S.js +0 -5144
  120. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  121. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  122. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  123. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  124. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  125. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  126. package/dist/media/playground-poster.jpg +0 -0
@@ -0,0 +1,222 @@
1
+ // The analytics of one agent turn, owned by the browser request (createHttpTransport.turn in agent-client.js):
2
+ // agent:turn_requested, the validated execution_telemetry frames, the terminal agent:turn_<outcome>, then exactly one
3
+ // agent:turn_summary and the post-turn edit window (post-turn-edits.js). Only closed values leave here: never tool
4
+ // names, arguments, messages or authored text.
5
+ import { bucketMs } from "../analytics.js";
6
+ import { bucketLong, refusalCodeOf } from "../analytics-strict.js";
7
+ import { bucketFrictionCount } from "../analytics-friction.js";
8
+ import { AGENT_TOOL_CATEGORIES, EXECUTION_TELEMETRY_VALUES } from "../execution-telemetry.js";
9
+ import { closePostTurnWindow, openPostTurnWindow } from "../post-turn-edits.js";
10
+ import { AGENT_FAILURE_CODES } from "./agent-failure-codes.js";
11
+ import { noteTurnOutcome } from "./connect-telemetry.js";
12
+ import { notePromptTurnEnded, takePrompt } from "./prompt-telemetry.js";
13
+
14
+ const validTelemetryId = (value) => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
15
+ const exactKeys = (value, keys) => value && typeof value === "object" && !Array.isArray(value)
16
+ && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
17
+ const advisory = (read, fallback) => { try { return read(); } catch { return fallback; } };
18
+
19
+ // motion.compile drafts per turn correlation id (src/commands/motion3.js). A turn that never finishes must not leak:
20
+ // past this many turns the oldest is forgotten.
21
+ const DRAFT_TURNS = 16;
22
+ // The post-turn window's timer never keeps a process (a test, the sidecar's node checks) alive.
23
+ const windowTimers = {
24
+ set: (fn, ms) => { const timer = setTimeout(fn, ms); timer?.unref?.(); return timer; },
25
+ clear: (timer) => clearTimeout(timer), now: () => Date.now(),
26
+ };
27
+ const drafts = new Map();
28
+
29
+ /** One compiled draft of `turnId` (the bus context's turnId): { clean, problems, stuck }. */
30
+ export function noteTurnDraft(turnId, { clean, problems, stuck } = {}) {
31
+ if (typeof turnId !== "string" || !turnId) return;
32
+ const draft = { clean: Boolean(clean), problems: Number.isFinite(problems) ? problems : 0 };
33
+ const row = drafts.get(turnId);
34
+ if (row) { row.n += 1; row.last = draft; row.stuck ||= Boolean(stuck); return; }
35
+ drafts.set(turnId, { n: 1, first: draft, last: draft, stuck: Boolean(stuck) });
36
+ while (drafts.size > DRAFT_TURNS) drafts.delete(drafts.keys().next().value);
37
+ }
38
+
39
+ /** The drafts part of agent:turn_summary for `turnId`; the turn's drafts are cleared. */
40
+ function takeDrafts(turnId) {
41
+ const row = drafts.get(turnId);
42
+ drafts.delete(turnId);
43
+ if (!row) return { drafts: "0", qa_trend: "na", stuck: false };
44
+ const qa = (draft) => (draft.clean ? "clean" : "problems");
45
+ const trend = row.n < 2 ? "na" : row.last.problems < row.first.problems ? "improved" : row.last.problems > row.first.problems ? "worse" : "flat";
46
+ return { drafts: bucketFrictionCount(row.n), qa_first: qa(row.first), qa_last: qa(row.last), qa_trend: trend, stuck: row.stuck };
47
+ }
48
+
49
+ // "TARGET_NOT_READY: The scene is not ready." -- the sidecar sends a refused Studio call as tool.done { ok: false }
50
+ // with the code only as the error's prefix (bin/agent/pi-tools.mjs formatToolError). A stop is no refusal.
51
+ const refusalOf = (event) => {
52
+ if (event.ok === false) {
53
+ const code = /^([A-Z][A-Z0-9_]*):/.exec(typeof event.error === "string" ? event.error : "")?.[1];
54
+ return code && code !== "ABORTED" ? code : null;
55
+ }
56
+ return event.result?.ok === false && typeof event.result.code === "string" ? event.result.code : null;
57
+ };
58
+
59
+ /** The tool part of agent:turn_summary, read from the turn's tool.start / tool.done frames. */
60
+ function createToolTally(clock, startedAt) {
61
+ const started = new Set(), done = new Set(), refusals = new Map();
62
+ let calls = 0, failed = 0, refused = 0, stale = 0, authored = false, firstAt = null;
63
+ return {
64
+ note(event) {
65
+ const id = typeof event?.callId === "string" ? event.callId : null;
66
+ if (event?.type === "tool.start") {
67
+ if (id && started.has(id)) return;
68
+ if (id) started.add(id);
69
+ calls += 1;
70
+ if (firstAt === null) firstAt = clock();
71
+ return;
72
+ }
73
+ if (event?.type !== "tool.done" || (id && done.has(id))) return;
74
+ if (id) done.add(id);
75
+ if (event.ok === false) failed += 1;
76
+ if (event.ok === true && event.result?.authored === true) authored = true;
77
+ if (event.autoRebased === true) stale += 1;
78
+ const code = refusalOf(event);
79
+ if (code) { refused += 1; const key = refusalCodeOf(code); refusals.set(key, (refusals.get(key) ?? 0) + 1); }
80
+ },
81
+ applied() { authored = true; },
82
+ props(outcome) {
83
+ let top = "none", most = 0;
84
+ for (const [code, n] of refusals) if (n > most) { top = code; most = n; }
85
+ const open = [...started].some((id) => !done.has(id));
86
+ return {
87
+ tool_calls: bucketFrictionCount(calls), tool_failed: bucketFrictionCount(failed), refused: bucketFrictionCount(refused), refusal_top: top,
88
+ stale_retry: bucketFrictionCount(stale), authored,
89
+ ...(firstAt === null ? {} : { first_tool: bucketLong(firstAt - startedAt) }),
90
+ cancel_phase: outcome !== "cancelled" ? "na" : calls === 0 ? "before_tool" : open ? "during_tool" : "after_tool",
91
+ };
92
+ },
93
+ };
94
+ }
95
+
96
+ // The real browser request owns the attempt, including HTTP refusal and Stop.
97
+ // These IDs never use panel session IDs, model call IDs or authored content.
98
+ /** `surface` is already resolved: "studio" or "workflow". */
99
+ export function startAgentTurn({ surface, capture, now, correlationId = null }) {
100
+ // The next turn ends the previous turn's edit window.
101
+ closePostTurnWindow();
102
+ const turnId = advisory(() => {
103
+ const bytes = new Uint8Array(16);
104
+ globalThis.crypto.getRandomValues(bytes);
105
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
106
+ }, null);
107
+ const clock = () => advisory(() => { const value = now(); return Number.isFinite(value) ? value : NaN; }, NaN);
108
+ // A Studio turn already has a host-owned id and its frozen envelope has no
109
+ // room for a second one, so the sidecar names its frames with THAT id. The
110
+ // analytics id stays the local hex one: it is the only id analytics accepts.
111
+ const matchId = correlationId ?? turnId;
112
+ const startedAt = clock();
113
+ let terminal = false;
114
+ let applied = false;
115
+ const tools = new Map();
116
+ const seenTools = new Set();
117
+ const tally = createToolTally(clock, startedAt);
118
+ const emit = (event, props) => {
119
+ if (!turnId) return;
120
+ try { Promise.resolve(capture(event, props)).catch(() => {}); } catch { /* telemetry is advisory */ }
121
+ };
122
+ const finish = (outcome, failureCode) => {
123
+ if (terminal) return;
124
+ terminal = true;
125
+ noteTurnOutcome(outcome); notePromptTurnEnded(outcome);
126
+ if (outcome === "cancelled") {
127
+ for (const [id, tool] of tools) {
128
+ if (!seenTools.has(id)) emit("agent:tool_executed", { turn_id: turnId, tool_category: tool.category, outcome: "cancelled", duration_bucket: bucketMs(clock() - tool.startedAt) });
129
+ }
130
+ }
131
+ tools.clear();
132
+ emit(`agent:turn_${outcome}`, { turn_id: turnId, duration_bucket: bucketMs(clock() - startedAt), ...(outcome !== "succeeded" ? { failure_code: failureCode } : {}) });
133
+ const summary = advisory(() => ({ turn_id: turnId, outcome, wall: bucketLong(clock() - startedAt), ...tally.props(outcome), ...takeDrafts(matchId) }), null);
134
+ if (summary) emit("agent:turn_summary", summary);
135
+ // Only a Studio turn is followed by Studio edits; the Workflow canvas has no edit groups to count.
136
+ if (turnId && surface === "studio") openPostTurnWindow(turnId, emit, windowTimers);
137
+ };
138
+ emit("agent:turn_requested", { surface, turn_id: turnId });
139
+ // The prompt the panel staged as it sent this turn (prompt-telemetry.js), with the same turn id.
140
+ const prompt = takePrompt(turnId);
141
+ if (prompt) emit("agent:prompt_sent", prompt);
142
+ return {
143
+ turnId,
144
+ cancel: () => finish("cancelled", "aborted"),
145
+ fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "upstream"),
146
+ /** A tool.start / tool.done frame the panel sees (after replay dedupe). */
147
+ tool(event) {
148
+ if (!terminal) advisory(() => tally.note(event), null);
149
+ },
150
+ frame(frame) {
151
+ // Local sidecar frames are still an untrusted boundary. Reject the whole
152
+ // frame, including extra fields, before dedupe or analytics capture.
153
+ try {
154
+ if (!turnId || terminal) return;
155
+ if (frame.type === "execution_tool_started") {
156
+ if (!exactKeys(frame, ["type", "turn_id", "telemetry_id", "tool_category"]) || frame.turn_id !== matchId
157
+ || !validTelemetryId(frame.telemetry_id) || !AGENT_TOOL_CATEGORIES.includes(frame.tool_category)) return;
158
+ if (!tools.has(frame.telemetry_id) && !seenTools.has(frame.telemetry_id)) tools.set(frame.telemetry_id, { category: frame.tool_category, startedAt: clock() });
159
+ return;
160
+ }
161
+ const { event, props } = frame;
162
+ if (props?.turn_id !== matchId) return;
163
+ if (event === "agent:tool_executed") {
164
+ if (!exactKeys(frame, ["type", "event", "props", "telemetry_id"]) || !validTelemetryId(frame.telemetry_id)
165
+ || !exactKeys(props, ["turn_id", "tool_category", "outcome", "duration_bucket"])
166
+ || !AGENT_TOOL_CATEGORIES.includes(props.tool_category) || !["succeeded", "failed", "cancelled"].includes(props.outcome)
167
+ || !EXECUTION_TELEMETRY_VALUES.duration_bucket.has(props.duration_bucket) || seenTools.has(frame.telemetry_id)) return;
168
+ seenTools.add(frame.telemetry_id);
169
+ tools.delete(frame.telemetry_id);
170
+ emit(event, { turn_id: turnId, tool_category: props.tool_category, outcome: props.outcome, duration_bucket: props.duration_bucket });
171
+ return;
172
+ }
173
+ if (!exactKeys(frame, ["type", "event", "props"])) return;
174
+ if (event === "agent:result_applied") {
175
+ if (!applied && exactKeys(props, ["turn_id"])) { applied = true; tally.applied(); emit(event, { turn_id: turnId }); }
176
+ return;
177
+ }
178
+ if (!["agent:turn_succeeded", "agent:turn_failed", "agent:turn_cancelled"].includes(event)) return;
179
+ const outcome = event.slice("agent:turn_".length);
180
+ const hasFailure = Object.hasOwn(props, "failure_code");
181
+ if (!exactKeys(props, ["turn_id", "duration_bucket", ...(hasFailure ? ["failure_code"] : [])])
182
+ || !EXECUTION_TELEMETRY_VALUES.duration_bucket.has(props.duration_bucket)
183
+ || (hasFailure && (outcome === "succeeded" || !AGENT_FAILURE_CODES.has(props.failure_code)))) return;
184
+ finish(outcome, outcome === "cancelled" ? "aborted" : props.failure_code || "unknown");
185
+ } catch { /* foreign getters and malformed telemetry cannot affect a turn */ }
186
+ },
187
+ };
188
+ }
189
+
190
+ // The agent-busy signal (the update notice disables "Update now" while a turn runs, src/update/UpdateDialog.jsx).
191
+ // createAgentTransport wraps every transport it returns, so the dock, an embedded Studio panel and sculpt mode all count.
192
+ let runningTurns = 0;
193
+ const turnListeners = new Set();
194
+ const notifyTurns = () => {
195
+ for (const listener of [...turnListeners]) { try { listener(runningTurns > 0); } catch { /* a listener cannot break a turn */ } }
196
+ };
197
+
198
+ /** True while any transport.turn() returned by createAgentTransport has not settled. */
199
+ export const agentTurnRunning = () => runningTurns > 0;
200
+
201
+ /** `listener(running)` on every turn start and end; returns the unsubscribe. */
202
+ export function subscribeAgentTurns(listener) {
203
+ turnListeners.add(listener);
204
+ return () => { turnListeners.delete(listener); };
205
+ }
206
+
207
+ /** Replace transport.turn in place so every end path (done, error, throw, abort) clears the signal. */
208
+ export function trackAgentTurns(transport) {
209
+ const turn = transport?.turn;
210
+ if (typeof turn !== "function") return transport;
211
+ transport.turn = async function trackedTurn(...args) {
212
+ runningTurns += 1;
213
+ notifyTurns();
214
+ try {
215
+ return await turn.apply(this, args);
216
+ } finally {
217
+ runningTurns -= 1;
218
+ notifyTurns();
219
+ }
220
+ };
221
+ return transport;
222
+ }
@@ -0,0 +1,86 @@
1
+ // The model-connect funnel: agent:connect_opened -> agent:connect_started ->
2
+ // agent:connect_finished, plus agent:model_changed. One attempt at a time: its
3
+ // provider, method, how far it got (step) and when it began. Only closed values
4
+ // leave: a provider id from the closed list (else "other"), a method, an outcome,
5
+ // a step and a duration bucket. Never a key, token, account, email or message.
6
+ // The connect UI needs the local sidecar, so a hosted build (no sidecar, the
7
+ // panel stays offline) never reaches these calls.
8
+ import { track } from "../analytics.js";
9
+ import { admit } from "../analytics-friction.js";
10
+ import { CONNECT_PROVIDERS, bucketLong } from "../analytics-strict.js";
11
+ const METHODS = new Set(["oauth", "api_key", "chatgpt"]);
12
+ const ENTRIES = new Set(["first_run", "failure_card", "signed_out", "menu", "other"]);
13
+ const CONNECTED_KEY = "cozyclay.agentConnected";
14
+ const OPENED_PER_SESSION = 5, MODEL_CHANGES_PER_SESSION = 10;
15
+
16
+ let send = track, now = () => Date.now();
17
+ let attempt = null;
18
+ let lastTurnFailed = false;
19
+
20
+ const providerOf = (id) => (CONNECT_PROVIDERS.includes(id) ? id : "other");
21
+ const emit = (event, props) => { try { send(event, props); } catch { /* telemetry is advisory */ } };
22
+ const storage = () => { try { return globalThis.localStorage ?? null; } catch { return null; } };
23
+
24
+ /** The connect UI opened. The signed-out card counts as first_run until this browser profile has connected once. */
25
+ export function connectOpened(entry) {
26
+ let value = ENTRIES.has(entry) ? entry : "other";
27
+ try { if (value === "signed_out" && storage()?.getItem(CONNECTED_KEY) !== "1") value = "first_run"; } catch { /* storage is optional */ }
28
+ if (admit("agent:connect_opened", OPENED_PER_SESSION)) emit("agent:connect_opened", { entry: value });
29
+ }
30
+
31
+ /** One attempt begins; an attempt still open is closed as abandoned first. */
32
+ export function connectStarted(providerId, method) {
33
+ if (attempt) connectFinished("abandoned");
34
+ attempt = { provider: providerOf(providerId), method: METHODS.has(method) ? method : "api_key", startedAt: now(), step: "list" };
35
+ emit("agent:connect_started", { provider: attempt.provider, method: attempt.method });
36
+ }
37
+
38
+ /** How far the open attempt got: waiting (the flow runs), prompt (it asks for a key, address or code). */
39
+ export function connectStep(step) {
40
+ if (attempt && ["list", "prompt", "waiting", "done"].includes(step)) attempt.step = step;
41
+ }
42
+
43
+ /** The open attempt ends: ok, failed, cancelled (the user's Cancel) or abandoned (closed or left unfinished). */
44
+ export function connectFinished(outcome) {
45
+ const ended = attempt;
46
+ if (!ended) return;
47
+ attempt = null;
48
+ const step = outcome === "ok" ? "done" : ended.step;
49
+ emit("agent:connect_finished", { provider: ended.provider, method: ended.method, outcome, step, duration: bucketLong(now() - ended.startedAt) });
50
+ if (outcome === "ok") { try { storage()?.setItem(CONNECTED_KEY, "1"); } catch { /* storage is optional */ } }
51
+ }
52
+
53
+ /** The connect panel went away. A ChatGPT attempt goes on in the agent panel; any other is abandoned. */
54
+ export function connectPanelClosed() {
55
+ if (attempt && attempt.method !== "chatgpt") connectFinished("abandoned");
56
+ }
57
+
58
+ /** The ChatGPT sign-in settles by ChatGPT's own status (`account`: /oauth/status), not by
59
+ * the panel's readiness: a key for another provider makes the panel ready while the
60
+ * ChatGPT sign-in is still pending. */
61
+ export function connectAuthState(state, account) {
62
+ if (attempt?.method !== "chatgpt") return;
63
+ if (account?.signedIn === true) connectFinished("ok");
64
+ else if (state === "signing-in" || account?.pending === true) connectStep("waiting");
65
+ // The browser sign-in failed or was closed, with or without another provider ready:
66
+ // the sidecar reports neither apart.
67
+ else if (state === "signed-out" || state === "ready" || state === "no-entitlement") connectFinished("abandoned");
68
+ }
69
+
70
+ /** A model picked in the dropdown (`provider/id`), and whether the last turn had failed. */
71
+ export function modelChanged(modelKey) {
72
+ const provider = providerOf(String(modelKey ?? "").split("/")[0]);
73
+ if (admit("agent:model_changed", MODEL_CHANGES_PER_SESSION)) emit("agent:model_changed", { provider, after_failure: lastTurnFailed });
74
+ }
75
+
76
+ /** agent-turn-telemetry.js reports each turn's outcome, for model_changed.after_failure. */
77
+ export function noteTurnOutcome(outcome) {
78
+ lastTurnFailed = outcome === "failed";
79
+ }
80
+
81
+ /** Tests only: capture what is sent, drive the clock, and start clean; returns the restore. */
82
+ export function connectTelemetrySeam({ capture = track, clock = () => Date.now() } = {}) {
83
+ const previous = [send, now];
84
+ send = capture; now = clock; attempt = null; lastTurnFailed = false;
85
+ return () => { [send, now] = previous; attempt = null; lastTurnFailed = false; };
86
+ }
@@ -0,0 +1,105 @@
1
+ // agent:prompt_sent: one per prompt the user sends to the agent, so retry loops and
2
+ // hesitation are measurable. Only numbers and closed values computed on the device
3
+ // leave: a length bucket and a similarity grade against the previous prompt. The
4
+ // text is never stored or sent; the previous prompt is kept only as its set of
5
+ // lowercase words (to grade the next one) and is dropped on pagehide.
6
+ //
7
+ // AgentPanel stages a prompt as it sends (stagePrompt); the turn's telemetry takes
8
+ // it when the turn really starts (takePrompt, agent-turn-telemetry.js), so a send the
9
+ // store refuses (empty, already streaming) never becomes an event.
10
+ import { CONNECT_PROVIDERS, bucketLong } from "../analytics-strict.js";
11
+ import { bucketFrictionCount } from "../analytics-friction.js";
12
+
13
+ const VIA = new Set(["typed", "retry_button", "regenerate", "chip", "supervisor"]);
14
+ const STAGED_MS = 10_000;
15
+
16
+ let now = () => Date.now();
17
+ let previousTokens = null, lastTurn = null, turns = 0, draftStartedAt = null, fromChip = false, staged = null;
18
+ if (typeof globalThis.addEventListener === "function") globalThis.addEventListener("pagehide", () => { previousTokens = null; staged = null; });
19
+
20
+ /** The prompt's words: lowercased, split on anything that is not a letter or digit. No stemming, no dictionary. */
21
+ export function promptTokens(text) {
22
+ return new Set(String(text ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean));
23
+ }
24
+
25
+ /** Jaccard of two word sets: >= 0.9 same, >= 0.5 near, else different; first without a previous prompt. */
26
+ export function similarityGrade(previous, next) {
27
+ if (!previous) return "first";
28
+ let shared = 0;
29
+ for (const token of next) if (previous.has(token)) shared += 1;
30
+ const union = previous.size + next.size - shared;
31
+ const jaccard = union === 0 ? 1 : shared / union;
32
+ return jaccard >= 0.9 ? "same" : jaccard >= 0.5 ? "near" : "different";
33
+ }
34
+
35
+ /** 1-20, 21-80, 81-240, gte241 characters. */
36
+ export function bucketPromptLength(n) {
37
+ if (n <= 20) return "1-20";
38
+ if (n <= 80) return "21-80";
39
+ if (n <= 240) return "81-240";
40
+ return "gte241";
41
+ }
42
+
43
+ /** A keystroke (or paste) into the draft: composing starts now if it had not; a chip's text was edited. */
44
+ export function noteDraftInput() {
45
+ if (draftStartedAt === null) draftStartedAt = now();
46
+ fromChip = false;
47
+ }
48
+
49
+ /** The draft was consumed without a new turn (an accepted steering message): composing starts over. */
50
+ export function noteDraftConsumed() {
51
+ draftStartedAt = null;
52
+ fromChip = false;
53
+ }
54
+
55
+ /** A suggestion chip filled the draft. */
56
+ export function noteChipDraft() {
57
+ if (draftStartedAt === null) draftStartedAt = now();
58
+ fromChip = true;
59
+ }
60
+
61
+ /** The panel is sending `text`: keep only what the event needs, never the text. */
62
+ export function stagePrompt(text, { via = "typed", model = "", attached = false, taskMode = false } = {}) {
63
+ try {
64
+ const trimmed = String(text ?? "").trim();
65
+ if (!trimmed) return;
66
+ const how = via === "typed" && fromChip ? "chip" : VIA.has(via) ? via : "typed";
67
+ const provider = String(model ?? "").split("/")[0];
68
+ const composing = (how === "typed" || how === "chip") && draftStartedAt !== null;
69
+ staged = { at: now(), tokens: promptTokens(trimmed), props: {
70
+ provider: CONNECT_PROVIDERS.includes(provider) ? provider : "other", via: how, prompt_len: bucketPromptLength(trimmed.length),
71
+ ...(composing ? { compose_ms: bucketLong(now() - draftStartedAt) } : {}), attached: Boolean(attached), task_mode: Boolean(taskMode),
72
+ } };
73
+ } catch {
74
+ staged = null;
75
+ }
76
+ }
77
+
78
+ /** The turn started: the staged prompt as agent:prompt_sent's payload for `turnId`, or null. */
79
+ export function takePrompt(turnId) {
80
+ const prompt = staged;
81
+ staged = null;
82
+ if (!prompt || !turnId || now() - prompt.at > STAGED_MS) return null;
83
+ turns += 1;
84
+ const props = {
85
+ turn_id: turnId, ...prompt.props, similarity: similarityGrade(previousTokens, prompt.tokens),
86
+ gap: lastTurn ? bucketLong(now() - lastTurn.at) : "first", prev_outcome: lastTurn?.outcome ?? "none", turn_index: bucketFrictionCount(turns),
87
+ };
88
+ previousTokens = prompt.tokens;
89
+ draftStartedAt = null;
90
+ fromChip = false;
91
+ return props;
92
+ }
93
+
94
+ /** A turn ended (agent-turn-telemetry.js): the next prompt's gap and prev_outcome. */
95
+ export function notePromptTurnEnded(outcome) {
96
+ lastTurn = { outcome: ["succeeded", "failed", "cancelled"].includes(outcome) ? outcome : "none", at: now() };
97
+ }
98
+
99
+ /** Tests only: drive the clock and start a clean session; returns the restore. */
100
+ export function promptTelemetrySeam(clock = () => Date.now()) {
101
+ const previous = now;
102
+ const reset = () => { previousTokens = null; lastTurn = null; turns = 0; draftStartedAt = null; fromChip = false; staged = null; };
103
+ now = clock; reset();
104
+ return () => { now = previous; reset(); };
105
+ }
@@ -5,13 +5,13 @@
5
5
  "og_title": "CozyClay privacy: analytics and telemetry disclosure",
6
6
  "headline": "Privacy",
7
7
  "description": "What cozyclay.org and the cozyclay npm package collect (anonymous usage analytics via PostHog, no cookies, no session recording, no project content), how to opt out, and what the hosted demo stores.",
8
- "published": "2026-09-10"
8
+ "published": "2026-10-25"
9
9
  }
10
10
  ---
11
11
  <div class="hero">
12
12
  <h1>Privacy</h1>
13
13
  <p class="lede">Short version: anonymous usage counts, no cookies, no recordings, never your project content. Every part of it can be turned off.</p>
14
- <p class="meta">Effective September 10, 2026</p>
14
+ <p class="meta">Effective October 25, 2026</p>
15
15
  </div>
16
16
 
17
17
  <section>
@@ -42,7 +42,7 @@
42
42
  A copied start command such as <code>npx cozyclay --via site</code> stores that one source label
43
43
  with the first-launch receipt instead of asking where you heard about CozyClay.
44
44
  Prompt text, asset names,
45
- file names, project content, local paths and any user-entered text are never collected. Session
45
+ file names, project content, local paths and any user-entered text are never collected. For a prompt sent to the agent, only two values derived from its text leave the device: a length bucket and a similarity grade (first, same, near or different) that the browser computes against the previous prompt. Neither the text nor its words are sent; the previous prompt is kept in the page only as its set of words, until the page closes. Session
46
46
  duration and action counts are bucketed. Source checkouts, forks, development servers, CI and tests
47
47
  send nothing.
48
48
  </p>
@@ -85,42 +85,69 @@ cclay telemetry on</code></pre>
85
85
  <table>
86
86
  <thead><tr><th>Event</th><th>Purpose</th></tr></thead>
87
87
  <tbody>
88
- <tr><td>install:first_launch, app:session_started, app:session_ended</td><td>Counting installs and sessions; duration and action counts are bucketed. install:first_launch may carry heard_from (x, hn, reddit, github, friend, other, site or playground), from the first-launch prompt or a copied <code>npx cozyclay --via</code> command. app:session_ended also carries per-session edit gesture counts in five groups, pose_edit_bucket, camera_edit_bucket, object_edit_bucket, shot_edit_bucket and prompt_edit_bucket, each bucketed (0, 1-3, 4-10 or gte11); never what was edited</td></tr>
89
- <tr><td>feature:used</td><td>One signal per feature per session: a fixed feature name such as pose_edit (a real bone or pose change), pose_save (saving a pose to the library), camera_fly or export_video</td></tr>
88
+ <tr><td>install:first_launch, app:session_started, app:session_ended</td><td>Counting installs and sessions; duration and action counts are bucketed. install:first_launch may carry heard_from (x, hn, reddit, github, friend, other, site or playground), from the first-launch prompt or a copied <code>npx cozyclay --via</code> command. app:session_ended also carries scenes_touched (how many scenes were created or loaded in the session, an exact count capped at 20, not bucketed), session_id (the opaque random session id the PostHog SDK generates, used only to join a session's last events, such as an error just before it ended) and per-session edit gesture counts in five groups, pose_edit_bucket, camera_edit_bucket, object_edit_bucket, shot_edit_bucket and prompt_edit_bucket, each bucketed (0, 1-3, 4-10 or gte11); never what was edited</td></tr>
89
+ <tr><td>app:friction_summary</td><td>Once per session, next to app:session_ended: how much friction the session had, as closed buckets only. active_time (the session duration bucket); counts of refusals the Studio gave the user (refusals_ui) and the agent (refusals_agent), user undos (undos), errors (errors), browser long tasks (long_tasks) and motion compiles (compiles), each as 0, 1, 2, 3, 4-5, 6-10 or gte11; the longest long task (long_task_max: lt50, 50-150, 150-500, 500-2000 or gte2000 ms); the share of late playback ticks (late_tick_ratio: 0, lt5, 5-20, 20-50 or gte50 %). rage_clicks and dead_clicks count every ui:rage_click and ui:dead_click, shortcut_ignored every shortcut:ignored, search_empty every search:no_results, and nav_thrash and camera_thrash every nav:thrash and camera:thrash, including those past their event's cap. Never which command, control, key or text was involved.</td></tr>
90
+ <tr><td>studio:refused</td><td>A command the Studio refused, as closed values only: code (one of the Studio's fixed error codes, such as INVALID_ARGUMENT, STALE_SCENE or TARGET_BUSY, else other), origin (ui for the user's own control, agent for the agent or an MCP client), phase (admission, refused before any change, or commit, refused after it started changing) and group (the command's family: object, shot, cast, scene, motion, edit, task, agent or other). At most 3 per code, group and origin per session; the rest are only counted in app:friction_summary. A cancel the caller asked for is not a refusal. Never the command's arguments, target or message.</td></tr>
91
+ <tr><td>edit:undone</td><td>A user undo (agent undos are not counted): age (lt2s, 2-10s, 10-60s or gte60s since the undone edit was made, only when the undo was done and its age is known), kind (pose, camera, object, shot, prompt, agent_turn for the agent's whole turn, ik_preview for an un-applied IK drag, or other), via (key, menu or button) and result (done, noop when there was nothing to undo, or refused). At most 20 per session. Never what was undone.</td></tr>
92
+ <tr><td>agent:turn_summary</td><td>One per agent turn: turn_id (the same random id as agent:turn_*); outcome (succeeded, failed or cancelled); wall (the turn's length) and first_tool (time to the first tool call, absent without tools), each lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m; counts of tool calls (tool_calls), failed tool calls (tool_failed), tool calls the Studio refused (refused), commands re-sent once after a stale revision or a busy gesture (stale_retry) and motion compiles (drafts), each as 0, 1, 2, 3, 4-5, 6-10 or gte11; refusal_top (the most frequent refusal code, or none); authored (true or false: the turn changed the scene); qa_first and qa_last (clean or problems, absent without drafts); qa_trend (improved, flat, worse, or na with fewer than 2 drafts); stuck (true or false: the motion compiler's stop rule fired); cancel_phase (before_tool, during_tool, after_tool or na). No prompt text, tool names, messages or responses.</td></tr>
93
+ <tr><td>agent:post_turn_edit</td><td>One per agent turn: turn_id (the same random id) and the user's own edit gestures in the 120 seconds after the turn ended, closed early when the next turn starts or the page closes. edited (true or false); counts per group (pose, camera, object, shot, and prompt_block, which counts edits to a take's prompt and take fields), each as 0, 1, 2, 3, 4-5, 6-10 or gte11, where one continuous drag is one gesture; first_delay (time to the first edit: lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m), only when edited is true. Never which object or what changed.</td></tr>
94
+ <tr><td>motion:draft_compiled</td><td>One per motion compile (each draft the agent's motion work produces), at most 50 per session; the rest only move app:friction_summary compiles. draft_index (the draft's number for its character in the turn) and qa_problems as 0, 1, 2, 3, 4-5, 6-10 or gte11; qa_clean; qa_frames (frame-level QA issues: 0, 1-10, 11-50, 51-200 or gte201); compile (the whole compile, QA included: lt50, 50-150, 150-500, 500-2000 or gte2000 ms); installed, stuck, repaired, searched and adjusted (true or false); frames (take length: lt30, 30-120, 121-480 or gte481). Never the motion spec, the take, skill names or QA text.</td></tr>
95
+ <tr><td>shortcut:ignored</td><td>A bound Studio key that did nothing, as closed values only: key_class (undo, redo, mode, transform, frame, duplicate or delete) and reason (no_selection, wrong_mode, workflow_mode, busy or empty_history). At most 5 per key_class per session; the rest only move app:friction_summary shortcut_ignored. Keys typed into a text field, a list box or an editable area, and keys of an input-method composition, are never looked at. Never the key character.</td></tr>
96
+ <tr><td>shortcut:unbound</td><td>A common key combination the Studio has no binding for, pressed outside any text field: combo (copy, paste, cut, select_all, group, find, rename or help). At most 3 per combo per session. Never the key character.</td></tr>
97
+ <tr><td>help:shortcuts_opened</td><td>Help &gt; Keyboard shortcuts was opened. No properties. At most 3 per session.</td></tr>
98
+ <tr><td>search:no_results</td><td>A search in the Content browser (surface assets) or the Outliner (outliner) that has shown nothing for 1 second: surface and query_len (the length of the search, 1, 2-3, 4-8 or gte9 characters). At most 3 per surface per session; every one moves app:friction_summary search_empty. Never the search text.</td></tr>
99
+ <tr><td>nav:thrash</td><td>4 or more switches of one panel or mode within 10 seconds with no edit in between: surface (dock_tab, mode or agent_panel), switches (4-5, 6-10 or gte11) and window_s (always 10). At most one per surface every 30 seconds; every one moves app:friction_summary nav_thrash. Never which tab or mode.</td></tr>
100
+ <tr><td>camera:thrash</td><td>6 or more viewport camera moves (orbit, fly or pan drags) within 20 seconds with no edit in between: gestures (6-10 or gte11), kind_mix (orbit, fly, pan or mixed) and window_s (always 20). At most one every 30 seconds; every one moves app:friction_summary camera_thrash. Never a camera position or view.</td></tr>
101
+ <tr><td>agent:connect_opened</td><td>The model-connect list opened: entry (first_run, failure_card, signed_out, menu or other; the signed-out card reads first_run until this browser profile has connected once). At most 5 per session.</td></tr>
102
+ <tr><td>agent:connect_started</td><td>One model-connect attempt began: provider (one of the providers CozyClay configures — openai-codex, anthropic, anthropic-subscription, openai, google, minimax, minimax-cn, openrouter, cliproxy, openai-compatible — or other) and method (oauth, api_key or chatgpt).</td></tr>
103
+ <tr><td>agent:connect_finished</td><td>That attempt ended: provider, method, outcome (ok, failed, cancelled with the Cancel button, abandoned when closed or left unfinished), step it had reached (list, prompt, waiting or done) and duration (lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m). Never the key, token, account, email or error text.</td></tr>
104
+ <tr><td>agent:model_changed</td><td>A model picked in the agent's model list: its provider (as above, or other) and after_failure (whether the last agent turn had failed). At most 10 per session.</td></tr>
105
+ <tr><td>agent:prompt_sent</td><td>One per prompt sent to the agent: turn_id (the same random id as agent:turn_*); provider (as for agent:connect_started, or other); via (typed, retry_button, regenerate, chip or supervisor); prompt_len (1-20, 21-80, 81-240 or gte241 characters); compose_ms (first keystroke to send, typed prompts only) and gap (since the previous turn ended, or first) as lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m; similarity (first, same, near or different, computed in the browser against the previous prompt's words); prev_outcome (succeeded, failed, cancelled or none); turn_index (1, 2, 3, 4-5, 6-10 or gte11); attached and task_mode (true or false). Never the prompt text or its words.</td></tr>
106
+ <tr><td>ui:rage_click</td><td>Three or more fast clicks (within 1 second and 24 pixels) on one primary control: control (a control id from a fixed list, such as tl_play or export_video, or other), clicks (3, 4-5 or gte6) and changed (whether the Studio responded within half a second). At most 2 per control per session. No coordinates, no text, no element or object ids.</td></tr>
107
+ <tr><td>ui:dead_click</td><td>A click on a primary control that did nothing: control (as above) and reason (aria_disabled when the control was disabled, refused when the Studio refused the command, no_effect when nothing changed within half a second). At most 2 per control per session. Clicks into text fields are never observed.</td></tr>
108
+ <tr><td>feature:used</td><td>One signal per feature per session: a fixed feature name such as pose_edit (a real bone or pose change), pose_save (saving a pose to the library), camera_fly, export_video, export_storyboard or export_render_pass</td></tr>
90
109
  <tr><td>$pageview, scene:created, scene:loaded, craft:first_action, activation:completed</td><td>Funnel and drop-off analysis</td></tr>
91
- <tr><td>craft:first_edit</td><td>First successful Studio authoring change per editor mount; only a fixed edit-kind label and definition version 1 (not camera navigation, playback, loading, restore, or undo/redo)</td></tr>
110
+ <tr><td>craft:first_edit</td><td>First successful Studio authoring change per editor mount; only a fixed edit-kind label and definition version 2 (not camera navigation, playback, loading, restore, or undo/redo)</td></tr>
92
111
  <tr><td>playground:first_edit</td><td>The same versioned first-edit definition for Playground, counted separately from Studio; no prompts or scene contents</td></tr>
93
112
  <tr><td>project:saved, project:opened</td><td>Whether people keep their work (object counts and ages are bucketed; never names or paths)</td></tr>
94
- <tr><td>motion:generate_requested</td><td>Explicit generation intent, not prompt-block editing: initiating surface (timeline, line_edit, trail, mcp, fal_card or agent), input_mode (prompt, pose, edit, a_to_b or still) and an ephemeral random request_id</td></tr>
95
- <tr><td>motion:preflight_blocked</td><td>Request readiness refusal: request_id, surface and normalized reason (unconfigured, unreachable, unsupported_route, locked or missing_input). locked means the AI video route is not enabled for the account yet; missing_input means the A/B captures were incomplete</td></tr>
96
- <tr><td>motion:preflight_passed</td><td>Request readiness accepted, before job start: request_id, surface and backend (none, local_kimodo, hosted or fal)</td></tr>
97
- <tr><td>motion:job_started</td><td>Generation execution started: request_id, backend and input_mode</td></tr>
98
- <tr><td>motion:job_succeeded</td><td>Generation completed before result application: request_id, backend, input_mode and bucketed duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s)</td></tr>
99
- <tr><td>motion:job_failed</td><td>Generation failure or real cancellation: request_id, backend, input_mode, duration_bucket and normalized error_code (aborted, unsupported_route, generation_failed, quota or unknown), never raw error messages</td></tr>
100
- <tr><td>motion:result_applied</td><td>A generated result was applied to its requesting character: request_id and backend. For the fal AI video route, applied means the generated clip was loaded into the Studio for extraction. Job success alone does not prove application.</td></tr>
101
113
  <tr><td>workflow:run_requested</td><td>Workflow execution requests: surface (workflow), ephemeral run_id and node_count_bucket (0, 1-3, 4-10 or gte11). No graph contents, node labels or outputs.</td></tr>
102
114
  <tr><td>workflow:run_succeeded, workflow:run_failed, workflow:run_cancelled</td><td>Workflow outcomes paired by run_id, with duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s); failures and actual cancellations also include failure_code (aborted, capture_failed, generation_failed or unknown). Starting another run does not imply cancellation.</td></tr>
103
115
  <tr><td>workflow:result_applied</td><td>Captured or generated image/video output was written to the Workflow canvas: run_id only, at most once per run, independently of a later failure. No image bytes or output contents.</td></tr>
104
116
  <tr><td>agent:turn_requested</td><td>Explicit Agent turn requests from surface (studio or workflow), paired by ephemeral turn_id. No prompts, responses or model identifiers.</td></tr>
105
117
  <tr><td>agent:tool_executed</td><td>Agent tool observations: turn_id, tool_category (workflow_read, workflow_write, workflow_run, frame_capture, image_generate, scene_write or other), outcome (succeeded, failed or cancelled) and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s). Tool names, arguments and results are excluded.</td></tr>
106
- <tr><td>agent:turn_succeeded, agent:turn_failed, agent:turn_cancelled</td><td>Agent turn outcomes: turn_id and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s); failures and actual cancellations also include failure_code (aborted, auth, rate_limited, tool_failed, upstream or unknown). Missing terminal delivery is not success.</td></tr>
118
+ <tr><td>agent:turn_succeeded, agent:turn_failed, agent:turn_cancelled</td><td>Agent turn outcomes: turn_id and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s); failures and actual cancellations also include failure_code (aborted, auth, rate_limited, tool_failed, upstream, key_refused, stale_scene, capability_missing, busy, invalid_request or unknown). auth means CozyClay's local agent has no sign-in or refused the session; key_refused means the model provider rejected the key or credentials; stale_scene means the turn was refused because the scene changed underneath it; capability_missing means a required capability was unavailable, for example the live hub port was already in use; busy means another turn or target was busy; invalid_request means the request itself was rejected. Missing terminal delivery is not success.</td></tr>
107
119
  <tr><td>agent:result_applied</td><td>An Agent turn produced an acknowledged editor change: turn_id only, at most once per turn. Successful reads and focus/navigation do not prove applied output.</td></tr>
108
120
  <tr><td>mcp:tool_requested</td><td>Connected-editor MCP execution requests: ephemeral request_id and tool_category (read, camera, scene_write, prompt_authoring, frame_capture, motion_generate, motion_apply, project_io or other).</td></tr>
109
121
  <tr><td>mcp:tool_executed</td><td>Connected-editor MCP outcomes: request_id, tool_category (read, camera, scene_write, prompt_authoring, frame_capture, motion_generate, motion_apply, project_io or other), outcome (succeeded, failed, uncertain or cancelled) and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s). Control-plane success does not prove a mutation applied.</td></tr>
110
122
  <tr><td>mcp:result_applied</td><td>A connected editor acknowledged an MCP mutation, paired by request_id. Detached or disconnected MCP sessions remain unobserved.</td></tr>
111
123
  <tr><td>export:blocking_frame_succeeded, export:video_succeeded</td><td>Export reliability</td></tr>
112
- <tr><td>export:attempt_started, export:attempt_succeeded, export:attempt_failed, export:attempt_cancelled</td><td>Export attempts and outcomes, paired by an ephemeral random attempt_id; export_kind (video, depth_video, frame or keyframe_pack), format (mp4, png or zip) and initiating surface (studio, workflow or embed). Terminal events include a bucketed duration_bucket; failures and real cancellations include only a normalized failure_code (unsupported_codec, encode_failed, render_failed, aborted or unknown), never raw error messages, filenames, URLs, paths, prompts or scene content. Success means pipeline completion or download handoff, not confirmation that the OS saved a file.</td></tr>
124
+ <tr><td>export:attempt_started, export:attempt_succeeded, export:attempt_failed, export:attempt_cancelled</td><td>Export attempts and outcomes, paired by an ephemeral random attempt_id; export_kind (video, depth_video, frame, keyframe_pack, contact_sheet, panel_pack, animatic, animation_project, workflow_send, storyboard, render_pass or otio), format (mp4, png, zip, cclayproject or otio), initiating surface (studio, workflow or embed), is_retry (whether the attempt retries an earlier one), retry_of (the retried attempt's attempt_id, only when is_retry is true) and objects (the scene's object count, bucketed 0, 1-3, 4-10 or gte11). Terminal events include a bucketed duration_bucket; failures and real cancellations include a normalized failure_code (unsupported_codec, encode_failed, render_failed, aborted or unknown), the stage where the export stopped (preparing, encoding, finalizing or handoff) and a progress bucket (0, 1-25, 26-50, 51-75 or 76-99 percent), never raw error messages, filenames, URLs, paths, prompts or scene content. Success means pipeline completion or download handoff, not confirmation that the OS saved a file.</td></tr>
125
+ <tr><td>export:saved</td><td>How each exported file was saved, one event per file: method (workspace, written into the export folder you chose, or download, a browser download) and reason (none; unsupported, the browser has no folder access; no_workspace, no export folder is chosen; permission, folder access was denied; or write_failed, the folder write failed and the file fell back to a download). Never the file name or path.</td></tr>
113
126
  <tr><td>export:keyframe_pack</td><td>Legacy Workflow pack handoffs, with numeric entries count and source=workflow; not an additional export attempt</td></tr>
127
+ <tr><td>sculpt_run</td><td>One event per finished Sculpt mode run: outcome (done, cancelled or failed), rounds (0 to 10), final_iou (how closely the final sculpt matched the reference image, 0 to 1 rounded to two decimals; omitted when the run was never scored), duration_ms (elapsed run time in milliseconds) and had_target (whether the run reworked an existing sculpt instead of creating one). Numbers only: never the reference image, recipe, part names or sculpt id.</td></tr>
114
128
  <tr><td>playground:opened, playground:first_action</td><td>Whether the home-page playground gets used</td></tr>
115
129
  <tr><td>app:error</td><td>Uncaught errors and unhandled promise rejections, at most ten distinct per session: error_kind (error or unhandled_rejection), error_type (a built-in name such as TypeError or DOMException, other or non_error), error_source (app, extension, external or unknown) and, only for the app's own scripts, error_file (the bundle file name), error_line and error_col. Never the error message, stack text, URLs, query strings, paths, prompts or scene content.</td></tr>
130
+ <tr><td>app:render_crash</td><td>A render error that brought up the studio's crash screen, at most two per session: error_type (the same closed labels as app:error). Never the error message, stack text, component names, file names or scene content.</td></tr>
131
+ <tr><td>app:gl_lost</td><td>The 3D view's graphics failed, at most once per kind per session: kind (unavailable when WebGL is off at startup, context_lost when the GPU context drops later). Never the GPU renderer string.</td></tr>
132
+ <tr><td>studio:ready</td><td>Once per session: how long the studio took to show its first frame, from page navigation to the first frame after the first render: boot (lt2s, 2-5s, 5-15s or gte15s). Never what was loaded.</td></tr>
133
+ <tr><td>playback:summary</td><td>One per playback run (play until stop, or until the frame rate or speed changes), at most 10 per session: duration (lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m); late_ticks, an estimate of dropped frames, not a measured one: the share of playback timer ticks that had to catch up more than one frame (0, lt5, 5-20, 20-50 or gte50 %); max_catchup (the most frames one tick caught up: 1, 2, 3-5 or gte6); fps (12, 24, 30, 60 or other); speed (0.5, 1, 2 or other); measured_fps (the browser's frame rate during playback, the device:profile frame-rate buckets). Every run also feeds app:friction_summary.late_tick_ratio. Never the scene, the take or the frame range.</td></tr>
134
+ <tr><td>guide:opened</td><td>The first-60-seconds guide opened, at most once per session: mode (animation or storyboard). Never step text or what you did.</td></tr>
135
+ <tr><td>guide:step</td><td>A guide step marked done, each step at most once per session: mode (animation or storyboard), step (1 to 4) and step_ms (time on that step: lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m).</td></tr>
136
+ <tr><td>guide:dismissed</td><td>The guide closed before its last step, at most once per session: mode (animation or storyboard) and reached (1 to 4, the step it was showing).</td></tr>
137
+ <tr><td>guide:completed</td><td>The guide's last step marked done, at most once per session: mode (animation or storyboard).</td></tr>
138
+ <tr><td>agent:panel_toggled</td><td>The agent panel opened or closed, at most 10 per session: open (true or false) and via (key for Cmd/Ctrl+B, button for the toolbar control). Never what the panel showed.</td></tr>
139
+ <tr><td>object:deleted_soon</td><td>You deleted an object you added in this session, at most 10 per session: age (how long after adding it: lt2s, 2-10s, 10-60s or gte60s). Object ids are reused names, so this is an estimate. Never the object's id, name or kind.</td></tr>
140
+ <tr><td>object:delete_undone</td><td>You undid an object deletion within its 7-second undo window, at most 10 per session: age (time from the deletion to the undo: lt2s or 2-10s). Never the object's id, name or kind.</td></tr>
141
+ <tr><td>update:notice_shown</td><td>The update notice appeared in the top bar of an npm studio, once per session: kind (global or npx) and behind (patch, minor or major: how far the running version is behind the newest release). Never a path or a command</td></tr>
142
+ <tr><td>update:clicked</td><td>A button on the update notice, at most 10 per session: action (update_now, copy_command, later, dismiss or release_notes) and kind (global or npx)</td></tr>
143
+ <tr><td>update:finished</td><td>A one-click update ended, at most 3 per session: outcome (ok or failed), error_class (none, permission, network, node_version, npm_missing or other) and duration (lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m). Never npm's output, a path or the command line</td></tr>
116
144
  <tr><td>device:profile</td><td>Once per session, whether the studio can run smoothly: gpu_vendor (nvidia, amd, intel, apple, qualcomm, arm, software, other or unknown), gpu_class (discrete, integrated, software or unknown), webgl (webgl2, webgl1 or none), cpu_bucket (1-4, 5-8, 9-16, gte17 or unknown), memory_bucket (lt4, 4-7, gte8 or unknown) and frame_rate_bucket (lt20, 20-40, 40-55, gte55 or unknown) from one three-second sample. The GPU renderer string and exact hardware values never leave the browser.</td></tr>
117
145
  <tr><td>survey:use_case</td><td>An optional question shown at most once after a successful export, only while telemetry is on: use_case (animation, film, game, ad_mv, personal, other or skip) and team (team, solo or skip). Closed choices only; no free text.</td></tr>
118
146
  <tr><td>hosted:composer_viewed, hosted:login_started, hosted:ticket_created, hosted:result_opened, hosted:opened_in_studio</td><td>Hosted demo funnel</td></tr>
119
147
  </tbody>
120
148
  </table>
121
149
  </div>
122
- <p class="note">Motion request_id values are fresh random 32-character hexadecimal values, held only for one request; they are not persisted or derived from prompts, hosts, credentials, project content or other identifiers. The ambiguous motion:generate_blocked event stopped being emitted in the September 15, 2026 issue #273 contract. Historical events are not relabeled and are excluded from the new explicit-demand metric. Automatic line previews and prompt-block edits are not explicit Generate requests.</p>
123
- <p class="note">Every regular app event and session-end beacon carries the same distribution (npm or hosted), app_version when available, origin_kind (local or hosted), internal_qa boolean, coarse operating-system label and, for npm sessions, install_kind (npx or global) and update_status (latest, outdated or unknown), read from the package's own cached update check. PostHog's free plan retains events for one year.</p>
150
+ <p class="note">Only the events in this table are sent; any other event name is dropped in the browser before it reaches PostHog. Every regular app event and session-end beacon carries the same distribution (npm or hosted), app_version when available, origin_kind (local or hosted), internal_qa boolean, coarse operating-system label and, for npm sessions, install_kind (npx or global) and update_status (latest, outdated or unknown), read from the package's own cached update check. The update notice asks the launcher on 127.0.0.1 whether a newer release exists; the launcher answers from that same cached check, so the notice adds no request to anyone else. Update now runs npm install -g cozyclay@&lt;version&gt; on your machine, and only when you press it. PostHog's free plan retains events for one year.</p>
124
151
  <p class="note">Workflow run_id, Agent turn_id and MCP request_id values are fresh random 32-character hexadecimal values held only for one execution. Execution telemetry contains only closed categories, outcomes and elapsed-time buckets; missing telemetry is left unknown, and a disconnected MCP editor is not backfilled.</p>
125
152
  </section>
126
153
 
@@ -1,11 +1,11 @@
1
1
  {
2
- "bin/agent/agent-routes.mjs": 804,
2
+ "bin/agent/agent-routes.mjs": 799,
3
3
  "bin/agent/agent-runner.mjs": 495,
4
4
  "bin/agent/providers.mjs": 255,
5
5
  "bin/agent/session-store.mjs": 324,
6
6
  "bin/agent/supervisor.mjs": 283,
7
7
  "bin/agent/video-adapters.mjs": 334,
8
- "bin/cozyclay.mjs": 399,
8
+ "bin/cozyclay.mjs": 351,
9
9
  "bin/live/cli.mjs": 456,
10
10
  "mcp/live-hub.mjs": 529,
11
11
  "mcp/tool-handlers.mjs": 1730,
@@ -14,12 +14,12 @@
14
14
  "mcp/verify-live-controller.mjs": 340,
15
15
  "mcp/verify-live-p0.mjs": 264,
16
16
  "mcp/verify-live.mjs": 416,
17
- "src/analytics.js": 1038,
18
- "src/App.jsx": 4448,
19
- "src/asset-pane.jsx": 643,
17
+ "src/analytics.js": 948,
18
+ "src/App.jsx": 4434,
19
+ "src/asset-pane.jsx": 642,
20
20
  "src/camera-follow.js": 505,
21
21
  "src/camera-move.js": 289,
22
- "src/command-bus.js": 356,
22
+ "src/command-bus.js": 355,
23
23
  "src/controls.jsx": 330,
24
24
  "src/domains/cast.js": 717,
25
25
  "src/domains/motion.js": 806,
@@ -28,7 +28,7 @@
28
28
  "src/domains/shots.js": 411,
29
29
  "src/dualview.jsx": 365,
30
30
  "src/hierarchy-panel.jsx": 777,
31
- "src/live-control.js": 338,
31
+ "src/live-control.js": 331,
32
32
  "src/matte-editor.js": 486,
33
33
  "src/matte.js": 472,
34
34
  "src/motion/collision-blockers.js": 273,
@@ -60,14 +60,14 @@
60
60
  "src/shell/MenuBar.jsx": 632,
61
61
  "src/shell/ViewportToolbar.jsx": 567,
62
62
  "src/shot-authoring.js": 345,
63
- "src/stage-canvas-rigs.jsx": 350,
63
+ "src/stage-canvas-rigs.jsx": 349,
64
64
  "src/stage-editor-gizmos.jsx": 1116,
65
65
  "src/studio-agent-commands.js": 487,
66
66
  "src/studio-agent-protocol.js": 480,
67
67
  "src/studio-app-binding.js": 346,
68
68
  "src/ui.jsx": 385,
69
- "src/workflow/agent-client.js": 1369,
70
- "src/workflow/AgentPanel.jsx": 811,
69
+ "src/workflow/agent-client.js": 1295,
70
+ "src/workflow/AgentPanel.jsx": 807,
71
71
  "src/workflow/WorkflowBuilder.jsx": 592,
72
72
  "tools/qa/css-rule-usage.mjs": 251,
73
73
  "tools/run-tests.mjs": 441