dsh-inner-browser-use 0.27.0 → 0.34.28
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/client.js +103 -8
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +376 -0
- package/docs/ROADMAP.md +88 -0
- package/index.js +320 -29
- package/package.json +5 -2
- package/read.js +111 -10
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +635 -83
package/read.js
CHANGED
|
@@ -257,13 +257,20 @@ function snapshot(mode, budget, max) {
|
|
|
257
257
|
const missing = []
|
|
258
258
|
for (const el of document.querySelectorAll('img,canvas')) {
|
|
259
259
|
const b = box(el)
|
|
260
|
-
|
|
260
|
+
/* A picture that failed to arrive shrinks to an error icon, so its own
|
|
261
|
+
declared size is what the page meant to show. */
|
|
262
|
+
const askW = Number(el.getAttribute?.('width')) || 0
|
|
263
|
+
const askH = Number(el.getAttribute?.('height')) || 0
|
|
264
|
+
const wide = Math.max(b.width, askW)
|
|
265
|
+
const high = Math.max(b.height, askH)
|
|
266
|
+
if (wide < 64 || high < 64 || wide * high < 8000) continue
|
|
261
267
|
const s = getComputedStyle(el)
|
|
262
268
|
if (s.visibility === 'hidden' || s.display === 'none') continue
|
|
263
269
|
/* A picture slot that never got its picture is the commonest way a
|
|
264
270
|
generated image goes missing, and it is silent by default. */
|
|
265
271
|
if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
|
|
266
|
-
missing.push({ width: Math.round(
|
|
272
|
+
missing.push({ width: Math.round(wide), height: Math.round(high) })
|
|
273
|
+
missing[missing.length - 1].name = String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
267
274
|
continue
|
|
268
275
|
}
|
|
269
276
|
const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
|
|
@@ -300,6 +307,38 @@ function snapshot(mode, budget, max) {
|
|
|
300
307
|
}
|
|
301
308
|
return said
|
|
302
309
|
})()
|
|
310
|
+
/* What no amount of text will tell the reader: the screen shows something
|
|
311
|
+
that has no words in the tree. Counted here so every read can say it. */
|
|
312
|
+
const blind = (() => {
|
|
313
|
+
const out = { pictures: 0, canvases: 0, videos: 0, frames: 0 }
|
|
314
|
+
const speaks = (el) => {
|
|
315
|
+
const said = String(el.getAttribute?.('alt') ?? '').trim() || String(el.getAttribute?.('aria-label') ?? '').trim() || String(el.getAttribute?.('title') ?? '').trim()
|
|
316
|
+
if (said.length > 1) return true
|
|
317
|
+
let near = el.closest?.('figure, picture')
|
|
318
|
+
if (near) {
|
|
319
|
+
const words = String(near.querySelector?.('figcaption')?.textContent ?? '').trim()
|
|
320
|
+
if (words.length > 1) return true
|
|
321
|
+
}
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
const shows = (el) => {
|
|
325
|
+
const b = box(el)
|
|
326
|
+
if (b.width < 48 || b.height < 48) return false
|
|
327
|
+
if (!inView(b)) return false
|
|
328
|
+
const st = getComputedStyle(el)
|
|
329
|
+
return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05
|
|
330
|
+
}
|
|
331
|
+
for (const el of document.querySelectorAll('img')) {
|
|
332
|
+
if (!shows(el) || speaks(el)) continue
|
|
333
|
+
out.pictures += 1
|
|
334
|
+
}
|
|
335
|
+
for (const el of document.querySelectorAll('canvas')) if (shows(el)) out.canvases += 1
|
|
336
|
+
for (const el of document.querySelectorAll('video')) if (shows(el)) out.videos += 1
|
|
337
|
+
/* A tracking pixel of a frame is not something the reader is missing: only
|
|
338
|
+
frames that occupy the screen are worth a sentence. */
|
|
339
|
+
for (const el of document.querySelectorAll('iframe')) if (shows(el)) out.frames += 1
|
|
340
|
+
return out
|
|
341
|
+
})()
|
|
303
342
|
return JSON.stringify({
|
|
304
343
|
url: location.href, // ui-copy-ignore
|
|
305
344
|
title: document.title, // ui-copy-ignore
|
|
@@ -313,6 +352,7 @@ function snapshot(mode, budget, max) {
|
|
|
313
352
|
})(),
|
|
314
353
|
controls: picked,
|
|
315
354
|
picture,
|
|
355
|
+
blind,
|
|
316
356
|
})
|
|
317
357
|
})()`
|
|
318
358
|
}
|
|
@@ -378,8 +418,8 @@ export function pictures(ref, count) {
|
|
|
378
418
|
if (el === null || el === undefined || seen.has(el)) return
|
|
379
419
|
seen.add(el)
|
|
380
420
|
const b = el.getBoundingClientRect()
|
|
381
|
-
const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
|
|
382
|
-
const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
|
|
421
|
+
const w = kind === 'canvas' ? el.width : kind === 'video' ? el.videoWidth || b.width : el.naturalWidth || b.width
|
|
422
|
+
const h = kind === 'canvas' ? el.height : kind === 'video' ? el.videoHeight || b.height : el.naturalHeight || b.height
|
|
383
423
|
if (w < 48 || h < 48) return
|
|
384
424
|
if (b.width < 8 || b.height < 8) return
|
|
385
425
|
const s = getComputedStyle(el)
|
|
@@ -392,9 +432,13 @@ export function pictures(ref, count) {
|
|
|
392
432
|
const tag = node.tagName
|
|
393
433
|
if (tag === 'IMG') add(node, 'img')
|
|
394
434
|
else if (tag === 'CANVAS') add(node, 'canvas')
|
|
435
|
+
/* A paused video is the frame the person is looking at; it never arrives as
|
|
436
|
+
an image file, so it has to be drawn out of the element. */
|
|
437
|
+
else if (tag === 'VIDEO' && (node.videoWidth || 0) > 0) add(node, 'video')
|
|
395
438
|
}
|
|
396
439
|
if (where && where.tagName === 'IMG') add(where, 'img')
|
|
397
440
|
if (where && where.tagName === 'CANVAS') add(where, 'canvas')
|
|
441
|
+
if (where && where.tagName === 'VIDEO' && (where.videoWidth || 0) > 0) add(where, 'video')
|
|
398
442
|
picked.sort((a, b) => b.area - a.area)
|
|
399
443
|
/* A picture nobody can look at still has to be describable: how much of it is
|
|
400
444
|
one colour tells a blank chart from a chart. Sampled at 32x32, which is
|
|
@@ -428,8 +472,15 @@ export function pictures(ref, count) {
|
|
|
428
472
|
let data = ''
|
|
429
473
|
let type = 'image/png'
|
|
430
474
|
try {
|
|
431
|
-
if (item.kind === 'canvas') {
|
|
432
|
-
|
|
475
|
+
if (item.kind === 'canvas' || item.kind === 'video') {
|
|
476
|
+
/* Same path for both: a video frame is copied onto a board and read as
|
|
477
|
+
a picture. Nothing here needs the video to be playing. */
|
|
478
|
+
const board = document.createElement('canvas')
|
|
479
|
+
board.width = Math.max(1, Math.round(item.w))
|
|
480
|
+
board.height = Math.max(1, Math.round(item.h))
|
|
481
|
+
const pen = board.getContext('2d')
|
|
482
|
+
pen.drawImage(item.el, 0, 0, board.width, board.height)
|
|
483
|
+
data = board.toDataURL('image/png')
|
|
433
484
|
} else {
|
|
434
485
|
const url = item.el.currentSrc || item.el.src || ''
|
|
435
486
|
const answer = await fetch(url, { credentials: 'include' })
|
|
@@ -612,15 +663,15 @@ function routeOf(page, frame, slot) {
|
|
|
612
663
|
* @param {any} page - sidecar page.
|
|
613
664
|
* @param {string} mode - read mode.
|
|
614
665
|
* @param {number} budget - characters of text to keep.
|
|
615
|
-
* @returns {Promise<{text: string, controls: any[]}>} what the frames add.
|
|
666
|
+
* @returns {Promise<{text: string, controls: any[], frames: number}>} what the frames add.
|
|
616
667
|
*/
|
|
617
668
|
async function readFrames(page, mode, budget) {
|
|
618
|
-
if (typeof page.frameList !== 'function') return { text: '', controls: [] }
|
|
669
|
+
if (typeof page.frameList !== 'function') return { text: '', controls: [], frames: 0 }
|
|
619
670
|
let frames = []
|
|
620
671
|
try {
|
|
621
672
|
frames = await page.frameList()
|
|
622
673
|
} catch (error) {
|
|
623
|
-
return { text: '', controls: [] }
|
|
674
|
+
return { text: '', controls: [], frames: 0 }
|
|
624
675
|
}
|
|
625
676
|
const children = frames.slice(1)
|
|
626
677
|
const controls = []
|
|
@@ -642,7 +693,7 @@ async function readFrames(page, mode, budget) {
|
|
|
642
693
|
continue
|
|
643
694
|
}
|
|
644
695
|
}
|
|
645
|
-
return { text, controls }
|
|
696
|
+
return { text, controls, frames: children.length }
|
|
646
697
|
}
|
|
647
698
|
|
|
648
699
|
/** Decode a ref into the frame it lives in and the number that frame knows. */
|
|
@@ -699,15 +750,51 @@ export async function readPage(page, options = {}) {
|
|
|
699
750
|
scroll: read?.scroll,
|
|
700
751
|
focused: read?.focused ?? null,
|
|
701
752
|
controls: Array.isArray(read?.controls) ? read.controls : [],
|
|
753
|
+
blind: blindSpots(read?.blind, framed.frames),
|
|
702
754
|
}
|
|
703
755
|
}
|
|
704
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Merge what the page counted with what the frame reader managed to see.
|
|
759
|
+
* @param {any} said - counts from the page.
|
|
760
|
+
* @param {number} [read] - frames that answered.
|
|
761
|
+
* @returns {{pictures: number, canvases: number, videos: number, frames: number}} what text cannot show.
|
|
762
|
+
*/
|
|
763
|
+
export function blindSpots(said, read) {
|
|
764
|
+
const n = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : 0)
|
|
765
|
+
/* The page knows which frames take up the screen; the frame reader knows how
|
|
766
|
+
many exist, tracking pixels included. The screen is what the reader is
|
|
767
|
+
missing, so its count wins whenever the page could count at all. */
|
|
768
|
+
const frames = said !== undefined && said !== null && said.frames !== undefined ? n(said.frames) : n(read)
|
|
769
|
+
return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames }
|
|
770
|
+
}
|
|
771
|
+
|
|
705
772
|
/**
|
|
706
773
|
* Resolve a ref into a point, at the moment of the action.
|
|
707
774
|
* @param {any} page - sidecar page.
|
|
708
775
|
* @param {number} ref - ref from a read.
|
|
709
776
|
* @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
|
|
710
777
|
*/
|
|
778
|
+
/**
|
|
779
|
+
* Whether a number still means what it meant when it was read.
|
|
780
|
+
*
|
|
781
|
+
* A page can keep the same element and change its words: "Send" becomes
|
|
782
|
+
* "Stop", a row is reused for another order. Pressing the old number then
|
|
783
|
+
* does something the person never asked for.
|
|
784
|
+
* @param {any} known - the control the read handed out.
|
|
785
|
+
* @param {any} spot - what the number resolves to now.
|
|
786
|
+
* @returns {string} what changed, or '' when it is the same control.
|
|
787
|
+
*/
|
|
788
|
+
export function differs(known, spot) {
|
|
789
|
+
if (known === undefined || known === null || spot === undefined || spot === null) return ''
|
|
790
|
+
const before = String(known.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
791
|
+
const now = String(spot.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
792
|
+
if (before === '' || now === '' || before === now) return ''
|
|
793
|
+
const head = Math.min(12, before.length, now.length)
|
|
794
|
+
if (head >= 4 && (before.slice(0, head) === now.slice(0, head) || before.includes(now.slice(0, head)) || now.includes(before.slice(0, head)))) return ''
|
|
795
|
+
return `that number now points at "${now}" instead of "${before}" — read the page again and use the new number.`
|
|
796
|
+
}
|
|
797
|
+
|
|
711
798
|
export async function locate(page, ref) {
|
|
712
799
|
const split = splitFrameRef(ref)
|
|
713
800
|
let route = null
|
|
@@ -747,6 +834,10 @@ export function renderPage(read, options = {}) {
|
|
|
747
834
|
const mode = String(options.mode ?? 'text')
|
|
748
835
|
const maxChars = Number(options.maxChars ?? 6000)
|
|
749
836
|
const lines = [read.url]
|
|
837
|
+
/* A browser error page is not a site with nothing on it: it is a site that
|
|
838
|
+
never arrived. Saying so is the only way the reader stops looking for the
|
|
839
|
+
content that would have been there. */
|
|
840
|
+
if (String(read.url).startsWith('chrome-error://')) lines.push('the page did not load — sidecar_nav to open the address again, or sidecar_open if the browser is gone')
|
|
750
841
|
if (read.title !== '') lines.push(`# ${read.title}`)
|
|
751
842
|
const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
|
|
752
843
|
const hidden = read.controls.length - shown.length
|
|
@@ -766,6 +857,16 @@ export function renderPage(read, options = {}) {
|
|
|
766
857
|
lines.push(body)
|
|
767
858
|
if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
|
|
768
859
|
}
|
|
860
|
+
if (read.blind !== undefined && read.blind !== null) {
|
|
861
|
+
const bits = []
|
|
862
|
+
if (read.blind.pictures > 0) bits.push(`${read.blind.pictures} picture(s) with no words`)
|
|
863
|
+
if (read.blind.canvases > 0) bits.push(`${read.blind.canvases} drawing surface(s)`)
|
|
864
|
+
if (read.blind.videos > 0) bits.push(`${read.blind.videos} video(s)`)
|
|
865
|
+
if (read.blind.frames > 0) bits.push(`${read.blind.frames} embedded frame(s)`)
|
|
866
|
+
/* Only when something is actually out of reach: a line saying "nothing is
|
|
867
|
+
hidden" on every read is a line the reader learns to skip. */
|
|
868
|
+
if (bits.length > 0) lines.push(`text cannot show: ${bits.join(', ')} — sidecar_grab takes the picture, sidecar_shot shows the screen`)
|
|
869
|
+
}
|
|
769
870
|
if (read.picture && Array.isArray(read.picture.missing)) {
|
|
770
871
|
/* Something was meant to be here and is not: say that instead of nothing. */
|
|
771
872
|
for (const gone of read.picture.missing.slice(0, 2)) {
|
package/review.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A review round: hand a repository address to the chat in the column, take its
|
|
3
|
+
* review back, and remember which commit the review was about. The log is what
|
|
4
|
+
* makes the next round checkable — advice given about a commit that has since
|
|
5
|
+
* moved on is stale, and saying so costs one line instead of a reread.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Where rounds are kept when the caller does not say. */
|
|
12
|
+
export const ROUND_DIR = join(process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh'), 'inner-browser-use', 'rounds')
|
|
13
|
+
|
|
14
|
+
/** How long an answer may be, so a chatty model cannot flood the log. */
|
|
15
|
+
const ANSWER_CAP = 6000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} text - one line of configuration.
|
|
19
|
+
* @returns {string} the value, or an empty string.
|
|
20
|
+
*/
|
|
21
|
+
function clean(text) {
|
|
22
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The message to send: enough for a reviewer that has never seen this project,
|
|
27
|
+
* and no more. The address carries the code, so the message does not paste any.
|
|
28
|
+
* @param {{repo?: string, commit?: string, focus?: string}} [what] - what to review.
|
|
29
|
+
* @returns {string} the message.
|
|
30
|
+
*/
|
|
31
|
+
export function brief(what = {}) {
|
|
32
|
+
const repo = clean(what.repo)
|
|
33
|
+
/* What the reviewer said last time and what the code did about it: a point
|
|
34
|
+
already answered is not raised again, so a round does not re-litigate. */
|
|
35
|
+
const handled = Array.isArray(what.already) ? what.already.filter((mark) => clean(mark?.status) !== 'open') : []
|
|
36
|
+
const commit = clean(what.commit)
|
|
37
|
+
const focus = clean(what.focus)
|
|
38
|
+
const lines = ['Please review the latest code and tell me what to change.']
|
|
39
|
+
if (repo !== '') lines.push(`Repository: ${repo}`)
|
|
40
|
+
if (commit !== '') lines.push(`Look at commit ${commit} (the newest one) and the files it touches.`)
|
|
41
|
+
else lines.push('Look at the newest commit and the files it touches.')
|
|
42
|
+
if (focus !== '') lines.push(`Focus on: ${focus}`)
|
|
43
|
+
/* The reviewer cannot open the repository, so the change travels with the
|
|
44
|
+
request. Capped: a whole diff would bury the question it is attached to. */
|
|
45
|
+
const diff = String(what.diff ?? '').trim()
|
|
46
|
+
if (diff !== '') {
|
|
47
|
+
const cap = 5000
|
|
48
|
+
lines.push('The change under review, inline (a diff):')
|
|
49
|
+
lines.push(diff.length <= cap ? diff : `${diff.slice(0, cap)}\n… (trimmed)`)
|
|
50
|
+
}
|
|
51
|
+
if (handled.length > 0) {
|
|
52
|
+
lines.push('Already handled from your previous review, do not raise these again:')
|
|
53
|
+
for (const mark of handled.slice(0, 12)) {
|
|
54
|
+
const note = clean(mark.evidence) === '' ? '' : ` (${clean(mark.evidence).slice(0, 120)})`
|
|
55
|
+
lines.push(`- ${clean(mark.text).slice(0, 200)} — ${clean(mark.status)}${note}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
lines.push('Answer with a short list: what is wrong, and the smallest change that fixes it. Name the file for each point.')
|
|
59
|
+
return lines.join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The suggestions in an answer, one line each: reviewers are asked for a list,
|
|
64
|
+
* and the list is what the next round acts on.
|
|
65
|
+
* @param {string} answer - what the reviewer said.
|
|
66
|
+
* @returns {string[]} the points, in the order given.
|
|
67
|
+
*/
|
|
68
|
+
export function points(answer) {
|
|
69
|
+
/* A page read flattens an answer into one line, so a list arrives as
|
|
70
|
+
"intro - first point - second point". Split on the markers a reviewer uses
|
|
71
|
+
for items, keep only pieces that carry a marker, and drop the echo of the
|
|
72
|
+
question when the page repeats it above the answer. */
|
|
73
|
+
const out = []
|
|
74
|
+
const seen = new Set()
|
|
75
|
+
for (const raw of String(answer ?? '').split(/\r?\n/)) {
|
|
76
|
+
const parts = raw.split(/\s+[-\u2022\u2013\u2014]\s+|\s+(?=\d+[.)]\s)/).map((part) => part.trim()).filter((part) => part !== '')
|
|
77
|
+
if (parts.length === 1 && !/^(?:[-*\u2022]|\d+[.)])\s+/.test(parts[0])) continue
|
|
78
|
+
for (let at = 0; at < parts.length; at += 1) {
|
|
79
|
+
let line = parts[at]
|
|
80
|
+
if (at === 0 && !/^(?:[-*\u2022]|\d+[.)])\s+/.test(line)) continue
|
|
81
|
+
line = line.replace(/^(?:Answer:\s*)/i, '').replace(/^(?:[-*\u2022]|\d+[.)])\s+/, '').trim()
|
|
82
|
+
if (/^(?:Please review|Repository:|Look at commit|Focus on:|Answer with)/i.test(line)) continue
|
|
83
|
+
if (line.length < 8 || line.length > 400) continue
|
|
84
|
+
const key = line.toLowerCase()
|
|
85
|
+
if (seen.has(key)) continue
|
|
86
|
+
seen.add(key)
|
|
87
|
+
out.push(line)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out.slice(0, 40)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write one round down. Append-only: a round is history, not state to rewrite.
|
|
95
|
+
* @param {{dir?: string, commit?: string, question?: string, answer?: string, tookMs?: number}} entry - the round.
|
|
96
|
+
* @returns {{file: string, round: any}} where it went and what was written.
|
|
97
|
+
*/
|
|
98
|
+
export function recordRound(entry = {}) {
|
|
99
|
+
const dir = clean(entry.dir) === '' ? ROUND_DIR : clean(entry.dir)
|
|
100
|
+
mkdirSync(dir, { recursive: true })
|
|
101
|
+
const file = join(dir, 'rounds.ndjson')
|
|
102
|
+
const round = {
|
|
103
|
+
at: new Date().toISOString(),
|
|
104
|
+
commit: clean(entry.commit),
|
|
105
|
+
question: clean(entry.question).slice(0, 400),
|
|
106
|
+
answer: String(entry.answer ?? '').replace(/\s+/g, ' ').trim().slice(0, ANSWER_CAP),
|
|
107
|
+
points: points(entry.answer),
|
|
108
|
+
tookMs: Math.max(0, Math.trunc(Number(entry.tookMs ?? 0)) || 0),
|
|
109
|
+
}
|
|
110
|
+
appendFileSync(file, `${JSON.stringify(round)}\n`)
|
|
111
|
+
return { file, round }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The last round played, for a round that wants to know what it was told before.
|
|
116
|
+
* @param {{dir?: string}} [where] - which log.
|
|
117
|
+
* @returns {any|null} the last round, or null when none was played.
|
|
118
|
+
*/
|
|
119
|
+
export function lastRound(where = {}) {
|
|
120
|
+
const dir = clean(where.dir) === '' ? ROUND_DIR : clean(where.dir)
|
|
121
|
+
const file = join(dir, 'rounds.ndjson')
|
|
122
|
+
if (!existsSync(file)) return null
|
|
123
|
+
const rows = readFileSync(file, 'utf8').split('\n').filter((row) => row.trim() !== '')
|
|
124
|
+
if (rows.length === 0) return null
|
|
125
|
+
/* Verdicts are separate rows, appended after the round they judge: find the
|
|
126
|
+
newest round, then fold in the marks written since. */
|
|
127
|
+
/** @type {any[]} */
|
|
128
|
+
const rows2 = []
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
try {
|
|
131
|
+
rows2.push(JSON.parse(row))
|
|
132
|
+
} catch {
|
|
133
|
+
/* a torn line costs its own row, not the log */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
let at = -1
|
|
137
|
+
for (let i = rows2.length - 1; i >= 0; i -= 1) {
|
|
138
|
+
if (rows2[i]?.kind !== 'verdict') {
|
|
139
|
+
at = i
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (at < 0) return null
|
|
144
|
+
const round = rows2[at]
|
|
145
|
+
const latest = new Map()
|
|
146
|
+
for (const row of rows2.slice(at + 1)) {
|
|
147
|
+
if (row?.kind !== 'verdict' || clean(row.roundAt) !== clean(round.at)) continue
|
|
148
|
+
for (const mark of Array.isArray(row.marks) ? row.marks : []) if (Number.isInteger(mark?.point)) latest.set(mark.point, mark)
|
|
149
|
+
}
|
|
150
|
+
return { ...round, marks: [...latest.values()].sort((a, b) => a.point - b.point) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** What a point can be, once the code has looked at it. */
|
|
154
|
+
export const MARK_STATUS = ['fixed', 'verified', 'invalid', 'open']
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Say what happened to the points of the last round. The verdict is history
|
|
158
|
+
* too: it is appended, and the next round reads it so a settled point is not
|
|
159
|
+
* raised twice.
|
|
160
|
+
* @param {{dir?: string, marks?: any[]}} entry - point numbers (1-based) and their outcome.
|
|
161
|
+
* @returns {{file: string, round: any, marks: any[]}|null} what was written, or null when there is no round.
|
|
162
|
+
*/
|
|
163
|
+
export function markRound(entry = {}) {
|
|
164
|
+
const dir = clean(entry.dir) === '' ? ROUND_DIR : clean(entry.dir)
|
|
165
|
+
const round = lastRound({ dir })
|
|
166
|
+
if (round === null) return null
|
|
167
|
+
const said = Array.isArray(round.points) ? round.points : []
|
|
168
|
+
/** @type {any[]} */
|
|
169
|
+
const marks = []
|
|
170
|
+
for (const raw of Array.isArray(entry.marks) ? entry.marks : []) {
|
|
171
|
+
const point = Math.trunc(Number(raw?.point ?? 0))
|
|
172
|
+
if (point < 1 || point > said.length) continue
|
|
173
|
+
const status = MARK_STATUS.includes(clean(raw?.status)) ? clean(raw.status) : 'open'
|
|
174
|
+
marks.push({ point, text: said[point - 1], status, evidence: clean(raw?.evidence).slice(0, 200), at: new Date().toISOString() })
|
|
175
|
+
}
|
|
176
|
+
if (marks.length === 0) return null
|
|
177
|
+
const file = join(dir, 'rounds.ndjson')
|
|
178
|
+
mkdirSync(dir, { recursive: true })
|
|
179
|
+
appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), kind: 'verdict', roundAt: round.at, commit: round.commit, marks })}\n`)
|
|
180
|
+
return { file, round, marks }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One line for the model: what the reviewer said last, and whether the code has
|
|
185
|
+
* moved since. A review of an old commit is worth reading and not worth obeying.
|
|
186
|
+
* @param {{dir?: string, commit?: string}} [now] - the commit the code is at now.
|
|
187
|
+
* @returns {string} the note, or an empty string when no round was played.
|
|
188
|
+
*/
|
|
189
|
+
export function lastRoundNote(now = {}) {
|
|
190
|
+
const last = lastRound(now)
|
|
191
|
+
if (last === null) return ''
|
|
192
|
+
const commit = clean(now.commit)
|
|
193
|
+
const same = commit === '' || last.commit === '' || last.commit === commit
|
|
194
|
+
const points = Array.isArray(last.points) ? last.points : []
|
|
195
|
+
const head = points.length === 0 ? 'the reviewer gave no list' : `${points.length} point${points.length === 1 ? '' : 's'}`
|
|
196
|
+
const when = same ? 'about this commit' : `about ${String(last.commit).slice(0, 12)}, and the code has moved since`
|
|
197
|
+
return `Last review (${last.at.slice(0, 16).replace('T', ' ')}): ${head}, ${when}.`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The reviewer's last points as a list to act on.
|
|
202
|
+
* @param {{dir?: string}} [where] - which log.
|
|
203
|
+
* @returns {string[]} the points.
|
|
204
|
+
*/
|
|
205
|
+
export function lastPoints(where = {}) {
|
|
206
|
+
const last = lastRound(where)
|
|
207
|
+
return last !== null && Array.isArray(last.points) ? last.points : []
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @returns {string} the directory the log lives in. */
|
|
211
|
+
export function roundDir() {
|
|
212
|
+
return dirname(join(ROUND_DIR, 'rounds.ndjson'))
|
|
213
|
+
}
|