tensorgrid-ui 1.10.0 → 2.1.0

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.
@@ -6,12 +6,21 @@
6
6
  * значит хранилище должно быть общим для обеих сторон. Память браузера для
7
7
  * этого не годится, Host её не видит.
8
8
  *
9
- * Файл лежит там же, где отметка об установке, и ищется тем же подъёмом
10
- * вверх: так путь не зависит от того, насколько глубоко pnpm разложил пакет.
9
+ * ФОРМА: СПИСОК, А НЕ ФИКСИРОВАННЫЕ ГНЁЗДА
10
+ *
11
+ * Раньше ревизоры были четырьмя жёсткими гнёздами, а сверху стояли три
12
+ * готовых режима — быстрый, обычный, глубокий. Это оказалось не тем
13
+ * решением: готовый режим отвечает за человека на вопрос, на который он
14
+ * вполне способен ответить сам, а четыре гнезда не дают позвать, скажем,
15
+ * три разные модели одного семейства.
16
+ *
17
+ * Теперь это список произвольной длины. Каждая запись говорит: кем
18
+ * работать (нашим подагентом или чужим продуктом), какой моделью, с каким
19
+ * усилием, участвует ли сейчас.
11
20
  *
12
21
  * Импортируются только встроенные модули Node.
13
22
  */
14
- import { KNOWN } from './reviewers.js'
23
+ import { KNOWN, REVIEWERS } from './reviewers.js'
15
24
  import { randomBytes } from 'node:crypto'
16
25
  import { readFile, rename, rm, writeFile } from 'node:fs/promises'
17
26
  import { existsSync } from 'node:fs'
@@ -20,133 +29,160 @@ import { fileURLToPath } from 'node:url'
20
29
 
21
30
  const FILE_NAME = 'tensorgrid-review.json'
22
31
 
23
- /**
24
- * Ревизоры берутся из `reviewers.js` — единственного источника.
25
- * Свой литерал здесь был третьим по счёту и расходился бы молча.
26
- */
32
+ /** Ревизоры берутся из `reviewers.js` — единственного источника. */
27
33
  export { KNOWN } from './reviewers.js'
28
- import { REVIEWERS } from './reviewers.js'
29
34
 
30
35
  /**
31
- * Что допустимо в модели и усилии.
36
+ * Сколько ревизоров можно завести.
32
37
  *
33
- * Те же правила, что и при запуске, но применяются ЗДЕСЬ, при сохранении.
34
- * Раньше проверка жила только в момент вызова: строка вроде «gpt 5 (новая)»
35
- * спокойно сохранялась и отвергалась через двадцать минут, при следующем
36
- * обзоре. В интерфейсе это было невидимо — список с неизвестным значением
37
- * показывает пустое поле, неотличимое от «как настроено».
38
+ * Предел есть, потому что каждый отдельный прогон модели: десяток
39
+ * означает десяток ожиданий и десяток счетов. Число намеренно щедрое:
40
+ * это заслон от случайной сотни, а не указание, сколько «правильно».
38
41
  */
39
- const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
40
- const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,95}$/
42
+ export const MAX_REVIEWERS = 8
41
43
 
42
44
  /**
43
- * Режимы обзора то, чем пользуются, не вникая.
45
+ * Что допустимо в провайдере, модели и усилии.
44
46
  *
45
- * Сотруднику незачем знать разницу между именами моделей. Ему нужно решить
46
- * одно: насколько тщательно проверить. Режим задаёт состав ревизоров и
47
- * усилие; кто хочет открывает подробные настройки и меняет поштучно.
47
+ * Проверка стоит ЗДЕСЬ, при сохранении, а не в момент вызова: раньше
48
+ * кривое значение сохранялось молча и отвергалось через двадцать минут,
49
+ * при следующем обзоре, причём в интерфейсе выглядело как «как настроено».
48
50
  *
49
- * Модель режимы НЕ задают намеренно: у каждого продукта своя настроена, и
50
- * навязывать поверх значило бы ломать то, что человек уже выбрал.
51
+ * Строгий вариант для внешних продуктов: значение уходит в аргументы
52
+ * командной строки, и косая черта там ни в одном имени не нужна.
53
+ * Послабленный — для наших: маршрут называет модели вида `x-ai/grok-4.6`,
54
+ * и общая проверка отвергала бы половину подключённых.
51
55
  */
52
- export const MODES = {
53
- fast: {
54
- label: 'Быстрый',
55
- hint: 'Один ревизор, обычное усилие. Минуты. Для рядовой работы.',
56
- enabled: ['internal'],
57
- effort: null,
58
- },
59
- normal: {
60
- label: 'Обычный',
61
- hint: 'Две разные модели. Они находят разное — одна упускает половину.',
62
- // Два НАШИХ ревизора на разных семействах моделей.
63
- //
64
- // Раньше здесь стояли два чужих продукта. Чистый опыт показал, что
65
- // обвязка почти ничего не решает — совпадение 13 находок из 14, — а
66
- // разное находят разные модели. Обе подписки работают и у нас, так что
67
- // тот же результат достаётся без двух лишних установок сотруднику.
68
- enabled: ['internal', 'internal-alt'],
69
- effort: null,
70
- },
71
- deep: {
72
- label: 'Глубокий',
73
- hint: 'Все доступные ревизоры на полном усилии. Долго и дорого — для того, что увидят люди.',
74
- // «Глубокий» означает ВСЕ, а не перечисленных поимённо: иначе добавление
75
- // четвёртого ревизора молча оставило бы самый тщательный режим без него.
76
- // Остальные режимы называют состав явно — в этом и есть их смысл.
77
- //
78
- // Сюда попадают и внешние продукты. Если они не установлены, обзор
79
- // прямо скажет «не найден» — это лучше, чем молча пропустить.
80
- enabled: [...KNOWN],
81
- effort: 'high',
82
- },
83
- }
84
-
85
- /** Режим по умолчанию: две модели — проверенная середина. */
86
- const DEFAULT_MODE = 'normal'
56
+ const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
57
+ const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
87
58
 
88
59
  /**
89
60
  * Что действует, пока пользователь ничего не выбрал.
90
61
  *
91
- * Два ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
92
- * означал бы, что половина находок не появится у того, кто в настройки не
93
- * заглядывал. Модель и усилие пустые берутся собственные настройки
94
- * продукта, то есть то, чем человек уже пользуется.
62
+ * Два ревизора на РАЗНЫХ семействах моделей из двух подписок. Два
63
+ * потому что они находят разное: один упускает половину. Разных семейств
64
+ * потому что два прогона одной модели дают почти одинаковые находки.
65
+ *
66
+ * Обе модели по подписке: обзор по умолчанию не должен молча тратить
67
+ * деньги поштучно.
95
68
  */
96
69
  export function defaults() {
97
70
  return {
98
- mode: DEFAULT_MODE,
99
- enabled: [...MODES[DEFAULT_MODE].enabled],
100
- // Записи строятся ПО СПИСКУ, а не перечисляются вручную: перечисление
101
- // и было тем третьим источником правды, из-за которого добавление
102
- // продукта оставило бы выбор неопределённым, а раздел настроек — упавшим.
103
- //
104
- // Нашим ревизорам модель проставляется СРАЗУ, и это важно: пустая
105
- // означала бы умолчание маршрута, то есть одну и ту же модель у обоих —
106
- // и второй ревизор стал бы дорогой копией первого. Внешним продуктам
107
- // остаётся пусто: у них своя настроена, навязывать поверх нечего.
108
- choices: Object.fromEntries(KNOWN.map((kind) => [kind, {
109
- provider: REVIEWERS[kind]?.defaultProvider ?? null,
110
- model: REVIEWERS[kind]?.defaultModel ?? null,
111
- effort: null,
112
- }])),
71
+ reviewers: [
72
+ {
73
+ id: 'r1',
74
+ kind: 'internal',
75
+ provider: REVIEWERS.internal.defaultProvider,
76
+ model: REVIEWERS.internal.defaultModel,
77
+ effort: null,
78
+ enabled: true,
79
+ },
80
+ {
81
+ id: 'r2',
82
+ kind: 'internal',
83
+ provider: REVIEWERS['internal-alt'].defaultProvider,
84
+ model: REVIEWERS['internal-alt'].defaultModel,
85
+ effort: null,
86
+ enabled: true,
87
+ },
88
+ ],
113
89
  }
114
90
  }
115
91
 
92
+ /** Признак записи. Короткий и без общих счётчиков: их сложно держать в файле. */
93
+ function freshId() {
94
+ return `r${randomBytes(4).toString('hex')}`
95
+ }
96
+
116
97
  /**
117
- * Применить режим, сохранив поштучный выбор моделей.
98
+ * Привести прочитанное к ожидаемой форме: файл правит и человек руками.
118
99
  *
119
- * Переключение режима меняет СОСТАВ и усилие, но не трогает выбранные
120
- * модели: человек, настроивший модель для Codex, не должен терять её
121
- * оттого, что разок проверил быстрым режимом.
100
+ * Понимает и ПРЕЖНЮЮ форму `enabled` + `choices` + `mode`. Без этого
101
+ * переход на список молча обнулил бы настройки у всех, кто уже что-то
102
+ * выбрал: файл бы не разобрался, а пустой файл означает умолчания.
122
103
  */
123
- export function applyMode(settings, mode) {
124
- // Принадлежность проверяется по СОБСТВЕННЫМ ключам.
125
- //
126
- // `MODES[mode] !== undefined` пропускало унаследованные имена: у обычного
127
- // объекта есть `constructor`, `toString` и прочие. Строка `"mode":
128
- // "constructor"` в правленом файле принималась как допустимый режим, а
129
- // нажатие любой кнопки уходило сюда и роняло маршрут: `spec.enabled` у
130
- // функции-конструктора не перебирается.
131
- const spec = Object.hasOwn(MODES, mode) ? MODES[mode] : undefined
132
- if (spec === undefined) return settings
104
+ export function normalise(raw) {
105
+ if (raw === null || typeof raw !== 'object') return defaults()
133
106
 
134
- const choices = {}
135
- for (const kind of KNOWN) {
136
- const previous = settings.choices?.[kind] ?? {}
137
- choices[kind] = {
138
- provider: previous.provider ?? null,
139
- model: previous.model ?? null,
140
- effort: spec.effort,
107
+ const source = Array.isArray(raw.reviewers) ? raw.reviewers : migrate(raw)
108
+ if (source === null) return defaults()
109
+
110
+ const seen = new Set()
111
+ const reviewers = []
112
+
113
+ for (const entry of source) {
114
+ if (reviewers.length >= MAX_REVIEWERS) break
115
+ if (entry === null || typeof entry !== 'object') continue
116
+
117
+ const kind = KNOWN.includes(entry.kind) ? entry.kind : null
118
+ if (kind === null) continue
119
+
120
+ const allowed = REVIEWERS[kind].kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
121
+ const pick = (value) => {
122
+ if (typeof value !== 'string') return null
123
+ const trimmed = value.trim()
124
+ return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
141
125
  }
126
+
127
+ // Признак обязан быть уникальным: по нему интерфейс отличает строки, и
128
+ // совпадение слило бы две записи в одну при первой же правке.
129
+ let id = typeof entry.id === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(entry.id) ? entry.id : freshId()
130
+ while (seen.has(id)) id = freshId()
131
+ seen.add(id)
132
+
133
+ reviewers.push({
134
+ id,
135
+ kind,
136
+ // Провайдер имеет смысл только у наших: внешний продукт ходит своим
137
+ // путём, и наш маршрут ему не указ.
138
+ provider: REVIEWERS[kind].kind === 'internal' ? pick(entry.provider) : null,
139
+ model: pick(entry.model),
140
+ effort: pick(entry.effort),
141
+ enabled: entry.enabled !== false,
142
+ })
142
143
  }
143
- return { mode, enabled: [...spec.enabled], choices }
144
+
145
+ // Пустой список допустим: это осознанный выбор «никого не звать», и
146
+ // подменять его умолчаниями значило бы звать тех, кого выключили.
147
+ return { reviewers }
144
148
  }
145
149
 
146
150
  /**
147
- * Каталог профиля: поднимаемся, пока не найдём отметку об установке.
148
- * @param startDir - откуда начинать подъём.
151
+ * Перевести настройки прежней формы в список.
152
+ *
153
+ * Прежде было: `enabled` — какие гнёзда включены, `choices` — настройка
154
+ * каждого гнезда. Переносим один к одному, сохраняя и выключенные: человек
155
+ * их настраивал, и терять эту работу при обновлении незачем.
156
+ *
157
+ * @returns записи в новой форме либо null, если прежней формы не видно.
149
158
  */
159
+ function migrate(raw) {
160
+ const hasOld = Array.isArray(raw.enabled) || (raw.choices !== null && typeof raw.choices === 'object')
161
+ if (!hasOld) return null
162
+
163
+ const enabled = new Set(Array.isArray(raw.enabled) ? raw.enabled : [])
164
+ const entries = []
165
+
166
+ for (const kind of KNOWN) {
167
+ const choice = raw.choices?.[kind]
168
+ const configured = choice !== undefined && choice !== null && typeof choice === 'object'
169
+ // Гнездо, которое не включали и не настраивали, переносить незачем:
170
+ // иначе список сразу открывается четырьмя пустыми строками.
171
+ if (!enabled.has(kind) && !configured) continue
172
+ entries.push({
173
+ id: freshId(),
174
+ kind,
175
+ provider: configured ? choice.provider : null,
176
+ model: configured ? choice.model : null,
177
+ effort: configured ? choice.effort : null,
178
+ enabled: enabled.has(kind),
179
+ })
180
+ }
181
+
182
+ return entries
183
+ }
184
+
185
+ /** Каталог профиля: поднимаемся, пока не найдём отметку об установке. */
150
186
  function findProfileRoot(startDir) {
151
187
  let dir = startDir
152
188
  for (let depth = 0; depth < 10; depth += 1) {
@@ -161,90 +197,21 @@ function findProfileRoot(startDir) {
161
197
  /**
162
198
  * Где лежит файл настроек.
163
199
  *
164
- * Сначала ищем от собственного расположения так работает установленный
165
- * пакет. Если отметки нет (запуск из репозитория, проверка), кладём рядом с
166
- * рабочей папкой, чтобы проверяемый код вёл себя как настоящий.
200
+ * Каталог ищется ТОЛЬКО от собственного расположения модуля. Запасной
201
+ * подъём от рабочей папки убран намеренно: подложенная в папку чата отметка
202
+ * об установке увела бы настройки в чужое дерево — в тот самый
203
+ * непроверенный код, который отдан на обзор. Две половины одного продукта
204
+ * не могут искать свой файл по разным правилам.
167
205
  *
168
- * @param cwd - рабочая папка сессии, запасной ориентир.
206
+ * @param cwd - оставлен ради совместимости вызовов; не используется.
207
+ * @param root - явный каталог; только для проверок.
169
208
  */
170
209
  export function settingsPath(cwd, root = null) {
171
210
  if (root !== null) return join(root, FILE_NAME)
172
- // Каталог ищется ТОЛЬКО от собственного расположения модуля.
173
- //
174
- // Запасной подъём от рабочей папки убран намеренно: журнал рядом прямо
175
- // объявляет его опасным, и справедливо — подложенная в папку чата отметка
176
- // об установке увела бы настройки в чужое дерево, то есть в тот самый
177
- // непроверенный код, который отдан на обзор. Две половины одного продукта
178
- // не могут искать свой файл по разным правилам.
179
211
  const found = findProfileRoot(dirname(fileURLToPath(import.meta.url)))
180
212
  return found === null ? null : join(found, FILE_NAME)
181
213
  }
182
214
 
183
- /** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
184
- export function normalise(raw) {
185
- const base = defaults()
186
- if (raw === null || typeof raw !== 'object') return base
187
-
188
- // Повторы отбрасываются: список запускает по процессу на элемент, и сотня
189
- // одинаковых имён означала бы сотню платных обзоров разом.
190
- const enabled = Array.isArray(raw.enabled)
191
- ? [...new Set(raw.enabled.filter((kind) => KNOWN.includes(kind)))]
192
- : base.enabled
193
-
194
- const choices = {}
195
- for (const kind of KNOWN) {
196
- const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
197
- // Недопустимое значение отбрасывается молча и превращается в «как
198
- // настроено»: сохранить его значило бы выключить обзор до тех пор, пока
199
- // кто-нибудь не догадается открыть файл.
200
- //
201
- // Наш ревизор ходит по маршруту, где имена содержат косую черту
202
- // (`x-ai/grok-4.6`), внешние — по командной строке, где её быть не
203
- // должно. Одна общая проверка отвергала бы половину моделей.
204
- const allowed = kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
205
- const pick = (value) => {
206
- if (typeof value !== 'string') return null
207
- const trimmed = value.trim()
208
- return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
209
- }
210
- choices[kind] = {
211
- provider: pick(source?.provider),
212
- model: pick(source?.model),
213
- effort: pick(source?.effort),
214
- }
215
- }
216
-
217
- // Режим ВСЕГДА выводится из состава, а не берётся из файла.
218
- //
219
- // Прежде сохранённое имя принималось на веру и никогда не сверялось с
220
- // `enabled`, хотя соседний комментарий обещал ровно эту сверку. После
221
- // ручной правки состава человек видел подсвеченным «Быстрый — один
222
- // ревизор, минуты», а запускались трое на полном усилии: счёт и время
223
- // втрое против показанного. Состав — факт, режим — его имя; имя не может
224
- // противоречить факту.
225
- return { mode: matchMode(enabled), enabled, choices }
226
- }
227
-
228
- /**
229
- * Узнать режим по составу ревизоров.
230
- *
231
- * Нужно, когда состав правили поштучно: режим в файле мог остаться от
232
- * прошлого выбора и врал бы в интерфейсе. Не совпал ни с одним — значит
233
- * набор свой, так и покажем.
234
- */
235
- function matchMode(enabled) {
236
- const same = (a, b) => a.length === b.length && a.every((item) => b.includes(item))
237
- for (const [name, spec] of Object.entries(MODES)) {
238
- if (same(spec.enabled, enabled)) return name
239
- }
240
- return null
241
- }
242
-
243
- /** Есть ли такой режим. Только собственные ключи — см. `applyMode`. */
244
- export function isMode(name) {
245
- return typeof name === 'string' && Object.hasOwn(MODES, name)
246
- }
247
-
248
215
  /**
249
216
  * Очередь записи на каждый файл.
250
217
  *
@@ -253,35 +220,26 @@ export function isMode(name) {
253
220
  * своё: побеждает последний, остальные исчезают бесследно. Проверено —
254
221
  * двенадцать одновременных записей давали одну.
255
222
  *
256
- * Случай не выдуманный: журнал пишут две стороны — Host в конце обзора и
257
- * браузер по нажатию в панели. Человек подтверждает находку ровно в тот
258
- * момент, когда завершается второй обзор.
259
- *
260
- * Очередь внутрипроцессная, и этого достаточно: оба пути проходят через
261
- * один Host. От двух ОДНОВРЕМЕННО ЗАПУЩЕННЫХ приложений она не спасает —
262
- * такой случай потребовал бы файловой блокировки, а он у нас невозможен:
263
- * второй экземпляр не поднимется на занятом порту.
223
+ * Очередь внутрипроцессная, и этого достаточно: оба пути — Host и браузер
224
+ * проходят через один процесс.
264
225
  */
265
226
  const queues = new Map()
266
227
 
267
228
  function serialise(path, work) {
268
229
  const previous = queues.get(path) ?? Promise.resolve()
269
230
  const next = previous.then(work, work)
270
- // В очереди держим ТОЛЬКО факт завершения, без результата и без отказа:
271
- // иначе одна неудачная запись уронила бы все следующие.
231
+ // В очереди держим только факт завершения: иначе одна неудачная запись
232
+ // уронила бы все следующие.
272
233
  queues.set(path, next.then(() => undefined, () => undefined))
273
234
  return next
274
235
  }
236
+
275
237
  /**
276
238
  * Переименовать с повтором.
277
239
  *
278
240
  * На Windows одновременное переименование двух временных файлов в одну цель
279
241
  * изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
280
- * операция. Это не ошибка вызывающего и не повод терять запись — достаточно
281
- * повторить через несколько миллисекунд.
282
- *
283
- * Повторяются только эти два кода. Всё прочее — настоящая ошибка (нет прав
284
- * на каталог, кончилось место), и глотать её нельзя.
242
+ * операция. Это не ошибка вызывающего и не повод терять запись.
285
243
  */
286
244
  async function renameWithRetry(from, to, attempts = 5) {
287
245
  for (let attempt = 1; ; attempt += 1) {
@@ -295,6 +253,7 @@ async function renameWithRetry(from, to, attempts = 5) {
295
253
  }
296
254
  }
297
255
  }
256
+
298
257
  /** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
299
258
  export async function readSettings(cwd, root = null) {
300
259
  const path = settingsPath(cwd, root)
@@ -310,10 +269,10 @@ export async function readSettings(cwd, root = null) {
310
269
  /**
311
270
  * Записать настройки. Возвращает то, что действительно сохранено.
312
271
  *
313
- * Запись идёт через временный файл с переименованием. Прямая перезапись
314
- * сначала обрезает файл, и чтение, пришедшееся на этот момент, видит пустоту
315
- * или обрывок а пустота у нас означает «включить обоих ревизоров», то есть
316
- * запустить продукт, который человек только что выключил.
272
+ * Присланное ЗАМЕЩАЕТ список целиком, и это правильно именно для списка:
273
+ * удаление записи иначе было бы невыразимо слияние всегда возвращало бы
274
+ * удалённое обратно. Ответственность за целость списка лежит на том, кто
275
+ * его прислал, а интерфейс всегда шлёт его полностью.
317
276
  */
318
277
  export async function writeSettings(cwd, value, root = null) {
319
278
  return serialise(settingsPath(cwd, root) ?? 'нет-пути', () => writeSettingsNow(cwd, value, root))
@@ -323,36 +282,9 @@ async function writeSettingsNow(cwd, value, root) {
323
282
  const path = settingsPath(cwd, root)
324
283
  if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
325
284
 
326
- // Присланное СЛИВАЕТСЯ с тем, что на диске, а не кладётся поверх.
327
- //
328
- // Прежде маршрут писал объект целиком. Две открытые вкладки настроек — и
329
- // та, что открыта дольше, одним нажатием галочки возвращала старые модели
330
- // и усилия: она шлёт свой устаревший снимок целиком. Любой отправитель,
331
- // приславший только `enabled`, стирал ВЕСЬ выбор моделей без единого слова.
332
- //
333
- // Сливаем поштучно: пришло поле — берём пришедшее, не пришло — оставляем
334
- // сохранённое.
335
- const current = await readSettings(cwd, root)
336
- const merged = {
337
- enabled: Array.isArray(value?.enabled) ? value.enabled : current.enabled,
338
- choices: Object.fromEntries(
339
- KNOWN.map((kind) => {
340
- const was = current.choices[kind] ?? { provider: null, model: null, effort: null }
341
- const came = value?.choices?.[kind]
342
- if (came === undefined || came === null || typeof came !== 'object') return [kind, was]
343
- return [kind, {
344
- provider: 'provider' in came ? came.provider : was.provider,
345
- model: 'model' in came ? came.model : was.model,
346
- effort: 'effort' in came ? came.effort : was.effort,
347
- }]
348
- }),
349
- ),
350
- }
351
-
352
- const normalised = normalise(merged)
285
+ const normalised = normalise(value)
353
286
  // Уникальное имя временного файла: у двух одновременных записей прежнее
354
- // совпадало, и вторая получала отказ на переименовании — первая уже
355
- // унесла общий файл.
287
+ // совпадало, и вторая получала отказ на переименовании.
356
288
  const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
357
289
  try {
358
290
  await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
package/lib/review.js CHANGED
@@ -292,17 +292,17 @@ export function parseFindings(text, reviewer) {
292
292
  * Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
293
293
  * пользователь должен видеть, кто именно не ответил.
294
294
  */
295
- async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal) {
295
+ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, entry, signal) {
296
296
  const outcome = await runReviewer(ctx, kind, {
297
297
  prompt,
298
298
  cwd,
299
- provider: choice?.provider ?? null,
300
- model: choice?.model ?? null,
301
- effort: choice?.effort ?? null,
299
+ provider: entry?.provider ?? null,
300
+ model: entry?.model ?? null,
301
+ effort: entry?.effort ?? null,
302
302
  signal,
303
303
  })
304
304
 
305
- const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
305
+ const used = { model: entry?.model ?? null, effort: entry?.effort ?? null }
306
306
 
307
307
  if (outcome.failed !== null) {
308
308
  return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
@@ -458,47 +458,57 @@ export async function apply(ctx) {
458
458
  }
459
459
  }
460
460
 
461
- // Выбор пользователя из настроек; вызов может его перекрыть.
461
+ // Состав целиком из настроек: список, который человек собрал сам.
462
462
  const settings = await readSettings(cwd)
463
- const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
464
- ? args.reviewers
465
- : settings.enabled
466
-
467
- // Повторы отбрасываются, и это не придирка: `reviewers` заполняет
468
- // модель, а список из ста одинаковых имён запустил бы сто платных
469
- // процессов разом по одному на каждый элемент.
470
- const wanted = [...new Set(asked)].filter((kind) => KNOWN_REVIEWERS.includes(kind))
471
-
472
- if (wanted.length === 0) {
473
- const problem = asked.length === 0
474
- ? 'не выбран ни один ревизор — включите их в настройках'
475
- : `неизвестные ревизоры: ${[...new Set(asked)].join(', ')}`
476
- return { findings: [], reviewers: [], problem }
463
+ let chosen = settings.reviewers.filter((entry) => entry.enabled)
464
+
465
+ // Вызов может сузить состав по виду ревизора — например «только
466
+ // Codex». Это именно СУЖЕНИЕ настроенного, а не свой набор: модель
467
+ // и усилие всё равно берутся из настроек, потому что их выбирал
468
+ // человек, а не тот, кто зовёт инструмент.
469
+ if (Array.isArray(args.reviewers) && args.reviewers.length > 0) {
470
+ const kinds = new Set(args.reviewers.filter((kind) => KNOWN_REVIEWERS.includes(kind)))
471
+ if (kinds.size > 0) chosen = chosen.filter((entry) => kinds.has(entry.kind))
472
+ }
473
+
474
+ if (chosen.length === 0) {
475
+ const anyConfigured = settings.reviewers.length > 0
476
+ return {
477
+ findings: [],
478
+ reviewers: [],
479
+ problem: anyConfigured
480
+ ? 'ни один ревизор не включён — включите нужных в настройках'
481
+ : 'ревизоры не настроены — добавьте хотя бы одного в настройках',
482
+ }
477
483
  }
478
484
 
479
485
  const prompt = buildPrompt(args)
480
486
 
481
- // Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
482
- // удвоил бы ожидание без всякой пользы.
487
+ // Спрашиваем ВСЕХ разом: они независимы, и последовательный опрос
488
+ // умножил бы ожидание без всякой пользы.
483
489
  const answers = await Promise.all(
484
- wanted.map((kind) =>
490
+ chosen.map((entry) =>
485
491
  askReviewer(
486
492
  // Наш ревизор заводит подагента, и тому нужен родитель —
487
493
  // агент, от имени которого идёт вызов. Внешним он не нужен.
488
494
  { ...ctx, get: (name) => ctx.get(name), reviewParent: exec.agent },
489
495
  runReviewer,
490
- kind,
496
+ entry.kind,
491
497
  prompt,
492
498
  cwd,
493
- settings.choices[kind],
499
+ entry,
494
500
  exec.signal,
495
501
  ),
496
502
  ),
497
503
  )
498
504
 
499
505
  const findings = ordered(answers.flatMap((entry) => entry.findings))
500
- const reviewers = answers.map((entry) => ({
506
+ const reviewers = answers.map((entry, index) => ({
501
507
  provider: entry.provider,
508
+ // Признак записи сохраняется вместе с итогом: в списке может быть
509
+ // несколько ревизоров одного вида на разных моделях, и без него
510
+ // статистика слила бы их в одну строку.
511
+ slot: chosen[index].id,
502
512
  failed: entry.failed,
503
513
  cancelled: entry.cancelled === true,
504
514
  found: entry.findings.length,
package/lib/reviewers.js CHANGED
@@ -63,7 +63,12 @@ export const REVIEWERS = {
63
63
  label: 'Наш ревизор',
64
64
  kind: 'internal',
65
65
  models: [],
66
- efforts: ['low', 'medium', 'high', 'max'],
66
+ // Уровни усилия НЕ перечисляются: у каждой модели они свои, и зашитый
67
+ // список врал почти везде. Проверено на живых данных: у claude-opus-5
68
+ // их шесть, у deepseek-v4-pro — три, а одна и та же модель через
69
+ // подписку и через OpenRouter даёт РАЗНЫЕ наборы. Их спрашивают у
70
+ // маршрута по выбранной модели.
71
+ efforts: [],
67
72
  defaultProvider: 'anthropic',
68
73
  defaultModel: 'claude-opus-5',
69
74
  },
@@ -71,7 +76,7 @@ export const REVIEWERS = {
71
76
  label: 'Наш ревизор, вторая модель',
72
77
  kind: 'internal',
73
78
  models: [],
74
- efforts: ['low', 'medium', 'high', 'max'],
79
+ efforts: [],
75
80
  // Намеренно ДРУГОЕ семейство: два прогона одной модели дадут почти
76
81
  // одинаковые находки, и второй ревизор не окупит своего времени.
77
82
  defaultProvider: 'openai-codex',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.10.0",
3
+ "version": "2.1.0",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",