@nitra/cursor
Advanced tools
+102
| /** | ||
| * Спільний транспорт до локального omlx-сервера (OpenAI-сумісний MLX, | ||
| * `http://localhost:8000/v1/chat/completions`). Text-only: жодних `tools`/ | ||
| * `tool_calls` — сервер їх не підтримує (див. ADR | ||
| * `260610-1349-агентна-пастка-js-owned-loop-через-omlx-замість-pi-tool-loop`). | ||
| * | ||
| * Маршрутизація між omlx і pi — за конвенцією префікса в model-id: | ||
| * `omlx/<model>` → прямий HTTP до omlx (локальний inference, без pi) | ||
| * будь-що інше → pi CLI (хмарні провайдери або pi-дефолт) | ||
| * | ||
| * Так `resolveModel(tier)` лишається незмінним: достатньо виставити локальний | ||
| * тир у форматі `N_LOCAL_MIN_MODEL=omlx/mlx-community--gemma-4-e2b-it-4bit`, і | ||
| * виклик сам піде напряму в omlx замість pi. | ||
| */ | ||
| import { spawnSync } from 'node:child_process' | ||
| import { env } from 'node:process' | ||
| /** Дефолтний endpoint omlx (override — `N_CURSOR_OMLX_URL`). */ | ||
| export const DEFAULT_OMLX_URL = 'http://127.0.0.1:8000/v1/chat/completions' | ||
| /** Дефолтна модель, якщо в id лишився голий `omlx/` (override — `N_CURSOR_OMLX_MODEL`). */ | ||
| export const DEFAULT_OMLX_MODEL = 'mlx-community--gemma-4-e2b-it-4bit' | ||
| const OMLX_PREFIX = 'omlx/' | ||
| /** | ||
| * Чи цей model-id адресує локальний omlx-бекенд (префікс `omlx/`). | ||
| * @param {unknown} model перевірюваний model-id | ||
| * @returns {boolean} true, якщо рядок починається з `omlx/` | ||
| */ | ||
| export function isOmlxModel(model) { | ||
| return typeof model === 'string' && model.startsWith(OMLX_PREFIX) | ||
| } | ||
| /** | ||
| * Прибирає `omlx/`-префікс → чистий model-id для omlx API. | ||
| * Не-omlx-рядки повертає без змін. | ||
| * @param {string} model model-id (можливо з префіксом) | ||
| * @returns {string} model-id без `omlx/` | ||
| */ | ||
| export function omlxModelId(model) { | ||
| return isOmlxModel(model) ? model.slice(OMLX_PREFIX.length) : model | ||
| } | ||
| /** | ||
| * Прямий HTTP-виклик до omlx через `curl` (spawnSync). Повертає текст | ||
| * `choices[0].message.content`. Ретраїть лише transient curl-помилки | ||
| * (18 = transfer closed, 52 = empty reply, 56 = recv failure). | ||
| * | ||
| * @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено) | ||
| * @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт | ||
| * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string }} [opts] | ||
| * @returns {string} непорожній контент відповіді | ||
| * @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті | ||
| */ | ||
| export function callOmlx(messages, model, opts = {}) { | ||
| const { | ||
| url = env.N_CURSOR_OMLX_URL ?? DEFAULT_OMLX_URL, | ||
| timeoutMs = 60_000, | ||
| temperature = 0.2, | ||
| maxTokens = 4096, | ||
| fallbackModel = env.N_CURSOR_OMLX_MODEL ?? DEFAULT_OMLX_MODEL | ||
| } = opts | ||
| const m = omlxModelId(model) || fallbackModel | ||
| const body = JSON.stringify({ model: m, messages, max_tokens: maxTokens, temperature }) | ||
| const TRANSIENT_CURL_CODES = new Set([18, 52, 56]) | ||
| let lastErr | ||
| for (let attempt = 1; attempt <= 3; attempt++) { | ||
| const r = spawnSync( | ||
| 'curl', | ||
| ['-sS', '-X', 'POST', url, '-H', 'Content-Type: application/json', '-H', 'Connection: close', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--data-binary', '@-'], | ||
| { input: body, encoding: 'utf8', timeout: timeoutMs + 5000 } | ||
| ) | ||
| if (r.error) { | ||
| lastErr = new Error(`omlx curl error: ${r.error.message}`) | ||
| break | ||
| } | ||
| if (r.status !== 0) { | ||
| if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) { | ||
| lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`) | ||
| continue | ||
| } | ||
| throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`) | ||
| } | ||
| let j | ||
| try { | ||
| j = JSON.parse(r.stdout) | ||
| } catch { | ||
| throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`) | ||
| } | ||
| if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`) | ||
| const content = j.choices?.[0]?.message?.content?.trim() ?? '' | ||
| if (!content) { | ||
| const finish = j.choices?.[0]?.finish_reason | ||
| throw new Error(`omlx empty content (finish=${finish})`) | ||
| } | ||
| return content | ||
| } | ||
| throw lastErr ?? new Error('omlx unknown failure') | ||
| } |
+9
-1
@@ -8,3 +8,3 @@ /** | ||
| * Приклад ~/.bashrc або .env: | ||
| * N_LOCAL_MIN_MODEL=ollama/gemma3:4b | ||
| * N_LOCAL_MIN_MODEL=omlx/mlx-community--gemma-4-e2b-it-4bit | ||
| * N_CLOUD_MIN_MODEL=openai/gpt-5.4-mini | ||
@@ -16,2 +16,10 @@ * N_CLOUD_AVG_MODEL=openai/gpt-5.4 | ||
| * | ||
| * ## Бекенд за префіксом model-id | ||
| * | ||
| * model-id з префіксом `omlx/...` маршрутизується прямим HTTP до локального | ||
| * omlx-сервера (`npm/lib/omlx.mjs`), минаючи pi; решта (`openai/...`, | ||
| * `ollama/...`, '') — через pi CLI. Тому локальні тири варто задавати у форматі | ||
| * `omlx/<model>`, аби local-inference йшов напряму, а pi лишався шаром для хмари | ||
| * (див. ADR 260610-1349). | ||
| * | ||
| * ## Каскад local → cloud (контракт) | ||
@@ -18,0 +26,0 @@ * |
+1
-1
| { | ||
| "name": "@nitra/cursor", | ||
| "version": "5.0.3", | ||
| "version": "5.1.0", | ||
| "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -6,5 +6,9 @@ /** | ||
| * 1. Cache lookup → hit → використати збережений verdict. | ||
| * 2. Cache miss → Tier 1 (LOCAL_MIN через pi) → parseVerdict. | ||
| * 3. Tier 1 fail (pi error / bad JSON / Zod) → Tier 2 (CLOUD_MIN через pi). | ||
| * 2. Cache miss → Tier 1 (resolveModel('min')) → parseVerdict. | ||
| * 3. Tier 1 fail (model error / bad JSON / Zod) → Tier 2 (CLOUD_MIN через pi). | ||
| * 4. Tier 2 fail → conservative fallback worth-testing/confidence=0. | ||
| * | ||
| * Бекенд обирається за model-id: `omlx/...` → прямий HTTP до omlx (локально), | ||
| * решта → pi CLI. Якщо omlx-Tier 1 недоступний, помилка падає в той самий catch | ||
| * і класифікація відкочується на хмарний Tier 2 через pi. | ||
| */ | ||
@@ -15,2 +19,3 @@ import { spawnSync } from 'node:child_process' | ||
| import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs' | ||
| import { callOmlx, isOmlxModel } from '../../lib/omlx.mjs' | ||
| import { deriveCacheKey, readCache, writeCache } from './cache.mjs' | ||
@@ -27,9 +32,13 @@ import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs' | ||
| /** | ||
| * Викликає pi і повертає raw stdout. | ||
| * Викликає LLM за model-id і повертає raw текст відповіді. | ||
| * `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI. | ||
| * @param {string} prompt текст промпта | ||
| * @param {string} model provider/model-id або '' для pi-дефолту | ||
| * @returns {string} stdout pi-процесу | ||
| * @throws якщо pi не знайдено або повертає ненульовий exit code | ||
| * @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту | ||
| * @returns {string} текст відповіді моделі | ||
| * @throws якщо backend недоступний або повертає помилку | ||
| */ | ||
| function callPi(prompt, model) { | ||
| function callModel(prompt, model) { | ||
| if (isOmlxModel(model)) { | ||
| return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 }) | ||
| } | ||
| const modelArgs = model ? ['--model', model] : [] | ||
@@ -50,6 +59,6 @@ const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], { | ||
| * @param {string} cwd корінь проєкту | ||
| * @param {(prompt: string, model: string) => string} callPiFn ін'єкція для тестів | ||
| * @param {(prompt: string, model: string) => string} callModelFn ін'єкція для тестів | ||
| * @returns {object} verdict класифікації | ||
| */ | ||
| function classifyOne(group, mutant, cwd, callPiFn) { | ||
| function classifyOne(group, mutant, cwd, callModelFn) { | ||
| const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}` | ||
@@ -60,3 +69,3 @@ const loc = `${group.file}:${mutant.line}:${mutant.col}` | ||
| try { | ||
| const text = callPiFn(prompt, resolveModel('min')) | ||
| const text = callModelFn(prompt, resolveModel('min')) | ||
| return parseVerdict(text) | ||
@@ -66,3 +75,3 @@ } catch { | ||
| try { | ||
| const text = callPiFn(prompt, CLOUD_MIN) | ||
| const text = callModelFn(prompt, CLOUD_MIN) | ||
| return parseVerdict(text) | ||
@@ -77,6 +86,6 @@ } catch (error) { | ||
| /** | ||
| * Класифікує survived мутантів через pi (LOCAL_MIN → CLOUD_MIN → fallback). | ||
| * Класифікує survived мутантів (resolveModel('min') → CLOUD_MIN → fallback). | ||
| * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived список вцілілих мутантів | ||
| * @param {string} cwd корінь проєкту | ||
| * @param {{cachePath?: string, callPi?: Function}} [opts] ін'єкції для тестів | ||
| * @param {{cachePath?: string, callModel?: Function}} [opts] ін'єкції для тестів | ||
| * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts | ||
@@ -86,3 +95,3 @@ */ | ||
| const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json') | ||
| const callPiFn = opts.callPi ?? callPi | ||
| const callModelFn = opts.callModel ?? callModel | ||
| const cacheModel = `${resolveModel('min') || 'default'}+${CLOUD_MIN || 'cloud'}` | ||
@@ -113,3 +122,3 @@ | ||
| if (!verdict) { | ||
| verdict = classifyOne(group, mutant, cwd, callPiFn) | ||
| verdict = classifyOne(group, mutant, cwd, callModelFn) | ||
| if (cacheKey) { | ||
@@ -116,0 +125,0 @@ cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() } |
@@ -80,3 +80,3 @@ /** | ||
| if (!a.length) return 0 | ||
| const s = [...a].sort((x, y) => x - y) | ||
| const s = a.toSorted((x, y) => x - y) | ||
| return s[Math.floor(s.length / 2)] | ||
@@ -83,0 +83,0 @@ } |
@@ -49,3 +49,3 @@ /** | ||
| export function extractAnchors(src) { | ||
| const urls = uniq([...src.matchAll(URL_RE)].map(m => m[0])) | ||
| const urls = uniq(Array.from(src.matchAll(URL_RE), m => m[0])) | ||
@@ -63,8 +63,8 @@ const magicStrings = [] | ||
| const errorMarkers = uniq([...src.matchAll(ERROR_MARKER_RE)].map(m => m[1])) | ||
| const configRefs = uniq([...src.matchAll(CONFIG_REF_RE)].map(m => m[1])) | ||
| const errorMarkers = uniq(Array.from(src.matchAll(ERROR_MARKER_RE), m => m[1])) | ||
| const configRefs = uniq(Array.from(src.matchAll(CONFIG_REF_RE), m => m[1])) | ||
| // Витягуємо code-block приклади тільки з file-header — там автор зазвичай показує контракт. | ||
| const headerMatch = src.match(FILE_HEADER_RE) | ||
| const examples = headerMatch ? uniq([...headerMatch[1].matchAll(CODE_BLOCK_RE)].map(m => m[1].trim())) : [] | ||
| const examples = headerMatch ? uniq(Array.from(headerMatch[1].matchAll(CODE_BLOCK_RE), m => m[1].trim())) : [] | ||
@@ -71,0 +71,0 @@ return { urls, magicStrings, errorMarkers, configRefs, examples } |
@@ -7,2 +7,3 @@ /** @see ./docs/docgen-gen.md */ | ||
| import { resolveModel } from '../../../lib/models.mjs' | ||
| import { callOmlx } from '../../../lib/omlx.mjs' | ||
| import { extractFacts } from './docgen-extract.mjs' | ||
@@ -96,46 +97,12 @@ import { extractAnchors } from './docgen-extract-anchors.mjs' | ||
| * omlx-бекенд: справжні OpenAI-сумісні messages (system+user збереженi). | ||
| * Вмикається `N_CURSOR_DOCGEN_BACKEND=omlx`. | ||
| * URL: `N_CURSOR_DOCGEN_OMLX_URL` або http://127.0.0.1:8000/v1/chat/completions. | ||
| * Модель: переданий `model`, потім `N_CURSOR_DOCGEN_OMLX_MODEL`, потім дефолт. | ||
| * Вмикається `N_CURSOR_DOCGEN_BACKEND=omlx`. Делегує у спільний `callOmlx` | ||
| * (npm/lib/omlx.mjs) з docgen-специфічними env-дефолтами URL/моделі. | ||
| */ | ||
| function callOmlxMessages(messages, model, timeoutMs, temperature = 0.2) { | ||
| const url = env.N_CURSOR_DOCGEN_OMLX_URL ?? 'http://127.0.0.1:8000/v1/chat/completions' | ||
| const m = model || env.N_CURSOR_DOCGEN_OMLX_MODEL || 'mlx-community--gemma-4-e2b-it-4bit' | ||
| const body = JSON.stringify({ | ||
| model: m, | ||
| messages, | ||
| max_tokens: 4096, | ||
| temperature | ||
| return callOmlx(messages, model, { | ||
| url: env.N_CURSOR_DOCGEN_OMLX_URL, | ||
| timeoutMs, | ||
| temperature, | ||
| fallbackModel: env.N_CURSOR_DOCGEN_OMLX_MODEL | ||
| }) | ||
| // Ретраїмо лише transient curl-помилки (18 = transfer closed, 56 = recv failure, 52 = empty reply). | ||
| const TRANSIENT_CURL_CODES = new Set([18, 52, 56]) | ||
| let lastErr | ||
| for (let attempt = 1; attempt <= 3; attempt++) { | ||
| const r = spawnSync( | ||
| 'curl', | ||
| ['-sS', '-X', 'POST', url, '-H', 'Content-Type: application/json', '-H', 'Connection: close', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--data-binary', '@-'], | ||
| { input: body, encoding: 'utf8', timeout: timeoutMs + 5000 } | ||
| ) | ||
| if (r.error) { | ||
| lastErr = new Error(`omlx curl error: ${r.error.message}`) | ||
| break | ||
| } | ||
| if (r.status !== 0) { | ||
| if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) { | ||
| lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`) | ||
| continue | ||
| } | ||
| throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`) | ||
| } | ||
| let j | ||
| try { j = JSON.parse(r.stdout) } catch { throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`) } | ||
| if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`) | ||
| const content = j.choices?.[0]?.message?.content?.trim() ?? '' | ||
| if (!content) { | ||
| const finish = j.choices?.[0]?.finish_reason | ||
| throw new Error(`omlx empty content (finish=${finish})`) | ||
| } | ||
| return content | ||
| } | ||
| throw lastErr ?? new Error('omlx unknown failure') | ||
| } | ||
@@ -142,0 +109,0 @@ |
@@ -8,2 +8,3 @@ /** @see ./docs/llm-worker.md */ | ||
| import { resolveModel } from '../../../lib/models.mjs' | ||
| import { callOmlx, isOmlxModel } from '../../../lib/omlx.mjs' | ||
@@ -90,8 +91,16 @@ // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud). | ||
| /** | ||
| * Запускає pi і повертає stdout як рядок. | ||
| * Викликає LLM за model-id і повертає текст відповіді. | ||
| * `omlx/...` → прямий HTTP до omlx (text-only, локально); решта → pi CLI. | ||
| * @param {string} prompt текст промпта | ||
| * @param {string} model назва моделі (provider/id) | ||
| * @returns {{ text: string, error?: string }} stdout pi або повідомлення про помилку | ||
| * @param {string} model назва моделі (provider/id, `omlx/...` або '') | ||
| * @returns {{ text: string, error?: string }} текст відповіді або повідомлення про помилку | ||
| */ | ||
| function callPi(prompt, model) { | ||
| function callModel(prompt, model) { | ||
| if (isOmlxModel(model)) { | ||
| try { | ||
| return { text: callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000 }) } | ||
| } catch (error) { | ||
| return { text: '', error: error.message } | ||
| } | ||
| } | ||
| const modelArgs = model ? ['--model', model] : [] | ||
@@ -122,5 +131,5 @@ const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], { | ||
| /** | ||
| * Парсить JSON-відповідь від pi. | ||
| * pi може обгорнути JSON у ```json ... ```, тому пробуємо витягти. | ||
| * @param {string} text сирий stdout pi | ||
| * Парсить JSON-відповідь від моделі. | ||
| * Модель може обгорнути JSON у ```json ... ```, тому пробуємо витягти. | ||
| * @param {string} text сирий текст відповіді | ||
| * @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null} розпарсений патч або null | ||
@@ -189,8 +198,8 @@ */ | ||
| // 3. Будуємо prompt і викликаємо pi | ||
| // 3. Будуємо prompt і викликаємо модель | ||
| const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files) | ||
| const { text, error: piError } = callPi(prompt, model) | ||
| const { text, error: modelError } = callModel(prompt, model) | ||
| if (piError) return { ok: false, error: piError } | ||
| if (!text) return { ok: false, error: 'pi returned empty response' } | ||
| if (modelError) return { ok: false, error: modelError } | ||
| if (!text) return { ok: false, error: 'model returned empty response' } | ||
@@ -197,0 +206,0 @@ // 4. Парсимо відповідь |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
5472444
0.1%773
0.13%33979
0.26%