@nitra/cursor 5.0.3 → 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,11 @@
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
+
3
9
  ## [5.0.3] - 2026-06-10
4
10
 
5
11
  ### 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.3",
3
+ "version": "5.1.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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
  }
@@ -77,7 +77,7 @@ const pi = await runBackendAsync('pi')
77
77
  function avg(a) { return a.length ? Math.round(a.reduce((x, y) => x + y, 0) / a.length) : 0 }
78
78
  function median(a) {
79
79
  if (!a.length) return 0
80
- const s = [...a].sort((x, y) => x - y)
80
+ const s = a.toSorted((x, y) => x - y)
81
81
  return s[Math.floor(s.length / 2)]
82
82
  }
83
83
 
@@ -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)