@nitra/cursor 5.0.2 → 5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.1.0] - 2026-06-10
4
+
5
+ ### Changed
6
+
7
+ - local-inference: маршрут моделей за префіксом `omlx/` напряму в omlx HTTP (npm/lib/omlx.mjs) минаючи pi; coverage-classify і fix/llm-worker переведено на спільний callOmlx, docgen де-дубльовано; pi лишається шаром для хмари й агентних задач (ADR 260610-1349)
8
+
9
+ ## [5.0.3] - 2026-06-10
10
+
11
+ ### Changed
12
+
13
+ - docs(abie): regenerate hc-yaml + http-route via omlx-orchestrator
14
+
3
15
  ## [5.0.2] - 2026-06-10
4
16
 
5
17
  ### Changed
package/lib/models.mjs CHANGED
@@ -5,13 +5,21 @@
5
5
  * Налаштовується один раз у середовищі; кожен скіл посилається на потрібний тир.
6
6
  *
7
7
  * Приклад ~/.bashrc або .env:
8
- * N_LOCAL_MIN_MODEL=ollama/gemma3:4b
8
+ * N_LOCAL_MIN_MODEL=omlx/mlx-community--gemma-4-e2b-it-4bit
9
9
  * N_CLOUD_MIN_MODEL=openai/gpt-5.4-mini
10
10
  * N_CLOUD_AVG_MODEL=openai/gpt-5.4
11
11
  * N_CLOUD_MAX_MODEL=openai/gpt-5.5
12
12
  *
13
13
  * Значення '' означає "pi дефолтний провайдер" (залежить від ~/.pi конфігу).
14
14
  *
15
+ * ## Бекенд за префіксом model-id
16
+ *
17
+ * model-id з префіксом `omlx/...` маршрутизується прямим HTTP до локального
18
+ * omlx-сервера (`npm/lib/omlx.mjs`), минаючи pi; решта (`openai/...`,
19
+ * `ollama/...`, '') — через pi CLI. Тому локальні тири варто задавати у форматі
20
+ * `omlx/<model>`, аби local-inference йшов напряму, а pi лишався шаром для хмари
21
+ * (див. ADR 260610-1349).
22
+ *
15
23
  * ## Каскад local → cloud (контракт)
16
24
  *
17
25
  * Використовуйте resolveModel(tier) замість прямих констант — система прозоро
package/lib/omlx.mjs ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Спільний транспорт до локального omlx-сервера (OpenAI-сумісний MLX,
3
+ * `http://localhost:8000/v1/chat/completions`). Text-only: жодних `tools`/
4
+ * `tool_calls` — сервер їх не підтримує (див. ADR
5
+ * `260610-1349-агентна-пастка-js-owned-loop-через-omlx-замість-pi-tool-loop`).
6
+ *
7
+ * Маршрутизація між omlx і pi — за конвенцією префікса в model-id:
8
+ * `omlx/<model>` → прямий HTTP до omlx (локальний inference, без pi)
9
+ * будь-що інше → pi CLI (хмарні провайдери або pi-дефолт)
10
+ *
11
+ * Так `resolveModel(tier)` лишається незмінним: достатньо виставити локальний
12
+ * тир у форматі `N_LOCAL_MIN_MODEL=omlx/mlx-community--gemma-4-e2b-it-4bit`, і
13
+ * виклик сам піде напряму в omlx замість pi.
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { env } from 'node:process'
17
+
18
+ /** Дефолтний endpoint omlx (override — `N_CURSOR_OMLX_URL`). */
19
+ export const DEFAULT_OMLX_URL = 'http://127.0.0.1:8000/v1/chat/completions'
20
+
21
+ /** Дефолтна модель, якщо в id лишився голий `omlx/` (override — `N_CURSOR_OMLX_MODEL`). */
22
+ export const DEFAULT_OMLX_MODEL = 'mlx-community--gemma-4-e2b-it-4bit'
23
+
24
+ const OMLX_PREFIX = 'omlx/'
25
+
26
+ /**
27
+ * Чи цей model-id адресує локальний omlx-бекенд (префікс `omlx/`).
28
+ * @param {unknown} model перевірюваний model-id
29
+ * @returns {boolean} true, якщо рядок починається з `omlx/`
30
+ */
31
+ export function isOmlxModel(model) {
32
+ return typeof model === 'string' && model.startsWith(OMLX_PREFIX)
33
+ }
34
+
35
+ /**
36
+ * Прибирає `omlx/`-префікс → чистий model-id для omlx API.
37
+ * Не-omlx-рядки повертає без змін.
38
+ * @param {string} model model-id (можливо з префіксом)
39
+ * @returns {string} model-id без `omlx/`
40
+ */
41
+ export function omlxModelId(model) {
42
+ return isOmlxModel(model) ? model.slice(OMLX_PREFIX.length) : model
43
+ }
44
+
45
+ /**
46
+ * Прямий HTTP-виклик до omlx через `curl` (spawnSync). Повертає текст
47
+ * `choices[0].message.content`. Ретраїть лише transient curl-помилки
48
+ * (18 = transfer closed, 52 = empty reply, 56 = recv failure).
49
+ *
50
+ * @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено)
51
+ * @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт
52
+ * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string }} [opts]
53
+ * @returns {string} непорожній контент відповіді
54
+ * @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
55
+ */
56
+ export function callOmlx(messages, model, opts = {}) {
57
+ const {
58
+ url = env.N_CURSOR_OMLX_URL ?? DEFAULT_OMLX_URL,
59
+ timeoutMs = 60_000,
60
+ temperature = 0.2,
61
+ maxTokens = 4096,
62
+ fallbackModel = env.N_CURSOR_OMLX_MODEL ?? DEFAULT_OMLX_MODEL
63
+ } = opts
64
+
65
+ const m = omlxModelId(model) || fallbackModel
66
+ const body = JSON.stringify({ model: m, messages, max_tokens: maxTokens, temperature })
67
+
68
+ const TRANSIENT_CURL_CODES = new Set([18, 52, 56])
69
+ let lastErr
70
+ for (let attempt = 1; attempt <= 3; attempt++) {
71
+ const r = spawnSync(
72
+ 'curl',
73
+ ['-sS', '-X', 'POST', url, '-H', 'Content-Type: application/json', '-H', 'Connection: close', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--data-binary', '@-'],
74
+ { input: body, encoding: 'utf8', timeout: timeoutMs + 5000 }
75
+ )
76
+ if (r.error) {
77
+ lastErr = new Error(`omlx curl error: ${r.error.message}`)
78
+ break
79
+ }
80
+ if (r.status !== 0) {
81
+ if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) {
82
+ lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`)
83
+ continue
84
+ }
85
+ throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
86
+ }
87
+ let j
88
+ try {
89
+ j = JSON.parse(r.stdout)
90
+ } catch {
91
+ throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`)
92
+ }
93
+ if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
94
+ const content = j.choices?.[0]?.message?.content?.trim() ?? ''
95
+ if (!content) {
96
+ const finish = j.choices?.[0]?.finish_reason
97
+ throw new Error(`omlx empty content (finish=${finish})`)
98
+ }
99
+ return content
100
+ }
101
+ throw lastErr ?? new Error('omlx unknown failure')
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "5.0.2",
3
+ "version": "5.1.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -2,25 +2,25 @@
2
2
 
3
3
  ## Огляд
4
4
 
5
- Файл виконує структурну валідацію конфігураційного файлу `hc.yaml` для перевірки відповідності даних визначенню політики перевірки стану. Валідація здійснюється порівнянням даних з контрактом `HealthCheckPolicy`, який визначений у рего-файлі. Ця функція забезпечує перевірку відповідно до схеми, визначеної за посиланням https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json. Використовується константа ABIE_HC_SCHEMA_URL, яка позначає цей URL. Результат валідації повертається у форматі булевого значення або null.
5
+ Файл виконує структурну валідацію конфігурації `modeline` у файлах `hc.yaml`. Функція `validateAbieHcModeline` перевіряє відповідність конфігурації визначеному контракту. Валідація проводиться порівнянням конфігурації з визначеною схемою, доступною за посиланням https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json. Цей процес забезпечує коректність конфігурації для ідентифікації (abie.mdc). Експортована константа ABIE_HC_SCHEMA_URL використовується для посилання на цю схему.
6
6
 
7
7
  ## Поведінка
8
8
 
9
- validateAbieHcModeline перевіряє modeline у вхідному контенті.
9
+ validateAbieHcModeline перевіряє modeline у файлі `hc.yaml`.
10
10
 
11
- Перевіряє, чи перший рядок не порожній. Якщо рядок порожній, повертає повідомлення про необхідність формату modeline (abie.mdc).
11
+ Перевіряє, чи перший рядок не порожній. Повертає повідомлення про необхідність наявності modeline `# yaml-language-server: $schema=… (abie.mdc)`.
12
12
 
13
- Перевіряє наявність modeline у першому рядку. Якщо modeline відсутній, повертає повідомлення про необхідність формату modeline (abie.mdc).
13
+ Перевіряє, чи перший рядок містить необхідний modeline. Повертає повідомлення про відсутність modeline $schema (abie.mdc).
14
14
 
15
- Перевіряє, чи значення $schema відповідає очікуваному URL. Якщо значення не відповідає, повертає повідомлення про необхідність використання URL https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json (abie.mdc).
15
+ Перевіряє, чи значення $schema відповідає очікуваному URL. Повертає повідомлення про неправильне значення $schema, включаючи необхідний URL: https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json (abie.mdc).
16
16
 
17
17
  Повертає null у разі успішної валідації.
18
18
 
19
19
  ## Публічний API
20
20
 
21
- ABIE_HC_SCHEMA_URL — Вказує на необхідний URL `$schema` для файлу `hc.yaml` (abie.mdc).
21
+ ABIE_HC_SCHEMA_URL — Зберігає референтний URL `$schema` для файлу `hc.yaml` (abie.mdc).
22
22
 
23
- validateAbieHcModeline — Перевіряє синтаксис modeline (`# yaml-language-server: $schema=...`) у файлі `hc.yaml`.
23
+ validateAbieHcModeline — Перевіряє формат modeline (`# yaml-language-server: $schema=...`) у файлі `hc.yaml`.
24
24
 
25
25
  ## Гарантії поведінки
26
26
 
@@ -2,31 +2,23 @@
2
2
 
3
3
  ## Огляд
4
4
 
5
- Файл надає інструмент для порівняльного аналізу конфігурації. Він використовується для підрахунку кількості посилань на спільні бекенди в базових маніфестах пакета. Ця інформація слугує для синхронізації кількості патчів у потоковому (overlay) прошарку з кількістю базових посилань.
5
+ Файл надає інструмент для порівняльного аналізу конфігурації HTTP-маршрутів. Він виконує порівняння кількості `backendRefs` для сервісів `auth-run-hl` та `file-link-hl` у базових маніфестах пакета з кількістю патчів, визначеною в оверлеях. Цей механізм використовується для синхронізації кількості патчів у верхньому рівні з фактичною кількістю посилань у базі. (abie.mdc)
6
6
 
7
7
  ## Поведінка
8
8
 
9
- ABIE_SHARED_CROSS_NS_BACKEND_NAMES визначає список спільних сервісів, які підлягають аналітиці.
9
+ ABIE_SHARED_CROSS_NS_BACKEND_NAMES
10
+ Визначає список спільних сервісів, які підлягають аналітиці.
10
11
 
11
- ABIE_SHARED_CROSS_NS_BACKEND_SET створює множину спільних сервісів для швидкої перевірки.
12
-
13
- checkSharedBackendRef перевіряє, чи посилається елемент на спільний сервіс, і перевіряє, чи відповідає його імена та namespace вимогам.
14
-
15
- httpRouteDocSharedCrossNsBackendStats збирає кількість посилань на спільні бекенди та фіксує помилки, якщо виявлено порушення вимог до namespace.
16
-
17
- analyzeAbieSharedBackendRefsInPackageK8s збирає статистику щодо посилань на спільні бекенди та помилки щодо namespace з базових YAML-документів пакета, виключаючи оверлей `ua`.
12
+ analyzeAbieSharedBackendRefsInPackageK8s
13
+ Збирає кількість посилань на спільні бекенди та порушення вимог до namespace у базових документах HTTPRoute пакета.
18
14
 
19
15
  ## Публічний API
20
16
 
21
- - ABIE_SHARED_CROSS_NS_BACKEND_NAMES — Ідентифікація назв бекендів, спільних між різними просторами імен.
22
- - analyzeAbieSharedBackendRefsInPackageK8s — Аналізує YAML-файли пакета, збираючи кількість спільних посилань на бекенди та виявляючи базові помилки.
17
+ ABIE_SHARED_CROSS_NS_BACKEND_NAMES — Формує імена для крос-нішових зв'язків між бекендами. (abie.mdc)
18
+
19
+ analyzeAbieSharedBackendRefsInPackageK8s — Збирає кількість спільних посилань `backendRefs` та базові помилки з YAML-файлів пакета, ігноруючи неймспейс `dev`. (abie.mdc)
23
20
 
24
21
  ## Гарантії поведінки
25
22
 
26
- * Функція повертає підрахунок `backendRefs` для спільних сервісів.
27
- * Підрахунок здійснюється у base-маніфестах пакета поза overlay `ua`.
28
- * Використовується `ua_http_route_concern` для синхронізації кількості patch-ів namespace у overlay із кількістю base-reference.
29
- * Функція є read-only.
30
- * Функція не виконує операцій з мережею.
31
- * Функція не використовує кешування.
32
- * Функція не змінює стан системи.
23
+ - Read-only: файл не виконує операцій запису у файлову систему.
24
+ - Не звертається до мережі.
@@ -3,14 +3,19 @@
3
3
  *
4
4
  * Routing:
5
5
  * 1. Cache lookup → hit → використати збережений verdict.
6
- * 2. Cache miss → Tier 1 (LOCAL_MIN через pi) → parseVerdict.
7
- * 3. Tier 1 fail (pi error / bad JSON / Zod) → Tier 2 (CLOUD_MIN через pi).
6
+ * 2. Cache miss → Tier 1 (resolveModel('min')) → parseVerdict.
7
+ * 3. Tier 1 fail (model error / bad JSON / Zod) → Tier 2 (CLOUD_MIN через pi).
8
8
  * 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
9
+ *
10
+ * Бекенд обирається за model-id: `omlx/...` → прямий HTTP до omlx (локально),
11
+ * решта → pi CLI. Якщо omlx-Tier 1 недоступний, помилка падає в той самий catch
12
+ * і класифікація відкочується на хмарний Tier 2 через pi.
9
13
  */
10
14
  import { spawnSync } from 'node:child_process'
11
15
  import { join } from 'node:path'
12
16
 
13
17
  import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
18
+ import { callOmlx, isOmlxModel } from '../../lib/omlx.mjs'
14
19
  import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
15
20
  import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
16
21
  import { parseVerdict } from './verdict-schema.mjs'
@@ -22,13 +27,17 @@ const FALLBACK_VERDICT = {
22
27
  }
23
28
 
24
29
  /**
25
- * Викликає pi і повертає raw stdout.
30
+ * Викликає LLM за model-id і повертає raw текст відповіді.
31
+ * `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI.
26
32
  * @param {string} prompt текст промпта
27
- * @param {string} model provider/model-id або '' для pi-дефолту
28
- * @returns {string} stdout pi-процесу
29
- * @throws якщо pi не знайдено або повертає ненульовий exit code
33
+ * @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
34
+ * @returns {string} текст відповіді моделі
35
+ * @throws якщо backend недоступний або повертає помилку
30
36
  */
31
- function callPi(prompt, model) {
37
+ function callModel(prompt, model) {
38
+ if (isOmlxModel(model)) {
39
+ return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 })
40
+ }
32
41
  const modelArgs = model ? ['--model', model] : []
33
42
  const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
34
43
  encoding: 'utf8',
@@ -44,21 +53,21 @@ function callPi(prompt, model) {
44
53
  * @param {{file: string, mutants: object[]}} group група мутантів одного файлу
45
54
  * @param {object} mutant конкретний мутант
46
55
  * @param {string} cwd корінь проєкту
47
- * @param {(prompt: string, model: string) => string} callPiFn ін'єкція для тестів
56
+ * @param {(prompt: string, model: string) => string} callModelFn ін'єкція для тестів
48
57
  * @returns {object} verdict класифікації
49
58
  */
50
- function classifyOne(group, mutant, cwd, callPiFn) {
59
+ function classifyOne(group, mutant, cwd, callModelFn) {
51
60
  const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
52
61
  const loc = `${group.file}:${mutant.line}:${mutant.col}`
53
62
 
54
63
  // Tier 1: resolveModel('min') — каскад local→cloud якщо локалі нема
55
64
  try {
56
- const text = callPiFn(prompt, resolveModel('min'))
65
+ const text = callModelFn(prompt, resolveModel('min'))
57
66
  return parseVerdict(text)
58
67
  } catch {
59
68
  // Tier 2: CLOUD_MIN
60
69
  try {
61
- const text = callPiFn(prompt, CLOUD_MIN)
70
+ const text = callModelFn(prompt, CLOUD_MIN)
62
71
  return parseVerdict(text)
63
72
  } catch (error) {
64
73
  console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${error.message}`)
@@ -68,15 +77,15 @@ function classifyOne(group, mutant, cwd, callPiFn) {
68
77
  }
69
78
 
70
79
  /**
71
- * Класифікує survived мутантів через pi (LOCAL_MIN → CLOUD_MIN → fallback).
80
+ * Класифікує survived мутантів (resolveModel('min') → CLOUD_MIN → fallback).
72
81
  * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived список вцілілих мутантів
73
82
  * @param {string} cwd корінь проєкту
74
- * @param {{cachePath?: string, callPi?: Function}} [opts] ін'єкції для тестів
83
+ * @param {{cachePath?: string, callModel?: Function}} [opts] ін'єкції для тестів
75
84
  * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
76
85
  */
77
86
  export function classify(survived, cwd, opts = {}) {
78
87
  const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
79
- const callPiFn = opts.callPi ?? callPi
88
+ const callModelFn = opts.callModel ?? callModel
80
89
  const cacheModel = `${resolveModel('min') || 'default'}+${CLOUD_MIN || 'cloud'}`
81
90
 
82
91
  const cache = readCache(cachePath)
@@ -102,7 +111,7 @@ export function classify(survived, cwd, opts = {}) {
102
111
  }
103
112
  }
104
113
  if (!verdict) {
105
- verdict = classifyOne(group, mutant, cwd, callPiFn)
114
+ verdict = classifyOne(group, mutant, cwd, callModelFn)
106
115
  if (cacheKey) {
107
116
  cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
108
117
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * A/B: docgen Tier 1 через pi cli (з omlx-провайдером у ~/.pi/agent/models.json)
3
+ * vs прямий callOmlxMessages (`N_CURSOR_DOCGEN_BACKEND=omlx`).
4
+ *
5
+ * Однаковий 8-сет файлів, однаковий оркестратор (E1+E2+E3+E4), різний backend.
6
+ * Пише в /tmp/docgen-compare/{pi,direct}/<idx>-<stem>.md і збирає метрики.
7
+ *
8
+ * Запуск: node npm/skills/docgen/js/docgen-compare-pi-vs-direct.mjs [--from N] [--limit N]
9
+ */
10
+ import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'
11
+ import { join, resolve, basename } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { execSync } from 'node:child_process'
14
+ import { env } from 'node:process'
15
+ import { generateDoc } from './docgen-gen.mjs'
16
+ import { extractFacts } from './docgen-extract.mjs'
17
+
18
+ const ROOT = resolve(fileURLToPath(import.meta.url), '../../../../..')
19
+ const TMP = '/tmp/docgen-compare'
20
+
21
+ const args = process.argv.slice(2)
22
+ const limitIdx = args.indexOf('--limit')
23
+ const limit = limitIdx !== -1 ? Number(args[limitIdx + 1]) : 8
24
+ const fromIdx = args.indexOf('--from')
25
+ const from = fromIdx !== -1 ? Number(args[fromIdx + 1]) : 1
26
+
27
+ const scanOut = execSync('node npm/bin/n-cursor.js docgen scan', { cwd: ROOT, encoding: 'utf8' })
28
+ const all = JSON.parse(scanOut)
29
+
30
+ const local = []
31
+ for (const f of all) {
32
+ try {
33
+ const src = readFileSync(join(ROOT, f.sourcePath), 'utf8')
34
+ const facts = extractFacts(src, join(ROOT, f.sourcePath))
35
+ const sym = (facts.internalSymbols ?? []).length
36
+ if (sym < 4) local.push({ ...f, sym })
37
+ } catch {}
38
+ }
39
+ const slice = local.slice(from, from + limit)
40
+
41
+ mkdirSync(join(TMP, 'pi'), { recursive: true })
42
+ mkdirSync(join(TMP, 'direct'), { recursive: true })
43
+
44
+ async function runBackendAsync(kind) {
45
+ if (kind === 'direct') env.N_CURSOR_DOCGEN_BACKEND = 'omlx'
46
+ else delete env.N_CURSOR_DOCGEN_BACKEND
47
+ const out = { ok: 0, err: 0, totalMs: 0, scores: [], lengths: [], errors: [], times: [] }
48
+ console.log(`\n══════ Backend: ${kind} ══════`)
49
+ for (let i = 0; i < slice.length; i++) {
50
+ const f = slice[i]
51
+ const t0 = Date.now()
52
+ const stem = basename(f.sourcePath).replace(/\.[^.]+$/, '')
53
+ const destFile = join(TMP, kind, `${String(i + 1).padStart(2, '0')}-${stem}.md`)
54
+ process.stdout.write(` [${i + 1}/${slice.length}] sym=${f.sym} ${f.sourcePath} ... `)
55
+ try {
56
+ const r = await generateDoc(join(ROOT, f.sourcePath), { symThreshold: 999, cloudModel: null })
57
+ writeFileSync(destFile, r.md)
58
+ const ms = Date.now() - t0
59
+ out.ok++
60
+ out.totalMs += ms
61
+ out.times.push(ms)
62
+ out.scores.push(r.score ?? 0)
63
+ out.lengths.push(r.md.length)
64
+ process.stdout.write(`✓ ${Math.round(ms / 1000)}s score=${r.score ?? '?'} chars=${r.md.length}\n`)
65
+ } catch (error) {
66
+ out.err++
67
+ out.errors.push({ path: f.sourcePath, msg: error.message })
68
+ process.stdout.write(`✗ ${error.message}\n`)
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ const direct = await runBackendAsync('direct')
75
+ const pi = await runBackendAsync('pi')
76
+
77
+ function avg(a) { return a.length ? Math.round(a.reduce((x, y) => x + y, 0) / a.length) : 0 }
78
+ function median(a) {
79
+ if (!a.length) return 0
80
+ const s = a.toSorted((x, y) => x - y)
81
+ return s[Math.floor(s.length / 2)]
82
+ }
83
+
84
+ const report = {
85
+ files: slice.map(f => f.sourcePath),
86
+ direct: { ok: direct.ok, err: direct.err, avgMs: avg(direct.times), medianMs: median(direct.times), avgScore: avg(direct.scores), avgChars: avg(direct.lengths), totalSec: Math.round(direct.totalMs / 1000) },
87
+ pi: { ok: pi.ok, err: pi.err, avgMs: avg(pi.times), medianMs: median(pi.times), avgScore: avg(pi.scores), avgChars: avg(pi.lengths), totalSec: Math.round(pi.totalMs / 1000) }
88
+ }
89
+ writeFileSync(join(TMP, 'report.json'), JSON.stringify(report, null, 2))
90
+
91
+ console.log(`\n${'─'.repeat(60)}\nA/B SUMMARY (${slice.length} файлів, той самий оркестратор)\n${'─'.repeat(60)}`)
92
+ console.log(`Backend | ok | err | avg s | median s | avg score | avg chars | total s`)
93
+ console.log(`direct (curl) | ${direct.ok} | ${direct.err} | ${Math.round(report.direct.avgMs / 1000)} | ${Math.round(report.direct.medianMs / 1000)} | ${report.direct.avgScore} | ${report.direct.avgChars} | ${report.direct.totalSec}`)
94
+ console.log(`pi cli | ${pi.ok} | ${pi.err} | ${Math.round(report.pi.avgMs / 1000)} | ${Math.round(report.pi.medianMs / 1000)} | ${report.pi.avgScore} | ${report.pi.avgChars} | ${report.pi.totalSec}`)
95
+ console.log(`\nФайли: ${TMP}/{direct,pi}/<idx>-<stem>.md\nReport: ${TMP}/report.json`)
@@ -46,7 +46,7 @@ function uniq(arr) {
46
46
  * }}
47
47
  */
48
48
  export function extractAnchors(src) {
49
- const urls = uniq([...src.matchAll(URL_RE)].map(m => m[0]))
49
+ const urls = uniq(Array.from(src.matchAll(URL_RE), m => m[0]))
50
50
 
51
51
  const magicStrings = []
52
52
  const seenNames = new Set()
@@ -59,12 +59,12 @@ export function extractAnchors(src) {
59
59
  }
60
60
  }
61
61
 
62
- const errorMarkers = uniq([...src.matchAll(ERROR_MARKER_RE)].map(m => m[1]))
63
- const configRefs = uniq([...src.matchAll(CONFIG_REF_RE)].map(m => m[1]))
62
+ const errorMarkers = uniq(Array.from(src.matchAll(ERROR_MARKER_RE), m => m[1]))
63
+ const configRefs = uniq(Array.from(src.matchAll(CONFIG_REF_RE), m => m[1]))
64
64
 
65
65
  // Витягуємо code-block приклади тільки з file-header — там автор зазвичай показує контракт.
66
66
  const headerMatch = src.match(FILE_HEADER_RE)
67
- const examples = headerMatch ? uniq([...headerMatch[1].matchAll(CODE_BLOCK_RE)].map(m => m[1].trim())) : []
67
+ const examples = headerMatch ? uniq(Array.from(headerMatch[1].matchAll(CODE_BLOCK_RE), m => m[1].trim())) : []
68
68
 
69
69
  return { urls, magicStrings, errorMarkers, configRefs, examples }
70
70
  }
@@ -4,6 +4,7 @@ import { basename } from 'node:path'
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { env } from 'node:process'
6
6
  import { resolveModel } from '../../../lib/models.mjs'
7
+ import { callOmlx } from '../../../lib/omlx.mjs'
7
8
  import { extractFacts } from './docgen-extract.mjs'
8
9
  import { extractAnchors } from './docgen-extract-anchors.mjs'
9
10
  import { oneShotMessages, sectionMessages, criticMessages, refineMessages, guaranteesFromMarkers } from './docgen-prompts.mjs'
@@ -92,50 +93,16 @@ function scoreDoc(md, facts) {
92
93
 
93
94
  /**
94
95
  * omlx-бекенд: справжні OpenAI-сумісні messages (system+user збереженi).
95
- * Вмикається `N_CURSOR_DOCGEN_BACKEND=omlx`.
96
- * URL: `N_CURSOR_DOCGEN_OMLX_URL` або http://127.0.0.1:8000/v1/chat/completions.
97
- * Модель: переданий `model`, потім `N_CURSOR_DOCGEN_OMLX_MODEL`, потім дефолт.
96
+ * Вмикається `N_CURSOR_DOCGEN_BACKEND=omlx`. Делегує у спільний `callOmlx`
97
+ * (npm/lib/omlx.mjs) з docgen-специфічними env-дефолтами URL/моделі.
98
98
  */
99
99
  function callOmlxMessages(messages, model, timeoutMs, temperature = 0.2) {
100
- const url = env.N_CURSOR_DOCGEN_OMLX_URL ?? 'http://127.0.0.1:8000/v1/chat/completions'
101
- const m = model || env.N_CURSOR_DOCGEN_OMLX_MODEL || 'mlx-community--gemma-4-e2b-it-4bit'
102
- const body = JSON.stringify({
103
- model: m,
104
- messages,
105
- max_tokens: 4096,
106
- temperature
100
+ return callOmlx(messages, model, {
101
+ url: env.N_CURSOR_DOCGEN_OMLX_URL,
102
+ timeoutMs,
103
+ temperature,
104
+ fallbackModel: env.N_CURSOR_DOCGEN_OMLX_MODEL
107
105
  })
108
- // Ретраїмо лише transient curl-помилки (18 = transfer closed, 56 = recv failure, 52 = empty reply).
109
- const TRANSIENT_CURL_CODES = new Set([18, 52, 56])
110
- let lastErr
111
- for (let attempt = 1; attempt <= 3; attempt++) {
112
- const r = spawnSync(
113
- 'curl',
114
- ['-sS', '-X', 'POST', url, '-H', 'Content-Type: application/json', '-H', 'Connection: close', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--data-binary', '@-'],
115
- { input: body, encoding: 'utf8', timeout: timeoutMs + 5000 }
116
- )
117
- if (r.error) {
118
- lastErr = new Error(`omlx curl error: ${r.error.message}`)
119
- break
120
- }
121
- if (r.status !== 0) {
122
- if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) {
123
- lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`)
124
- continue
125
- }
126
- throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
127
- }
128
- let j
129
- try { j = JSON.parse(r.stdout) } catch { throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`) }
130
- if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
131
- const content = j.choices?.[0]?.message?.content?.trim() ?? ''
132
- if (!content) {
133
- const finish = j.choices?.[0]?.finish_reason
134
- throw new Error(`omlx empty content (finish=${finish})`)
135
- }
136
- return content
137
- }
138
- throw lastErr ?? new Error('omlx unknown failure')
139
106
  }
140
107
 
141
108
  /**
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { env } from 'node:process'
7
7
  import { resolveModel } from '../../../lib/models.mjs'
8
+ import { callOmlx, isOmlxModel } from '../../../lib/omlx.mjs'
8
9
 
9
10
  // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
10
11
  // Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
@@ -86,12 +87,20 @@ function buildPrompt(ruleId, ruleMdc, output, files) {
86
87
  }
87
88
 
88
89
  /**
89
- * Запускає pi і повертає stdout як рядок.
90
+ * Викликає LLM за model-id і повертає текст відповіді.
91
+ * `omlx/...` → прямий HTTP до omlx (text-only, локально); решта → pi CLI.
90
92
  * @param {string} prompt текст промпта
91
- * @param {string} model назва моделі (provider/id)
92
- * @returns {{ text: string, error?: string }} stdout pi або повідомлення про помилку
93
+ * @param {string} model назва моделі (provider/id, `omlx/...` або '')
94
+ * @returns {{ text: string, error?: string }} текст відповіді або повідомлення про помилку
93
95
  */
94
- function callPi(prompt, model) {
96
+ function callModel(prompt, model) {
97
+ if (isOmlxModel(model)) {
98
+ try {
99
+ return { text: callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000 }) }
100
+ } catch (error) {
101
+ return { text: '', error: error.message }
102
+ }
103
+ }
95
104
  const modelArgs = model ? ['--model', model] : []
96
105
  const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
97
106
  encoding: 'utf8',
@@ -117,9 +126,9 @@ function callPi(prompt, model) {
117
126
  }
118
127
 
119
128
  /**
120
- * Парсить JSON-відповідь від pi.
121
- * pi може обгорнути JSON у ```json ... ```, тому пробуємо витягти.
122
- * @param {string} text сирий stdout pi
129
+ * Парсить JSON-відповідь від моделі.
130
+ * Модель може обгорнути JSON у ```json ... ```, тому пробуємо витягти.
131
+ * @param {string} text сирий текст відповіді
123
132
  * @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null} розпарсений патч або null
124
133
  */
125
134
  function parseResponse(text) {
@@ -183,12 +192,12 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
183
192
  })
184
193
  .filter(Boolean)
185
194
 
186
- // 3. Будуємо prompt і викликаємо pi
195
+ // 3. Будуємо prompt і викликаємо модель
187
196
  const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
188
- const { text, error: piError } = callPi(prompt, model)
197
+ const { text, error: modelError } = callModel(prompt, model)
189
198
 
190
- if (piError) return { ok: false, error: piError }
191
- if (!text) return { ok: false, error: 'pi returned empty response' }
199
+ if (modelError) return { ok: false, error: modelError }
200
+ if (!text) return { ok: false, error: 'model returned empty response' }
192
201
 
193
202
  // 4. Парсимо відповідь
194
203
  const parsed = parseResponse(text)