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.
- package/lib/client.js +316 -35
- package/lib/http-runtime.js +87 -4
- package/lib/index.js +17 -1
- package/lib/review-log.js +277 -0
- package/lib/review-settings.js +102 -7
- package/lib/review.js +212 -39
- package/lib/reviewers.js +178 -17
- package/package.json +1 -1
package/lib/review.js
CHANGED
|
@@ -42,13 +42,17 @@
|
|
|
42
42
|
/**
|
|
43
43
|
* Список ревизоров берётся из ОДНОГО места — `reviewers.js`.
|
|
44
44
|
*
|
|
45
|
-
* Здесь стоял свой
|
|
46
|
-
*
|
|
47
|
-
* добавление
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* Здесь стоял свой литерал, а ещё один жил в `review-settings.js`: три
|
|
46
|
+
* источника правды на один список. Ревизор указал, чем это кончится —
|
|
47
|
+
* добавление продукта в один файл из трёх делает запись выбора
|
|
48
|
+
* неопределённой и роняет весь раздел настроек. В прошлый раз я починил
|
|
49
|
+
* половину и отчитался за целое; следующий обзор это и поймал.
|
|
50
|
+
*
|
|
51
|
+
* Импорт статический: `reviewers.js` тянет только встроенные модули Node,
|
|
52
|
+
* поэтому он загружается везде, где загружается этот файл, — включая тесты
|
|
53
|
+
* на машине без dsh.
|
|
50
54
|
*/
|
|
51
|
-
const KNOWN_REVIEWERS =
|
|
55
|
+
const KNOWN_REVIEWERS = KNOWN
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* Задание ревизору.
|
|
@@ -114,9 +118,58 @@ function buildPrompt({ request, paths, focus }) {
|
|
|
114
118
|
return lines.join('\n')
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
import { KNOWN } from './reviewers.js'
|
|
122
|
+
|
|
117
123
|
/** Уровни важности в порядке убывания — по нему сортируется итог. */
|
|
118
|
-
const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
|
|
124
|
+
const SEVERITY_ORDER = ['критично', 'не указана', 'серьёзно', 'мелочь']
|
|
119
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Найти пути, уводящие за пределы рабочей папки.
|
|
128
|
+
*
|
|
129
|
+
* Проверка идёт по РЕАЛЬНОМУ пути, а не по написанному: символическая
|
|
130
|
+
* ссылка внутри папки может указывать наружу, и сравнение строк этого не
|
|
131
|
+
* увидит. Несуществующий путь не считается выходом — о нём ревизор скажет
|
|
132
|
+
* сам, и отказывать в обзоре из-за опечатки незачем.
|
|
133
|
+
*
|
|
134
|
+
* @param cwd - рабочая папка чата.
|
|
135
|
+
* @param paths - пути из вызова, возможно отсутствующие.
|
|
136
|
+
* @returns те из них, что оказались снаружи.
|
|
137
|
+
*/
|
|
138
|
+
async function pathsOutside(cwd, paths) {
|
|
139
|
+
if (!Array.isArray(paths) || paths.length === 0) return []
|
|
140
|
+
|
|
141
|
+
const { realpath } = await import('node:fs/promises')
|
|
142
|
+
const { isAbsolute, join, relative, resolve } = await import('node:path')
|
|
143
|
+
|
|
144
|
+
const settle = async (value) => {
|
|
145
|
+
try {
|
|
146
|
+
return await realpath(value)
|
|
147
|
+
} catch {
|
|
148
|
+
return resolve(value)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const root = await settle(cwd)
|
|
153
|
+
const outside = []
|
|
154
|
+
|
|
155
|
+
for (const entry of paths) {
|
|
156
|
+
const text = String(entry)
|
|
157
|
+
const full = await settle(isAbsolute(text) ? text : join(cwd, text))
|
|
158
|
+
const step = relative(root, full)
|
|
159
|
+
// Пустой `step` — это сам корень; `..` в начале — выход наружу.
|
|
160
|
+
if (step !== '' && (step.startsWith('..') || isAbsolute(step))) outside.push(text)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return outside
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
167
|
+
function ordered(findings) {
|
|
168
|
+
return [...findings].sort((a, b) => {
|
|
169
|
+
const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
|
170
|
+
return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
120
173
|
/**
|
|
121
174
|
* Пределы на разбор.
|
|
122
175
|
*
|
|
@@ -171,8 +224,6 @@ export function parseFindings(text, reviewer) {
|
|
|
171
224
|
}
|
|
172
225
|
|
|
173
226
|
for (const block of blocks) {
|
|
174
|
-
if (findings.length >= MAX_FINDINGS) break
|
|
175
|
-
|
|
176
227
|
const [titleLine, ...rest] = block.split('\n')
|
|
177
228
|
// Жирная разметка вокруг названий полей снимается: модели любят её
|
|
178
229
|
// добавлять, а без этого поле просто не находилось и содержимое терялось.
|
|
@@ -195,12 +246,18 @@ export function parseFindings(text, reviewer) {
|
|
|
195
246
|
}
|
|
196
247
|
|
|
197
248
|
const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
|
|
198
|
-
|
|
249
|
+
// Важность, которую ревизор не указал, ОТМЕЧАЕТСЯ как неуказанную.
|
|
250
|
+
//
|
|
251
|
+
// Прежде она молча становилась «мелочью», и критичная находка с
|
|
252
|
+
// пропущенной строкой уезжала в самый низ списка — туда, куда не
|
|
253
|
+
// смотрят. Теперь неуказанная идёт СРАЗУ ПОСЛЕ критичных: пусть лучше
|
|
254
|
+
// человек прочтёт лишнее, чем пропустит важное.
|
|
255
|
+
const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level))
|
|
199
256
|
|
|
200
257
|
findings.push({
|
|
201
258
|
reviewer,
|
|
202
259
|
title: clip(titleLine.replace(/\*\*/g, '').trim()),
|
|
203
|
-
severity,
|
|
260
|
+
severity: severity ?? 'не указана',
|
|
204
261
|
where: field('ГДЕ'),
|
|
205
262
|
what: field('ЧТО НЕ ТАК'),
|
|
206
263
|
why: field('ПОЧЕМУ ЭТО ВАЖНО'),
|
|
@@ -208,7 +265,25 @@ export function parseFindings(text, reviewer) {
|
|
|
208
265
|
})
|
|
209
266
|
}
|
|
210
267
|
|
|
211
|
-
|
|
268
|
+
// Предел применяется ПОСЛЕ сортировки по важности, а не по ходу разбора.
|
|
269
|
+
//
|
|
270
|
+
// Раньше обрезка шла по порядку в тексте: критичная находка на 61-м месте
|
|
271
|
+
// пропадала, а шестьдесят мелочей выше неё оставались. Ревизор выкладывает
|
|
272
|
+
// находки не по важности, так что порядок в тексте ничего не значит.
|
|
273
|
+
if (findings.length <= MAX_FINDINGS) return findings
|
|
274
|
+
|
|
275
|
+
const kept = ordered(findings).slice(0, MAX_FINDINGS)
|
|
276
|
+
kept.push({
|
|
277
|
+
reviewer,
|
|
278
|
+
title: `показаны не все находки: ${findings.length - MAX_FINDINGS} отброшено`,
|
|
279
|
+
severity: 'мелочь',
|
|
280
|
+
where: null,
|
|
281
|
+
what: `Ревизор вернул ${findings.length} находок, показаны ${MAX_FINDINGS} самых важных.`,
|
|
282
|
+
why: 'Остальные отброшены, чтобы обзор не вытеснил из работы то, что он проверял.',
|
|
283
|
+
howToCheck: null,
|
|
284
|
+
truncationNotice: true,
|
|
285
|
+
})
|
|
286
|
+
return kept
|
|
212
287
|
}
|
|
213
288
|
|
|
214
289
|
/**
|
|
@@ -221,6 +296,7 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
|
|
|
221
296
|
const outcome = await runReviewer(ctx, kind, {
|
|
222
297
|
prompt,
|
|
223
298
|
cwd,
|
|
299
|
+
provider: choice?.provider ?? null,
|
|
224
300
|
model: choice?.model ?? null,
|
|
225
301
|
effort: choice?.effort ?? null,
|
|
226
302
|
signal,
|
|
@@ -235,19 +311,18 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
|
|
|
235
311
|
return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
|
|
236
312
|
}
|
|
237
313
|
|
|
238
|
-
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
239
|
-
function ordered(findings) {
|
|
240
|
-
return [...findings].sort((a, b) => {
|
|
241
|
-
const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
|
242
|
-
return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
|
-
|
|
246
314
|
/** Человекочитаемый вид итога. */
|
|
247
315
|
export function render(value) {
|
|
248
316
|
const lines = []
|
|
249
317
|
|
|
250
|
-
|
|
318
|
+
// Массив ревизоров может отсутствовать: схема результата его не требует,
|
|
319
|
+
// и любой ранний выход из инструмента давал объект без него. Обращение к
|
|
320
|
+
// `.length` роняло отрисовку — то есть пользователь вместо объяснения
|
|
321
|
+
// получал ошибку инструмента.
|
|
322
|
+
const reviewers = Array.isArray(value.reviewers) ? value.reviewers : []
|
|
323
|
+
const findings = Array.isArray(value.findings) ? value.findings : []
|
|
324
|
+
|
|
325
|
+
if (reviewers.length === 0) {
|
|
251
326
|
// Настоящая причина приходит в `problem` и обязана быть показана.
|
|
252
327
|
//
|
|
253
328
|
// Здесь стояла одна фраза на все случаи — про несмонтированные
|
|
@@ -260,18 +335,18 @@ export function render(value) {
|
|
|
260
335
|
: 'Обзор не выполнен: ни один ревизор не выбран.'
|
|
261
336
|
}
|
|
262
337
|
|
|
263
|
-
const failed =
|
|
264
|
-
const worked =
|
|
338
|
+
const failed = reviewers.filter((r) => r.failed !== null)
|
|
339
|
+
const worked = reviewers.filter((r) => r.failed === null)
|
|
265
340
|
|
|
266
|
-
lines.push(`Ревизоров опрошено: ${worked.length} из ${
|
|
341
|
+
lines.push(`Ревизоров опрошено: ${worked.length} из ${reviewers.length}. Находок: ${findings.length}.`)
|
|
267
342
|
for (const entry of failed) lines.push(` ✗ ${entry.provider} — ${entry.failed}`)
|
|
268
343
|
lines.push('')
|
|
269
344
|
|
|
270
|
-
if (
|
|
345
|
+
if (findings.length === 0) {
|
|
271
346
|
// «Находок нет» говорится ТОЛЬКО когда обзор действительно состоялся.
|
|
272
347
|
// Отказ или отмена, поданные как чистый результат, — худшее, что этот
|
|
273
348
|
// инструмент может сделать: пользователь сочтёт работу проверенной.
|
|
274
|
-
const cancelled =
|
|
349
|
+
const cancelled = reviewers.some((entry) => entry.cancelled === true)
|
|
275
350
|
if (cancelled) lines.push('Обзор отменён — судить не по чему.')
|
|
276
351
|
else if (worked.length === 0) lines.push('Судить не по чему: не ответил ни один ревизор.')
|
|
277
352
|
else lines.push('Находок нет.')
|
|
@@ -280,7 +355,7 @@ export function render(value) {
|
|
|
280
355
|
|
|
281
356
|
// Сортируем здесь, а не полагаемся на вызывающего: опасное должно быть
|
|
282
357
|
// сверху при любом способе получить эти данные.
|
|
283
|
-
for (const finding of ordered(
|
|
358
|
+
for (const finding of ordered(findings)) {
|
|
284
359
|
lines.push(`### ${finding.title} [${finding.severity}] — ${finding.reviewer}`)
|
|
285
360
|
if (finding.where) lines.push(`ГДЕ: ${finding.where}`)
|
|
286
361
|
if (finding.what) lines.push(`ЧТО НЕ ТАК: ${finding.what}`)
|
|
@@ -292,7 +367,7 @@ export function render(value) {
|
|
|
292
367
|
// Совпадение двух независимых ревизоров — сильный признак: они друг друга
|
|
293
368
|
// не видели. Расхождение тоже полезно, но его надо проверять самому.
|
|
294
369
|
const byReviewer = new Map()
|
|
295
|
-
for (const finding of
|
|
370
|
+
for (const finding of findings) {
|
|
296
371
|
byReviewer.set(finding.reviewer, (byReviewer.get(finding.reviewer) ?? 0) + 1)
|
|
297
372
|
}
|
|
298
373
|
lines.push(`По ревизорам: ${[...byReviewer].map(([name, count]) => `${name} — ${count}`).join(', ')}.`)
|
|
@@ -358,12 +433,31 @@ export async function apply(ctx) {
|
|
|
358
433
|
const { readSettings } = await import('./review-settings.js')
|
|
359
434
|
|
|
360
435
|
// Рабочая папка берётся у сессии, как это делают поставочные
|
|
361
|
-
// провайдеры: ревизор
|
|
436
|
+
// провайдеры: ревизор смотрит ту же папку, в которой идёт работа.
|
|
362
437
|
const cwd = exec.agent?.session?.header?.cwd
|
|
363
438
|
if (typeof cwd !== 'string' || cwd === '') {
|
|
364
439
|
return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
|
|
365
440
|
}
|
|
366
441
|
|
|
442
|
+
// Ревизор не выходит за папку чата.
|
|
443
|
+
//
|
|
444
|
+
// Он запускается с правами на чтение, и без этой проверки путь вида
|
|
445
|
+
// `../../` или абсолютный увёл бы его в чужое дерево — к переписке,
|
|
446
|
+
// документам, настройкам других программ. Отказ явный, с указанием
|
|
447
|
+
// пути: молчаливое усечение списка выглядело бы как обзор, который
|
|
448
|
+
// просто ничего не нашёл.
|
|
449
|
+
const outside = await pathsOutside(cwd, args.paths)
|
|
450
|
+
if (outside.length > 0) {
|
|
451
|
+
return {
|
|
452
|
+
findings: [],
|
|
453
|
+
reviewers: [],
|
|
454
|
+
problem:
|
|
455
|
+
`эти пути вне рабочей папки чата: ${outside.join(', ')}. ` +
|
|
456
|
+
'Ревизор читает только папку, в которой идёт работа. Чтобы посмотреть другую, ' +
|
|
457
|
+
'откройте чат в ней или разрешите доступ явно.',
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
367
461
|
// Выбор пользователя из настроек; вызов может его перекрыть.
|
|
368
462
|
const settings = await readSettings(cwd)
|
|
369
463
|
const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
|
|
@@ -387,19 +481,98 @@ export async function apply(ctx) {
|
|
|
387
481
|
// Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
|
|
388
482
|
// удвоил бы ожидание без всякой пользы.
|
|
389
483
|
const answers = await Promise.all(
|
|
390
|
-
wanted.map((kind) =>
|
|
484
|
+
wanted.map((kind) =>
|
|
485
|
+
askReviewer(
|
|
486
|
+
// Наш ревизор заводит подагента, и тому нужен родитель —
|
|
487
|
+
// агент, от имени которого идёт вызов. Внешним он не нужен.
|
|
488
|
+
{ ...ctx, get: (name) => ctx.get(name), reviewParent: exec.agent },
|
|
489
|
+
runReviewer,
|
|
490
|
+
kind,
|
|
491
|
+
prompt,
|
|
492
|
+
cwd,
|
|
493
|
+
settings.choices[kind],
|
|
494
|
+
exec.signal,
|
|
495
|
+
),
|
|
496
|
+
),
|
|
391
497
|
)
|
|
392
498
|
|
|
499
|
+
const findings = ordered(answers.flatMap((entry) => entry.findings))
|
|
500
|
+
const reviewers = answers.map((entry) => ({
|
|
501
|
+
provider: entry.provider,
|
|
502
|
+
failed: entry.failed,
|
|
503
|
+
cancelled: entry.cancelled === true,
|
|
504
|
+
found: entry.findings.length,
|
|
505
|
+
model: entry.model,
|
|
506
|
+
effort: entry.effort,
|
|
507
|
+
}))
|
|
508
|
+
|
|
509
|
+
// Обзор записывается в журнал — иначе приговор выносится в переписке
|
|
510
|
+
// и там же теряется. Без записи никто не узнает, какой ревизор на
|
|
511
|
+
// какой модели чаще прав, а это единственная величина, по которой
|
|
512
|
+
// видно, стоит ли он своих денег.
|
|
513
|
+
//
|
|
514
|
+
// Неудача записи НЕ отменяет обзор: находки важнее статистики.
|
|
515
|
+
let recorded = null
|
|
516
|
+
try {
|
|
517
|
+
const { recordRun } = await import('./review-log.js')
|
|
518
|
+
recorded = await recordRun({ request: args.request, reviewers, findings })
|
|
519
|
+
} catch {
|
|
520
|
+
recorded = null
|
|
521
|
+
}
|
|
522
|
+
|
|
393
523
|
return {
|
|
394
|
-
findings:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
524
|
+
findings: recorded === null
|
|
525
|
+
? findings
|
|
526
|
+
: findings.map((finding, index) => ({ ...finding, id: recorded.findingIds[index] })),
|
|
527
|
+
reviewers,
|
|
528
|
+
...(recorded === null
|
|
529
|
+
? { note: 'журнал обзоров недоступен — приговоры не сохранятся' }
|
|
530
|
+
: { runId: recorded.runId }),
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
}),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
ctx.tools.register(
|
|
537
|
+
defineTool({
|
|
538
|
+
name: 'review_verdict',
|
|
539
|
+
description:
|
|
540
|
+
'Record your verdict on one finding from the last second_opinion review, with the evidence you gathered. ' +
|
|
541
|
+
'Roughly a third of findings are wrong — often plausibly wrong, reasoned from real evidence toward a false conclusion. ' +
|
|
542
|
+
'Verify before you judge: run it, read the surrounding code, reproduce the steps the reviewer gave. ' +
|
|
543
|
+
'The evidence field is not a formality: a verdict without it is an opinion, and this whole tool exists against opinions passed off as facts. ' +
|
|
544
|
+
'Call this once per finding after a review, before telling the user what you are going to fix.',
|
|
545
|
+
parameters: {
|
|
546
|
+
id: { type: 'string', required: true, description: 'Finding id returned by second_opinion.' },
|
|
547
|
+
verdict: {
|
|
548
|
+
type: 'string',
|
|
549
|
+
required: true,
|
|
550
|
+
enum: ['confirmed', 'false', 'deferred'],
|
|
551
|
+
description: 'confirmed — you reproduced or proved it; false — you proved it wrong; deferred — real but not worth acting on now.',
|
|
552
|
+
},
|
|
553
|
+
evidence: {
|
|
554
|
+
type: 'string',
|
|
555
|
+
required: true,
|
|
556
|
+
description:
|
|
557
|
+
'What exactly you checked: the command you ran and its result, the file and line you read, the behaviour you observed. ' +
|
|
558
|
+
'Not "looks right" — what you actually did.',
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
output: {
|
|
562
|
+
schema: { type: 'object', additionalProperties: true },
|
|
563
|
+
render: (_args, value) => [{ type: 'text', text: value.text ?? '' }],
|
|
564
|
+
},
|
|
565
|
+
async execute(args) {
|
|
566
|
+
try {
|
|
567
|
+
const { recordVerdict } = await import('./review-log.js')
|
|
568
|
+
const result = await recordVerdict(args.id, args.verdict, args.evidence, 'agent')
|
|
569
|
+
if (result.applied === false) {
|
|
570
|
+
return { text: `Приговор не изменён: по этой находке уже высказался человек (${result.kept.verdict}).` }
|
|
571
|
+
}
|
|
572
|
+
const names = { confirmed: 'подтверждено', false: 'ложная', deferred: 'отложено' }
|
|
573
|
+
return { text: `Записано: ${names[args.verdict]} — ${args.evidence.slice(0, 120)}` }
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return { text: `Записать не удалось: ${String(error?.message ?? error)}` }
|
|
403
576
|
}
|
|
404
577
|
},
|
|
405
578
|
}),
|
package/lib/reviewers.js
CHANGED
|
@@ -46,24 +46,89 @@ const STDERR_CAP = 64 * 1024
|
|
|
46
46
|
export const REVIEWERS = {
|
|
47
47
|
'claude-code': {
|
|
48
48
|
label: 'Claude Code',
|
|
49
|
+
kind: 'external',
|
|
49
50
|
command: 'claude',
|
|
51
|
+
// Список — подсказка, а НЕ ограничение: интерфейс даёт ввести и своё имя.
|
|
52
|
+
// Жёсткий перечень устарел бы при первом же выпуске новой модели, а
|
|
53
|
+
// выяснить у продукта его собственный список нечем.
|
|
50
54
|
models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
|
|
51
55
|
efforts: ['low', 'medium', 'high'],
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
// Имена, которыми продукт авторизуется. См. `authEnvFor` ниже.
|
|
57
|
+
authEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
|
|
54
58
|
},
|
|
55
59
|
codex: {
|
|
56
60
|
label: 'Codex',
|
|
61
|
+
kind: 'external',
|
|
57
62
|
command: 'codex',
|
|
58
63
|
models: ['gpt-6-astra', 'gpt-5.6-sol'],
|
|
59
64
|
efforts: ['low', 'medium', 'high', 'xhigh'],
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
authEnv: ['OPENAI_API_KEY', 'CODEX_HOME'],
|
|
66
|
+
},
|
|
67
|
+
internal: {
|
|
68
|
+
label: 'Наш ревизор',
|
|
69
|
+
// Единственный, кто работает НЕ отдельным продуктом, а нашим подагентом
|
|
70
|
+
// на нашем же маршруте моделей.
|
|
71
|
+
//
|
|
72
|
+
// Он появился из опыта: та же модель в другой обвязке нашла почти
|
|
73
|
+
// непересекающийся набор находок. Значит третий угол зрения даёт не
|
|
74
|
+
// только другая модель, но и другое окружение.
|
|
75
|
+
//
|
|
76
|
+
// И он единственный, чей список моделей берётся ЖИВЫМ из маршрута:
|
|
77
|
+
// Claude Code и Codex понимают только свои имена, а здесь доступно всё,
|
|
78
|
+
// что подключено — включая сотни моделей через OpenRouter.
|
|
79
|
+
kind: 'internal',
|
|
80
|
+
models: [],
|
|
81
|
+
efforts: ['low', 'medium', 'high', 'max'],
|
|
62
82
|
},
|
|
63
83
|
}
|
|
64
84
|
|
|
65
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* ЕДИНСТВЕННЫЙ источник списка ревизоров.
|
|
87
|
+
*
|
|
88
|
+
* Список жил в трёх файлах, и ревизор указал, чем это кончится: добавление
|
|
89
|
+
* продукта в один файл из трёх делает запись выбора неопределённой и роняет
|
|
90
|
+
* весь раздел настроек. Я тогда починил половину и отчитался за целое —
|
|
91
|
+
* следующий обзор это и поймал.
|
|
92
|
+
*/
|
|
93
|
+
export const KNOWN = Object.keys(REVIEWERS)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Что допустимо в модели и усилии.
|
|
97
|
+
*
|
|
98
|
+
* Строгий вариант — для ВНЕШНИХ ревизоров: значение уходит в аргументы
|
|
99
|
+
* командной строки, и косая черта там ни в одном имени не нужна, а вот
|
|
100
|
+
* ведущий дефис превратил бы значение в ключ команды.
|
|
101
|
+
*
|
|
102
|
+
* Для НАШЕГО ревизора черта обязательна: маршрут называет модели вида
|
|
103
|
+
* `anthropic/claude-opus-5`, а через OpenRouter — `x-ai/grok-4.6`. Туда
|
|
104
|
+
* значение уходит не в командную строку, а в опции вызова, поэтому
|
|
105
|
+
* послабление безопасно. Одна общая проверка отвергала бы половину
|
|
106
|
+
* подключённых моделей — что и обнаружилось при первой же сверке.
|
|
107
|
+
*/
|
|
66
108
|
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
109
|
+
const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Вернуть ревизору его собственные ключи авторизации.
|
|
113
|
+
*
|
|
114
|
+
* Служба запуска вычищает из окружения ребёнка всё, что похоже на секрет —
|
|
115
|
+
* `KEY`, `TOKEN`, `SECRET`, `PASSWORD`. Для чужого процесса это правильно:
|
|
116
|
+
* наши ключи ему незачем. Но ревизор авторизуется как раз такими именами, и
|
|
117
|
+
* у пользователя, который входит ключом, а не подпиской, обзор молча не
|
|
118
|
+
* работал бы — при том что та же команда из консоли отвечает.
|
|
119
|
+
*
|
|
120
|
+
* Поэтому возвращаем ТОЧЕЧНО: только имена этого продукта и только если они
|
|
121
|
+
* заданы. Ничего чужого, никаких шаблонов.
|
|
122
|
+
*/
|
|
123
|
+
function authEnvFor(kind) {
|
|
124
|
+
const names = REVIEWERS[kind]?.authEnv ?? []
|
|
125
|
+
const env = {}
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const value = process.env[name]
|
|
128
|
+
if (typeof value === 'string' && value !== '') env[name] = value
|
|
129
|
+
}
|
|
130
|
+
return env
|
|
131
|
+
}
|
|
67
132
|
|
|
68
133
|
/**
|
|
69
134
|
* Собрать аргументы запуска.
|
|
@@ -114,22 +179,87 @@ export function buildArgs(kind, { model, effort, answerFile }) {
|
|
|
114
179
|
throw new Error(`неизвестный ревизор: ${kind}`)
|
|
115
180
|
}
|
|
116
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Позвать нашего собственного ревизора — подагента на нашем маршруте.
|
|
184
|
+
*
|
|
185
|
+
* Отличается от внешних принципиально: не отдельный продукт, а агент в этом
|
|
186
|
+
* же процессе. Отсюда и возможности, которых нет у остальных: любая модель
|
|
187
|
+
* из подключённых, включая сотни через OpenRouter, и усилие рассуждения из
|
|
188
|
+
* тех же настроек, что у основного агента.
|
|
189
|
+
*
|
|
190
|
+
* Рабочую папку он берёт у родителя сам — она и есть папка чата, что
|
|
191
|
+
* совпадает с ограничением доступа для внешних.
|
|
192
|
+
*/
|
|
193
|
+
async function runInternal(ctx, kind, { prompt, provider, model, effort, signal }) {
|
|
194
|
+
const subagents = ctx.get === undefined ? ctx.subagents : ctx.get('subagents')
|
|
195
|
+
if (subagents === undefined) {
|
|
196
|
+
return { kind, failed: 'реестр подагентов не смонтирован', answer: null }
|
|
197
|
+
}
|
|
198
|
+
if (typeof subagents.list === 'function' && !subagents.list().includes('spawn')) {
|
|
199
|
+
return { kind, failed: 'провайдер spawn недоступен', answer: null }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const agentOptions = {}
|
|
203
|
+
// Провайдер и модель приходят ПОРОЗНЬ и порознь же передаются.
|
|
204
|
+
//
|
|
205
|
+
// Соблазнительно вывести провайдера из имени модели по первой косой
|
|
206
|
+
// черте — и это неверно. У OpenRouter `x-ai/grok-4.6` целиком является
|
|
207
|
+
// именем модели, а провайдер называется `openrouter`. Деление по черте
|
|
208
|
+
// дало бы несуществующего провайдера `x-ai`, и маршрут отказал бы.
|
|
209
|
+
if (provider) agentOptions.provider = String(provider)
|
|
210
|
+
if (model) agentOptions.model = String(model)
|
|
211
|
+
if (effort) agentOptions.reasoningEffort = String(effort)
|
|
212
|
+
|
|
213
|
+
let run = null
|
|
214
|
+
try {
|
|
215
|
+
run = await subagents.start('spawn', {
|
|
216
|
+
label: 'обзор: наш ревизор',
|
|
217
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
218
|
+
parent: ctx.reviewParent,
|
|
219
|
+
signal,
|
|
220
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
221
|
+
})
|
|
222
|
+
const result = await run.result
|
|
223
|
+
const answer = (result?.output ?? [])
|
|
224
|
+
.filter((block) => block?.type === 'text')
|
|
225
|
+
.map((block) => String(block.text ?? ''))
|
|
226
|
+
.join('')
|
|
227
|
+
|
|
228
|
+
if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
229
|
+
if (answer.trim() === '') return { kind, failed: 'пустой ответ', answer: null }
|
|
230
|
+
return { kind, failed: null, cancelled: false, truncated: false, answer, model: model ?? null, effort: effort ?? null }
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
233
|
+
return { kind, failed: String(error?.message ?? error), answer: null }
|
|
234
|
+
} finally {
|
|
235
|
+
if (run !== null) {
|
|
236
|
+
try {
|
|
237
|
+
await run.dispose()
|
|
238
|
+
} catch {
|
|
239
|
+
// Уборка не должна подменять результат обзора.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
117
245
|
/**
|
|
118
246
|
* Запустить одного ревизора и вернуть его ответ.
|
|
119
247
|
*
|
|
120
248
|
* Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
|
|
121
249
|
* целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
|
|
122
250
|
*
|
|
123
|
-
* @param ctx - контекст со службой `subprocess
|
|
251
|
+
* @param ctx - контекст со службой `subprocess`; для нашего ревизора ещё и
|
|
252
|
+
* `subagents`, а в `reviewParent` — агент, от имени которого идёт вызов.
|
|
124
253
|
* @param kind - имя ревизора.
|
|
125
254
|
* @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
|
|
126
255
|
*/
|
|
127
|
-
export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
|
|
256
|
+
export async function runReviewer(ctx, kind, { prompt, cwd, provider, model, effort, signal, timeoutMs }) {
|
|
128
257
|
const spec = REVIEWERS[kind]
|
|
129
258
|
if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
|
|
130
259
|
|
|
131
|
-
|
|
132
|
-
|
|
260
|
+
const allowed = spec.kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
|
|
261
|
+
for (const [label, value] of [['провайдер', provider], ['модель', model], ['усилие', effort]]) {
|
|
262
|
+
if (value !== undefined && value !== null && value !== '' && !allowed.test(String(value))) {
|
|
133
263
|
return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
|
|
134
264
|
}
|
|
135
265
|
}
|
|
@@ -144,8 +274,15 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
144
274
|
return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
145
275
|
}
|
|
146
276
|
|
|
277
|
+
// Наш ревизор — не отдельный продукт, а подагент на нашем маршруте.
|
|
278
|
+
// Ни команды, ни аргументов, ни временных файлов ему не нужно.
|
|
279
|
+
if (spec.kind === 'internal') {
|
|
280
|
+
return runInternal(ctx, kind, { prompt, provider, model, effort, signal })
|
|
281
|
+
}
|
|
282
|
+
|
|
147
283
|
let workDir = null
|
|
148
284
|
let handle = null
|
|
285
|
+
let truncated = false
|
|
149
286
|
// Своя отмена поверх пользовательской: ревизор без ограничения по времени
|
|
150
287
|
// держал бы обзор бесконечно.
|
|
151
288
|
const deadline = new AbortController()
|
|
@@ -175,6 +312,9 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
175
312
|
},
|
|
176
313
|
graceMs: 5000,
|
|
177
314
|
signal: deadline.signal,
|
|
315
|
+
// Точечный возврат собственных ключей продукта: служба вычищает всё
|
|
316
|
+
// похожее на секрет, а ревизор именно ими и авторизуется.
|
|
317
|
+
env: authEnvFor(kind),
|
|
178
318
|
})
|
|
179
319
|
|
|
180
320
|
const outcome = await handle.done
|
|
@@ -221,14 +361,24 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
221
361
|
return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
222
362
|
}
|
|
223
363
|
} else {
|
|
224
|
-
|
|
364
|
+
// Признак потери читается, а не игнорируется.
|
|
365
|
+
//
|
|
366
|
+
// Собранный вывод хранит только ХВОСТ потока: длинный ответ обрезается
|
|
367
|
+
// с НАЧАЛА, а список ревизор начинает с самого опасного. Молча отдать
|
|
368
|
+
// такой обрывок как полный обзор — значит потерять именно то, ради
|
|
369
|
+
// чего обзор затевался.
|
|
370
|
+
const read = await handle.collected.stdout.readFrom(0)
|
|
371
|
+
answer = read.text
|
|
372
|
+
if (read.lossy === true) {
|
|
373
|
+
truncated = true
|
|
374
|
+
}
|
|
225
375
|
}
|
|
226
376
|
|
|
227
377
|
if (answer.trim() === '') {
|
|
228
378
|
return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
229
379
|
}
|
|
230
380
|
|
|
231
|
-
return { kind, failed: null, cancelled: false, answer, model: model ?? null, effort: effort ?? null }
|
|
381
|
+
return { kind, failed: null, cancelled: false, truncated, answer, model: model ?? null, effort: effort ?? null }
|
|
232
382
|
} catch (error) {
|
|
233
383
|
if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
234
384
|
const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
|
|
@@ -239,12 +389,23 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
239
389
|
if (handle !== null) {
|
|
240
390
|
try {
|
|
241
391
|
handle.terminate()
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// это
|
|
246
|
-
// копятся после каждой отмены.
|
|
247
|
-
|
|
392
|
+
// Ждём смерти процесса, но НЕ бесконечно.
|
|
393
|
+
//
|
|
394
|
+
// Ждать нужно: без ожидания уборка идёт при живом процессе с открытым
|
|
395
|
+
// файлом итога, на Windows это отказ, `force` его проглатывает, и
|
|
396
|
+
// временные каталоги копятся после каждой отмены.
|
|
397
|
+
//
|
|
398
|
+
// Ограничивать тоже нужно: `waitForExit` ждёт опустошения всего
|
|
399
|
+
// управляемого диапазона, а не только самой команды. Потомок,
|
|
400
|
+
// который не умирает, держал бы вызов после тайм-аута — в
|
|
401
|
+
// неотменяемом `finally`, где «стоп» уже не поможет.
|
|
402
|
+
const cleanup = new AbortController()
|
|
403
|
+
const cleanupTimer = setTimeout(() => cleanup.abort(), 10000)
|
|
404
|
+
try {
|
|
405
|
+
await handle.waitForExit(cleanup.signal)
|
|
406
|
+
} finally {
|
|
407
|
+
clearTimeout(cleanupTimer)
|
|
408
|
+
}
|
|
248
409
|
} catch {
|
|
249
410
|
// Уборка не должна подменять результат.
|
|
250
411
|
}
|
package/package.json
CHANGED