dsh-inner-browser-use 0.18.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/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
+ }