@nitra/cursor 9.1.0 → 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 +12 -0
- package/lib/llm.mjs +20 -0
- package/lib/omlx.mjs +54 -23
- package/package.json +1 -1
- package/rules/doc-files/js/docgen-crc.mjs +29 -8
- package/rules/doc-files/js/docgen-files-batch.mjs +73 -17
- package/rules/doc-files/js/docgen-gen.mjs +5 -5
- package/rules/doc-files/js/docgen-scan.mjs +28 -1
- package/{scripts/docs/lint-cli.md → rules/lint/js/docs/orchestrate.md} +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## [9.1.1] - 2026-06-14
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 📝 docs(lint): перенести doc оркестратора lint-cli.md → rules/lint/js/docs/orchestrate.md
|
|
14
|
+
|
|
3
15
|
## [9.1.0] - 2026-06-14
|
|
4
16
|
|
|
5
17
|
### 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
|
-
*
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
* @
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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 — без
|
|
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
|
-
|
|
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') →
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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 ??
|
|
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
|
-
|
|
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
|
/**
|