retake-dev 0.4.0 → 0.5.1
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.
- package/CHANGELOG.md +125 -0
- package/README.md +173 -10
- package/bin/retake.js +14 -1
- package/package.json +7 -1
- package/src/core.js +23 -1
- package/src/mark.svg +1 -0
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +7 -3
- package/src/runtime/10-animations.js +9 -0
- package/src/runtime/60-preview.js +3 -1
- package/src/runtime/62-motion.js +106 -0
- package/src/runtime/65-timeline.js +39 -0
- package/src/runtime/67-csssource.js +73 -1
- package/src/runtime/70-boot.js +28 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +225 -19
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/00-state.js +7 -0
- package/src/shell/10-dock.js +208 -13
- package/src/shell/15-session.js +66 -10
- package/src/shell/20-timeline.js +47 -16
- package/src/shell/25-input.js +29 -2
- package/src/shell/30-notes.js +709 -113
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +79 -8
- package/src/shell/shell.html +10 -3
- package/types/client.d.ts +16 -0
- package/types/index.d.ts +16 -0
- package/types/next.d.ts +50 -0
package/src/server/mcp.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// Finds the dev server from --url / RETAKE_URL, else <cwd or a parent>/.retake/server.json.
|
|
9
9
|
import fs from "node:fs"
|
|
10
10
|
import path from "node:path"
|
|
11
|
+
import { fmt, parseTime, readRecording, report } from "./moments.js"
|
|
12
|
+
import { cleanSource, isCompiled, resolveSource } from "./sourcemap.js"
|
|
13
|
+
import { animFromClip, animationReport, animationSummary, elementBlock, animationBlock, noteText, primaryOf } from "../note-text.js"
|
|
11
14
|
|
|
12
15
|
const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"]
|
|
13
16
|
const PKG = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
|
|
@@ -17,7 +20,7 @@ function findServerInfo(from = process.cwd()) {
|
|
|
17
20
|
const f = path.join(dir, ".retake", "server.json")
|
|
18
21
|
if (fs.existsSync(f)) {
|
|
19
22
|
try {
|
|
20
|
-
return JSON.parse(fs.readFileSync(f, "utf8"))
|
|
23
|
+
return { ...JSON.parse(fs.readFileSync(f, "utf8")), root: dir }
|
|
21
24
|
} catch {}
|
|
22
25
|
}
|
|
23
26
|
if (path.dirname(dir) === dir) return null
|
|
@@ -26,7 +29,7 @@ function findServerInfo(from = process.cwd()) {
|
|
|
26
29
|
|
|
27
30
|
/** @param {{ url?: string | null, token?: string }} [options] */
|
|
28
31
|
export function createClient({ url, token } = {}) {
|
|
29
|
-
/** @type {{ url: string, token?: string } | null} */
|
|
32
|
+
/** @type {{ url: string, token?: string, root?: string } | null} */
|
|
30
33
|
let info = null
|
|
31
34
|
const base = () => {
|
|
32
35
|
if (url) return url.replace(/\/$/, "")
|
|
@@ -60,7 +63,13 @@ export function createClient({ url, token } = {}) {
|
|
|
60
63
|
}
|
|
61
64
|
return {
|
|
62
65
|
base,
|
|
66
|
+
// The project folder (where .retake/ is), to make source paths relative.
|
|
67
|
+
root: () => {
|
|
68
|
+
info = info || findServerInfo()
|
|
69
|
+
return (info && info.root) || process.cwd()
|
|
70
|
+
},
|
|
63
71
|
session: () => call("GET", "/__retake/session"),
|
|
72
|
+
recording: (branchId) => call("GET", `/__retake/recording/${encodeURIComponent(branchId)}`),
|
|
64
73
|
notes: () => call("GET", "/__retake/notes"),
|
|
65
74
|
note: (id) => call("GET", `/__retake/notes/${encodeURIComponent(id)}`),
|
|
66
75
|
patch: (id, body) => call("PATCH", `/__retake/notes/${encodeURIComponent(id)}`, body),
|
|
@@ -101,9 +110,12 @@ export function createClient({ url, token } = {}) {
|
|
|
101
110
|
|
|
102
111
|
// ---- presenting notes -----------------------------------------------------------
|
|
103
112
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
// Older sessions named timelines "Main" and "Take N"; the dock now shows
|
|
114
|
+
// those defaults as "Timeline N" (migratedName in shell/10-dock.js).
|
|
115
|
+
function withNames(session) {
|
|
116
|
+
if (!session || !Array.isArray(session.branches)) return session
|
|
117
|
+
const old = (b) => (String(b.id) === "1" ? "Main" : `Take ${b.id}`)
|
|
118
|
+
return { ...session, branches: session.branches.map((b) => (b.name === old(b) ? { ...b, name: `Timeline ${b.id}` } : b)) }
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
function summary(n, session) {
|
|
@@ -116,21 +128,49 @@ function summary(n, session) {
|
|
|
116
128
|
at: fmt(n.t),
|
|
117
129
|
selector: n.selector || (n.el && n.el.selector) || null,
|
|
118
130
|
component: n.component || (n.el && n.el.components && n.el.components[0]) || null,
|
|
119
|
-
source: n.source ? `${n.source.file}${n.source.line ? ":" + n.source.line : ""}` : null,
|
|
131
|
+
source: n.source ? (unmapped(n.source) ? "unknown (compiled bundle; get_note tries its source map)" : `${n.source.file}${n.source.line ? ":" + n.source.line : ""}`) : null,
|
|
132
|
+
...(n.range ? { range: `${fmt(n.range.from)} → ${fmt(n.range.to)}` } : {}),
|
|
133
|
+
...(animationSummary(n) ? { animation: animationSummary(n) } : {}),
|
|
120
134
|
replies: (n.replies || []).length,
|
|
121
135
|
}
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
|
|
138
|
+
// A source that is only a compiled bundle's line (no source map read yet).
|
|
139
|
+
const unmapped = (src) => !!src && !!(src.compiled || isCompiled(src.file))
|
|
140
|
+
|
|
141
|
+
function clipText(c, t) {
|
|
125
142
|
const name = c.label ? `${c.label} ` : ""
|
|
126
143
|
const at = `${Math.round(Number(c.offset) || 0)}ms into`
|
|
127
|
-
|
|
144
|
+
const start = c.start != null ? c.start : t - (Number(c.offset) || 0)
|
|
145
|
+
const end = c.end !== undefined ? c.end : Number(c.duration) > 0 ? start + Number(c.duration) : null
|
|
146
|
+
const span = `; it runs ${fmt(start)} → ${end != null ? fmt(end) : "still running"}`
|
|
147
|
+
return Number(c.duration) > 0 ? `${at} a ${Math.round(c.duration)}ms ${name}animation (clip ${c.id}${span})` : `${at} a running ${name}animation (clip ${c.id}${span})`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Where the element was written, as one line: the note's own source, the
|
|
151
|
+
// original file:line behind a compiled bundle's (see resolveSourceOf), or why
|
|
152
|
+
// it isn't known.
|
|
153
|
+
function sourceLine(n, resolved) {
|
|
154
|
+
const src = n.source
|
|
155
|
+
if (!src) return null
|
|
156
|
+
if (resolved && resolved.file) return `Source: ${resolved.file}${resolved.line ? ":" + resolved.line : ""} (through the source map of the bundle it was served in)`
|
|
157
|
+
if (unmapped(src)) {
|
|
158
|
+
const by = [n.component || (n.el && n.el.components && n.el.components[0]), n.selector || (n.el && n.el.selector)].filter(Boolean)
|
|
159
|
+
return `Source: not known. The note only has a line of a compiled bundle${resolved && resolved.library ? ", which maps into library code" : ", with no source map to read it by"}; find the element by ${by.length > 1 ? `its component (${by[0]}) and selector` : by.length ? by[0] : "its selector"} instead.`
|
|
160
|
+
}
|
|
161
|
+
return `Source: ${src.file}${src.line ? ":" + src.line : ""}`
|
|
128
162
|
}
|
|
129
163
|
|
|
130
|
-
// Everything an agent needs to act on one note, as readable text.
|
|
131
|
-
|
|
164
|
+
// Everything an agent needs to act on one note, as readable text. A note
|
|
165
|
+
// that says which animations it is about (anims, from the dock) is written by
|
|
166
|
+
// the same formatter as the dock's Copy for agent (src/note-text.js).
|
|
167
|
+
function describe(n, session, resolved = null) {
|
|
132
168
|
const b = (session.branches || []).find((x) => String(x.id) === String(n.branchId))
|
|
133
169
|
const parent = b && (session.branches || []).find((x) => String(x.id) === String(b.parentId))
|
|
170
|
+
if (Array.isArray(n.anims)) {
|
|
171
|
+
const text = noteText(n, { start: 0, timeline: b ? b.name : String(n.branchId), parent: parent ? parent.name : null, forkAt: parent ? b.forkAt : null, sourceLine: sourceLine(n, resolved) || undefined, status: true })
|
|
172
|
+
return `${text}\n\nThe note is pinned to ${fmt(n.range ? n.range.from : n.t)} in the recording of that timeline. get_moment with id "${n.id}" shows what happened around it; get_animation with id "${n.id}" maps any recording time onto the animation's own clock.`
|
|
173
|
+
}
|
|
134
174
|
const el = n.el || {}
|
|
135
175
|
const lines = [
|
|
136
176
|
`Note ${n.id} (${n.status || "pending"}) on timeline "${b ? b.name : n.branchId}" at ${fmt(n.t)}`,
|
|
@@ -141,11 +181,13 @@ function describe(n, session) {
|
|
|
141
181
|
`Element: ${el.label || ""}${el.text ? ` "${el.text}"` : ""}`.trim(),
|
|
142
182
|
`Selector: ${n.selector || el.selector || "?"}`,
|
|
143
183
|
n.component || (el.components && el.components.length) ? `Component: ${n.component || el.components.join(" < ")}` : null,
|
|
144
|
-
n
|
|
184
|
+
sourceLine(n, resolved),
|
|
145
185
|
n.classes && n.classes.length ? `Classes: ${[].concat(n.classes).join(" ")}` : null,
|
|
146
186
|
n.rect ? `Box: ${Math.round(n.rect.w)}×${Math.round(n.rect.h)} at (${Math.round(n.rect.x)}, ${Math.round(n.rect.y)})` : null,
|
|
147
|
-
n.clip ? `During an animation: ${clipText(n.clip)}` : null,
|
|
187
|
+
n.clip ? `During an animation: ${clipText(n.clip, n.t)}` : null,
|
|
148
188
|
el.page ? `Page: ${el.page}` : null,
|
|
189
|
+
"",
|
|
190
|
+
`The note is pinned to ${fmt(n.t)} in the recording of that timeline: the user means what was on screen then. get_moment with id "${n.id}" shows what happened around it (actions, animations on this element with their start and end, requests); check it before asking the user about timing.`,
|
|
149
191
|
]
|
|
150
192
|
if ((n.replies || []).length) {
|
|
151
193
|
lines.push("", "Conversation:")
|
|
@@ -167,6 +209,52 @@ const TOOLS = [
|
|
|
167
209
|
description: "Everything about one note: what the user asked, the element (selector, React component, source file:line, classes, size), the moment and timeline, and the conversation so far.",
|
|
168
210
|
inputSchema: { type: "object", properties: { id: { type: ["string", "number"] } }, required: ["id"] },
|
|
169
211
|
},
|
|
212
|
+
{
|
|
213
|
+
name: "get_moment",
|
|
214
|
+
description:
|
|
215
|
+
"What happened in the recording around a note's moment (or any moment of a timeline): the user's actions, route changes and requests with their times, the animations on or near the noted element (start, end, duration, what they animate between, how far along at the moment), other animations, and when the screen changed most. Use it before asking the user about timing: a note is pinned to a moment, and the answer is usually in the recording.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
id: { type: ["string", "number"], description: "A note id: the moment, timeline and element come from the note" },
|
|
220
|
+
timeline: { type: ["string", "number"], description: "Without a note: a timeline's name or id (default: the active one)" },
|
|
221
|
+
at: { type: ["string", "number"], description: "Without a note: the moment, as the dock shows it (00:10.91)" },
|
|
222
|
+
selector: { type: "string", description: "Without a note: an element to focus on" },
|
|
223
|
+
before_seconds: { type: "number", description: "How far back to look (default 5)" },
|
|
224
|
+
after_seconds: { type: "number", description: "How far ahead to look (default 5)" },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "get_animation",
|
|
230
|
+
description:
|
|
231
|
+
"One animation in detail: what it is and where it is defined, what started it, its timing (delay, duration, iterations, easing) and every keyframe, and recording times mapped onto its own clock (local ms after its delay, progress, eased progress, the keyframe segment) with the conversion to CSS %, Motion `times` and GSAP seconds. Values and boxes are included where the note sampled them. Give a note id (its primary animation, or `clip` to pick another of its animations), or a timeline and a clip id.",
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: "object",
|
|
234
|
+
properties: {
|
|
235
|
+
id: { type: ["string", "number"], description: "A note id: its primary animation and its moment or range" },
|
|
236
|
+
clip: { type: "string", description: "A clip id (from get_moment / get_timeline_events / the note)" },
|
|
237
|
+
timeline: { type: ["string", "number"], description: "Without a note: a timeline's name or id (default: the active one)" },
|
|
238
|
+
at: { type: ["string", "number"], description: "A recording time to map (00:01.20)" },
|
|
239
|
+
from: { type: ["string", "number"], description: "Start of a range to map" },
|
|
240
|
+
to: { type: ["string", "number"], description: "End of a range to map" },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "get_timeline_events",
|
|
246
|
+
description: "Everything recorded on a timeline between two times: the user's actions, routes, page loads, requests, animations (optionally around one element) and screen changes, in order. Times as the dock shows them (00:10.91).",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: "object",
|
|
249
|
+
properties: {
|
|
250
|
+
timeline: { type: ["string", "number"], description: "A timeline's name or id (default: the active one)" },
|
|
251
|
+
from: { type: ["string", "number"], description: "Start of the window (default: the start)" },
|
|
252
|
+
to: { type: ["string", "number"], description: "End of the window (default: the end)" },
|
|
253
|
+
selector: { type: "string", description: "Focus on the animations on or near this element" },
|
|
254
|
+
limit: { type: "number", description: "Most events to list (default 60, max 200)" },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
170
258
|
{
|
|
171
259
|
name: "get_active_timeline",
|
|
172
260
|
description: "Which timeline the user is looking at in the Retake dock, how it relates to the others (branch point, code version) and its open notes.",
|
|
@@ -194,9 +282,34 @@ const TOOLS = [
|
|
|
194
282
|
},
|
|
195
283
|
]
|
|
196
284
|
|
|
285
|
+
// The guide every coding agent gets at initialize (also in skills/retake-notes).
|
|
286
|
+
const INSTRUCTIONS = `Notes are change requests the user left on elements of their app in the Retake timeline, each pinned to a moment (or a range) of a recording of the running app. list_notes, then get_note for details; get_moment shows what happened around a note's moment (actions, animations on the element, requests), so check it before asking the user about timing. acknowledge when you start, resolve with a summary when done.
|
|
287
|
+
|
|
288
|
+
Animations. A note on an animated element names the animation (its kind, where it is defined, its timing and every keyframe) and the exact point or range the user meant on that animation's own clock: local ms after its delay, progress (local / duration), eased progress (after the effect's easing; keyframe offsets apply to this), and the keyframe segment it falls in, with the computed values and the element's box there and one frame either side. Numbers the user typed ("at 100ms", "200-400 ms") are local time of that animation unless the note says otherwise. Change only what the note scopes: keep the values at every other point and the total duration.
|
|
289
|
+
Most notes end with an "Exact edit": the animation's keyframes again on plain time, with the note's point or range edges as keyframes of their own and the matching part of each curve on both sides. Put it in place of the original (it moves exactly as before), then change only the marked part. If it says the @keyframes is shared, it gives this element its own copy; keep that unless the user meant every element.
|
|
290
|
+
- CSS @keyframes: add stops at the range edges as % (local / duration) with the edge values given, so the rest doesn't move; change only what is between them. animation-timing-function inside a keyframe applies to the segment after it (default ease). A hold = two stops with the same value. If the duration changes, recompute every %. If the selector matches several elements, scope the change unless all were meant.
|
|
291
|
+
- CSS transition: one segment only. For a hold, a two-step motion or a sub-range, use linear(...) stops or a cubic-bezier, or replace it with a keyframes animation started by the same trigger (given).
|
|
292
|
+
- WAAPI (element.animate): add { offset, ...values, easing } at the edges. With an effect-level easing, offsets apply after it: set the effect easing to linear and move easing onto each keyframe first.
|
|
293
|
+
- Motion: values are arrays with times (0-1 of the duration, delay excluded) and an ease array (one per segment); insert entries at local / duration. A spring has no ms range: convert it to keyframes + times, or tune stiffness/damping/bounce.
|
|
294
|
+
- GSAP: retime with the position parameter ("<", "-=0.4", seconds); change part of a tween by splitting it into two .to() calls at the edge values.
|
|
295
|
+
- Script-driven (inline style writes): edit the code at the given location and gate the change on the same elapsed time, or move it into keyframes.
|
|
296
|
+
- SVG stroke draw: the dash offset is linear in progress; "pause halfway" = two equal stops around 50%.
|
|
297
|
+
- Scroll-driven: the axis is scroll progress, not time; edit the keyframe % or animation-range. For JS parallax, clamp to a scroll range instead of changing the factor.
|
|
298
|
+
- canvas / WebGL: Retake can't see inside; edit the per-frame code, gated on the same elapsed time.
|
|
299
|
+
get_animation maps any recording time onto an animation's clock and into CSS %, Motion times and GSAP seconds. After editing, check the values at the range edges and one frame either side against the note's.`
|
|
300
|
+
|
|
301
|
+
// For get_moment on a note: which recorded clips the note says are its element's.
|
|
302
|
+
function knownOf(note) {
|
|
303
|
+
const rel = new Map()
|
|
304
|
+
for (const a of note.anims || []) if (a.id) rel.set(String(a.id), a.relation === "on" ? "on" : a.relation || "on")
|
|
305
|
+
for (const c of note.inside || []) if (c.id && !rel.has(String(c.id))) rel.set(String(c.id), "inside")
|
|
306
|
+
return { rel, running: (note.anims || []).map((a) => a.name || a.kind) }
|
|
307
|
+
}
|
|
308
|
+
|
|
197
309
|
const isOpen = (n) => !n.status || n.status === "pending" || n.status === "acknowledged"
|
|
198
310
|
|
|
199
311
|
export function createTools(client) {
|
|
312
|
+
const readSession = async () => withNames(await client.session())
|
|
200
313
|
const byId = async (id) => {
|
|
201
314
|
const note = await client.note(id).catch((err) => {
|
|
202
315
|
if (/→ 404/.test(err.message)) throw new Error(`no note with id ${id}; list_notes shows the ids`)
|
|
@@ -204,18 +317,110 @@ export function createTools(client) {
|
|
|
204
317
|
})
|
|
205
318
|
return note
|
|
206
319
|
}
|
|
320
|
+
// A timeline by name or id (default: the active one) and its recording.
|
|
321
|
+
const timelineOf = (session, which) => {
|
|
322
|
+
const branches = session.branches || []
|
|
323
|
+
const b = which == null || which === "" ? branches.find((x) => String(x.id) === String(session.activeId)) || branches[0] : branches.find((x) => String(x.id) === String(which)) || branches.find((x) => String(x.name).toLowerCase() === String(which).toLowerCase())
|
|
324
|
+
if (!b) throw new Error(which == null ? "no timelines yet: record something in the dock first" : `no timeline "${which}"; get_active_timeline lists them`)
|
|
325
|
+
return b
|
|
326
|
+
}
|
|
327
|
+
const recordingOf = async (b) => {
|
|
328
|
+
const json = await client.recording(b.id).catch((err) => {
|
|
329
|
+
if (/→ 404/.test(err.message)) throw new Error(`timeline "${b.name}" has no saved recording yet (the dock saves it when paused); ask the user to pause, then try again`)
|
|
330
|
+
throw err
|
|
331
|
+
})
|
|
332
|
+
return readRecording(json)
|
|
333
|
+
}
|
|
334
|
+
const resolved = new Map()
|
|
335
|
+
const resolveSourceOf = async (n) => {
|
|
336
|
+
const src = n.source
|
|
337
|
+
if (!src || !unmapped(src)) return null
|
|
338
|
+
const key = `${src.file}:${src.line}:${src.col || ""}`
|
|
339
|
+
if (!resolved.has(key)) resolved.set(key, resolveSource(src, { base: client.base(), root: client.root() }).catch(() => null))
|
|
340
|
+
return resolved.get(key)
|
|
341
|
+
}
|
|
342
|
+
// An absolute source path inside the project reads better relative to it.
|
|
343
|
+
const relSource = (n) => {
|
|
344
|
+
if (!n.source || !n.source.file || !String(n.source.file).startsWith("/")) return n
|
|
345
|
+
const file = cleanSource(n.source.file, { root: client.root() })
|
|
346
|
+
return file === n.source.file ? n : { ...n, source: { ...n.source, file } }
|
|
347
|
+
}
|
|
207
348
|
const handlers = {
|
|
208
349
|
async list_notes({ status = "open" } = {}) {
|
|
209
|
-
const session = await
|
|
350
|
+
const session = await readSession()
|
|
210
351
|
const notes = (session.notes || []).filter((n) => (status === "all" ? true : status === "open" ? isOpen(n) : (n.status || "pending") === status))
|
|
211
352
|
return { count: notes.length, notes: notes.map((n) => summary(n, session)) }
|
|
212
353
|
},
|
|
213
354
|
async get_note({ id }) {
|
|
214
|
-
const [session, note] = await Promise.all([
|
|
215
|
-
return describe(note, session)
|
|
355
|
+
const [session, note] = await Promise.all([readSession(), byId(id)])
|
|
356
|
+
return describe(relSource(note), session, await resolveSourceOf(note))
|
|
357
|
+
},
|
|
358
|
+
async get_moment(/** @type {any} */ { id, timeline, at, selector, before_seconds = 5, after_seconds = 5 } = {}) {
|
|
359
|
+
const session = await readSession()
|
|
360
|
+
const before = Math.min(Math.max(Number(before_seconds) || 0, 0), 120) * 1000
|
|
361
|
+
const after = Math.min(Math.max(Number(after_seconds) || 0, 0), 120) * 1000
|
|
362
|
+
if (id != null && id !== "") {
|
|
363
|
+
const note = await byId(id)
|
|
364
|
+
const b = timelineOf(session, note.branchId)
|
|
365
|
+
const rec = await recordingOf(b)
|
|
366
|
+
const sel = note.selector || (note.el && note.el.selector) || null
|
|
367
|
+
const component = note.component || (note.el && note.el.components && note.el.components[0]) || null
|
|
368
|
+
const title = `Timeline "${b.name}" around note ${note.id} ("${note.text}") on ${sel || "an element"}:`
|
|
369
|
+
if (!Array.isArray(note.anims)) return report(rec, { at: note.t, from: note.t - before, to: note.t + after, selector: sel, component, title })
|
|
370
|
+
// The note knows which animations are its element's (by their recorded
|
|
371
|
+
// path): the element section and the verdict come from it, so this and
|
|
372
|
+
// get_note can't disagree.
|
|
373
|
+
const t0 = note.range ? note.range.from : note.t
|
|
374
|
+
const t1 = note.range ? note.range.to : note.t
|
|
375
|
+
const ctx = { start: rec.start || 0 }
|
|
376
|
+
const head = [...elementBlock(note, ctx), "", ...animationBlock(note, ctx)].join("\n")
|
|
377
|
+
const ctxReport = report(rec, { at: note.t, from: t0 - before, to: t1 + after, selector: sel, component, title, known: knownOf(note) })
|
|
378
|
+
return `Note ${note.id}'s element:\n${head}\n\n${ctxReport}`
|
|
379
|
+
}
|
|
380
|
+
if (at == null || at === "") throw new Error("get_moment needs a note id, or a timeline moment (at, like 00:10.91)")
|
|
381
|
+
const b = timelineOf(session, timeline)
|
|
382
|
+
const rec = await recordingOf(b)
|
|
383
|
+
const t = parseTime(at) + (rec.start || 0)
|
|
384
|
+
return report(rec, { at: t, from: t - before, to: t + after, selector: selector || null, title: `Timeline "${b.name}" around ${fmt(t - (rec.start || 0))}:` })
|
|
385
|
+
},
|
|
386
|
+
async get_animation(/** @type {any} */ { id, clip, timeline, at, from, to } = {}) {
|
|
387
|
+
const session = await readSession()
|
|
388
|
+
const times = (rec) => [at, from, to].filter((v) => v != null && v !== "").map((v) => parseTime(v) + (rec.start || 0))
|
|
389
|
+
if (id != null && id !== "") {
|
|
390
|
+
const note = await byId(id)
|
|
391
|
+
const anims = note.anims || []
|
|
392
|
+
let a = clip ? anims.find((x) => String(x.id) === String(clip)) : primaryOf(note)
|
|
393
|
+
const b = timelineOf(session, note.branchId)
|
|
394
|
+
const rec = await recordingOf(b).catch(() => null)
|
|
395
|
+
if (!a && rec) {
|
|
396
|
+
const c = (rec.clips || []).find((x) => String(x.id) === String(clip || (note.clip && note.clip.id)))
|
|
397
|
+
if (c) a = animFromClip(c)
|
|
398
|
+
}
|
|
399
|
+
if (!a) return `Note ${note.id} has no animation${anims.length ? ` with clip id ${clip}` : ""}: nothing animated on its element at its moment. get_moment with id "${note.id}" lists what animated nearby.`
|
|
400
|
+
const sampled = [a.at, a.from, a.to, ...(a.samples || [])].filter(Boolean)
|
|
401
|
+
const Ts = rec && times(rec).length ? times(rec) : sampled.length ? [...new Set([a.at, a.from, a.to].filter(Boolean).map((p) => p.T))] : []
|
|
402
|
+
return `Animation of note ${note.id} on ${note.selector || (note.el && note.el.selector) || "its element"}:\n${animationReport(a, Ts, { start: rec ? rec.start || 0 : 0, sampled })}`
|
|
403
|
+
}
|
|
404
|
+
if (!clip) throw new Error("get_animation needs a note id, or a clip id (with a timeline)")
|
|
405
|
+
const b = timelineOf(session, timeline)
|
|
406
|
+
const rec = await recordingOf(b)
|
|
407
|
+
const c = (rec.clips || []).find((x) => String(x.id) === String(clip))
|
|
408
|
+
if (!c) throw new Error(`no clip ${clip} on timeline "${b.name}"; get_timeline_events lists them`)
|
|
409
|
+
return `Timeline "${b.name}", clip ${c.id} on ${c.selector || "?"}:\n${animationReport(animFromClip(c), times(rec), { start: rec.start || 0 })}`
|
|
410
|
+
},
|
|
411
|
+
async get_timeline_events(/** @type {any} */ { timeline, from, to, selector, limit = 60 } = {}) {
|
|
412
|
+
const session = await readSession()
|
|
413
|
+
const b = timelineOf(session, timeline)
|
|
414
|
+
const rec = await recordingOf(b)
|
|
415
|
+
const s = rec.start || 0
|
|
416
|
+
const lo = from == null || from === "" ? null : parseTime(from) + s
|
|
417
|
+
const hi = to == null || to === "" ? null : parseTime(to) + s
|
|
418
|
+
if (lo != null && hi != null && hi < lo) throw new Error("`to` is before `from`")
|
|
419
|
+
const n = Math.min(Math.max(Number(limit) || 60, 1), 200)
|
|
420
|
+
return report(rec, { from: lo, to: hi, selector: selector || null, limit: n, title: `Timeline "${b.name}":` })
|
|
216
421
|
},
|
|
217
422
|
async get_active_timeline() {
|
|
218
|
-
const session = await
|
|
423
|
+
const session = await readSession()
|
|
219
424
|
const branches = session.branches || []
|
|
220
425
|
const active = branches.find((b) => String(b.id) === String(session.activeId)) || null
|
|
221
426
|
const parent = active && branches.find((b) => String(b.id) === String(active.parentId))
|
|
@@ -245,12 +450,12 @@ export function createTools(client) {
|
|
|
245
450
|
async watch_notes({ timeout_seconds = 60 } = {}) {
|
|
246
451
|
const ms = Math.min(Math.max(Number(timeout_seconds) || 60, 1), 600) * 1000
|
|
247
452
|
const key = (n) => `${n.status || "pending"}|${(n.replies || []).filter((r) => r.from === "user").length}|${n.text}`
|
|
248
|
-
const before = new Map(((await
|
|
453
|
+
const before = new Map(((await readSession()).notes || []).map((n) => [String(n.id), key(n)]))
|
|
249
454
|
const deadline = Date.now() + ms
|
|
250
455
|
while (Date.now() < deadline) {
|
|
251
456
|
const got = await client.nextEvent(["session", "note-updated"], deadline - Date.now())
|
|
252
457
|
if (!got) break
|
|
253
|
-
const session = await
|
|
458
|
+
const session = await readSession()
|
|
254
459
|
const changed = (session.notes || []).filter((n) => before.get(String(n.id)) !== key(n))
|
|
255
460
|
// Ignore what agents did (replies from "agent" don't change the key).
|
|
256
461
|
if (changed.length) return { changed: changed.map((n) => ({ ...summary(n, session), new: !before.has(String(n.id)) })) }
|
|
@@ -277,7 +482,8 @@ export async function runMcp({ url } = {}) {
|
|
|
277
482
|
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
278
483
|
capabilities: { tools: { listChanged: false } },
|
|
279
484
|
serverInfo: { name: "retake", version: PKG.version },
|
|
280
|
-
instructions:
|
|
485
|
+
instructions:
|
|
486
|
+
INSTRUCTIONS,
|
|
281
487
|
})
|
|
282
488
|
}
|
|
283
489
|
if (method === "notifications/initialized" || (method && method.startsWith("notifications/"))) return
|