@myrtilleio/redline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.js ADDED
@@ -0,0 +1,544 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { formatMarkdown } from './format.js';
5
+ import { combineChecks, diffFingerprints } from './guards.js';
6
+ import { renderTaste, tasteEntry } from './taste.js';
7
+
8
+ const VERDICTS = new Set(['good', 'fix', 'idea']);
9
+ const MAX_POINTS = 4000;
10
+
11
+ function str(v, max) {
12
+ return typeof v === 'string' ? v.slice(0, max) : undefined;
13
+ }
14
+
15
+ function num(v) {
16
+ const n = Number(v);
17
+ return Number.isFinite(n) ? n : 0;
18
+ }
19
+
20
+ function rect(r) {
21
+ return r && typeof r === 'object' ? { x: num(r.x), y: num(r.y), w: num(r.w), h: num(r.h) } : undefined;
22
+ }
23
+
24
+ function target(t) {
25
+ if (!t || typeof t !== 'object' || typeof t.selector !== 'string') return undefined;
26
+ const styles = {};
27
+ if (t.styles && typeof t.styles === 'object') {
28
+ for (const [k, v] of Object.entries(t.styles).slice(0, 20)) if (typeof v === 'string') styles[k.slice(0, 40)] = v.slice(0, 200);
29
+ }
30
+ return {
31
+ selector: t.selector.slice(0, 500),
32
+ html: str(t.html, 300),
33
+ text: str(t.text, 200),
34
+ rect: rect(t.rect),
35
+ styles,
36
+ };
37
+ }
38
+
39
+ function paths(list) {
40
+ if (!Array.isArray(list)) return undefined;
41
+ let budget = MAX_POINTS;
42
+ return list
43
+ .filter(Array.isArray)
44
+ .slice(0, 20)
45
+ .map((path) => {
46
+ const kept = path.slice(0, Math.max(budget, 0)).map((p) => [num(p && p[0]), num(p && p[1])]);
47
+ budget -= kept.length;
48
+ return kept;
49
+ });
50
+ }
51
+
52
+ function stroke(s) {
53
+ if (!s || !Array.isArray(s.paths)) return undefined;
54
+ return {
55
+ paths: paths(s.paths), // page coordinates
56
+ local: paths(s.local), // relative to the target's top-left, so the mark follows it through layout changes
57
+ width: Math.min(Math.max(num(s.width) || 4, 1), 40),
58
+ };
59
+ }
60
+
61
+ const CSS_PROP = /^-?[a-z][a-z-]{1,40}$/;
62
+
63
+ // A live edit the reviewer made on the page: exact before → after values.
64
+ function edits(e) {
65
+ if (!e || typeof e !== 'object') return undefined;
66
+ const props = (Array.isArray(e.props) ? e.props : [])
67
+ .filter((p) => p && CSS_PROP.test(p.prop) && typeof p.to === 'string')
68
+ .slice(0, 30)
69
+ .map((p) => ({ prop: p.prop, from: str(p.from, 200) || '', to: p.to.slice(0, 200) }));
70
+ const text = e.text && typeof e.text.to === 'string' ? { from: str(e.text.from, 2000) || '', to: e.text.to.slice(0, 2000) } : undefined;
71
+ if (!props.length && !text) return undefined;
72
+ return { scope: e.scope === 'all' ? 'all' : 'element', selector: str(e.selector, 500), props, text };
73
+ }
74
+
75
+ const ASSET_PATH = /^(assets|shots)\/[\w.-]{1,80}$/;
76
+
77
+ function references(list) {
78
+ if (!Array.isArray(list)) return undefined;
79
+ const out = list
80
+ .filter((r) => r && ASSET_PATH.test(r.path))
81
+ .slice(0, 6)
82
+ .map((r) => ({ path: r.path, name: str(r.name, 120) || '' }));
83
+ return out.length ? out : undefined;
84
+ }
85
+
86
+ // Page state to restore before a screenshot: which controls to switch on/off.
87
+ function replay(list) {
88
+ if (!Array.isArray(list)) return undefined;
89
+ return list
90
+ .filter((r) => r && typeof r.selector === 'string' && typeof r.state === 'string')
91
+ .slice(0, 8)
92
+ .map((r) => ({ selector: r.selector.slice(0, 500), state: r.state.slice(0, 20) }));
93
+ }
94
+
95
+ /** Keep only known fields, with size limits, from client-supplied input. */
96
+ function clean(input, existing) {
97
+ const a = { ...(existing || {}) };
98
+ if (input.kind === 'note' || input.kind === 'stroke') a.kind = input.kind;
99
+ if (VERDICTS.has(input.verdict)) a.verdict = input.verdict;
100
+ if (typeof input.comment === 'string') a.comment = input.comment.slice(0, 5000);
101
+ if (Array.isArray(input.tags)) a.tags = input.tags.filter((t) => typeof t === 'string').slice(0, 12).map((t) => t.slice(0, 40));
102
+ if (typeof input.page === 'string') a.page = input.page.slice(0, 500);
103
+ if ('target' in input) a.target = target(input.target);
104
+ if (Array.isArray(input.covers)) a.covers = input.covers.map(target).filter(Boolean).slice(0, 6);
105
+ if ('stroke' in input) a.stroke = stroke(input.stroke);
106
+ // A drawn box: an area of the target, in px from its top-left (scaled with it later).
107
+ if ('region' in input) a.region = input.region && typeof input.region === 'object' ? { x: num(input.region.x), y: num(input.region.y), w: num(input.region.w), h: num(input.region.h) } : undefined;
108
+ if (input.anchor && typeof input.anchor === 'object') a.anchor = { dx: num(input.anchor.dx), dy: num(input.anchor.dy) };
109
+ if (input.viewport) a.viewport = { w: num(input.viewport.w), h: num(input.viewport.h) };
110
+ if (Array.isArray(input.uiState)) a.uiState = input.uiState.filter((t) => typeof t === 'string').slice(0, 8).map((t) => t.slice(0, 80));
111
+ if (typeof input.quote === 'string') a.quote = input.quote.slice(0, 300);
112
+ if (typeof input.url === 'string') a.url = input.url.slice(0, 500);
113
+ if ('edits' in input) a.edits = edits(input.edits);
114
+ // Which section of the page the mark is in (captured by the overlay).
115
+ if (input.section && typeof input.section === 'object' && typeof input.section.selector === 'string') {
116
+ a.section = { selector: input.section.selector.slice(0, 500), heading: str(input.section.heading, 120) || '' };
117
+ }
118
+ // A reply from the reviewer, appended to the conversation.
119
+ if (typeof input.reply === 'string' && input.reply.trim()) {
120
+ a.thread = [...(a.thread || []), { by: 'you', text: input.reply.trim().slice(0, 5000), at: new Date().toISOString() }];
121
+ }
122
+ if ('arrow' in input) a.arrow = input.arrow && target(input.arrow.from) && target(input.arrow.to) ? { from: target(input.arrow.from), to: target(input.arrow.to) } : undefined;
123
+ if ('references' in input) a.references = references(input.references);
124
+ if (Array.isArray(input.uiReplay)) a.uiReplay = replay(input.uiReplay);
125
+ if (input.request && typeof input.request === 'object') {
126
+ const n = Math.round(num(input.request.variants));
127
+ a.request = n >= 2 && n <= 5 ? { variants: n } : undefined;
128
+ } else if (input.request === null) a.request = undefined;
129
+ if ('chosen' in input) a.chosen = typeof input.chosen === 'string' && /^[A-E]$/.test(input.chosen) ? input.chosen : undefined;
130
+ if (input.status === 'open' || input.status === 'resolved') a.status = input.status;
131
+ // Turn-taking: a mark is a draft until sent; the reviewer accepts a fix, or redoes it.
132
+ if (typeof input.draft === 'boolean') input.draft ? (a.draft = true) : delete a.draft;
133
+ if (typeof input.accepted === 'boolean') input.accepted ? (a.accepted = true) : delete a.accepted;
134
+ if (typeof input.resolution === 'string') a.resolution = input.resolution.slice(0, 2000);
135
+ if ('afterTarget' in input) a.afterTarget = typeof input.afterTarget === 'string' && input.afterTarget ? input.afterTarget.slice(0, 500) : undefined;
136
+ return a;
137
+ }
138
+
139
+ // Forget a guard so a new baseline is taken (the look that's approved now).
140
+ function dropGuard(a) {
141
+ delete a.guard;
142
+ if (a.shotSigs) for (const k of Object.keys(a.shotSigs)) if (k.startsWith('guard-')) delete a.shotSigs[k];
143
+ }
144
+
145
+ export class Store {
146
+ constructor(dir) {
147
+ this.dir = dir;
148
+ this.file = join(dir, 'feedback.json');
149
+ this.briefFile = join(dir, 'FEEDBACK.md');
150
+ this.queue = Promise.resolve();
151
+ this.roundsQueue = Promise.resolve();
152
+ this.tasteQueue = Promise.resolve();
153
+ }
154
+
155
+ async all() {
156
+ try {
157
+ const data = JSON.parse(await readFile(this.file, 'utf8'));
158
+ return Array.isArray(data.annotations) ? data.annotations : [];
159
+ } catch (err) {
160
+ if (err.code === 'ENOENT') return [];
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ async list(page) {
166
+ const all = await this.all();
167
+ return page ? all.filter((a) => a.page === page) : all;
168
+ }
169
+
170
+ async get(id) {
171
+ return (await this.all()).find((a) => a.id === id);
172
+ }
173
+
174
+ // Serialize read-modify-write cycles so concurrent requests can't drop edits.
175
+ mutate(fn) {
176
+ const run = this.queue.then(async () => {
177
+ const list = await this.all();
178
+ const result = await fn(list);
179
+ await this.write(list);
180
+ return result;
181
+ });
182
+ this.queue = run.catch(() => {});
183
+ return run;
184
+ }
185
+
186
+ async write(list) {
187
+ await mkdir(this.dir, { recursive: true });
188
+ const tmp = `${this.file}.${process.pid}.tmp`;
189
+ await writeFile(tmp, JSON.stringify({ version: 1, annotations: list }, null, 2) + '\n');
190
+ await rename(tmp, this.file);
191
+ await writeFile(this.briefFile, formatMarkdown(list, { cli: true, rounds: (await this.rounds()).rounds, taste: await this.taste() }));
192
+ }
193
+
194
+ create(input) {
195
+ return this.mutate((list) => {
196
+ const now = new Date().toISOString();
197
+ const a = clean(input);
198
+ a.id = 'a_' + randomBytes(4).toString('hex');
199
+ // Undoing a delete restores the item under its old number, if that's still free.
200
+ const wanted = Number.isInteger(input.n) && input.n > 0 && !list.some((x) => x.n === input.n);
201
+ a.n = wanted ? input.n : list.reduce((m, x) => Math.max(m, x.n || 0), 0) + 1;
202
+ a.kind ||= 'note';
203
+ a.verdict ||= 'idea';
204
+ a.page ||= '/';
205
+ a.comment ||= '';
206
+ a.status = 'open';
207
+ a.createdAt = now;
208
+ a.updatedAt = now;
209
+ list.push(a);
210
+ return a;
211
+ });
212
+ }
213
+
214
+ async update(id, patch) {
215
+ const judgments = [];
216
+ const a = await this.mutate((list) => {
217
+ const i = list.findIndex((a) => a.id === id);
218
+ if (i < 0) return undefined;
219
+ const prev = list[i];
220
+ const a = clean(patch, prev);
221
+ // Reopening keeps what was already tried, so the agent doesn't repeat it.
222
+ if (patch.status === 'open' && prev.status === 'resolved') {
223
+ if (prev.resolution) a.history = [...(prev.history || []), prev.resolution];
224
+ delete a.resolution;
225
+ delete a.accepted;
226
+ }
227
+ // The reviewer's judgments: each one is remembered (the taste ledger), and
228
+ // counted for the round the item was sent in (first-pass accept rate).
229
+ const reply = typeof patch.reply === 'string' ? patch.reply.trim() : '';
230
+ if (patch.sendBack === true) {
231
+ // An approval that changed goes back to the agent as a keep: restore it.
232
+ judgments.push(tasteEntry('sent-back', prev, { diff: prev.guard && prev.guard.now && prev.guard.now.diff }));
233
+ // With the agent now: it stops asking for a check until the next round's guard run.
234
+ if (a.guard) a.guard = { ...a.guard, sentBack: true };
235
+ } else if (patch.accepted === true && !prev.accepted) {
236
+ judgments.push(tasteEntry('accepted', prev));
237
+ // An accepted fix is guarded as it looks now; a keep stays guarded as first approved.
238
+ if (prev.verdict !== 'good') dropGuard(a);
239
+ } else if (patch.status === 'open' && prev.status === 'resolved' && patch.draft === true) {
240
+ judgments.push(tasteEntry('redo', prev, { reply }));
241
+ }
242
+ if (patch.rebaseline === true && a.guard) {
243
+ judgments.push(tasteEntry('new-look', prev, { diff: a.guard.now && a.guard.now.diff }));
244
+ dropGuard(a);
245
+ }
246
+ // Gone from the page on purpose: stop guarding it.
247
+ if (patch.unguard === true) a.guard = { off: true, at: new Date().toISOString() };
248
+ // Live content (a feed, a date, a carousel): from now on, watch its look, not its words and images.
249
+ if (patch.dynamic === true) {
250
+ a.guardIgnore = ['text'];
251
+ dropGuard(a);
252
+ }
253
+ a.updatedAt = new Date().toISOString();
254
+ list[i] = a;
255
+ return a;
256
+ });
257
+ if (a && judgments.length) {
258
+ // Counted first, so TASTE.md (rendered when the judgment is remembered) has the new rate.
259
+ for (const j of judgments) if ((j.kind === 'accepted' || j.kind === 'redo') && a.round) await this.tally(a.round, j.kind);
260
+ await this.remember(judgments);
261
+ }
262
+ return a;
263
+ }
264
+
265
+ /** `target`: a selector for the "after" screenshot when the fix restructured the markup. */
266
+ async resolve(id, note, target) {
267
+ const a = await this.update(id, { status: 'resolved', resolution: note || '', afterTarget: target || null });
268
+ if (a && note) return this.agentSays(id, note, 'resolved');
269
+ return a;
270
+ }
271
+
272
+ /** The agent answers in an item's conversation. */
273
+ agentSays(id, text, tag) {
274
+ return this.mutate((list) => {
275
+ const a = list.find((x) => x.id === id || String(x.n) === String(id).replace(/^#/, ''));
276
+ if (!a) throw Object.assign(new Error(`no feedback item ${id}`), { status: 404 });
277
+ a.thread = [...(a.thread || []), { by: 'agent', text: String(text).slice(0, 5000), at: new Date().toISOString(), ...(tag ? { tag } : {}) }];
278
+ a.updatedAt = new Date().toISOString();
279
+ return a;
280
+ });
281
+ }
282
+
283
+ // ------------------------------------------------------------ rounds
284
+ // Review happens in turns. The reviewer's marks are drafts until they press
285
+ // Send, which hands them to the agent as round N. The agent fixes them, then
286
+ // closes the round with a short report. The reviewer accepts each fix or asks
287
+ // for a redo, which becomes a draft for the next round.
288
+
289
+ roundsFile() {
290
+ return join(this.dir, 'rounds.json');
291
+ }
292
+
293
+ async rounds() {
294
+ try {
295
+ return JSON.parse(await readFile(this.roundsFile(), 'utf8'));
296
+ } catch {
297
+ return { rounds: [] };
298
+ }
299
+ }
300
+
301
+ async writeRounds(data) {
302
+ await mkdir(this.dir, { recursive: true });
303
+ await writeFile(this.roundsFile(), JSON.stringify(data, null, 2) + '\n');
304
+ }
305
+
306
+ /** Send every draft to the agent as the next round. Returns the round, or null if nothing to send. */
307
+ async send() {
308
+ const data = await this.rounds();
309
+ const n = (data.rounds.at(-1)?.n || 0) + 1;
310
+ const count = await this.mutate((list) => {
311
+ let c = 0;
312
+ for (const a of list) {
313
+ if (a.draft) {
314
+ delete a.draft;
315
+ // Every round it was sent in, so a round's report still shows what happened to it then.
316
+ a.rounds = [...(a.rounds || (a.round ? [a.round] : [])), n];
317
+ a.round = n;
318
+ c++;
319
+ }
320
+ }
321
+ return c;
322
+ });
323
+ if (!count) return null;
324
+ const round = { n, sentAt: new Date().toISOString(), count };
325
+ await this.mutateRounds((d) => d.rounds.push(round));
326
+ // Green marks are the clearest statement of taste there is: remember them.
327
+ const kept = (await this.all()).filter((a) => a.round === n && a.verdict === 'good' && !a.history && !(a.guard && a.guard.sentBack));
328
+ if (kept.length) await this.remember(kept.map((a) => tasteEntry('keep', a)));
329
+ await this.mutate(() => {}); // refresh the brief with the round header
330
+ return round;
331
+ }
332
+
333
+ /** Serialized read-modify-write of rounds.json. */
334
+ mutateRounds(fn) {
335
+ const run = this.roundsQueue.then(async () => {
336
+ const data = await this.rounds();
337
+ const result = await fn(data);
338
+ await this.writeRounds(data);
339
+ return result;
340
+ });
341
+ this.roundsQueue = run.catch(() => {});
342
+ return run;
343
+ }
344
+
345
+ /** The round the agent owes an answer to: sent and not yet closed with a report. */
346
+ async pendingRound() {
347
+ const last = (await this.rounds()).rounds.at(-1);
348
+ return last && !last.returnedAt ? last : null;
349
+ }
350
+
351
+ /**
352
+ * Block until the reviewer has sent a round the agent hasn't closed yet, and return it;
353
+ * right away if one is already waiting. Resolves null when `timeoutMs` passes (0 = never)
354
+ * or `signal` aborts. Polls rounds.json because the review server that records the Send
355
+ * usually runs in another process.
356
+ */
357
+ async waitForRound({ timeoutMs = 0, signal, pollMs = 1000, onTick } = {}) {
358
+ const until = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
359
+ for (;;) {
360
+ const round = await this.pendingRound();
361
+ if (round) return round;
362
+ if (signal?.aborted || Date.now() >= until) return null;
363
+ await new Promise((ok) => {
364
+ const t = setTimeout(done, Math.min(pollMs, Math.max(until - Date.now(), 0)));
365
+ function done() {
366
+ clearTimeout(t);
367
+ signal?.removeEventListener('abort', done);
368
+ ok();
369
+ }
370
+ signal?.addEventListener('abort', done, { once: true });
371
+ });
372
+ onTick?.();
373
+ }
374
+ }
375
+
376
+ /** Count a judgment for round `n`: accepted first time, or sent back for a redo. */
377
+ tally(n, kind) {
378
+ return this.mutateRounds((data) => {
379
+ const r = data.rounds.find((x) => x.n === n);
380
+ const key = kind === 'accepted' ? 'accepted' : 'redone';
381
+ if (r) r[key] = (r[key] || 0) + 1;
382
+ });
383
+ }
384
+
385
+ // ------------------------------------------------------------ taste
386
+ // Every judgment the reviewer makes (keep, accept, redo, send back, keep the new
387
+ // look) is appended to taste.json and rendered as TASTE.md. It is never pruned by
388
+ // `clear`: it's what the agent should know about this reviewer, round after round.
389
+
390
+ async taste() {
391
+ try {
392
+ const data = JSON.parse(await readFile(join(this.dir, 'taste.json'), 'utf8'));
393
+ return Array.isArray(data.entries) ? data.entries : [];
394
+ } catch {
395
+ return [];
396
+ }
397
+ }
398
+
399
+ remember(entries) {
400
+ const run = this.tasteQueue.then(async () => {
401
+ const all = [...(await this.taste()), ...entries.filter(Boolean)].slice(-500);
402
+ await mkdir(this.dir, { recursive: true });
403
+ await writeFile(join(this.dir, 'taste.json'), JSON.stringify({ version: 1, entries: all }, null, 2) + '\n');
404
+ await writeFile(join(this.dir, 'TASTE.md'), renderTaste(all, (await this.rounds()).rounds));
405
+ });
406
+ this.tasteQueue = run.catch(() => {});
407
+ return run;
408
+ }
409
+
410
+ /** The agent closes the current round: what it did, how it reads the page, and at most a few questions. */
411
+ async finishRound({ summary = '', reading = '', questions = [] } = {}) {
412
+ const round = await this.mutateRounds((data) => {
413
+ const round = data.rounds.at(-1);
414
+ if (!round) throw Object.assign(new Error('no round has been sent yet'), { status: 400 });
415
+ return Object.assign(round, {
416
+ returnedAt: new Date().toISOString(),
417
+ summary: String(summary).slice(0, 2000),
418
+ reading: String(reading).slice(0, 2000),
419
+ questions: (Array.isArray(questions) ? questions : []).slice(0, 3).map((q) => ({ text: String(q).slice(0, 500) })),
420
+ });
421
+ });
422
+ await this.mutate(() => {});
423
+ return round;
424
+ }
425
+
426
+ /** The reviewer answers the round report: corrects the agent's reading, answers its questions. */
427
+ async answerRound(n, { readingCorrection, answers } = {}) {
428
+ const round = await this.mutateRounds((data) => {
429
+ const round = data.rounds.find((r) => r.n === Number(n));
430
+ if (!round) throw Object.assign(new Error(`no round ${n}`), { status: 404 });
431
+ if (typeof readingCorrection === 'string') round.readingCorrection = readingCorrection.slice(0, 2000);
432
+ if (Array.isArray(answers)) (round.questions || []).forEach((q, i) => typeof answers[i] === 'string' && (q.answer = answers[i].slice(0, 1000)));
433
+ return round;
434
+ });
435
+ await this.mutate(() => {});
436
+ // What the page is for, in the reviewer's words, belongs with their taste.
437
+ if (round.readingCorrection) await this.remember([{ kind: 'reading', at: new Date().toISOString(), round: round.n, reply: round.readingCorrection }]);
438
+ return round;
439
+ }
440
+
441
+ remove(id) {
442
+ return this.mutate((list) => {
443
+ const i = list.findIndex((a) => a.id === id);
444
+ if (i < 0) return false;
445
+ list.splice(i, 1);
446
+ return true;
447
+ });
448
+ }
449
+
450
+ /** Save an uploaded image (data: URL) under assets/, named by content hash. */
451
+ async saveAsset(dataUrl) {
452
+ const m = /^data:image\/(png|jpeg|webp|gif);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl || '');
453
+ if (!m) throw Object.assign(new Error('expected a PNG, JPEG, WebP or GIF data URL'), { status: 400 });
454
+ const buf = Buffer.from(m[2], 'base64');
455
+ if (buf.length > 8 * 1024 * 1024) throw Object.assign(new Error('image larger than 8 MB'), { status: 413 });
456
+ const name = createHash('sha1').update(buf).digest('hex').slice(0, 16) + '.' + (m[1] === 'jpeg' ? 'jpg' : m[1]);
457
+ await mkdir(join(this.dir, 'assets'), { recursive: true });
458
+ await writeFile(join(this.dir, 'assets', name), buf);
459
+ return `assets/${name}`;
460
+ }
461
+
462
+ /**
463
+ * Record a screenshot (written by the shot service); `sig` says what it shows,
464
+ * `box` where the item's element sits in it (so the overlay can line it up with the page).
465
+ */
466
+ setShot(id, key, path, sig, box) {
467
+ return this.mutate((list) => {
468
+ const a = list.find((x) => x.id === id);
469
+ if (!a) return undefined;
470
+ a.shots = { ...(a.shots || {}), [key]: path };
471
+ a.shotSigs = { ...(a.shotSigs || {}), [key]: sig };
472
+ if (box) a.shotBoxes = { ...(a.shotBoxes || {}), [key]: box };
473
+ return a;
474
+ });
475
+ }
476
+
477
+ /** An approval's baseline: its screenshot and fingerprint as approved. */
478
+ /**
479
+ * An approval's baseline: its screenshot and fingerprint as approved, at the width it
480
+ * was approved at, or (`width` = MOBILE) on a phone. Not on the page at phone width
481
+ * (a desktop-only element): nothing to guard there, and that is remembered.
482
+ */
483
+ setGuardBase(id, path, fp, sig, box, width) {
484
+ return this.mutate((list) => {
485
+ const a = list.find((x) => x.id === id);
486
+ if (!a || (!fp && !width)) return a;
487
+ const base = fp ? { shot: path, fp, box, at: new Date().toISOString() } : null;
488
+ a.guard = { ...(a.guard || {}) };
489
+ if (width) a.guard.mobile = base ? { base } : { skip: true };
490
+ else a.guard.base = base;
491
+ a.shotSigs = { ...(a.shotSigs || {}), [width ? `guard-base@${width}` : 'guard-base']: sig };
492
+ return a;
493
+ });
494
+ }
495
+
496
+ /** The approval as it is now, compared with its baseline. `fp` null: it's gone from the page. */
497
+ setGuardCheck(id, path, fp, sig, width) {
498
+ return this.mutate((list) => {
499
+ const a = list.find((x) => x.id === id);
500
+ const slot = a && a.guard && (width ? a.guard.mobile : a.guard);
501
+ if (!slot || !slot.base) return a;
502
+ const diff = diffFingerprints(slot.base.fp, fp, { ignore: a.guardIgnore });
503
+ slot.check = { shot: path || undefined, at: new Date().toISOString(), status: !fp ? 'missing' : diff.length ? 'changed' : 'held', diff };
504
+ // One verdict for the approval, across the widths it's guarded at.
505
+ a.guard.now = combineChecks(a.guard);
506
+ delete a.guard.sentBack;
507
+ a.shotSigs = { ...(a.shotSigs || {}), [width ? `guard-now@${width}` : 'guard-now']: sig };
508
+ return a;
509
+ });
510
+ }
511
+
512
+ /**
513
+ * The agent's options for an item: CSS (`&` stands for the item's element) and/or
514
+ * replacement inner HTML, shown to the reviewer as switchable previews.
515
+ */
516
+ proposeVariants(id, variants) {
517
+ return this.mutate((list) => {
518
+ const a = list.find((x) => x.id === id || String(x.n) === String(id).replace(/^#/, ''));
519
+ if (!a) throw Object.assign(new Error(`no feedback item ${id}`), { status: 404 });
520
+ if (!Array.isArray(variants) || !variants.length) throw Object.assign(new Error('variants must be a non-empty array'), { status: 400 });
521
+ a.variants = variants.slice(0, 5).map((v, i) => ({
522
+ key: 'ABCDE'[i],
523
+ label: str(v && v.label, 80) || `Option ${'ABCDE'[i]}`,
524
+ css: str(v && v.css, 20000) || '',
525
+ html: str(v && v.html, 50000) || '',
526
+ note: str(v && v.note, 500) || '',
527
+ }));
528
+ a.chosen = undefined;
529
+ a.status = 'open';
530
+ a.updatedAt = new Date().toISOString();
531
+ return a;
532
+ });
533
+ }
534
+
535
+ /** Remove annotations matching `pred`; returns how many were removed. */
536
+ prune(pred) {
537
+ return this.mutate((list) => {
538
+ const keep = list.filter((a) => !pred(a));
539
+ const removed = list.length - keep.length;
540
+ list.splice(0, list.length, ...keep);
541
+ return removed;
542
+ });
543
+ }
544
+ }
package/src/sync.js ADDED
@@ -0,0 +1,126 @@
1
+ // Sync a project to your Redline account (opt-in: `redline proxy … --cloud`), so the
2
+ // hosted MCP server can hand your rounds to an agent running anywhere.
3
+ //
4
+ // This machine stays the source of truth. Up: whenever the notes change, one snapshot
5
+ // of the project (items, rounds, taste and the agent's brief) plus any new screenshots.
6
+ // Down: what the agent asked the hosted MCP to do (resolve, reply, close the round…),
7
+ // applied here with the very same tools the local MCP server uses, then synced back up.
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { extname, join } from 'node:path';
10
+ import { formatMarkdown } from './format.js';
11
+ import { createMcpHandler } from './mcp.js';
12
+
13
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
14
+
15
+ export function startSync({ store, storeDir, server, key, project, log = () => {} }) {
16
+ const base = `${server}/api/v1/sync/${encodeURIComponent(project)}`;
17
+ const headers = { 'x-api-key': key };
18
+ const tools = createMcpHandler({ storeDir });
19
+ let stopped = false;
20
+ let lastSig = '';
21
+ let uploaded = null; // screenshot paths the account already has
22
+ let pushing = null;
23
+ let dirty = false;
24
+
25
+ // What changed locally, cheaply: the store's files' sizes and times.
26
+ async function signature() {
27
+ const parts = [];
28
+ for (const f of ['feedback.json', 'rounds.json', 'taste.json']) {
29
+ const s = await stat(join(storeDir, f)).catch(() => null);
30
+ parts.push(s ? `${s.size}:${s.mtimeMs}` : '-');
31
+ }
32
+ return parts.join('|');
33
+ }
34
+
35
+ async function push() {
36
+ const [items, rounds, taste] = await Promise.all([store.all(), store.rounds(), store.taste()]);
37
+ const open = items.filter((a) => a.status !== 'resolved');
38
+ const brief = formatMarkdown(open, { rounds: rounds.rounds, taste }).replace(/\n## /, '\n\n## ');
39
+ if (!uploaded) {
40
+ const res = await fetch(base, { headers });
41
+ if (!res.ok) throw new Error(`sync refused (${res.status}): ${(await res.json().catch(() => ({}))).message || ''}`);
42
+ uploaded = new Set((await res.json()).shots || []);
43
+ }
44
+ // Screenshots and reference images first, so the brief never points at a missing one.
45
+ const paths = new Set();
46
+ for (const a of items) {
47
+ for (const p of Object.values(a.shots || {})) paths.add(p);
48
+ for (const r of a.references || []) if (r.path) paths.add(r.path);
49
+ }
50
+ for (const p of paths) {
51
+ if (uploaded.has(p) || !MIME[extname(p).toLowerCase()]) continue;
52
+ const bytes = await readFile(join(storeDir, p)).catch(() => null);
53
+ if (!bytes || bytes.length > 4 * 1024 * 1024) continue;
54
+ const res = await fetch(`${base}/shot?path=${encodeURIComponent(p)}`, { method: 'PUT', headers: { ...headers, 'content-type': MIME[extname(p).toLowerCase()] }, body: bytes });
55
+ if (res.ok) uploaded.add(p);
56
+ }
57
+ const res = await fetch(base, {
58
+ method: 'POST',
59
+ headers: { ...headers, 'content-type': 'application/json' },
60
+ body: JSON.stringify({ items, rounds, taste, brief }),
61
+ });
62
+ if (!res.ok) throw new Error(`sync refused (${res.status})`);
63
+ }
64
+
65
+ // One push at a time; a change during a push means one more right after.
66
+ async function pushSoon() {
67
+ if (pushing) return void (dirty = true);
68
+ pushing = (async () => {
69
+ do {
70
+ dirty = false;
71
+ try {
72
+ await push();
73
+ } catch (err) {
74
+ log(`cloud sync: ${err.message}`);
75
+ }
76
+ } while (dirty && !stopped);
77
+ })();
78
+ await pushing;
79
+ pushing = null;
80
+ }
81
+
82
+ async function watch() {
83
+ while (!stopped) {
84
+ const sig = await signature();
85
+ if (sig !== lastSig) {
86
+ lastSig = sig;
87
+ await pushSoon();
88
+ }
89
+ await new Promise((ok) => setTimeout(ok, 700));
90
+ }
91
+ }
92
+
93
+ // The agent's requests from the hosted MCP, applied here.
94
+ async function commands() {
95
+ let after = 0;
96
+ while (!stopped) {
97
+ try {
98
+ const res = await fetch(`${base}/commands?after=${after}`, { headers, signal: AbortSignal.timeout(35_000) });
99
+ if (!res.ok) throw new Error(`commands refused (${res.status})`);
100
+ for (const c of (await res.json()).commands || []) {
101
+ after = Math.max(after, c.id);
102
+ const out = await tools.handle({ jsonrpc: '2.0', id: c.id, method: 'tools/call', params: { name: c.tool, arguments: c.arguments || {} } });
103
+ const r = out && out.result;
104
+ const text = (r && r.content && r.content.map((x) => x.text).filter(Boolean).join('\n')) || '';
105
+ const ok = !!r && !r.isError;
106
+ await pushSoon(); // the change, up before the agent hears it's done
107
+ await fetch(`${base}/commands`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ id: c.id, ok, result: { text }, error: ok ? undefined : text }) });
108
+ log(`cloud: the agent's ${c.tool} ${ok ? 'applied' : 'failed'}`);
109
+ }
110
+ } catch (err) {
111
+ if (stopped) break;
112
+ log(`cloud sync: ${err.message}; retrying`);
113
+ await new Promise((ok) => setTimeout(ok, 3000));
114
+ }
115
+ }
116
+ }
117
+
118
+ watch();
119
+ commands();
120
+ return {
121
+ stop() {
122
+ stopped = true;
123
+ },
124
+ flush: () => pushSoon(),
125
+ };
126
+ }