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/client.js +103 -8
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +485 -0
- package/docs/ROADMAP.md +146 -0
- package/docs/VERIFY.md +14 -0
- package/index.js +320 -29
- package/package.json +9 -3
- package/read.js +403 -39
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +764 -115
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
|
+
}
|