@nitra/cursor 5.0.1 → 5.0.3

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.
@@ -2,43 +2,23 @@
2
2
 
3
3
  ## Огляд
4
4
 
5
- Cross-документна аналітика abie `HTTPRoute`-маніфестів: рахує посилання (`backendRefs`) на спільні `-hl`-сервіси у base-шарі пакета (поза overlay `ua`) і водночас фіксує порушення `namespace`. Слугує джерелом істини для `ua_http_route`-концерну, який звіряє кількість namespace-patch-ів в overlay із кількістю base-reference.
5
+ Файл надає інструмент для порівняльного аналізу конфігурації HTTP-маршрутів. Він виконує порівняння кількості `backendRefs` для сервісів `auth-run-hl` та `file-link-hl` у базових маніфестах пакета з кількістю патчів, визначеною в оверлеях. Цей механізм використовується для синхронізації кількості патчів у верхньому рівні з фактичною кількістю посилань у базі. (abie.mdc)
6
6
 
7
7
  ## Поведінка
8
8
 
9
- 1. Обходить переданий перелік YAML-файлів під k8s-каталогом, лишаючи тільки ті, що належать base-шару abie-пакета й не входять до overlay `ua`.
10
- 2. Кожен відібраний файл читається й безпечно парситься як набір YAML-документів; документи з помилками парсингу пропускаються (інші документи в тому ж файлі продовжують аналізуватися).
11
- 3. Враховуються лише документи з `kind: HTTPRoute`. Інші види та структурно некоректні корені дають нульовий внесок.
12
- 4. Для `HTTPRoute` обхід заходить у `spec.rules[].backendRefs[]` і для кожного посилання перевіряє, чи його `name` належить набору спільних cross-namespace сервісів.
13
- 5. Спільними вважаються рівно два сервіси: `auth-run-hl` та `file-link-hl`. Кожне таке посилання збільшує загальний лічильник посилань на 1.
14
- 6. Якщо посилання на спільний сервіс не має `namespace: dev`, додається помилка виду `<rel>: HTTPRoute backendRefs до <name> має містити namespace: dev (abie.mdc)`.
15
- 7. Підсумок по всьому пакету повертається як агрегований лічильник посилань (`refCount`) і список base-помилок (`baseErrors`).
16
-
17
- Приклад спільного backend-посилання, яке проходить перевірку:
18
-
19
- ```yaml
20
- kind: HTTPRoute
21
- spec:
22
- rules:
23
- - backendRefs:
24
- - name: auth-run-hl
25
- namespace: dev
26
- ```
9
+ ABIE_SHARED_CROSS_NS_BACKEND_NAMES
10
+ Визначає список спільних сервісів, які підлягають аналітиці.
27
11
 
28
- ## Публічний API
12
+ analyzeAbieSharedBackendRefsInPackageK8s
13
+ Збирає кількість посилань на спільні бекенди та порушення вимог до namespace у базових документах HTTPRoute пакета.
29
14
 
30
- - `analyzeAbieSharedBackendRefsInPackageK8s` — за коренем репозиторію, шляхом каталогу пакета й переліком YAML-файлів повертає сумарну кількість base-посилань на спільні `-hl`-сервіси та список порушень `namespace` у base-шарі.
31
- - `ABIE_SHARED_CROSS_NS_BACKEND_NAMES` — заморожений перелік імен спільних cross-namespace сервісів (`auth-run-hl`, `file-link-hl`), за якими ведеться підрахунок.
15
+ ## Публічний API
32
16
 
33
- ## Де використовується
17
+ ABIE_SHARED_CROSS_NS_BACKEND_NAMES Формує імена для крос-нішових зв'язків між бекендами. (abie.mdc)
34
18
 
35
- Споживається `ua_http_route`-концерном (`npm/rules/abie/js/ua_http_route.mjs`) для синхронізації числа namespace-patch-ів в overlay `ua` із кількістю base-reference.
19
+ analyzeAbieSharedBackendRefsInPackageK8s Збирає кількість спільних посилань `backendRefs` та базові помилки з YAML-файлів пакета, ігноруючи неймспейс `dev`. (abie.mdc)
36
20
 
37
21
  ## Гарантії поведінки
38
22
 
39
- - Read-only: файли лише читаються, аналіз нічого не пише на диск.
40
- - Парсинг fail-safe: помилки читання/розбору YAML придушуються, а документи з помилками не враховуються — некоректний файл не зриває аналіз пакета.
41
- - Структурна стійкість: `null`, масиви та поля неочікуваного типу на будь-якому рівні (корінь, `spec`, `rules`, окреме посилання) дають нульовий внесок без винятків.
42
- - Лічильник рахує тільки посилання на два визначені спільні сервіси; інші backend-сервіси ігноруються.
43
- - Перелік спільних імен заморожений і не може бути змінений споживачами.
44
- - Шляхи у повідомленнях нормалізовані до `/`, тож вихід однаковий на POSIX і Windows.
23
+ - Read-only: файл не виконує операцій запису у файлову систему.
24
+ - Не звертається до мережі.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Тимчасовий A/B-batch: docgen Tier 1 через omlx (gemma-4-e2b 4bit на MLX)
3
+ * замість pi/ollama. Перезаписує всі docs/<stem>.md для файлів з sym<4,
4
+ * НЕ ескалює в cloud. Призначення — порівняння якості omlx vs попередньої версії.
5
+ *
6
+ * Запуск: node npm/skills/docgen/js/docgen-batch-omlx.mjs [--limit N] [--from N]
7
+ * --limit N — обробити перші N файлів зі списку sym<4
8
+ * --from N — почати з індексу N (для дозапуску)
9
+ */
10
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, resolve } 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
+
20
+ const args = process.argv.slice(2)
21
+ const limitIdx = args.indexOf('--limit')
22
+ const limit = limitIdx !== -1 ? Number(args[limitIdx + 1]) : Infinity
23
+ const fromIdx = args.indexOf('--from')
24
+ const from = fromIdx !== -1 ? Number(args[fromIdx + 1]) : 0
25
+
26
+ env.N_CURSOR_DOCGEN_BACKEND = 'omlx'
27
+
28
+ const scanOut = execSync('node npm/bin/n-cursor.js docgen scan', { cwd: ROOT, encoding: 'utf8' })
29
+ const all = JSON.parse(scanOut)
30
+
31
+ const local = []
32
+ for (const f of all) {
33
+ try {
34
+ const src = readFileSync(join(ROOT, f.sourcePath), 'utf8')
35
+ const facts = extractFacts(src, join(ROOT, f.sourcePath))
36
+ const sym = (facts.internalSymbols ?? []).length
37
+ if (sym < 4) local.push({ ...f, sym })
38
+ } catch {
39
+ /* пропускаємо нечитані */
40
+ }
41
+ }
42
+
43
+ const slice = local.slice(from, from + limit)
44
+ console.log(`📋 Файлів sym<4 у проєкті: ${local.length}; обробляємо: ${slice.length} (from=${from}, limit=${limit === Infinity ? 'усе' : limit})`)
45
+ console.log(`🤖 Бекенд: omlx → ${env.N_CURSOR_DOCGEN_OMLX_URL ?? 'http://127.0.0.1:8000/v1/chat/completions'}`)
46
+
47
+ const stats = { ok: 0, err: 0, totalMs: 0, scores: [], errors: [] }
48
+
49
+ for (let i = 0; i < slice.length; i++) {
50
+ const f = slice[i]
51
+ const t0 = Date.now()
52
+ const pct = Math.round(((i + 1) / slice.length) * 100)
53
+ process.stdout.write(` [${i + 1}/${slice.length} ${pct}%] sym=${f.sym} ${f.sourcePath} ... `)
54
+ try {
55
+ const result = await generateDoc(join(ROOT, f.sourcePath), {
56
+ symThreshold: 999, // не уходити в cloud за sym
57
+ cloudModel: null // повністю вимкнути cloud-fallback навіть при low det-score
58
+ })
59
+ const docAbs = join(ROOT, f.docPath)
60
+ mkdirSync(dirname(docAbs), { recursive: true })
61
+ writeFileSync(docAbs, result.md)
62
+ const ms = Date.now() - t0
63
+ stats.ok++
64
+ stats.totalMs += ms
65
+ stats.scores.push(result.score ?? 0)
66
+ process.stdout.write(`✓ ${Math.round(ms / 1000)}s score=${result.score ?? '?'} tier=${result.tier}\n`)
67
+ } catch (error) {
68
+ stats.err++
69
+ stats.errors.push({ path: f.sourcePath, msg: error.message })
70
+ process.stdout.write(`✗ ${error.message}\n`)
71
+ }
72
+ }
73
+
74
+ const avgScore = stats.scores.length ? Math.round(stats.scores.reduce((a, b) => a + b, 0) / stats.scores.length) : 0
75
+ console.log(`\n${'─'.repeat(60)}`)
76
+ console.log(`✓ OK: ${stats.ok} ✗ Err: ${stats.err}`)
77
+ console.log(` Сумарний час: ${Math.round(stats.totalMs / 1000)}s; середній на файл: ${stats.ok ? Math.round(stats.totalMs / stats.ok / 1000) : 0}s`)
78
+ console.log(` Середній det-score: ${avgScore}`)
79
+ if (stats.errors.length) {
80
+ console.log('Помилки:')
81
+ for (const e of stats.errors) console.log(` - ${e.path}: ${e.msg}`)
82
+ }
@@ -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].sort((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`)
@@ -0,0 +1,92 @@
1
+ /**
2
+ * E1 (Fact-anchoring): детермінований витяг «анкорів» — конкретних фрагментів
3
+ * з коду, які LLM зобовʼязана згадати в документації, щоб не зісковзнути на
4
+ * generic-фрази.
5
+ *
6
+ * Категорії анкорів:
7
+ * - urls : усі https?://… у вихідному коді
8
+ * - magicStrings : export const X = '…' з непорожнім value (≤120 символів)
9
+ * - errorMarkers : суфікси повідомлень про помилки виду `(rule.mdc)`
10
+ * - configRefs : посилання на .json-конфіги проєкту (.n-cursor.json, …)
11
+ * - examples : ```…```-блоки у file-header JSDoc (першому коментарі файла)
12
+ *
13
+ * Всі регулярки — на сирому src без AST: дешево, безпечно, без false-positive
14
+ * критичної ваги (надмір — менша проблема, ніж пропуск).
15
+ */
16
+
17
+ const URL_RE = /https?:\/\/[^\s'"`)<>]+/g
18
+ const EXPORT_CONST_RE = /export\s+const\s+([A-Z][A-Z0-9_]+)\s*=\s*(['"`])([^'"`]+)\2/g
19
+ const ERROR_MARKER_RE = /\(([a-z][\w-]*\.mdc)\)/g
20
+ const CONFIG_REF_RE = /\b(\.[a-z][\w.-]*\.json)\b/gi
21
+ const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
22
+ const CODE_BLOCK_RE = /```[a-z]*\n([\s\S]*?)\n\s*\*?\s*```/g
23
+
24
+ /** Dedup масив, зберігаючи порядок появи. */
25
+ function uniq(arr) {
26
+ const seen = new Set()
27
+ const out = []
28
+ for (const x of arr) {
29
+ if (!seen.has(x)) {
30
+ seen.add(x)
31
+ out.push(x)
32
+ }
33
+ }
34
+ return out
35
+ }
36
+
37
+ /**
38
+ * Витягує анкори з вихідного коду файла.
39
+ * @param {string} src
40
+ * @returns {{
41
+ * urls: string[],
42
+ * magicStrings: Array<{name:string, value:string}>,
43
+ * errorMarkers: string[],
44
+ * configRefs: string[],
45
+ * examples: string[]
46
+ * }}
47
+ */
48
+ export function extractAnchors(src) {
49
+ const urls = uniq([...src.matchAll(URL_RE)].map(m => m[0]))
50
+
51
+ const magicStrings = []
52
+ const seenNames = new Set()
53
+ for (const m of src.matchAll(EXPORT_CONST_RE)) {
54
+ const name = m[1]
55
+ const value = m[3]
56
+ if (!seenNames.has(name) && value.length <= 120) {
57
+ seenNames.add(name)
58
+ magicStrings.push({ name, value })
59
+ }
60
+ }
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]))
64
+
65
+ // Витягуємо code-block приклади тільки з file-header — там автор зазвичай показує контракт.
66
+ const headerMatch = src.match(FILE_HEADER_RE)
67
+ const examples = headerMatch ? uniq([...headerMatch[1].matchAll(CODE_BLOCK_RE)].map(m => m[1].trim())) : []
68
+
69
+ return { urls, magicStrings, errorMarkers, configRefs, examples }
70
+ }
71
+
72
+ /**
73
+ * Форматує анкори у компактний текст для system-промпта.
74
+ * Якщо анкорів немає взагалі — повертає порожній рядок (системний блок про
75
+ * анкори не додається, щоб не вводити LLM в оману «обовʼязковими» полями).
76
+ * @param {ReturnType<typeof extractAnchors>} a
77
+ * @returns {string}
78
+ */
79
+ export function anchorsToPrompt(a) {
80
+ const blocks = []
81
+ if (a.urls.length) blocks.push(`URLs (згадай у тексті): ${a.urls.join(', ')}`)
82
+ if (a.magicStrings.length) {
83
+ blocks.push(
84
+ `Експортовані константи-рядки (наведи назву і призначення): ${a.magicStrings.map(s => `${s.name}=${JSON.stringify(s.value)}`).join('; ')}`
85
+ )
86
+ }
87
+ if (a.errorMarkers.length) blocks.push(`Маркери повідомлень (згадай у Поведінці): ${a.errorMarkers.map(m => `(${m})`).join(', ')}`)
88
+ if (a.configRefs.length) blocks.push(`Конфіги, на які спирається код: ${a.configRefs.join(', ')}`)
89
+ if (a.examples.length) blocks.push(`Приклади з документації автора (наведи дослівно у Поведінці):\n${a.examples.map(e => '```\n' + e + '\n```').join('\n')}`)
90
+ if (!blocks.length) return ''
91
+ return `АНКОРИ ДО ОБОВ'ЯЗКОВОГО ВКЛЮЧЕННЯ:\n${blocks.join('\n')}`
92
+ }
@@ -5,7 +5,8 @@ import { spawnSync } from 'node:child_process'
5
5
  import { env } from 'node:process'
6
6
  import { resolveModel } from '../../../lib/models.mjs'
7
7
  import { extractFacts } from './docgen-extract.mjs'
8
- import { STYLE, oneShotPromptText, sectionMessages } from './docgen-prompts.mjs'
8
+ import { extractAnchors } from './docgen-extract-anchors.mjs'
9
+ import { oneShotMessages, sectionMessages, criticMessages, refineMessages, guaranteesFromMarkers } from './docgen-prompts.mjs'
9
10
 
10
11
  const QUALITY_THRESHOLD = 70
11
12
 
@@ -89,8 +90,62 @@ function scoreDoc(md, facts) {
89
90
  return { score: Math.max(0, score), issues }
90
91
  }
91
92
 
92
- /** Викликає pi і повертає stdout. Кидає якщо pi повертає ненульовий код. */
93
- function callPi(prompt, model, timeoutMs) {
93
+ /**
94
+ * 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`, потім дефолт.
98
+ */
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
107
+ })
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
+ }
140
+
141
+ /**
142
+ * Універсальний виклик LLM за повним messages-масивом.
143
+ * - omlx: шле messages напряму (system збережено)
144
+ * - pi: конкатенує message.content (pi приймає лише plain prompt)
145
+ */
146
+ function callLlm(messages, model, timeoutMs, temperature = 0.2) {
147
+ if (env.N_CURSOR_DOCGEN_BACKEND === 'omlx') return callOmlxMessages(messages, model, timeoutMs, temperature)
148
+ const prompt = messages.map(m => m.content).join('\n\n')
94
149
  const modelArgs = model ? ['--model', model] : []
95
150
  const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
96
151
  encoding: 'utf8',
@@ -101,9 +156,30 @@ function callPi(prompt, model, timeoutMs) {
101
156
  return r.stdout?.trim() ?? ''
102
157
  }
103
158
 
104
- /** One-shot: один pi-виклик на весь документ. */
159
+ /**
160
+ * E2 — один цикл critique→refine на секцію.
161
+ * Повертає або уточнену чорнетку, або оригінал якщо критик повідомив NONE.
162
+ */
163
+ function critiqueRefineSection(sectionKey, draft, facts, anchors, model, timeoutMs) {
164
+ const critique = callLlm(criticMessages(sectionKey, draft, facts, anchors), model, timeoutMs).trim()
165
+ if (!critique || /^\s*NONE\s*$/i.test(critique) || critique.length < 12) return draft
166
+ const refined = callLlm(refineMessages(sectionKey, draft, critique, facts, anchors), model, timeoutMs).trim()
167
+ return stripSignatures(stripSection(refined)) || draft
168
+ }
169
+
170
+ /**
171
+ * Чи треба refine для секції API: тільки якщо є >1 експорту і всі desc-и порожні
172
+ * (саме там модель схильна писати «застосовує логіку до файлу»).
173
+ */
174
+ function apiNeedsRefine(facts) {
175
+ const exps = facts.exports ?? []
176
+ if (exps.length <= 1) return false
177
+ return exps.every(e => !e.desc)
178
+ }
179
+
180
+ /** One-shot: один виклик LLM на весь документ. */
105
181
  function piOneShot(facts, src, model, timeoutMs = 120_000) {
106
- const text = callPi(`${STYLE}\n\n${oneShotPromptText(facts, src)}`, model, timeoutMs)
182
+ const text = callLlm(oneShotMessages(facts, src), model, timeoutMs)
107
183
  let md = stripSignatures(stripSection(text))
108
184
  if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
109
185
  return { md: md + '\n', genTok: 0 }
@@ -129,12 +205,19 @@ function assemble(stem, sections) {
129
205
  * Orchestrated: N окремих pi-викликів, по одному на секцію.
130
206
  * Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
131
207
  */
132
- function piOrchestrated(facts, src, model, timeoutMs) {
208
+ function piOrchestrated(facts, src, model, timeoutMs, { anchors = null, temperature = 0.2 } = {}) {
133
209
  const sections = {}
134
- for (const s of sectionMessages(facts, src)) {
135
- // messages = [{role:'system',content}, {role:'user',content}] plain text prompt для pi
136
- const prompt = s.messages.map(m => m.content).join('\n\n')
137
- sections[s.key] = stripSignatures(stripSection(callPi(prompt, model, timeoutMs)))
210
+ const anc = anchors ?? extractAnchors(src)
211
+ // E3: «Гарантії» детермінований шаблон з markers (0 LLM-запитів, 0 generic-фраз)
212
+ sections.guarantees = guaranteesFromMarkers(facts)
213
+ for (const s of sectionMessages(facts, src, anc)) {
214
+ if (s.key === 'guarantees') continue // вже згенеровано детерміновано
215
+ let draft = stripSignatures(stripSection(callLlm(s.messages, model, timeoutMs, temperature)))
216
+ // E2 + E3: critique→refine лише для секцій, де gemma-4 зриває на generic
217
+ if (s.key === 'overview' || (s.key === 'api' && apiNeedsRefine(facts))) {
218
+ draft = critiqueRefineSection(s.key, draft, facts, anc, model, timeoutMs)
219
+ }
220
+ sections[s.key] = draft
138
221
  }
139
222
  return { md: assemble(basename(facts.relPath), sections), genTok: 0 }
140
223
  }
@@ -181,10 +264,11 @@ export async function generateDoc(
181
264
  // Tier 1: pi orchestrated (секція за секцією), timeout на секцію = LOCAL_TIMEOUT_MS
182
265
  // facts.unsupported → one-shot (структура файлу нестандартна)
183
266
  let r
267
+ const anchors = facts.unsupported ? null : extractAnchors(src)
184
268
  try {
185
269
  r = facts.unsupported
186
270
  ? piOneShot(facts, src, model, LOCAL_TIMEOUT_MS)
187
- : piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS)
271
+ : piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS, { anchors })
188
272
  } catch (error) {
189
273
  if (cloudModel) {
190
274
  const r2 = piOneShot(facts, src, cloudModel)
@@ -194,7 +278,25 @@ export async function generateDoc(
194
278
  }
195
279
 
196
280
  // Stage 2.5: детермінований скоринг (0 токенів) — gate перед Tier 2
197
- const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
281
+ let { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
282
+
283
+ // E4: best-of-N. Якщо score нижчий за threshold і немає cloud-fallback — спроба
284
+ // ще раз з вищою температурою, керуємо через env (повторні прогони коштовні).
285
+ if (detScore < threshold && !cloudModel && !facts.unsupported && env.N_CURSOR_DOCGEN_BEST_OF !== '0') {
286
+ try {
287
+ const r2 = piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5 })
288
+ const s2 = scoreDoc(r2.md, facts)
289
+ if (s2.score > detScore) {
290
+ r = r2
291
+ detScore = s2.score
292
+ detIssues = [...s2.issues, 'best-of-2:retry-won']
293
+ } else {
294
+ detIssues = [...detIssues, 'best-of-2:retry-lost']
295
+ }
296
+ } catch (error) {
297
+ detIssues = [...detIssues, `best-of-2:retry-error: ${error.message}`]
298
+ }
299
+ }
198
300
 
199
301
  if (detScore < threshold && cloudModel) {
200
302
  const r2 = piOneShot(facts, src, cloudModel)