@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 +12 -0
- package/lib/models.mjs +9 -1
- package/lib/omlx.mjs +102 -0
- package/package.json +1 -1
- package/rules/abie/lib/docs/hc-yaml.md +7 -7
- package/rules/abie/lib/docs/http-route.md +10 -18
- package/scripts/coverage-classify/index.mjs +24 -15
- package/skills/docgen/js/docgen-compare-pi-vs-direct.mjs +95 -0
- package/skills/docgen/js/docgen-extract-anchors.mjs +4 -4
- package/skills/docgen/js/docgen-gen.mjs +8 -41
- package/skills/fix/js/llm-worker.mjs +20 -11
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=
|
|
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
|
@@ -2,25 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Огляд
|
|
4
4
|
|
|
5
|
-
Файл виконує структурну валідацію
|
|
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
|
-
Перевіряє, чи перший рядок не порожній.
|
|
11
|
+
Перевіряє, чи перший рядок не порожній. Повертає повідомлення про необхідність наявності modeline `# yaml-language-server: $schema=… (abie.mdc)`.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Перевіряє, чи перший рядок містить необхідний modeline. Повертає повідомлення про відсутність modeline $schema (abie.mdc).
|
|
14
14
|
|
|
15
|
-
Перевіряє, чи значення $schema відповідає очікуваному URL.
|
|
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 —
|
|
21
|
+
ABIE_HC_SCHEMA_URL — Зберігає референтний URL `$schema` для файлу `hc.yaml` (abie.mdc).
|
|
22
22
|
|
|
23
|
-
validateAbieHcModeline — Перевіряє
|
|
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
|
-
Файл надає інструмент для порівняльного аналізу
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
17
|
+
ABIE_SHARED_CROSS_NS_BACKEND_NAMES — Формує імена для крос-нішових зв'язків між бекендами. (abie.mdc)
|
|
18
|
+
|
|
19
|
+
analyzeAbieSharedBackendRefsInPackageK8s — Збирає кількість спільних посилань `backendRefs` та базові помилки з YAML-файлів пакета, ігноруючи неймспейс `dev`. (abie.mdc)
|
|
23
20
|
|
|
24
21
|
## Гарантії поведінки
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
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 (
|
|
7
|
-
* 3. Tier 1 fail (
|
|
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
|
-
* Викликає
|
|
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}
|
|
29
|
-
* @throws якщо
|
|
33
|
+
* @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
|
|
34
|
+
* @returns {string} текст відповіді моделі
|
|
35
|
+
* @throws якщо backend недоступний або повертає помилку
|
|
30
36
|
*/
|
|
31
|
-
function
|
|
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}
|
|
56
|
+
* @param {(prompt: string, model: string) => string} callModelFn ін'єкція для тестів
|
|
48
57
|
* @returns {object} verdict класифікації
|
|
49
58
|
*/
|
|
50
|
-
function classifyOne(group, mutant, cwd,
|
|
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 =
|
|
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 =
|
|
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 мутантів
|
|
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,
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
63
|
-
const configRefs = uniq(
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
*
|
|
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 }}
|
|
93
|
+
* @param {string} model назва моделі (provider/id, `omlx/...` або '')
|
|
94
|
+
* @returns {{ text: string, error?: string }} текст відповіді або повідомлення про помилку
|
|
93
95
|
*/
|
|
94
|
-
function
|
|
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-відповідь від
|
|
121
|
-
*
|
|
122
|
-
* @param {string} text сирий
|
|
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 і викликаємо
|
|
195
|
+
// 3. Будуємо prompt і викликаємо модель
|
|
187
196
|
const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
|
|
188
|
-
const { text, error:
|
|
197
|
+
const { text, error: modelError } = callModel(prompt, model)
|
|
189
198
|
|
|
190
|
-
if (
|
|
191
|
-
if (!text) return { ok: false, error: '
|
|
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)
|