@nitra/cursor 5.2.1 → 5.3.1
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 +13 -0
- package/bin/n-cursor.js +72 -50
- package/lib/llm.mjs +60 -47
- package/lib/models.mjs +1 -1
- package/lib/omlx-trace.mjs +158 -0
- package/lib/omlx.mjs +49 -11
- package/package.json +1 -1
- package/rules/js-bun-db/js-bun-db.mdc +7 -7
- package/rules/js-lint/js-lint.mdc +14 -1
- package/rules/js-run/js-run.mdc +16 -16
- package/rules/k8s/js/manifests.mjs +144 -82
- package/rules/npm-module/js/header_doc_pointer.mjs +72 -27
- package/rules/npm-module/js/rule_meta.mjs +72 -36
- package/rules/npm-module/js/skill_meta.mjs +59 -35
- package/rules/style-lint/js/tooling.mjs +13 -4
- package/rules/style-lint/style-lint.mdc +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.vue.baseline.mjs +1 -1
- package/rules/test/js/stryker_config.mjs +33 -5
- package/rules/test/js/vitest-config-pool-forks.mjs +11 -7
- package/rules/test/test.mdc +9 -9
- package/rules/vue/vue.mdc +6 -6
- package/scripts/coverage-classify/index.mjs +5 -17
- package/scripts/coverage-classify/verdict-schema.mjs +1 -1
- package/scripts/lib/assert-project-root.mjs +1 -1
- package/scripts/lib/discover-check-rules-from-cursor.mjs +1 -1
- package/scripts/lib/rule-predicates.mjs +30 -18
- package/scripts/lib/run-rule-cli.mjs +1 -1
- package/scripts/lib/run-standard-rule.mjs +1 -1
- package/scripts/post-tool-use-fix.mjs +3 -3
- package/scripts/skills-cli.mjs +5 -5
- package/scripts/worktree-cli.mjs +5 -5
- package/skills/doc-files/js/docgen-extract.mjs +1 -1
- package/skills/doc-files/js/docgen-files-batch.mjs +65 -34
- package/skills/doc-files/js/docgen-gen.mjs +121 -36
- package/skills/doc-files/js/docgen-prompts.mjs +20 -5
- package/skills/fix/js/llm-worker.mjs +10 -22
- package/skills/fix/js/orchestrator.mjs +64 -35
- package/skills/fix/js/t0.mjs +44 -32
- package/skills/start-check/js/check.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.3.1] - 2026-06-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- lint: усунено eslint/oxlint-помилки (24) — ігнор кореневого `.worktrees/` в eslint.config, GENERIC_RE→масив дрібних patternів (sonarjs/regex-complexity), Array#sort→toSorted; cspell: додано `ollama` у словник
|
|
8
|
+
|
|
9
|
+
## [5.3.0] - 2026-06-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- js-lint: нові JS-файли створюються з явним розширенням .mjs/.cjs (не .js); приклади нового вихідного коду в js-run/js-bun-db/vue переведено на .mjs. test: підтримка vitest.config.mjs — pool-forks і stryker_config приймають .mjs/.js (новий канон .mjs, legacy .js не дублюється), stryker.configFile приводиться до фактичного імені. style-lint: чекер розпізнає stylelint.config.mjs/.cjs та .stylelintrc.mjs/.cjs.
|
|
14
|
+
- llm wire-trace: always-on багатий JSONL-запис на кожен `callLlm` (обидва канали — reasoning + слід) у `<cwd>/.n-cursor/llm-trace.jsonl` (gitignored, недеструктивна ротація 50 MB, kill-switch `N_CURSOR_LLM_TRACE=0`); `callOmlxRaw` дістає reasoning_content/usage/finish_reason/attempts (`callOmlx` лишається `string`-обгорткою); `fix`+`coverage-classify` мігровано з прямого `callOmlx`/`pi`-spawn на спільний `callLlm` (caller-мітка). Спека: docs/specs/2026-06-10-omlx-wire-trace-capture-design.md
|
|
15
|
+
|
|
3
16
|
## [5.2.1] - 2026-06-11
|
|
4
17
|
|
|
5
18
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -1243,7 +1243,73 @@ function logRemovedManagedItems(title, basePath, names) {
|
|
|
1243
1243
|
}
|
|
1244
1244
|
|
|
1245
1245
|
/**
|
|
1246
|
-
*
|
|
1246
|
+
* Визначає список правил для fix: явно задані (з валідацією проти available) або
|
|
1247
|
+
* discovery з `.cursor/rules/*.mdc`. Друкує діагностику й кидає на невідомих правилах.
|
|
1248
|
+
* @param {string[]} requestedRules запитані правила (порожній → discovery)
|
|
1249
|
+
* @param {string[]} available доступні в пакеті rule-id
|
|
1250
|
+
* @param {boolean} json json-режим (впливає на early-return вивід при порожньому discovery)
|
|
1251
|
+
* @returns {Promise<string[]|null>} список id або null — нічого запускати (вивід уже зроблено)
|
|
1252
|
+
*/
|
|
1253
|
+
async function resolveFixRuleIds(requestedRules, available, json) {
|
|
1254
|
+
if (requestedRules.length > 0) {
|
|
1255
|
+
const unknown = requestedRules.filter(id => !available.includes(id))
|
|
1256
|
+
if (unknown.length > 0) {
|
|
1257
|
+
console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
|
|
1258
|
+
console.log(` Доступні: ${available.join(', ')}`)
|
|
1259
|
+
throw new Error(`Unknown rules: ${unknown.join(', ')}`)
|
|
1260
|
+
}
|
|
1261
|
+
return requestedRules
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const mdcFiles = await listProjectRulesMdcFiles()
|
|
1265
|
+
if (mdcFiles.length === 0) {
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`Немає файлів *.mdc у ${RULES_DIR}/. Запустіть \`npx ${PACKAGE_NAME}\` або вкажіть правила: \`npx ${PACKAGE_NAME} fix bun ga\``
|
|
1268
|
+
)
|
|
1269
|
+
}
|
|
1270
|
+
const idsToRun = discoverCheckRulesFromCursorRules(available, mdcFiles)
|
|
1271
|
+
if (idsToRun.length === 0) {
|
|
1272
|
+
if (json) {
|
|
1273
|
+
process.stdout.write(`${JSON.stringify({ total: 0, failed: 0, rules: [] })}\n`)
|
|
1274
|
+
return null
|
|
1275
|
+
}
|
|
1276
|
+
console.log(
|
|
1277
|
+
`\n🔍 ${PACKAGE_NAME} fix — у ${RULES_DIR}/ немає правил з programmatic перевіркою ` +
|
|
1278
|
+
`(відповідного fix.mjs у пакеті). Нічого не запущено.\n`
|
|
1279
|
+
)
|
|
1280
|
+
return null
|
|
1281
|
+
}
|
|
1282
|
+
return idsToRun
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Прогоняє `fix.mjs` кожного правила окремим процесом; збирає timings і (для json) per-rule output.
|
|
1287
|
+
* @param {string[]} idsToRun правила до запуску
|
|
1288
|
+
* @param {boolean} json json-режим — захоплює stdout/stderr у структуру замість inherit у термінал
|
|
1289
|
+
* @returns {{ totalFailed: number, timings: {id:string, ms:number, ok:boolean}[], ruleResults: {ruleId:string, ok:boolean, output:string}[] }} підсумок прогону
|
|
1290
|
+
*/
|
|
1291
|
+
function runRuleFixProcesses(idsToRun, json) {
|
|
1292
|
+
let totalFailed = 0
|
|
1293
|
+
const timings = []
|
|
1294
|
+
const ruleResults = []
|
|
1295
|
+
for (const id of idsToRun) {
|
|
1296
|
+
const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
|
|
1297
|
+
const startedAt = Date.now()
|
|
1298
|
+
const result = json
|
|
1299
|
+
? spawnSync('bun', [fixPath], { encoding: 'utf8' })
|
|
1300
|
+
: spawnSync('bun', [fixPath], { stdio: 'inherit' })
|
|
1301
|
+
const ok = result.status === 0
|
|
1302
|
+
timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
|
|
1303
|
+
if (json) {
|
|
1304
|
+
ruleResults.push({ ruleId: id, ok, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() })
|
|
1305
|
+
}
|
|
1306
|
+
if (!ok) totalFailed++
|
|
1307
|
+
}
|
|
1308
|
+
return { totalFailed, timings, ruleResults }
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Spawn-wrapper для `npx \@nitra/cursor fix [<rule>...]`. Один шлях у коді: для кожного правила
|
|
1247
1313
|
* робить `bun rules/<id>/fix.mjs` як окремий процес. Сам `fix.mjs` читає `.n-cursor.json`,
|
|
1248
1314
|
* перевіряє whitelist (`runRuleCli`) і друкує per-rule summary.
|
|
1249
1315
|
*
|
|
@@ -1272,56 +1338,12 @@ async function runFixCommand(requestedRules, opts = {}) {
|
|
|
1272
1338
|
throw new Error('No rules found')
|
|
1273
1339
|
}
|
|
1274
1340
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
|
|
1280
|
-
console.log(` Доступні: ${available.join(', ')}`)
|
|
1281
|
-
throw new Error(`Unknown rules: ${unknown.join(', ')}`)
|
|
1282
|
-
}
|
|
1283
|
-
idsToRun = requestedRules
|
|
1284
|
-
} else {
|
|
1285
|
-
const mdcFiles = await listProjectRulesMdcFiles()
|
|
1286
|
-
if (mdcFiles.length === 0) {
|
|
1287
|
-
throw new Error(
|
|
1288
|
-
`Немає файлів *.mdc у ${RULES_DIR}/. Запустіть \`npx ${PACKAGE_NAME}\` або вкажіть правила: \`npx ${PACKAGE_NAME} fix bun ga\``
|
|
1289
|
-
)
|
|
1290
|
-
}
|
|
1291
|
-
idsToRun = discoverCheckRulesFromCursorRules(available, mdcFiles)
|
|
1292
|
-
if (idsToRun.length === 0) {
|
|
1293
|
-
if (json) {
|
|
1294
|
-
process.stdout.write(`${JSON.stringify({ total: 0, failed: 0, rules: [] })}\n`)
|
|
1295
|
-
return
|
|
1296
|
-
}
|
|
1297
|
-
console.log(
|
|
1298
|
-
`\n🔍 ${PACKAGE_NAME} fix — у ${RULES_DIR}/ немає правил з programmatic перевіркою ` +
|
|
1299
|
-
`(відповідного fix.mjs у пакеті). Нічого не запущено.\n`
|
|
1300
|
-
)
|
|
1301
|
-
return
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1341
|
+
// json-режим: захоплюємо stdout/stderr правила у структуру (а не inherit у термінал),
|
|
1342
|
+
// щоб віддати агенту згруповано {ruleId, ok, output} і він читав лише впалі.
|
|
1343
|
+
const idsToRun = await resolveFixRuleIds(requestedRules, available, json)
|
|
1344
|
+
if (idsToRun === null) return
|
|
1304
1345
|
|
|
1305
|
-
|
|
1306
|
-
/** @type {{ id: string, ms: number, ok: boolean }[]} */
|
|
1307
|
-
const timings = []
|
|
1308
|
-
/** @type {{ ruleId: string, ok: boolean, output: string }[]} */
|
|
1309
|
-
const ruleResults = []
|
|
1310
|
-
for (const id of idsToRun) {
|
|
1311
|
-
const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
|
|
1312
|
-
const startedAt = Date.now()
|
|
1313
|
-
// json-режим: захоплюємо stdout/stderr правила у структуру (а не inherit у термінал),
|
|
1314
|
-
// щоб віддати агенту згруповано {ruleId, ok, output} і він читав лише впалі.
|
|
1315
|
-
const result = json
|
|
1316
|
-
? spawnSync('bun', [fixPath], { encoding: 'utf8' })
|
|
1317
|
-
: spawnSync('bun', [fixPath], { stdio: 'inherit' })
|
|
1318
|
-
const ok = result.status === 0
|
|
1319
|
-
timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
|
|
1320
|
-
if (json) {
|
|
1321
|
-
ruleResults.push({ ruleId: id, ok, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() })
|
|
1322
|
-
}
|
|
1323
|
-
if (!ok) totalFailed++
|
|
1324
|
-
}
|
|
1346
|
+
const { totalFailed, timings, ruleResults } = runRuleFixProcesses(idsToRun, json)
|
|
1325
1347
|
|
|
1326
1348
|
if (json) {
|
|
1327
1349
|
process.stdout.write(`${JSON.stringify({ total: idsToRun.length, failed: totalFailed, rules: ruleResults })}\n`)
|
package/lib/llm.mjs
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Жодних env-перемикачів бекенда: рядок моделі сам визначає транспорт.
|
|
9
9
|
*
|
|
10
|
-
* Wire-trace (
|
|
11
|
-
* кожен виклик
|
|
12
|
-
*
|
|
10
|
+
* Wire-trace (спека 2026-06-10-omlx-wire-trace-capture-design): **always-on**
|
|
11
|
+
* багатий JSONL-запис на кожен виклик — обидва канали (reasoning + слід). Для
|
|
12
|
+
* omlx захоплює content/reasoning/usage/finish_reason/attempts; для pi — лише
|
|
13
|
+
* те, що CLI дає (rich-поля null). Деталі запису/шляху/ротації — `omlx-trace.mjs`.
|
|
13
14
|
*/
|
|
14
15
|
import { spawnSync } from 'node:child_process'
|
|
15
|
-
import { appendFileSync } from 'node:fs'
|
|
16
16
|
import { env } from 'node:process'
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { callOmlxRaw, isOmlxModel } from './omlx.mjs'
|
|
19
|
+
import { buildTraceRecord, writeTrace } from './omlx-trace.mjs'
|
|
19
20
|
|
|
20
21
|
/** Дефолтний timeout одного виклику (узгоджено з LOCAL_TIMEOUT доки-конвеєра). */
|
|
21
22
|
const DEFAULT_TIMEOUT_MS = 120_000
|
|
@@ -29,20 +30,6 @@ export function pickBackend(model) {
|
|
|
29
30
|
return isOmlxModel(model) ? 'omlx' : 'pi'
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
* Fail-safe append JSONL-рядка трейсу у файл з `N_CURSOR_LLM_TRACE`.
|
|
34
|
-
* @param {object} entry один запис трейсу
|
|
35
|
-
*/
|
|
36
|
-
function trace(entry) {
|
|
37
|
-
const file = env.N_CURSOR_LLM_TRACE
|
|
38
|
-
if (!file) return
|
|
39
|
-
try {
|
|
40
|
-
appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
41
|
-
} catch {
|
|
42
|
-
// трейс не має ламати основний виклик
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
33
|
/**
|
|
47
34
|
* Виклик через `pi` CLI: messages конкатенуються у plain prompt
|
|
48
35
|
* (pi не приймає messages-масив), tools вимкнено.
|
|
@@ -64,42 +51,68 @@ function callPi(messages, model, timeoutMs) {
|
|
|
64
51
|
}
|
|
65
52
|
|
|
66
53
|
/**
|
|
67
|
-
* Універсальний LLM-виклик з маршрутизацією за префіксом model-id
|
|
54
|
+
* Універсальний LLM-виклик з маршрутизацією за префіксом model-id і always-on
|
|
55
|
+
* wire-trace (обидва канали).
|
|
68
56
|
* @param {Array<{role:string, content:string}>} messages OpenAI-style messages (system зберігається на omlx)
|
|
69
57
|
* @param {string} model model-id; `omlx/<m>` → прямий HTTP, інакше → pi CLI
|
|
70
|
-
* @param {{ timeoutMs?: number, temperature?: number, maxTokens?: number, url?: string }} [opts] timeout, температура, ліміт виходу, override URL
|
|
58
|
+
* @param {{ timeoutMs?: number, temperature?: number, maxTokens?: number, url?: string, caller?: string }} [opts] timeout, температура, ліміт виходу, override URL, мітка викликача для trace
|
|
71
59
|
* @returns {string} текст відповіді (непорожній на omlx; pi може повернути '')
|
|
72
60
|
*/
|
|
73
61
|
export function callLlm(messages, model, opts = {}) {
|
|
74
|
-
const { timeoutMs = DEFAULT_TIMEOUT_MS, temperature = 0.2, maxTokens, url } = opts
|
|
62
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, temperature = 0.2, maxTokens, url, caller } = opts
|
|
75
63
|
const backend = pickBackend(model)
|
|
64
|
+
const resolvedCaller = caller ?? env.N_CURSOR_TRACE_CALLER ?? 'unknown'
|
|
76
65
|
const t0 = Date.now()
|
|
77
|
-
const promptChars = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0)
|
|
78
66
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
model,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
let content
|
|
68
|
+
let reasoning = null
|
|
69
|
+
let reasoningSource = null
|
|
70
|
+
let finishReason = null
|
|
71
|
+
let usage = null
|
|
72
|
+
let attempts = 1
|
|
73
|
+
if (backend === 'omlx') {
|
|
74
|
+
const raw = callOmlxRaw(messages, model, { url, timeoutMs, temperature, ...(maxTokens ? { maxTokens } : {}) })
|
|
75
|
+
;({ content, reasoning, reasoningSource, finishReason, usage, attempts } = raw)
|
|
76
|
+
} else {
|
|
77
|
+
content = callPi(messages, model, timeoutMs)
|
|
78
|
+
}
|
|
79
|
+
writeTrace(
|
|
80
|
+
buildTraceRecord({
|
|
81
|
+
ts: new Date().toISOString(),
|
|
82
|
+
caller: resolvedCaller,
|
|
83
|
+
backend,
|
|
84
|
+
model,
|
|
85
|
+
temperature,
|
|
86
|
+
maxTokens,
|
|
87
|
+
messages,
|
|
88
|
+
content,
|
|
89
|
+
reasoning,
|
|
90
|
+
reasoningSource,
|
|
91
|
+
finishReason,
|
|
92
|
+
usage,
|
|
93
|
+
ms: Date.now() - t0,
|
|
94
|
+
attempts,
|
|
95
|
+
ok: true,
|
|
96
|
+
error: null
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
return content
|
|
93
100
|
} catch (error) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
writeTrace(
|
|
102
|
+
buildTraceRecord({
|
|
103
|
+
ts: new Date().toISOString(),
|
|
104
|
+
caller: resolvedCaller,
|
|
105
|
+
backend,
|
|
106
|
+
model,
|
|
107
|
+
temperature,
|
|
108
|
+
maxTokens,
|
|
109
|
+
messages,
|
|
110
|
+
ms: Date.now() - t0,
|
|
111
|
+
attempts: null,
|
|
112
|
+
ok: false,
|
|
113
|
+
error: String(error.message).slice(0, 200)
|
|
114
|
+
})
|
|
115
|
+
)
|
|
103
116
|
throw error
|
|
104
117
|
}
|
|
105
118
|
}
|
|
@@ -124,7 +137,7 @@ const AUTH_ERROR_MARKER = 'authentication_error'
|
|
|
124
137
|
export function omlxHealthCheck(opts = {}) {
|
|
125
138
|
const { url, model = '', timeoutMs = DEFAULT_TIMEOUT_MS } = opts
|
|
126
139
|
try {
|
|
127
|
-
|
|
140
|
+
callOmlxRaw([{ role: 'user', content: 'ok' }], model, { url, timeoutMs, maxTokens: 1, temperature: 0 })
|
|
128
141
|
return { ok: true, reason: null, detail: '' }
|
|
129
142
|
} catch (error) {
|
|
130
143
|
const detail = String(error.message)
|
package/lib/models.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ## Бекенд за префіксом model-id
|
|
16
16
|
*
|
|
17
|
-
* model-id з префіксом `omlx/...`
|
|
17
|
+
* model-id з префіксом `omlx/...` іде прямим HTTP до локального
|
|
18
18
|
* omlx-сервера (`npm/lib/omlx.mjs`), минаючи pi; решта (`openai/...`,
|
|
19
19
|
* `ollama/...`, '') — через pi CLI. Тому локальні тири варто задавати у форматі
|
|
20
20
|
* `omlx/<model>`, аби local-inference йшов напряму, а pi лишався шаром для хмари
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-trace LLM-викликів: будує й пише багатий JSONL-запис на кожен виклик
|
|
3
|
+
* `callLlm` (див. `npm/lib/llm.mjs`). Захоплює **обидва канали** — reasoning
|
|
4
|
+
* (думки моделі) і спостережуваний слід (request/response/usage/latency/retry).
|
|
5
|
+
*
|
|
6
|
+
* Дизайн-спека: `docs/specs/2026-06-10-omlx-wire-trace-capture-design.md`.
|
|
7
|
+
*
|
|
8
|
+
* Двошарова модель:
|
|
9
|
+
* - RAW (цей модуль) → `<cwd>/.n-cursor/llm-trace.jsonl` (gitignored, локальний,
|
|
10
|
+
* недеструктивна ротація) — сирий потік, доживає до батч-агрегації.
|
|
11
|
+
* - AGGREGATE (друга спека) → `docs/omlx-insights/` (коммітиться в git, назавжди).
|
|
12
|
+
*
|
|
13
|
+
* Always-on: пишеться завжди. `N_CURSOR_LLM_TRACE=0|false|off|no` — kill-switch;
|
|
14
|
+
* будь-яке інше значення — override-шлях замість дефолтного.
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { cwd, env } from 'node:process'
|
|
20
|
+
|
|
21
|
+
/** Ліміт символів на одне `message.content` у записі (захист обсягу/чутливості). */
|
|
22
|
+
export const MAX_MSG_CHARS = 8000
|
|
23
|
+
|
|
24
|
+
/** Поріг недеструктивної ротації активного файлу (байти). */
|
|
25
|
+
export const ROTATE_BYTES = 50 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
/** Значення `N_CURSOR_LLM_TRACE`, що вимикають трасування повністю. */
|
|
28
|
+
const KILL_VALUES = new Set(['0', 'false', 'off', 'no'])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Шлях активного trace-файлу або `null`, якщо трасування вимкнено kill-switch-ем.
|
|
32
|
+
* Пріоритет: `N_CURSOR_LLM_TRACE` (kill-switch → null; інакше явний шлях) →
|
|
33
|
+
* дефолт `<cwd>/.n-cursor/llm-trace.jsonl` (корінь споживацького проєкту).
|
|
34
|
+
* @returns {string|null} абсолютний/відносний шлях до .jsonl або null
|
|
35
|
+
*/
|
|
36
|
+
export function tracePath() {
|
|
37
|
+
const override = env.N_CURSOR_LLM_TRACE
|
|
38
|
+
if (override !== undefined) {
|
|
39
|
+
if (KILL_VALUES.has(override.toLowerCase())) return null
|
|
40
|
+
if (override) return override
|
|
41
|
+
}
|
|
42
|
+
return join(cwd(), '.n-cursor', 'llm-trace.jsonl')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Обрізає кожне `message.content` до `MAX_MSG_CHARS` і рахує sha256 повного
|
|
47
|
+
* (необрізаного) масиву для дедуплікації.
|
|
48
|
+
* @param {Array<{role:string, content:string}>} messages вихідні messages
|
|
49
|
+
* @returns {{ messages: Array<{role:string, content:string}>, messages_sha256: string, messages_truncated: boolean }} обрізані messages, hash і прапор обрізки
|
|
50
|
+
*/
|
|
51
|
+
export function capMessages(messages) {
|
|
52
|
+
const src = messages ?? []
|
|
53
|
+
let truncated = false
|
|
54
|
+
const capped = src.map(m => {
|
|
55
|
+
const content = m?.content ?? ''
|
|
56
|
+
if (content.length > MAX_MSG_CHARS) {
|
|
57
|
+
truncated = true
|
|
58
|
+
return { role: m.role, content: content.slice(0, MAX_MSG_CHARS) }
|
|
59
|
+
}
|
|
60
|
+
return { role: m?.role, content }
|
|
61
|
+
})
|
|
62
|
+
const messages_sha256 = createHash('sha256').update(JSON.stringify(src)).digest('hex')
|
|
63
|
+
return { messages: capped, messages_sha256, messages_truncated: truncated }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Будує нормалізований trace-запис. Поля, яких backend не дає (pi: reasoning/
|
|
68
|
+
* usage/finish_reason), лишаються `null` за побудовою.
|
|
69
|
+
* @param {object} i вхід
|
|
70
|
+
* @param {string} i.ts ISO-час завершення виклику
|
|
71
|
+
* @param {string} i.caller хто викликав (doc-files|fix|coverage|unknown)
|
|
72
|
+
* @param {'omlx'|'pi'} i.backend бекенд
|
|
73
|
+
* @param {string} i.model model-id
|
|
74
|
+
* @param {number} [i.temperature] температура
|
|
75
|
+
* @param {number} [i.maxTokens] ліміт виходу
|
|
76
|
+
* @param {Array<{role:string, content:string}>} i.messages messages запиту
|
|
77
|
+
* @param {string|null} [i.content] відповідь
|
|
78
|
+
* @param {string|null} [i.reasoning] думки моделі
|
|
79
|
+
* @param {string|null} [i.reasoningSource] джерело reasoning
|
|
80
|
+
* @param {string|null} [i.finishReason] finish_reason
|
|
81
|
+
* @param {object|null} [i.usage] usage verbatim
|
|
82
|
+
* @param {number} i.ms latency
|
|
83
|
+
* @param {number|null} [i.attempts] кількість спроб
|
|
84
|
+
* @param {boolean} i.ok успіх
|
|
85
|
+
* @param {string|null} [i.error] текст помилки
|
|
86
|
+
* @returns {object} JSONL-готовий запис
|
|
87
|
+
*/
|
|
88
|
+
export function buildTraceRecord(i) {
|
|
89
|
+
const capped = capMessages(i.messages)
|
|
90
|
+
return {
|
|
91
|
+
ts: i.ts,
|
|
92
|
+
caller: i.caller,
|
|
93
|
+
backend: i.backend,
|
|
94
|
+
model: i.model,
|
|
95
|
+
temperature: i.temperature ?? null,
|
|
96
|
+
max_tokens: i.maxTokens ?? null,
|
|
97
|
+
messages: capped.messages,
|
|
98
|
+
messages_sha256: capped.messages_sha256,
|
|
99
|
+
messages_truncated: capped.messages_truncated,
|
|
100
|
+
content: i.content ?? null,
|
|
101
|
+
reasoning: i.reasoning ?? null,
|
|
102
|
+
reasoning_source: i.reasoningSource ?? null,
|
|
103
|
+
finish_reason: i.finishReason ?? null,
|
|
104
|
+
usage: i.usage ?? null,
|
|
105
|
+
ms: i.ms,
|
|
106
|
+
attempts: i.attempts ?? null,
|
|
107
|
+
ok: i.ok,
|
|
108
|
+
error: i.error ?? null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Імʼя архіву для ротації: `llm-trace.jsonl` → `llm-trace.<seq>.jsonl`
|
|
114
|
+
* (нестандартні імена без `.jsonl` → `<file>.<seq>`).
|
|
115
|
+
* @param {string} file активний trace-файл
|
|
116
|
+
* @param {number} seq порядковий номер архіву
|
|
117
|
+
* @returns {string} шлях архіву
|
|
118
|
+
*/
|
|
119
|
+
function archiveName(file, seq) {
|
|
120
|
+
return file.endsWith('.jsonl') ? `${file.slice(0, -'.jsonl'.length)}.${seq}.jsonl` : `${file}.${seq}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Недеструктивна ротація: якщо активний файл перевищує `ROTATE_BYTES`,
|
|
125
|
+
* перейменовує його в перший вільний `llm-trace.<seq>.jsonl` (без перезапису
|
|
126
|
+
* наявних архівів). Відсутній файл / помилка stat — no-op.
|
|
127
|
+
* @param {string} file активний trace-файл
|
|
128
|
+
*/
|
|
129
|
+
export function rotateIfNeeded(file) {
|
|
130
|
+
let size
|
|
131
|
+
try {
|
|
132
|
+
size = statSync(file).size
|
|
133
|
+
} catch {
|
|
134
|
+
return // файлу ще нема — нічого ротувати
|
|
135
|
+
}
|
|
136
|
+
if (size <= ROTATE_BYTES) return
|
|
137
|
+
let seq = 1
|
|
138
|
+
while (existsSync(archiveName(file, seq))) seq++
|
|
139
|
+
renameSync(file, archiveName(file, seq))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Fail-safe запис одного trace-рядка. Резолвить шлях (kill-switch → no-op),
|
|
144
|
+
* ротує за потреби, створює теку, append-ить JSONL. Будь-яка помилка IO
|
|
145
|
+
* ковтається — трасування **ніколи** не ламає основний виклик.
|
|
146
|
+
* @param {object} record запис від `buildTraceRecord`
|
|
147
|
+
*/
|
|
148
|
+
export function writeTrace(record) {
|
|
149
|
+
const file = tracePath()
|
|
150
|
+
if (!file) return
|
|
151
|
+
try {
|
|
152
|
+
rotateIfNeeded(file)
|
|
153
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
154
|
+
appendFileSync(file, JSON.stringify(record) + '\n')
|
|
155
|
+
} catch {
|
|
156
|
+
// трейс не має ламати основний виклик
|
|
157
|
+
}
|
|
158
|
+
}
|
package/lib/omlx.mjs
CHANGED
|
@@ -70,16 +70,42 @@ export function omlxModelId(model) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
* `
|
|
75
|
-
*
|
|
73
|
+
* Витягує reasoning (думки моделі) з omlx-`message`. Джерела за пріоритетом:
|
|
74
|
+
* - `field` — окреме поле `message.reasoning_content` (Qwen3-Thinking тощо);
|
|
75
|
+
* - `think_tag` — `<think>…</think>` усередині `content` (інші thinking-моделі);
|
|
76
|
+
* - `truncated` — `finish_reason: "length"` зрізав думку в `content` до закриття
|
|
77
|
+
* тега → сирий reasoning лишився в `content` без `</think>`;
|
|
78
|
+
* - `null` — reasoning немає (не-thinking модель).
|
|
79
|
+
* @param {{content?:string, reasoning_content?:string}} message обʼєкт `choices[0].message`
|
|
80
|
+
* @param {string|null} finishReason `choices[0].finish_reason`
|
|
81
|
+
* @returns {{ reasoning: string|null, reasoningSource: 'field'|'think_tag'|'truncated'|null }} текст думок і його джерело
|
|
82
|
+
*/
|
|
83
|
+
const THINK_TAG_RE = /<think>([\s\S]*?)<\/think>/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
*
|
|
87
|
+
*/
|
|
88
|
+
export function extractReasoning(message, finishReason) {
|
|
89
|
+
const field = message?.reasoning_content
|
|
90
|
+
if (field && field.trim()) return { reasoning: field, reasoningSource: 'field' }
|
|
91
|
+
const content = message?.content ?? ''
|
|
92
|
+
const m = content.match(THINK_TAG_RE)
|
|
93
|
+
if (m) return { reasoning: m[1].trim(), reasoningSource: 'think_tag' }
|
|
94
|
+
if (finishReason === 'length' && content.trim()) return { reasoning: content, reasoningSource: 'truncated' }
|
|
95
|
+
return { reasoning: null, reasoningSource: null }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ядро прямого HTTP-виклику до omlx через `curl` (spawnSync). Повертає **багатий**
|
|
100
|
+
* обʼєкт: контент + reasoning + usage + finish_reason + кількість спроб. Ретраїть
|
|
101
|
+
* лише transient curl-помилки (18 = transfer closed, 52 = empty reply, 56 = recv failure).
|
|
76
102
|
* @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено)
|
|
77
103
|
* @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт
|
|
78
104
|
* @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ
|
|
79
|
-
* @returns {string}
|
|
105
|
+
* @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат виклику
|
|
80
106
|
* @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
|
|
81
107
|
*/
|
|
82
|
-
export function
|
|
108
|
+
export function callOmlxRaw(messages, model, opts = {}) {
|
|
83
109
|
const {
|
|
84
110
|
url = env.N_CURSOR_OMLX_URL ?? DEFAULT_OMLX_URL,
|
|
85
111
|
timeoutMs = 60_000,
|
|
@@ -136,12 +162,24 @@ export function callOmlx(messages, model, opts = {}) {
|
|
|
136
162
|
throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`)
|
|
137
163
|
}
|
|
138
164
|
if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
return content
|
|
165
|
+
const message = j.choices?.[0]?.message ?? {}
|
|
166
|
+
const finishReason = j.choices?.[0]?.finish_reason ?? null
|
|
167
|
+
const content = message.content?.trim() ?? ''
|
|
168
|
+
if (!content) throw new Error(`omlx empty content (finish=${finishReason})`)
|
|
169
|
+
const { reasoning, reasoningSource } = extractReasoning(message, finishReason)
|
|
170
|
+
return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt }
|
|
145
171
|
}
|
|
146
172
|
throw lastErr ?? new Error('omlx unknown failure')
|
|
147
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Тонка обгортка над `callOmlxRaw` для споживачів, яким потрібен лише текст.
|
|
177
|
+
* Контракт незмінний: повертає непорожній `choices[0].message.content`.
|
|
178
|
+
* @param {Array<{role:string, content:string}>} messages OpenAI-messages
|
|
179
|
+
* @param {string} model model-id (з/без `omlx/`-префікса)
|
|
180
|
+
* @param {object} [opts] ті самі опції, що й у `callOmlxRaw`
|
|
181
|
+
* @returns {string} непорожній контент відповіді
|
|
182
|
+
*/
|
|
183
|
+
export function callOmlx(messages, model, opts = {}) {
|
|
184
|
+
return callOmlxRaw(messages, model, opts).content
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
|
|
3
3
|
globs: "**/package.json,**/src/conn/**"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.15'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Підтримувані версії баз даних
|
|
@@ -300,10 +300,10 @@ const rows = await sql.unsafe(query, values)
|
|
|
300
300
|
|
|
301
301
|
Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
|
|
302
302
|
|
|
303
|
-
Для явного конфігу — `new SQL(...)` як **singleton** на рівні модуля, а не на кожен запит. Файл кладеться у `src/conn/db.
|
|
303
|
+
Для явного конфігу — `new SQL(...)` як **singleton** на рівні модуля, а не на кожен запит. Файл кладеться у `src/conn/db.mjs` і експортує іменовані константи `pgWrite` (основний запис) та `pgRead` (read-only replica), щоб glob `**/src/conn/**` у правилах покривав ці файли:
|
|
304
304
|
|
|
305
305
|
```javascript
|
|
306
|
-
// src/conn/db.
|
|
306
|
+
// src/conn/db.mjs
|
|
307
307
|
import { SQL } from 'bun'
|
|
308
308
|
|
|
309
309
|
export const pgWrite = new SQL({
|
|
@@ -454,16 +454,16 @@ ${pgRead.array(ids, 'int4')}
|
|
|
454
454
|
|
|
455
455
|
OXC formatter (oxfmt ≥ 0.49) примусово розгортає будь-який `CallExpression`, де перший аргумент є `CallExpression` з callback, у багаторядковий блок — незалежно від `printWidth`. Тому `pgWrite.array(arr.map(r => r.field), 'type')` всередині tagged template literal завжди стає 4-рядковим блоком. `col(arr, 'field')` (перший аргумент — identifier, другий — string literal) цей тригер не зачіпає і лишається однорядковим.
|
|
456
456
|
|
|
457
|
-
Канонічне місце хелпера — `src/utils/col.
|
|
457
|
+
Канонічне місце хелпера — `src/utils/col.mjs` (або `src/conn/col.mjs` залежно від структури проєкту):
|
|
458
458
|
|
|
459
459
|
```javascript
|
|
460
|
-
// src/utils/col.
|
|
460
|
+
// src/utils/col.mjs
|
|
461
461
|
export const col = (arr, key) => arr.map(r => r[key])
|
|
462
462
|
```
|
|
463
463
|
|
|
464
464
|
```javascript
|
|
465
|
-
import { pgWrite } from '#src/conn/db.
|
|
466
|
-
import { col } from '#src/utils/col.
|
|
465
|
+
import { pgWrite } from '#src/conn/db.mjs'
|
|
466
|
+
import { col } from '#src/utils/col.mjs'
|
|
467
467
|
|
|
468
468
|
// ❌ oxfmt розгортає на 4+ рядки незалежно від printWidth
|
|
469
469
|
${pgWrite.array(rows.map(r => r.id), 'int4')}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Перевірка JavaScript коду
|
|
3
3
|
globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.30'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
**oxlint**, **ESLint**, **jscpd**, **knip**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`**, **`bunx knip`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config`** — версія не нижче канонічного мінімуму зі snippet нижче (semver-поріг, єдине джерело істини) (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint). Dependency-політику CI-етапу (`@e18e/eslint-plugin` і oxlint/eslint/jscpd/knip окремо не додавати) винесено в `js-lint-ci`.
|
|
@@ -23,6 +23,19 @@ version: '1.29'
|
|
|
23
23
|
|
|
24
24
|
Канон `type` + `scripts.lint-js` (substring requirement) і мінімальна `@nitra/eslint-config` (semver-поріг `devDependencies`): [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
|
|
25
25
|
|
|
26
|
+
## Розширення нових файлів — `.mjs` / `.cjs`, не `.js`
|
|
27
|
+
|
|
28
|
+
**Нові** JS-файли створюй з явним розширенням модуля:
|
|
29
|
+
|
|
30
|
+
- **`.mjs`** — для ESM (типовий випадок);
|
|
31
|
+
- **`.cjs`** — для CommonJS, де він справді потрібен.
|
|
32
|
+
|
|
33
|
+
Голий **`.js`** для нового файлу **заборонено**. Розширення `.js` інтерпретується як ESM чи CJS лише за полем `package.json#type`, тож той самий файл читається по-різному залежно від пакета. Явне `.mjs`/`.cjs` робить тип модуля однозначним **без читання `package.json`** — навіть якщо `type` зміниться або файл перемістять в інший пакет. Це доповнює вимогу `"type": "module"` вище: `type` лишається каноном для всього дерева, а розширення нового файлу прибирає залежність від нього.
|
|
34
|
+
|
|
35
|
+
Стосується **backend і frontend** — будь-який новий вихідний файл: `src/`, тести `*.test.*`, `scripts/`, `src/conn/` тощо.
|
|
36
|
+
|
|
37
|
+
**Існуючі `.js` лишаються як є** — масово перейменовувати не треба; це конвенція для нового коду. Автоматичної перевірки тут немає: stateless-скан не відрізнить новий файл від існуючого, тож `.js` нікого не фейлить.
|
|
38
|
+
|
|
26
39
|
У `.vscode/extensions.json` `recommendations` мають містити `dbaeumer.vscode-eslint`, `github.vscode-github-actions`, `oxc.oxc-vscode`: [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json)
|
|
27
40
|
|
|
28
41
|
У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/rules/js-lint/js/data/tooling/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**). Поле **`ignorePatterns`** працює як **`rules`**: канонічні патерни з **`oxlint-canonical.json`** (наразі **`**/schema.graphql`**, **`**/auto-imports.d.ts`**) мають бути присутні, додаткові локальні glob-и дозволені. Канон **`oxlint-canonical.json`** — source-of-truth, редагується напряму; у споживачі оновлюється копіюванням файлу з репозиторію пакета. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
|