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/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
- const fmt = (ms) => {
105
- const s = Math.max(0, Number(ms) || 0) / 1000
106
- return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
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
- function clipText(c) {
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
- return Number(c.duration) > 0 ? `${at} a ${Math.round(c.duration)}ms ${name}animation (clip ${c.id})` : `${at} a running ${name}animation (clip ${c.id})`
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
- function describe(n, session) {
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.source ? `Source: ${n.source.file}${n.source.line ? ":" + n.source.line : ""}` : null,
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 client.session()
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([client.session(), byId(id)])
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 client.session()
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 client.session()).notes || []).map((n) => [String(n.id), key(n)]))
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 client.session()
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: "Notes are change requests the user left on elements of their prototype in the Retake timeline. list_notes, then get_note for details; acknowledge when you start, resolve with a summary when done.",
485
+ instructions:
486
+ INSTRUCTIONS,
281
487
  })
282
488
  }
283
489
  if (method === "notifications/initialized" || (method && method.startsWith("notifications/"))) return