tensorgrid-ui 1.4.1 → 1.6.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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Журнал обзоров: что нашли ревизоры и что из этого подтвердилось.
3
+ *
4
+ * ЗАЧЕМ ЭТО НУЖНО
5
+ *
6
+ * Ревизор ошибается примерно в трети случаев. Сегодня это проверено трижды:
7
+ * из четырёх выборочно проверенных находок одна оказалась ложной, причём
8
+ * обоснованной — ревизор запустил проверку, получил верный результат и
9
+ * сделал из него неверный вывод, честно пометив, чего не смог проверить.
10
+ *
11
+ * Пока приговор выносится в переписке и там же теряется, происходят две
12
+ * плохие вещи. Пользователь видит только пересказ агента, то есть его
13
+ * фильтрацию вместе с его ошибками. И никто не знает, какой ревизор на какой
14
+ * модели чаще прав — а это единственная величина, по которой можно судить,
15
+ * стоит ли он своих денег.
16
+ *
17
+ * КТО СУДИТ
18
+ *
19
+ * Не тот, кто смотрит, а тот, кто делал работу. Просить человека, не
20
+ * разбирающегося в коде, решить «настоящая это ошибка или нет» — значит
21
+ * просить невозможного и получить в ответ случайные нажатия, которые ещё и
22
+ * будут выглядеть как данные.
23
+ *
24
+ * Поэтому приговор выносит агент и обязан приложить ДОКАЗАТЕЛЬСТВО: что
25
+ * именно он запустил или прочитал. Человек может возразить, но не обязан
26
+ * ничего решать.
27
+ *
28
+ * Импортируются только встроенные модули Node.
29
+ */
30
+ import { readFile, rename, writeFile } from 'node:fs/promises'
31
+ import { existsSync } from 'node:fs'
32
+ import { createHash } from 'node:crypto'
33
+ import { dirname, join } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
35
+
36
+ const FILE_NAME = 'tensorgrid-review-log.json'
37
+
38
+ /**
39
+ * Сколько обзоров храним.
40
+ *
41
+ * Журнал нужен для статистики, а не для архива. Двести обзоров — это месяцы
42
+ * работы, и по ним уже видно, какой ревизор чаще прав. Расти бесконечно
43
+ * файлу нельзя: его читают при каждом открытии настроек.
44
+ */
45
+ const MAX_RUNS = 200
46
+
47
+ /** Допустимые приговоры. Четвёртого состояния нет: неразобранное — «ждёт». */
48
+ export const VERDICTS = ['pending', 'confirmed', 'false', 'deferred']
49
+
50
+ /** Каталог профиля: поднимаемся, пока не найдём отметку об установке. */
51
+ function findProfileRoot(startDir) {
52
+ let dir = startDir
53
+ for (let depth = 0; depth < 10; depth += 1) {
54
+ if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
55
+ const parent = dirname(dir)
56
+ if (parent === dir) break
57
+ dir = parent
58
+ }
59
+ return null
60
+ }
61
+
62
+ /**
63
+ * Где лежит журнал. Рядом с настройками обзора.
64
+ *
65
+ * Каталог ищется ОТ СОБСТВЕННОГО расположения модуля, а не от рабочей
66
+ * папки. Это важно: рабочую папку задаёт чат, и подложенная в неё отметка
67
+ * об установке увела бы журнал в чужое дерево. Читают и пишут журнал
68
+ * разные стороны — агент и браузер, — и указывать они обязаны на один файл.
69
+ *
70
+ * @param root - явный каталог; только для проверок, где установленного
71
+ * профиля нет вовсе.
72
+ */
73
+ export function logPath(root = null) {
74
+ const found = root ?? findProfileRoot(dirname(fileURLToPath(import.meta.url)))
75
+ return found === null ? null : join(found, FILE_NAME)
76
+ }
77
+
78
+ /**
79
+ * Устойчивый признак находки.
80
+ *
81
+ * Считается по содержанию, а не по порядковому номеру: тот же дефект,
82
+ * найденный повторно в следующем обзоре, получит тот же признак, и прежний
83
+ * приговор к нему подойдёт. Нумерация этого не дала бы — она сдвигается от
84
+ * любой новой находки выше по списку.
85
+ */
86
+ export function findingId(finding) {
87
+ const parts = [finding.reviewer, finding.title, finding.where ?? '']
88
+ return createHash('sha1').update(parts.join('\u0000')).digest('hex').slice(0, 12)
89
+ }
90
+
91
+ /** Пустой журнал. Отсутствие файла — не ошибка, а «обзоров ещё не было». */
92
+ export function empty() {
93
+ return { runs: [], verdicts: {} }
94
+ }
95
+
96
+ /** Привести прочитанное к ожидаемой форме: файл могли править руками. */
97
+ export function normalise(raw) {
98
+ if (raw === null || typeof raw !== 'object') return empty()
99
+
100
+ const runs = Array.isArray(raw.runs)
101
+ ? raw.runs.filter((run) => run !== null && typeof run === 'object' && typeof run.id === 'string').slice(-MAX_RUNS)
102
+ : []
103
+
104
+ const verdicts = {}
105
+ if (raw.verdicts !== null && typeof raw.verdicts === 'object') {
106
+ for (const [id, entry] of Object.entries(raw.verdicts)) {
107
+ if (entry === null || typeof entry !== 'object') continue
108
+ if (!VERDICTS.includes(entry.verdict)) continue
109
+ verdicts[id] = {
110
+ verdict: entry.verdict,
111
+ // Доказательство — обязательная часть приговора, но пустое лучше,
112
+ // чем потерянная запись: по нему как раз видно, что проверки не было.
113
+ evidence: typeof entry.evidence === 'string' ? entry.evidence : '',
114
+ by: entry.by === 'human' ? 'human' : 'agent',
115
+ at: typeof entry.at === 'string' ? entry.at : null,
116
+ }
117
+ }
118
+ }
119
+
120
+ return { runs, verdicts }
121
+ }
122
+
123
+ /** Прочитать журнал. */
124
+ export async function read(root = null) {
125
+ const path = logPath(root)
126
+ if (path === null) return empty()
127
+ try {
128
+ return normalise(JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')))
129
+ } catch {
130
+ return empty()
131
+ }
132
+ }
133
+
134
+ /** Записать журнал через временный файл: чтение не должно увидеть обрезок. */
135
+ async function write(value, root = null) {
136
+ const path = logPath(root)
137
+ if (path === null) throw new Error('не найден каталог профиля — некуда писать журнал обзоров')
138
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`
139
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
140
+ await rename(temporary, path)
141
+ }
142
+
143
+ /**
144
+ * Записать состоявшийся обзор.
145
+ *
146
+ * @param run - что спрашивали, кого звали и что нашли.
147
+ * @returns признак записи и признаки находок — по ним потом выносится приговор.
148
+ */
149
+ export async function recordRun(run, root = null) {
150
+ const log = await read(root)
151
+
152
+ const findings = (run.findings ?? []).map((finding) => ({
153
+ id: findingId(finding),
154
+ reviewer: finding.reviewer,
155
+ severity: finding.severity,
156
+ title: finding.title,
157
+ where: finding.where ?? null,
158
+ what: finding.what ?? null,
159
+ why: finding.why ?? null,
160
+ howToCheck: finding.howToCheck ?? null,
161
+ }))
162
+
163
+ const entry = {
164
+ id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
165
+ at: new Date().toISOString(),
166
+ request: typeof run.request === 'string' ? run.request.slice(0, 400) : '',
167
+ // Модель и усилие сохраняются вместе с находками: без них статистика
168
+ // точности бессмысленна — «codex ошибается» ничего не значит, если
169
+ // неизвестно, на какой модели и с каким усилием.
170
+ reviewers: (run.reviewers ?? []).map((r) => ({
171
+ provider: r.provider,
172
+ model: r.model ?? null,
173
+ effort: r.effort ?? null,
174
+ failed: r.failed ?? null,
175
+ found: r.found ?? 0,
176
+ })),
177
+ findings,
178
+ }
179
+
180
+ log.runs.push(entry)
181
+ if (log.runs.length > MAX_RUNS) log.runs = log.runs.slice(-MAX_RUNS)
182
+
183
+ await write(log, root)
184
+ return { runId: entry.id, findingIds: findings.map((f) => f.id) }
185
+ }
186
+
187
+ /**
188
+ * Вынести приговор по находке.
189
+ *
190
+ * `evidence` не формальность: приговор без доказательства — это мнение, а
191
+ * инструмент затевался как раз против мнений, выдаваемых за факты.
192
+ *
193
+ * @param id - признак находки.
194
+ * @param verdict - подтверждено, ложная или отложено.
195
+ * @param evidence - что именно проверено: команда, файл, наблюдение.
196
+ * @param by - агент или человек; человек перекрывает агента.
197
+ */
198
+ export async function recordVerdict(id, verdict, evidence, by = 'agent', root = null) {
199
+ if (!VERDICTS.includes(verdict)) throw new Error(`неизвестный приговор: ${String(verdict)}`)
200
+
201
+ const log = await read(root)
202
+ const existing = log.verdicts[id]
203
+
204
+ // Человек перекрывает агента, агент человека — нет. Иначе следующий обзор
205
+ // молча отменил бы возражение, ради которого всё и затевалось.
206
+ if (existing !== undefined && existing.by === 'human' && by === 'agent') {
207
+ return { id, kept: existing, applied: false }
208
+ }
209
+
210
+ const entry = {
211
+ verdict,
212
+ evidence: typeof evidence === 'string' ? evidence.slice(0, 800) : '',
213
+ by: by === 'human' ? 'human' : 'agent',
214
+ at: new Date().toISOString(),
215
+ }
216
+ log.verdicts[id] = entry
217
+ await write(log, root)
218
+ return { id, kept: entry, applied: true }
219
+ }
220
+
221
+ /**
222
+ * Точность ревизоров по накопленному журналу.
223
+ *
224
+ * Считаются ТОЛЬКО находки с вынесенным приговором: непроверенные не значат
225
+ * ни «правда», ни «ложь», и включать их значило бы врать в обе стороны.
226
+ * Поэтому рядом с долей всегда стоит, на скольких находках она посчитана —
227
+ * доля по трём находкам не значит ничего.
228
+ */
229
+ export function accuracy(log) {
230
+ const byReviewer = new Map()
231
+
232
+ const key = (finding, run) => {
233
+ const used = (run.reviewers ?? []).find((r) => r.provider === finding.reviewer)
234
+ const model = used?.model ?? null
235
+ return model === null ? finding.reviewer : `${finding.reviewer} · ${model}`
236
+ }
237
+
238
+ for (const run of log.runs) {
239
+ for (const finding of run.findings ?? []) {
240
+ const verdict = log.verdicts[finding.id]
241
+ if (verdict === undefined || verdict.verdict === 'pending' || verdict.verdict === 'deferred') continue
242
+ const name = key(finding, run)
243
+ const stat = byReviewer.get(name) ?? { reviewer: name, confirmed: 0, wrong: 0 }
244
+ if (verdict.verdict === 'confirmed') stat.confirmed += 1
245
+ else stat.wrong += 1
246
+ byReviewer.set(name, stat)
247
+ }
248
+ }
249
+
250
+ return [...byReviewer.values()]
251
+ .map((stat) => ({
252
+ ...stat,
253
+ judged: stat.confirmed + stat.wrong,
254
+ // Доля не округляется до целых процентов в источнике: пусть показ
255
+ // решает сам, а данные остаются точными.
256
+ rate: stat.confirmed + stat.wrong === 0 ? null : stat.confirmed / (stat.confirmed + stat.wrong),
257
+ }))
258
+ .sort((a, b) => b.judged - a.judged)
259
+ }
260
+
261
+ /** Находки последнего обзора вместе с приговорами — то, что показывает панель. */
262
+ export function latest(log) {
263
+ const run = log.runs[log.runs.length - 1]
264
+ if (run === undefined) return null
265
+ return {
266
+ id: run.id,
267
+ at: run.at,
268
+ request: run.request,
269
+ reviewers: run.reviewers,
270
+ findings: (run.findings ?? []).map((finding) => ({
271
+ ...finding,
272
+ verdict: log.verdicts[finding.id]?.verdict ?? 'pending',
273
+ evidence: log.verdicts[finding.id]?.evidence ?? '',
274
+ by: log.verdicts[finding.id]?.by ?? null,
275
+ })),
276
+ }
277
+ }
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * Импортируются только встроенные модули Node.
13
13
  */
14
+ import { KNOWN } from './reviewers.js'
14
15
  import { readFile, rename, writeFile } from 'node:fs/promises'
15
16
  import { existsSync } from 'node:fs'
16
17
  import { dirname, join } from 'node:path'
@@ -18,8 +19,11 @@ import { fileURLToPath } from 'node:url'
18
19
 
19
20
  const FILE_NAME = 'tensorgrid-review.json'
20
21
 
21
- /** Ревизоры, которых мы умеем звать. Порядок — порядок показа. */
22
- export const KNOWN = ['claude-code', 'codex']
22
+ /**
23
+ * Ревизоры берутся из `reviewers.js` — единственного источника.
24
+ * Свой литерал здесь был третьим по счёту и расходился бы молча.
25
+ */
26
+ export { KNOWN } from './reviewers.js'
23
27
 
24
28
  /**
25
29
  * Что допустимо в модели и усилии.
@@ -31,20 +35,85 @@ export const KNOWN = ['claude-code', 'codex']
31
35
  * показывает пустое поле, неотличимое от «как настроено».
32
36
  */
33
37
  const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
38
+ const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,95}$/
39
+
40
+ /**
41
+ * Режимы обзора — то, чем пользуются, не вникая.
42
+ *
43
+ * Сотруднику незачем знать разницу между именами моделей. Ему нужно решить
44
+ * одно: насколько тщательно проверить. Режим задаёт состав ревизоров и
45
+ * усилие; кто хочет — открывает подробные настройки и меняет поштучно.
46
+ *
47
+ * Модель режимы НЕ задают намеренно: у каждого продукта своя настроена, и
48
+ * навязывать поверх значило бы ломать то, что человек уже выбрал.
49
+ */
50
+ export const MODES = {
51
+ fast: {
52
+ label: 'Быстрый',
53
+ hint: 'Один ревизор, обычное усилие. Минуты. Для рядовой работы.',
54
+ enabled: ['claude-code'],
55
+ effort: null,
56
+ },
57
+ normal: {
58
+ label: 'Обычный',
59
+ hint: 'Два разных продукта. Они находят разное — один упускает половину.',
60
+ enabled: ['claude-code', 'codex'],
61
+ effort: null,
62
+ },
63
+ deep: {
64
+ label: 'Глубокий',
65
+ hint: 'Все ревизоры на полном усилии, включая нашего на выбранной модели. Долго и дорого — для того, что увидят люди.',
66
+ // «Глубокий» означает ВСЕ, а не перечисленных поимённо: иначе добавление
67
+ // четвёртого ревизора молча оставило бы самый тщательный режим без него.
68
+ // Остальные режимы называют состав явно — в этом и есть их смысл.
69
+ enabled: [...KNOWN],
70
+ effort: 'high',
71
+ },
72
+ }
73
+
74
+ /** Режим по умолчанию: два ревизора — проверенная середина. */
75
+ const DEFAULT_MODE = 'normal'
34
76
 
35
77
  /**
36
78
  * Что действует, пока пользователь ничего не выбрал.
37
79
  *
38
- * Оба ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
80
+ * Два ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
39
81
  * означал бы, что половина находок не появится у того, кто в настройки не
40
82
  * заглядывал. Модель и усилие пустые — берутся собственные настройки
41
83
  * продукта, то есть то, чем человек уже пользуется.
42
84
  */
43
85
  export function defaults() {
44
86
  return {
45
- enabled: [...KNOWN],
46
- choices: { 'claude-code': { model: null, effort: null }, codex: { model: null, effort: null } },
87
+ mode: DEFAULT_MODE,
88
+ enabled: [...MODES[DEFAULT_MODE].enabled],
89
+ // Записи строятся ПО СПИСКУ, а не перечисляются вручную: перечисление
90
+ // и было тем третьим источником правды, из-за которого добавление
91
+ // продукта оставило бы выбор неопределённым, а раздел настроек — упавшим.
92
+ choices: Object.fromEntries(KNOWN.map((kind) => [kind, { provider: null, model: null, effort: null }])),
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Применить режим, сохранив поштучный выбор моделей.
98
+ *
99
+ * Переключение режима меняет СОСТАВ и усилие, но не трогает выбранные
100
+ * модели: человек, настроивший модель для Codex, не должен терять её
101
+ * оттого, что разок проверил быстрым режимом.
102
+ */
103
+ export function applyMode(settings, mode) {
104
+ const spec = MODES[mode]
105
+ if (spec === undefined) return settings
106
+
107
+ const choices = {}
108
+ for (const kind of KNOWN) {
109
+ const previous = settings.choices?.[kind] ?? {}
110
+ choices[kind] = {
111
+ provider: previous.provider ?? null,
112
+ model: previous.model ?? null,
113
+ effort: spec.effort,
114
+ }
47
115
  }
116
+ return { mode, enabled: [...spec.enabled], choices }
48
117
  }
49
118
 
50
119
  /**
@@ -94,20 +163,46 @@ export function normalise(raw) {
94
163
  // Недопустимое значение отбрасывается молча и превращается в «как
95
164
  // настроено»: сохранить его значило бы выключить обзор до тех пор, пока
96
165
  // кто-нибудь не догадается открыть файл.
166
+ //
167
+ // Наш ревизор ходит по маршруту, где имена содержат косую черту
168
+ // (`x-ai/grok-4.6`), внешние — по командной строке, где её быть не
169
+ // должно. Одна общая проверка отвергала бы половину моделей.
170
+ const allowed = kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
97
171
  const pick = (value) => {
98
172
  if (typeof value !== 'string') return null
99
173
  const trimmed = value.trim()
100
- return trimmed !== '' && SAFE_VALUE.test(trimmed) ? trimmed : null
174
+ return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
101
175
  }
102
176
  choices[kind] = {
177
+ provider: pick(source?.provider),
103
178
  model: pick(source?.model),
104
179
  effort: pick(source?.effort),
105
180
  }
106
181
  }
107
182
 
183
+ // Режим — подсказка для интерфейса, а не источник состава: состав уже
184
+ // записан в `enabled`. Неизвестное имя превращается в «свой набор»,
185
+ // потому что именно им оно и является.
186
+ const mode = typeof raw.mode === 'string' && MODES[raw.mode] !== undefined ? raw.mode : null
187
+
108
188
  // Пустой список допустим только как осознанный выбор «никого не звать»;
109
189
  // отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
110
- return { enabled, choices }
190
+ return { mode: mode ?? matchMode(enabled), enabled, choices }
191
+ }
192
+
193
+ /**
194
+ * Узнать режим по составу ревизоров.
195
+ *
196
+ * Нужно, когда состав правили поштучно: режим в файле мог остаться от
197
+ * прошлого выбора и врал бы в интерфейсе. Не совпал ни с одним — значит
198
+ * набор свой, так и покажем.
199
+ */
200
+ function matchMode(enabled) {
201
+ const same = (a, b) => a.length === b.length && a.every((item) => b.includes(item))
202
+ for (const [name, spec] of Object.entries(MODES)) {
203
+ if (same(spec.enabled, enabled)) return name
204
+ }
205
+ return null
111
206
  }
112
207
 
113
208
  /** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */