@nitra/cursor 9.1.1 → 9.2.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
+ ## [9.2.0] - 2026-06-14
4
+
5
+ ### Changed
6
+
7
+ - docgen: класифікація omlx-збоїв (transient/systemic/permanent) — ретрай ETIMEDOUT з backoff, circuit-breaker на systemic-каскад (exit 2), permanent→skip; scan поважає .gitignore; прибрано хардкод DEFAULT_OMLX_MODEL (fail-loud, модель через N_LOCAL_MIN_MODEL)
8
+
3
9
  ## [9.1.1] - 2026-06-14
4
10
 
5
11
  ### Changed
package/lib/llm.mjs CHANGED
@@ -121,6 +121,26 @@ export function callLlm(messages, model, opts = {}) {
121
121
  const MEMORY_GUARD_MARKER = 'memory ceiling'
122
122
  /** Тип помилки omlx про відсутній/хибний API-ключ. */
123
123
  const AUTH_ERROR_MARKER = 'authentication_error'
124
+ /** Детерміновані помилки: контекст/модель — ретрай чи чекання не допоможе. */
125
+ const PERMANENT_RE = /too long|exceeds[^.]*context|not found/i
126
+
127
+ /**
128
+ * Класифікує omlx-помилку **після** того, як `callOmlxRaw` вичерпав внутрішні
129
+ * ретраї — для реакції оркестратора (skip vs circuit-breaker vs звичайна помилка):
130
+ * - `permanent` — детерміновано (контекст завеликий, модель відсутня): skip, не ретраїти;
131
+ * - `systemic` — середовище/сервер (memory-guard, auth, down/таймаут): каскадить → circuit-breaker;
132
+ * - `transient` — решта (empty content, bad json): рідкісне, не каскадить.
133
+ * @param {string} message текст `error.message`
134
+ * @returns {'transient'|'systemic'|'permanent'} клас помилки
135
+ */
136
+ export function classifyOmlxError(message) {
137
+ const m = String(message)
138
+ if (PERMANENT_RE.test(m)) return 'permanent'
139
+ if (m.includes(MEMORY_GUARD_MARKER) || m.includes(AUTH_ERROR_MARKER) || m.startsWith('omlx curl')) {
140
+ return 'systemic'
141
+ }
142
+ return 'transient'
143
+ }
124
144
 
125
145
  /**
126
146
  * Preflight-перевірка omlx перед масовим прогоном: мінімальний chat-виклик
package/lib/omlx.mjs CHANGED
@@ -45,11 +45,20 @@ export function resolveOmlxApiKey(apiKey) {
45
45
  }
46
46
  }
47
47
 
48
- /** Дефолтна модель, якщо в id лишився голий `omlx/` (override — `N_CURSOR_OMLX_MODEL`). */
49
- export const DEFAULT_OMLX_MODEL = 'mlx-community--gemma-4-e2b-it-4bit'
50
-
51
48
  const OMLX_PREFIX = 'omlx/'
52
49
 
50
+ /** Backoff між transient-ретраями curl (мс): 2 паузи на 3 спроби. */
51
+ const BACKOFF_MS = [2000, 8000]
52
+
53
+ /**
54
+ * Блокуюча пауза без зайнятого циклу (sync — для retry-loop у `callOmlxRaw`).
55
+ * @param {number} ms тривалість паузи
56
+ * @returns {void}
57
+ */
58
+ function sleepSync(ms) {
59
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
60
+ }
61
+
53
62
  /**
54
63
  * Чи цей model-id адресує локальний omlx-бекенд (префікс `omlx/`).
55
64
  * @param {unknown} model перевірюваний model-id
@@ -92,15 +101,38 @@ export function extractReasoning(message, finishReason) {
92
101
  return { reasoning: null, reasoningSource: null }
93
102
  }
94
103
 
104
+ /**
105
+ * Парсить успішну (curl-exit 0) omlx-відповідь у багатий обʼєкт.
106
+ * @param {string} stdout сире тіло відповіді curl
107
+ * @param {number} attempt номер успішної спроби (для поля `attempts`)
108
+ * @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат
109
+ * @throws {Error} на поганому JSON, api-помилці чи порожньому контенті
110
+ */
111
+ function parseOmlxResponse(stdout, attempt) {
112
+ let j
113
+ try {
114
+ j = JSON.parse(stdout)
115
+ } catch {
116
+ throw new Error(`omlx bad json: ${stdout?.slice(0, 200) ?? ''}`)
117
+ }
118
+ if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
119
+ const message = j.choices?.[0]?.message ?? {}
120
+ const finishReason = j.choices?.[0]?.finish_reason ?? null
121
+ const content = message.content?.trim() ?? ''
122
+ if (!content) throw new Error(`omlx empty content (finish=${finishReason})`)
123
+ const { reasoning, reasoningSource } = extractReasoning(message, finishReason)
124
+ return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt }
125
+ }
126
+
95
127
  /**
96
128
  * Ядро прямого HTTP-виклику до omlx через `curl` (spawnSync). Повертає **багатий**
97
129
  * обʼєкт: контент + reasoning + usage + finish_reason + кількість спроб. Ретраїть
98
- * лише transient curl-помилки (18 = transfer closed, 52 = empty reply, 56 = recv failure).
130
+ * transient-помилки (curl 18/28/52/56 + spawnSync ETIMEDOUT) із backoff 2s→8s.
99
131
  * @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено)
100
- * @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт
101
- * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ
132
+ * @param {string} model model-id (з/без `omlx/`-префікса); порожній і без `fallbackModel` throw
133
+ * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string, backoffMs?: number[] }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ, backoff між ретраями (мс)
102
134
  * @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат виклику
103
- * @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
135
+ * @throws {Error} на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
104
136
  */
105
137
  export function callOmlxRaw(messages, model, opts = {}) {
106
138
  const {
@@ -108,18 +140,23 @@ export function callOmlxRaw(messages, model, opts = {}) {
108
140
  timeoutMs = 60_000,
109
141
  temperature = 0.2,
110
142
  maxTokens = 4096,
111
- fallbackModel = env.N_CURSOR_OMLX_MODEL ?? DEFAULT_OMLX_MODEL,
112
- apiKey
143
+ fallbackModel = env.N_CURSOR_OMLX_MODEL ?? '',
144
+ apiKey,
145
+ backoffMs = BACKOFF_MS
113
146
  } = opts
114
147
 
115
148
  const m = omlxModelId(model) || fallbackModel
149
+ if (!m) {
150
+ throw new Error('omlx: модель не задано — постав N_LOCAL_MIN_MODEL (або N_CURSOR_OMLX_MODEL)')
151
+ }
116
152
  const body = JSON.stringify({ model: m, messages, max_tokens: maxTokens, temperature })
117
153
  // Ключ локального сервера в argv допустимий: localhost-секрет власної машини,
118
154
  // короткоживучий процес; stdin уже зайнятий body (`--data-binary @-`).
119
155
  const key = resolveOmlxApiKey(apiKey)
120
156
  const authArgs = key ? ['-H', `Authorization: Bearer ${key}`] : []
121
157
 
122
- const TRANSIENT_CURL_CODES = new Set([18, 52, 56])
158
+ // 18=transfer closed, 28=operation timeout, 52=empty reply, 56=recv failure — усі transient.
159
+ const TRANSIENT_CURL_CODES = new Set([18, 28, 52, 56])
123
160
  let lastErr
124
161
  for (let attempt = 1; attempt <= 3; attempt++) {
125
162
  const r = spawnSync(
@@ -143,28 +180,22 @@ export function callOmlxRaw(messages, model, opts = {}) {
143
180
  )
144
181
  if (r.error) {
145
182
  lastErr = new Error(`omlx curl error: ${r.error.message}`)
183
+ // spawnSync-таймаут (ETIMEDOUT) — transient: сервер перевантажений, ретраїмо з backoff.
184
+ if (r.error.code === 'ETIMEDOUT' && attempt < 3) {
185
+ sleepSync(backoffMs[attempt - 1])
186
+ continue
187
+ }
146
188
  break
147
189
  }
148
190
  if (r.status !== 0) {
149
191
  if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) {
150
192
  lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`)
193
+ sleepSync(backoffMs[attempt - 1])
151
194
  continue
152
195
  }
153
196
  throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
154
197
  }
155
- let j
156
- try {
157
- j = JSON.parse(r.stdout)
158
- } catch {
159
- throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`)
160
- }
161
- if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
162
- const message = j.choices?.[0]?.message ?? {}
163
- const finishReason = j.choices?.[0]?.finish_reason ?? null
164
- const content = message.content?.trim() ?? ''
165
- if (!content) throw new Error(`omlx empty content (finish=${finishReason})`)
166
- const { reasoning, reasoningSource } = extractReasoning(message, finishReason)
167
- return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt }
198
+ return parseOmlxResponse(r.stdout, attempt)
168
199
  }
169
200
  throw lastErr ?? new Error('omlx unknown failure')
170
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "9.1.1",
3
+ "version": "9.2.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -20,9 +20,14 @@
20
20
  * docgen:
21
21
  * source: src/lib/foo.js
22
22
  * crc: a3f1c9e0
23
+ * model: omlx/gemma-4-e4b-it-OptiQ-4bit
23
24
  * score: 55
24
25
  * issues: short-behavior,internal-name:bar
25
26
  * ---
27
+ *
28
+ * `model` — повний id моделі-генератора (як повертає resolveModel, із префіксом
29
+ * провайдера). Пасивна метадата: маркер «віку» доки за моделлю на додачу до CRC
30
+ * джерела. На staleness НЕ впливає — звіряється лише `crc`.
26
31
  */
27
32
  import { existsSync, readFileSync } from 'node:fs'
28
33
  import { crc32 as zlibCrc32 } from 'node:zlib'
@@ -46,6 +51,7 @@ export function crc32(input) {
46
51
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/u
47
52
  const SOURCE_RE = /^[ \t]{0,8}source:[ \t]{0,8}(.+)$/mu
48
53
  const CRC_RE = /^[ \t]{0,8}crc:[ \t]{0,8}(.+)$/mu
54
+ const MODEL_RE = /^[ \t]{0,8}model:[ \t]{0,8}(.+)$/mu
49
55
  const SCORE_RE = /^[ \t]{0,8}score:[ \t]{0,8}(\d+)$/mu
50
56
  const ISSUES_RE = /^[ \t]{0,8}issues:[ \t]{0,8}(.+)$/mu
51
57
  const LEADING_NEWLINES_RE = /^\n+/u
@@ -53,10 +59,10 @@ const ISSUE_CODE_TAIL_RE = /[,:]$/u
53
59
 
54
60
  /**
55
61
  * Парсить frontmatter файлової доки. Без блоку — `data:null` і `body` дорівнює входу.
56
- * Поля `score`/`issues` опційні (back-compat зі старими доками): без них —
57
- * `score:null`, `issues:[]`.
62
+ * Поля `model`/`score`/`issues` опційні (back-compat зі старими доками): без них —
63
+ * `model:null`, `score:null`, `issues:[]`.
58
64
  * @param {string} md вміст md-файлу
59
- * @returns {{ data: { source: string|null, crc: string|null, score: number|null, issues: string[] }|null, body: string }} метадані + тіло без frontmatter
65
+ * @returns {{ data: { source: string|null, crc: string|null, model: string|null, score: number|null, issues: string[] }|null, body: string }} метадані + тіло без frontmatter
60
66
  */
61
67
  export function parseDocFrontmatter(md) {
62
68
  const match = md.match(FRONTMATTER_RE)
@@ -68,6 +74,7 @@ export function parseDocFrontmatter(md) {
68
74
  data: {
69
75
  source: block.match(SOURCE_RE)?.[1].trim() ?? null,
70
76
  crc: block.match(CRC_RE)?.[1].trim() ?? null,
77
+ model: block.match(MODEL_RE)?.[1].trim() ?? null,
71
78
  score: scoreRaw === undefined ? null : Number(scoreRaw),
72
79
  issues: issuesRaw
73
80
  ? issuesRaw
@@ -97,14 +104,16 @@ function issueCodes(issues) {
97
104
  }
98
105
 
99
106
  /**
100
- * Будує frontmatter-блок із шляхом джерела, CRC і (опційно) якістю.
107
+ * Будує frontmatter-блок із шляхом джерела, CRC, (опційно) моделлю-генератором і якістю.
101
108
  * @param {string} source відносний шлях джерела
102
109
  * @param {string} crc CRC32 джерела у hex
103
110
  * @param {{ score: number, issues?: string[] }|null} [quality] det-оцінка доки; null — без полів якості
104
- * @returns {string} рядок `---\ndocgen:\n source: …\n crc: …[\n score: …][\n issues: …]\n---\n`
111
+ * @param {string|null} [model] повний id моделі-генератора; null — без поля `model`
112
+ * @returns {string} рядок `---\ndocgen:\n source: …\n crc: …[\n model: …][\n score: …][\n issues: …]\n---\n`
105
113
  */
106
- export function buildDocFrontmatter(source, crc, quality = null) {
114
+ export function buildDocFrontmatter(source, crc, quality = null, model = null) {
107
115
  const lines = [`source: ${source}`, `crc: ${crc}`]
116
+ if (model) lines.push(`model: ${model}`)
108
117
  if (quality && typeof quality.score === 'number') {
109
118
  lines.push(`score: ${quality.score}`)
110
119
  const codes = issueCodes(quality.issues ?? [])
@@ -120,11 +129,12 @@ export function buildDocFrontmatter(source, crc, quality = null) {
120
129
  * @param {string} source відносний шлях джерела
121
130
  * @param {string} crc CRC32 джерела у hex
122
131
  * @param {{ score: number, issues?: string[] }|null} [quality] det-оцінка доки
132
+ * @param {string|null} [model] повний id моделі-генератора; null — без поля `model`
123
133
  * @returns {string} md зі свіжим frontmatter
124
134
  */
125
- export function stampDoc(md, source, crc, quality = null) {
135
+ export function stampDoc(md, source, crc, quality = null, model = null) {
126
136
  const { body } = parseDocFrontmatter(md)
127
- return `${buildDocFrontmatter(source, crc, quality)}\n${body.replace(LEADING_NEWLINES_RE, '')}`
137
+ return `${buildDocFrontmatter(source, crc, quality, model)}\n${body.replace(LEADING_NEWLINES_RE, '')}`
128
138
  }
129
139
 
130
140
  /**
@@ -148,6 +158,17 @@ export function readDocQuality(docAbsPath) {
148
158
  return { score: data?.score ?? null, issues: data?.issues ?? [] }
149
159
  }
150
160
 
161
+ /**
162
+ * Модель-генератор, збережена у frontmatter доки; `null` — доки немає або поле відсутнє
163
+ * (старі доки до введення `model`).
164
+ * @param {string} docAbsPath абсолютний шлях md-доки
165
+ * @returns {string|null} повний id моделі або null
166
+ */
167
+ export function readDocModel(docAbsPath) {
168
+ if (!existsSync(docAbsPath)) return null
169
+ return parseDocFrontmatter(readFileSync(docAbsPath, 'utf8')).data?.model ?? null
170
+ }
171
+
151
172
  /**
152
173
  * Стан застарілості доки відносно її джерела.
153
174
  * `missing` — доки немає; `crc-mismatch` — CRC джерела ≠ CRC у доці; інакше свіжа.
@@ -10,13 +10,13 @@
10
10
  * Перед масовим прогоном — health-check omlx: memory-guard зайнятої 8GB машини
11
11
  * означає «відклади прогін», а не сотні хибних «✗» у звіті.
12
12
  */
13
- import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'
13
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
 
16
16
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
17
- import { omlxHealthCheck, pickBackend } from '../../../lib/llm.mjs'
17
+ import { omlxHealthCheck, pickBackend, classifyOmlxError } from '../../../lib/llm.mjs'
18
18
  import { generateDoc, DEFAULT_LOCAL_MODEL } from './docgen-gen.mjs'
19
- import { crc32, stampDoc, readDocQuality, QUALITY_THRESHOLD } from './docgen-crc.mjs'
19
+ import { crc32, stampDoc, readDocQuality, readDocModel, QUALITY_THRESHOLD } from './docgen-crc.mjs'
20
20
  import { resolveRoot, scanForDocFiles } from './docgen-scan.mjs'
21
21
 
22
22
  /**
@@ -64,6 +64,9 @@ function selectTargets(root, all, { overwrite, retryDegraded }) {
64
64
  * @returns {string|null} текст фатальної проблеми або null якщо можна генерувати
65
65
  */
66
66
  function preflightProblem() {
67
+ if (!DEFAULT_LOCAL_MODEL) {
68
+ return 'модель не задано. Вистав N_LOCAL_MIN_MODEL (напр. omlx/mlx-community--gemma-4-e4b-it-OptiQ-4bit) і повтори.'
69
+ }
67
70
  if (pickBackend(DEFAULT_LOCAL_MODEL) !== 'omlx') return null
68
71
  const hc = omlxHealthCheck({ model: DEFAULT_LOCAL_MODEL })
69
72
  if (hc.ok) return null
@@ -103,17 +106,39 @@ function fmtTiming(r) {
103
106
  return `${s(r.ms)} (llm ${s(llmMs)}/${r.llmCalls ?? 0} calls, orch ${s(r.ms - llmMs)})`
104
107
  }
105
108
 
109
+ /** Скільки systemic-збоїв підряд → негайний abort батчу (fail-fast, без cooldown). */
110
+ const SYSTEMIC_ABORT_STREAK = 3
111
+
112
+ /**
113
+ * Діагностика розміру джерела (для дослідження, що роздуває контекст):
114
+ * байти + груба оцінка токенів (~bytes/4). Без size-guard-гейта — лише вивід.
115
+ * @param {number} bytes розмір файлу в байтах
116
+ * @returns {string} напр. `12.3KB ~3.1k tok`
117
+ */
118
+ function fmtSize(bytes) {
119
+ return `${(bytes / 1024).toFixed(1)}KB ~${(bytes / 4 / 1000).toFixed(1)}k tok`
120
+ }
121
+
106
122
  /**
107
123
  * Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес.
124
+ * Помилку класифікує (`classifyOmlxError`): `permanent` → skip (не «помилка для
125
+ * перегону»), `systemic`/`transient` → у `errors`. Повертає клас для циклу
126
+ * (circuit-breaker рахує systemic-підряд).
108
127
  * @param {object} file елемент scanForDocFiles
109
128
  * @param {string} root абсолютний корінь
110
129
  * @param {{ done: number, total: number }} progress позиція у прогресі
111
- * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats акумулятор статистики
112
- * @returns {Promise<void>}
130
+ * @param {{ ok: number, degraded: number, err: number, errors: string[], skipped: string[] }} stats акумулятор
131
+ * @returns {Promise<'ok'|'permanent'|'systemic'|'transient'>} результат для керування циклом
113
132
  */
114
133
  async function generateOne(file, root, progress, stats) {
115
134
  const sourceAbs = join(root, file.sourcePath)
116
- process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} `)
135
+ let size = 0
136
+ try {
137
+ size = statSync(sourceAbs).size
138
+ } catch {
139
+ // файл зник між скануванням і генерацією — лишаємо розмір 0
140
+ }
141
+ process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} [${fmtSize(size)}] … `)
117
142
  try {
118
143
  const docAbs = join(root, file.docPath)
119
144
  // Варіант B: передаємо наявну доку, щоб зберегти захищену секцію «Призначення»
@@ -123,7 +148,7 @@ async function generateOne(file, root, progress, stats) {
123
148
  mkdirSync(dirname(docAbs), { recursive: true })
124
149
  const quality =
125
150
  result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
126
- writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality))
151
+ writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality, result.model))
127
152
  stats.ok++
128
153
  if (result.degraded) {
129
154
  stats.degraded++
@@ -131,24 +156,38 @@ async function generateOne(file, root, progress, stats) {
131
156
  } else {
132
157
  process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc} ${fmtTiming(result)}\n`)
133
158
  }
159
+ return 'ok'
134
160
  } catch (error) {
135
- stats.err++
136
- stats.errors.push(file.sourcePath)
137
- process.stdout.write(`✗ ${error.message}\n`)
161
+ const cls = classifyOmlxError(error.message)
162
+ if (cls === 'permanent') {
163
+ stats.skipped.push(file.sourcePath)
164
+ process.stdout.write(`⊘ skip (permanent): ${error.message}\n`)
165
+ } else {
166
+ stats.err++
167
+ stats.errors.push(file.sourcePath)
168
+ process.stdout.write(`✗ ${cls}: ${error.message}\n`)
169
+ }
170
+ return cls
138
171
  }
139
172
  }
140
173
 
141
174
  /**
142
175
  * Підсумковий звіт прогону у stdout.
143
- * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats статистика
176
+ * @param {{ ok: number, degraded: number, err: number, errors: string[], skipped: string[] }} stats статистика
144
177
  * @returns {void}
145
178
  */
146
179
  function reportStats(stats) {
147
- console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`)
180
+ console.log(
181
+ `\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err} ⊘ Skip: ${stats.skipped.length}`
182
+ )
148
183
  if (stats.errors.length > 0) {
149
184
  console.log('Помилки:')
150
185
  for (const e of stats.errors) console.log(` - ${e}`)
151
186
  }
187
+ if (stats.skipped.length > 0) {
188
+ console.log('Пропущено (permanent — завеликий контекст / модель відсутня):')
189
+ for (const e of stats.skipped) console.log(` - ${e}`)
190
+ }
152
191
  if (stats.degraded > 0) {
153
192
  console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor fix-doc-files --retry-degraded`)
154
193
  }
@@ -157,7 +196,7 @@ function reportStats(stats) {
157
196
  /**
158
197
  * `doc-files gen` — згенерувати документацію для застарілих/відсутніх док.
159
198
  * @param {string[]} argv аргументи після назви субкоманди
160
- * @returns {Promise<number>} exit-код: 0 — без помилок, 1 — хоча б одна помилка або фейл preflight
199
+ * @returns {Promise<number>} exit-код: 0 — без помилок; 1 — помилки/фейл preflight; 2 systemic-abort
161
200
  */
162
201
  export async function runDocFilesGenCli(argv) {
163
202
  const root = resolveRoot(argv)
@@ -182,22 +221,38 @@ export async function runDocFilesGenCli(argv) {
182
221
  }
183
222
 
184
223
  console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`)
185
- const stats = { ok: 0, degraded: 0, err: 0, errors: [] }
224
+ const stats = { ok: 0, degraded: 0, err: 0, errors: [], skipped: [] }
186
225
 
187
226
  let done = 0
227
+ let systemicStreak = 0
228
+ let aborted = false
188
229
  for (const file of targets) {
189
230
  done++
190
- await generateOne(file, root, { done, total: targets.length }, stats)
231
+ const status = await generateOne(file, root, { done, total: targets.length }, stats)
232
+ // Circuit-breaker: K systemic-збоїв підряд → негайний abort (середовище впало,
233
+ // решта файлів так само згорить). Будь-який не-systemic результат скидає лічильник.
234
+ if (status === 'systemic') {
235
+ if (++systemicStreak >= SYSTEMIC_ABORT_STREAK) {
236
+ aborted = true
237
+ console.error(
238
+ `\n✗ doc-files: ${SYSTEMIC_ABORT_STREAK} systemic-збої підряд (omlx memory-guard / сервер) — abort на ${done}/${targets.length}.\n Звільни RAM або перезапусти omlx і повтори — зроблене лишилось, решта підбереться за CRC.`
239
+ )
240
+ break
241
+ }
242
+ } else {
243
+ systemicStreak = 0
244
+ }
191
245
  }
192
246
 
193
247
  reportStats(stats)
248
+ if (aborted) return 2
194
249
  return stats.err > 0 ? 1 : 0
195
250
  }
196
251
 
197
252
  /**
198
253
  * `doc-files stamp` — детерміновано (пере)штампувати frontmatter `source`+`crc`
199
254
  * у НАЯВНИХ доках без виклику LLM. Для міграції док, які ще не мають CRC.
200
- * Поля якості (`score`/`issues`) при цьому зберігаються з наявного frontmatter.
255
+ * Поля `model` та якості (`score`/`issues`) при цьому зберігаються з наявного frontmatter.
201
256
  * @param {string[]} argv аргументи після назви субкоманди
202
257
  * @returns {number} exit-код: 0 — успіх
203
258
  */
@@ -211,7 +266,8 @@ export function runDocFilesStampCli(argv) {
211
266
  const crc = crc32(readFileSync(sourceAbs))
212
267
  const md = readFileSync(docAbs, 'utf8')
213
268
  const { score, issues } = readDocQuality(docAbs)
214
- writeFileSync(docAbs, stampDoc(md, file.sourcePath, crc, score === null ? null : { score, issues }))
269
+ const model = readDocModel(docAbs)
270
+ writeFileSync(docAbs, stampDoc(md, file.sourcePath, crc, score === null ? null : { score, issues }, model))
215
271
  stamped++
216
272
  }
217
273
  console.log(`✓ fix-doc-files --stamp: оновлено frontmatter у ${stamped} доці(ах).`)
@@ -3,7 +3,6 @@ import { readFileSync, existsSync } from 'node:fs'
3
3
  import { basename } from 'node:path'
4
4
  import { env } from 'node:process'
5
5
  import { resolveModel } from '../../../lib/models.mjs'
6
- import { DEFAULT_OMLX_MODEL } from '../../../lib/omlx.mjs'
7
6
  import { callLlm as callLlmRaw } from '../../../lib/llm.mjs'
8
7
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
8
  import { docPathForSource } from './docgen-scan.mjs'
@@ -365,11 +364,12 @@ function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, tempera
365
364
  /** Максимальний час генерації одного LLM-виклику. */
366
365
  const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
367
366
  /**
368
- * Дефолтна модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min') → omlx напряму.
369
- * Останній fallback гарантує local-only шлях без жодних env (через pi CLI той
370
- * самий локальний виклик виміряно повільніший на ~46%).
367
+ * Дефолтна модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min') (N_LOCAL_MIN_MODEL).
368
+ * Без хардкод-fallback: модель налаштовує кожен локально (`N_LOCAL_MIN_MODEL`); якщо
369
+ * нічого не задано порожньо, і preflight оркестратора фейлить гучно (а не шле
370
+ * запит до неіснуючої моделі).
371
371
  */
372
- export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? (resolveModel('min') || `omlx/${DEFAULT_OMLX_MODEL}`)
372
+ export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? resolveModel('min')
373
373
 
374
374
  /**
375
375
  * Головний API: файл → md-дока з det-оцінкою.
@@ -78,9 +78,35 @@ export function describeFile(root, sourcePath) {
78
78
  return { sourcePath, docPath, stale, reason }
79
79
  }
80
80
 
81
+ /**
82
+ * Підмножина шляхів, які git вважає ігнорованими (`.gitignore` + global excludes).
83
+ * Один батч-виклик `git check-ignore --stdin`. Tracked-файли git не репортить як
84
+ * ігноровані (тож `euscp.js` лишається кандидатом). Поза git-репо / коли жоден не
85
+ * ігнорується — порожній набір (graceful: фільтр просто не застосовується).
86
+ * @param {string} root абсолютний корінь (cwd для git)
87
+ * @param {string[]} relPaths posix-шляхи від кореня
88
+ * @returns {Set<string>} підмножина ігнорованих relPaths
89
+ */
90
+ function gitIgnoredPaths(root, relPaths) {
91
+ if (relPaths.length === 0) return new Set()
92
+ try {
93
+ const out = execFileSync('git', ['check-ignore', '--stdin'], {
94
+ cwd: root,
95
+ input: relPaths.join('\n'),
96
+ encoding: 'utf8',
97
+ stdio: ['pipe', 'pipe', 'ignore'] // git пише «not a git repository» у stderr — глушимо
98
+ })
99
+ return new Set(out.split('\n').map(s => s.trim()).filter(Boolean))
100
+ } catch {
101
+ // exit 1 (жоден не ігнорується) і 128 (не git-репо) → execFileSync кидає; обидва = «не фільтруємо».
102
+ return new Set()
103
+ }
104
+ }
105
+
81
106
  /**
82
107
  * Рекурсивно обходить дерево від `root`, повертає кодові файли зі станом застарілості.
83
108
  * Синхронний `readdirSync` — детермінований порядок без гонок; обсяг дерева це дозволяє.
109
+ * Поверх `DOCGEN_IGNORE_GLOBS` відсіює ще й те, що в `.gitignore` (через git check-ignore).
84
110
  * @param {string} root абсолютний корінь обходу
85
111
  * @returns {Array<{sourcePath:string, docPath:string, stale:boolean, reason:'missing'|'crc-mismatch'|null}>} кандидати з відносними шляхами
86
112
  */
@@ -111,7 +137,8 @@ export function scanForDocFiles(root) {
111
137
  }
112
138
 
113
139
  walk(root)
114
- return results
140
+ const ignored = gitIgnoredPaths(root, results.map(r => r.sourcePath))
141
+ return ignored.size ? results.filter(r => !ignored.has(r.sourcePath)) : results
115
142
  }
116
143
 
117
144
  /**