tensorgrid-ui 1.6.1 → 1.10.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.
@@ -12,7 +12,8 @@
12
12
  * Импортируются только встроенные модули Node.
13
13
  */
14
14
  import { KNOWN } from './reviewers.js'
15
- import { readFile, rename, writeFile } from 'node:fs/promises'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
16
17
  import { existsSync } from 'node:fs'
17
18
  import { dirname, join } from 'node:path'
18
19
  import { fileURLToPath } from 'node:url'
@@ -24,6 +25,7 @@ const FILE_NAME = 'tensorgrid-review.json'
24
25
  * Свой литерал здесь был третьим по счёту и расходился бы молча.
25
26
  */
26
27
  export { KNOWN } from './reviewers.js'
28
+ import { REVIEWERS } from './reviewers.js'
27
29
 
28
30
  /**
29
31
  * Что допустимо в модели и усилии.
@@ -51,27 +53,36 @@ export const MODES = {
51
53
  fast: {
52
54
  label: 'Быстрый',
53
55
  hint: 'Один ревизор, обычное усилие. Минуты. Для рядовой работы.',
54
- enabled: ['claude-code'],
56
+ enabled: ['internal'],
55
57
  effort: null,
56
58
  },
57
59
  normal: {
58
60
  label: 'Обычный',
59
- hint: 'Два разных продукта. Они находят разное — один упускает половину.',
60
- enabled: ['claude-code', 'codex'],
61
+ hint: 'Две разные модели. Они находят разное — одна упускает половину.',
62
+ // Два НАШИХ ревизора на разных семействах моделей.
63
+ //
64
+ // Раньше здесь стояли два чужих продукта. Чистый опыт показал, что
65
+ // обвязка почти ничего не решает — совпадение 13 находок из 14, — а
66
+ // разное находят разные модели. Обе подписки работают и у нас, так что
67
+ // тот же результат достаётся без двух лишних установок сотруднику.
68
+ enabled: ['internal', 'internal-alt'],
61
69
  effort: null,
62
70
  },
63
71
  deep: {
64
72
  label: 'Глубокий',
65
- hint: 'Все ревизоры на полном усилии, включая нашего на выбранной модели. Долго и дорого — для того, что увидят люди.',
73
+ hint: 'Все доступные ревизоры на полном усилии. Долго и дорого — для того, что увидят люди.',
66
74
  // «Глубокий» означает ВСЕ, а не перечисленных поимённо: иначе добавление
67
75
  // четвёртого ревизора молча оставило бы самый тщательный режим без него.
68
76
  // Остальные режимы называют состав явно — в этом и есть их смысл.
77
+ //
78
+ // Сюда попадают и внешние продукты. Если они не установлены, обзор
79
+ // прямо скажет «не найден» — это лучше, чем молча пропустить.
69
80
  enabled: [...KNOWN],
70
81
  effort: 'high',
71
82
  },
72
83
  }
73
84
 
74
- /** Режим по умолчанию: два ревизора — проверенная середина. */
85
+ /** Режим по умолчанию: две модели — проверенная середина. */
75
86
  const DEFAULT_MODE = 'normal'
76
87
 
77
88
  /**
@@ -89,7 +100,16 @@ export function defaults() {
89
100
  // Записи строятся ПО СПИСКУ, а не перечисляются вручную: перечисление
90
101
  // и было тем третьим источником правды, из-за которого добавление
91
102
  // продукта оставило бы выбор неопределённым, а раздел настроек — упавшим.
92
- choices: Object.fromEntries(KNOWN.map((kind) => [kind, { provider: null, model: null, effort: null }])),
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
+ }])),
93
113
  }
94
114
  }
95
115
 
@@ -101,7 +121,14 @@ export function defaults() {
101
121
  * оттого, что разок проверил быстрым режимом.
102
122
  */
103
123
  export function applyMode(settings, mode) {
104
- const spec = MODES[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
105
132
  if (spec === undefined) return settings
106
133
 
107
134
  const choices = {}
@@ -140,10 +167,17 @@ function findProfileRoot(startDir) {
140
167
  *
141
168
  * @param cwd - рабочая папка сессии, запасной ориентир.
142
169
  */
143
- export function settingsPath(cwd) {
144
- const here = dirname(fileURLToPath(import.meta.url))
145
- const root = findProfileRoot(here) ?? (typeof cwd === 'string' && cwd !== '' ? findProfileRoot(cwd) : null)
146
- return root === null ? null : join(root, FILE_NAME)
170
+ export function settingsPath(cwd, root = null) {
171
+ if (root !== null) return join(root, FILE_NAME)
172
+ // Каталог ищется ТОЛЬКО от собственного расположения модуля.
173
+ //
174
+ // Запасной подъём от рабочей папки убран намеренно: журнал рядом прямо
175
+ // объявляет его опасным, и справедливо — подложенная в папку чата отметка
176
+ // об установке увела бы настройки в чужое дерево, то есть в тот самый
177
+ // непроверенный код, который отдан на обзор. Две половины одного продукта
178
+ // не могут искать свой файл по разным правилам.
179
+ const found = findProfileRoot(dirname(fileURLToPath(import.meta.url)))
180
+ return found === null ? null : join(found, FILE_NAME)
147
181
  }
148
182
 
149
183
  /** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
@@ -180,14 +214,15 @@ export function normalise(raw) {
180
214
  }
181
215
  }
182
216
 
183
- // Режим подсказка для интерфейса, а не источник состава: состав уже
184
- // записан в `enabled`. Неизвестное имя превращается в «свой набор»,
185
- // потому что именно им оно и является.
186
- const mode = typeof raw.mode === 'string' && MODES[raw.mode] !== undefined ? raw.mode : null
187
-
188
- // Пустой список допустим только как осознанный выбор «никого не звать»;
189
- // отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
190
- return { mode: mode ?? matchMode(enabled), enabled, choices }
217
+ // Режим ВСЕГДА выводится из состава, а не берётся из файла.
218
+ //
219
+ // Прежде сохранённое имя принималось на веру и никогда не сверялось с
220
+ // `enabled`, хотя соседний комментарий обещал ровно эту сверку. После
221
+ // ручной правки состава человек видел подсвеченным «Быстрый — один
222
+ // ревизор, минуты», а запускались трое на полном усилии: счёт и время
223
+ // втрое против показанного. Состав факт, режим его имя; имя не может
224
+ // противоречить факту.
225
+ return { mode: matchMode(enabled), enabled, choices }
191
226
  }
192
227
 
193
228
  /**
@@ -205,9 +240,64 @@ function matchMode(enabled) {
205
240
  return null
206
241
  }
207
242
 
243
+ /** Есть ли такой режим. Только собственные ключи — см. `applyMode`. */
244
+ export function isMode(name) {
245
+ return typeof name === 'string' && Object.hasOwn(MODES, name)
246
+ }
247
+
248
+ /**
249
+ * Очередь записи на каждый файл.
250
+ *
251
+ * Запись идёт по схеме «прочитать целиком → изменить → записать целиком»,
252
+ * и без очереди одновременные вызовы читают ОДНО состояние, а пишут каждый
253
+ * своё: побеждает последний, остальные исчезают бесследно. Проверено —
254
+ * двенадцать одновременных записей давали одну.
255
+ *
256
+ * Случай не выдуманный: журнал пишут две стороны — Host в конце обзора и
257
+ * браузер по нажатию в панели. Человек подтверждает находку ровно в тот
258
+ * момент, когда завершается второй обзор.
259
+ *
260
+ * Очередь внутрипроцессная, и этого достаточно: оба пути проходят через
261
+ * один Host. От двух ОДНОВРЕМЕННО ЗАПУЩЕННЫХ приложений она не спасает —
262
+ * такой случай потребовал бы файловой блокировки, а он у нас невозможен:
263
+ * второй экземпляр не поднимется на занятом порту.
264
+ */
265
+ const queues = new Map()
266
+
267
+ function serialise(path, work) {
268
+ const previous = queues.get(path) ?? Promise.resolve()
269
+ const next = previous.then(work, work)
270
+ // В очереди держим ТОЛЬКО факт завершения, без результата и без отказа:
271
+ // иначе одна неудачная запись уронила бы все следующие.
272
+ queues.set(path, next.then(() => undefined, () => undefined))
273
+ return next
274
+ }
275
+ /**
276
+ * Переименовать с повтором.
277
+ *
278
+ * На Windows одновременное переименование двух временных файлов в одну цель
279
+ * изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
280
+ * операция. Это не ошибка вызывающего и не повод терять запись — достаточно
281
+ * повторить через несколько миллисекунд.
282
+ *
283
+ * Повторяются только эти два кода. Всё прочее — настоящая ошибка (нет прав
284
+ * на каталог, кончилось место), и глотать её нельзя.
285
+ */
286
+ async function renameWithRetry(from, to, attempts = 5) {
287
+ for (let attempt = 1; ; attempt += 1) {
288
+ try {
289
+ await rename(from, to)
290
+ return
291
+ } catch (error) {
292
+ const retriable = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
293
+ if (!retriable || attempt >= attempts) throw error
294
+ await new Promise((resolve) => setTimeout(resolve, attempt * 15))
295
+ }
296
+ }
297
+ }
208
298
  /** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
209
- export async function readSettings(cwd) {
210
- const path = settingsPath(cwd)
299
+ export async function readSettings(cwd, root = null) {
300
+ const path = settingsPath(cwd, root)
211
301
  if (path === null) return defaults()
212
302
  try {
213
303
  const text = await readFile(path, 'utf8')
@@ -225,12 +315,51 @@ export async function readSettings(cwd) {
225
315
  * или обрывок — а пустота у нас означает «включить обоих ревизоров», то есть
226
316
  * запустить продукт, который человек только что выключил.
227
317
  */
228
- export async function writeSettings(cwd, value) {
229
- const path = settingsPath(cwd)
318
+ export async function writeSettings(cwd, value, root = null) {
319
+ return serialise(settingsPath(cwd, root) ?? 'нет-пути', () => writeSettingsNow(cwd, value, root))
320
+ }
321
+
322
+ async function writeSettingsNow(cwd, value, root) {
323
+ const path = settingsPath(cwd, root)
230
324
  if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
231
- const normalised = normalise(value)
232
- const temporary = `${path}.${process.pid}.tmp`
233
- await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
234
- await rename(temporary, path)
325
+
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)
353
+ // Уникальное имя временного файла: у двух одновременных записей прежнее
354
+ // совпадало, и вторая получала отказ на переименовании — первая уже
355
+ // унесла общий файл.
356
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
357
+ try {
358
+ await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
359
+ await renameWithRetry(temporary, path)
360
+ } catch (error) {
361
+ await rm(temporary, { force: true }).catch(() => {})
362
+ throw error
363
+ }
235
364
  return normalised
236
365
  }
package/lib/reviewers.js CHANGED
@@ -44,9 +44,56 @@ const STDERR_CAP = 64 * 1024
44
44
  * список устарел бы при первом же выпуске нового имени модели.
45
45
  */
46
46
  export const REVIEWERS = {
47
+ // ── Наши ревизоры ────────────────────────────────────────────────────
48
+ //
49
+ // Работают не отдельными продуктами, а нашими подагентами на нашем же
50
+ // маршруте моделей. Их ДВА, и это не прихоть: два разных СЕМЕЙСТВА
51
+ // моделей и есть то разнообразие, ради которого раньше звали чужое.
52
+ //
53
+ // Чистый опыт показал, что обвязка почти ничего не решает: одна модель в
54
+ // Claude Code и у нас дала совпадение 13 находок из 14. Разное находят
55
+ // разные МОДЕЛИ, а не разные обвязки. А обе подписки — Anthropic и
56
+ // OpenAI — доступны прямо здесь, со своими списками моделей и без
57
+ // поштучной оплаты.
58
+ //
59
+ // Список моделей пуст намеренно: он приходит живым из маршрута. Внешние
60
+ // продукты понимают только свои имена, а здесь доступно всё подключённое,
61
+ // включая сотни моделей через OpenRouter.
62
+ internal: {
63
+ label: 'Наш ревизор',
64
+ kind: 'internal',
65
+ models: [],
66
+ efforts: ['low', 'medium', 'high', 'max'],
67
+ defaultProvider: 'anthropic',
68
+ defaultModel: 'claude-opus-5',
69
+ },
70
+ 'internal-alt': {
71
+ label: 'Наш ревизор, вторая модель',
72
+ kind: 'internal',
73
+ models: [],
74
+ efforts: ['low', 'medium', 'high', 'max'],
75
+ // Намеренно ДРУГОЕ семейство: два прогона одной модели дадут почти
76
+ // одинаковые находки, и второй ревизор не окупит своего времени.
77
+ defaultProvider: 'openai-codex',
78
+ defaultModel: 'gpt-6-astra',
79
+ },
80
+
81
+ // ── Внешние продукты ─────────────────────────────────────────────────
82
+ //
83
+ // Оставлены, но НЕ требуются: в умолчания не входят, в установку не
84
+ // включены, и без них всё работает.
85
+ //
86
+ // Причина не в том, что они плохи, а в том, что не добавляют. По деньгам
87
+ // — подписка работает и у нас. По находкам — совпадение 13 из 14. А
88
+ // стоят они двух лишних установок сотруднику и почти всех дефектов,
89
+ // которые пришлось чинить: аргументы, поток ввода, забор ответа,
90
+ // обёртки Windows, ключи авторизации, отмена, тайм-аут.
91
+ //
92
+ // Кому они уже установлены — пусть включает и пользуется.
47
93
  'claude-code': {
48
94
  label: 'Claude Code',
49
95
  kind: 'external',
96
+ optional: true,
50
97
  command: 'claude',
51
98
  // Список — подсказка, а НЕ ограничение: интерфейс даёт ввести и своё имя.
52
99
  // Жёсткий перечень устарел бы при первом же выпуске новой модели, а
@@ -59,27 +106,12 @@ export const REVIEWERS = {
59
106
  codex: {
60
107
  label: 'Codex',
61
108
  kind: 'external',
109
+ optional: true,
62
110
  command: 'codex',
63
111
  models: ['gpt-6-astra', 'gpt-5.6-sol'],
64
112
  efforts: ['low', 'medium', 'high', 'xhigh'],
65
113
  authEnv: ['OPENAI_API_KEY', 'CODEX_HOME'],
66
114
  },
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'],
82
- },
83
115
  }
84
116
 
85
117
  /**
@@ -108,6 +140,19 @@ export const KNOWN = Object.keys(REVIEWERS)
108
140
  const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
109
141
  const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
110
142
 
143
+ /**
144
+ * Что разрешено НАШЕМУ ревизору.
145
+ *
146
+ * Список РАЗРЕШЕНИЙ, а не запретов, и это принципиально. Запрет пришлось бы
147
+ * дополнять при каждом новом инструменте харнесса: забыли один раз — и
148
+ * ревизор снова умеет писать. Разрешение ошибается в безопасную сторону:
149
+ * незнакомый инструмент по умолчанию недоступен.
150
+ *
151
+ * Те же три возможности, что у Claude Code: читать файл, искать по именам,
152
+ * искать по содержимому. Больше обзору не нужно ничего.
153
+ */
154
+ const REVIEW_TOOLS = { allow: ['Read', 'Glob', 'Grep'] }
155
+
111
156
  /**
112
157
  * Вернуть ревизору его собственные ключи авторизации.
113
158
  *
@@ -217,6 +262,18 @@ async function runInternal(ctx, kind, { prompt, provider, model, effort, signal
217
262
  prompt: [{ type: 'text', text: prompt }],
218
263
  parent: ctx.reviewParent,
219
264
  signal,
265
+ // ТОЛЬКО ЧТЕНИЕ — так же, как у внешних ревизоров.
266
+ //
267
+ // Без этого ревизор наследовал бы весь набор инструментов родителя,
268
+ // включая запись файлов и запуск команд. Ровно та дыра, которую оба
269
+ // внешних ревизора назвали критичной у Claude Code: я убрал её там и
270
+ // немедленно завёл здесь, просто не передав ограничения.
271
+ //
272
+ // Асимметрия недопустима: обзор, который «ничего не меняет», не должен
273
+ // иметь права изменить всё — тем более что ревизор по заданию читает
274
+ // чужой непроверенный код и указание внутри этого кода исполнилось бы
275
+ // без подтверждения.
276
+ toolFilter: REVIEW_TOOLS,
220
277
  ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
221
278
  })
222
279
  const result = await run.result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.6.1",
3
+ "version": "1.10.0",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -16,8 +16,18 @@
16
16
  },
17
17
  "./package.json": "./package.json"
18
18
  },
19
+ "bin": {
20
+ "tensorgrid-ui": "lib/cli.mjs"
21
+ },
19
22
  "files": [
20
- "lib"
23
+ "lib",
24
+ "install.ps1",
25
+ "preset",
26
+ "profile",
27
+ "brand/tensorgrid.ico",
28
+ "tools/round-ico-corners.mjs",
29
+ "env/snapshot.json",
30
+ "LICENSE"
21
31
  ],
22
32
  "peerDependencies": {
23
33
  "@deepseek-ai/dsh-tools": "*"