@nitra/cursor 3.28.0 → 3.29.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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.0",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -51,8 +51,6 @@
|
|
|
51
51
|
"rename-yaml-extensions": "bun ./bin/n-cursor.js rename-yaml-extensions"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
55
|
-
"@anthropic-ai/sdk": "^0.100.1",
|
|
56
54
|
"oxc-parser": "^0.128.0",
|
|
57
55
|
"picomatch": "^4.0.4",
|
|
58
56
|
"smol-toml": "^1.6.1",
|
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public API класифікатора: classify(survived, cwd, opts) → verdicts[]
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
*
|
|
9
|
-
* Prompt caching: system-prompt передається з cache_control: ephemeral —
|
|
10
|
-
* усі мутанти одного прогону reuse кешований префікс на стороні API.
|
|
4
|
+
* Routing:
|
|
5
|
+
* 1. Cache lookup → hit → використати збережений verdict.
|
|
6
|
+
* 2. Cache miss → Tier 1 (LOCAL_MIN через pi) → parseVerdict.
|
|
7
|
+
* 3. Tier 1 fail (pi error / bad JSON / Zod) → Tier 2 (CLOUD_MIN через pi).
|
|
8
|
+
* 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
|
|
11
9
|
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
12
11
|
import { join } from 'node:path'
|
|
13
|
-
import { env } from 'node:process'
|
|
14
|
-
import { setTimeout } from 'node:timers/promises'
|
|
15
12
|
|
|
13
|
+
import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
|
|
16
14
|
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
17
15
|
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
18
16
|
import { parseVerdict } from './verdict-schema.mjs'
|
|
19
17
|
|
|
20
|
-
const MODEL = 'claude-sonnet-4-6'
|
|
21
|
-
const MAX_RETRIES = 2
|
|
22
|
-
const DEFAULT_RETRY_DELAY_MS = 1000
|
|
23
|
-
|
|
24
18
|
const FALLBACK_VERDICT = {
|
|
25
19
|
verdict: 'worth-testing',
|
|
26
20
|
confidence: 0,
|
|
@@ -28,36 +22,67 @@ const FALLBACK_VERDICT = {
|
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @param {
|
|
34
|
-
* @
|
|
35
|
-
* @
|
|
36
|
-
* @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
|
|
25
|
+
* Викликає pi і повертає raw stdout.
|
|
26
|
+
* @param {string} prompt
|
|
27
|
+
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
28
|
+
* @returns {string}
|
|
29
|
+
* @throws якщо pi не знайдено або повертає ненульовий exit code
|
|
37
30
|
*/
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
31
|
+
function callPi(prompt, model) {
|
|
32
|
+
const modelArgs = model ? ['--model', model] : []
|
|
33
|
+
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
timeout: 60_000
|
|
36
|
+
})
|
|
37
|
+
if (r.error) throw new Error(`pi error: ${r.error.message}`)
|
|
38
|
+
if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`)
|
|
39
|
+
return r.stdout?.trim() ?? ''
|
|
40
|
+
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Два тири: LOCAL_MIN → Tier 2 CLOUD_MIN → FALLBACK_VERDICT.
|
|
44
|
+
* @param {{file: string, mutants: object[]}} group
|
|
45
|
+
* @param {object} mutant
|
|
46
|
+
* @param {string} cwd
|
|
47
|
+
* @param {(prompt: string, model: string) => string} callPiFn ін'єкція для тестів
|
|
48
|
+
* @returns {object} verdict
|
|
49
|
+
*/
|
|
50
|
+
function classifyOne(group, mutant, cwd, callPiFn) {
|
|
51
|
+
const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
|
|
52
|
+
const loc = `${group.file}:${mutant.line}:${mutant.col}`
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
// Tier 1: resolveModel('min') — каскад local→cloud якщо локалі нема
|
|
48
55
|
try {
|
|
49
|
-
|
|
56
|
+
const text = callPiFn(prompt, resolveModel('min'))
|
|
57
|
+
return parseVerdict(text)
|
|
50
58
|
} catch {
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
// Tier 2: CLOUD_MIN
|
|
60
|
+
try {
|
|
61
|
+
const text = callPiFn(prompt, CLOUD_MIN)
|
|
62
|
+
return parseVerdict(text)
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${e.message}`)
|
|
65
|
+
return { ...FALLBACK_VERDICT }
|
|
66
|
+
}
|
|
53
67
|
}
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Класифікує survived мутантів через pi (LOCAL_MIN → CLOUD_MIN → fallback).
|
|
72
|
+
* @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived
|
|
73
|
+
* @param {string} cwd корінь проєкту
|
|
74
|
+
* @param {{cachePath?: string, callPi?: Function}} [opts] ін'єкції для тестів
|
|
75
|
+
* @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
|
|
76
|
+
*/
|
|
77
|
+
export async function classify(survived, cwd, opts = {}) {
|
|
78
|
+
const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
|
|
79
|
+
const callPiFn = opts.callPi ?? callPi
|
|
80
|
+
const cacheModel = `${resolveModel('min') || 'default'}+${CLOUD_MIN || 'cloud'}`
|
|
56
81
|
|
|
57
82
|
const cache = readCache(cachePath)
|
|
58
|
-
if (cache.model !==
|
|
83
|
+
if (cache.model !== cacheModel) {
|
|
59
84
|
cache.entries = {}
|
|
60
|
-
cache.model =
|
|
85
|
+
cache.model = cacheModel
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
const verdicts = []
|
|
@@ -77,7 +102,7 @@ export async function classify(survived, cwd, opts = {}) {
|
|
|
77
102
|
}
|
|
78
103
|
}
|
|
79
104
|
if (!verdict) {
|
|
80
|
-
verdict =
|
|
105
|
+
verdict = classifyOne(group, mutant, cwd, callPiFn)
|
|
81
106
|
if (cacheKey) {
|
|
82
107
|
cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
|
|
83
108
|
}
|
|
@@ -90,40 +115,3 @@ export async function classify(survived, cwd, opts = {}) {
|
|
|
90
115
|
writeCache(cachePath, cache)
|
|
91
116
|
return verdicts
|
|
92
117
|
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Один виклик API з retry. На фейл після MAX_RETRIES — повертає FALLBACK_VERDICT.
|
|
96
|
-
* @param {{messages: {create: Function}}} client SDK client
|
|
97
|
-
* @param {{file: string}} group group для контексту
|
|
98
|
-
* @param {object} mutant mutant data
|
|
99
|
-
* @param {string} cwd корінь
|
|
100
|
-
* @param {number} retryDelayMs base delay для exp-backoff (0 у тестах)
|
|
101
|
-
* @returns {Promise<object>} verdict (parsed або fallback)
|
|
102
|
-
*/
|
|
103
|
-
async function classifyOne(client, group, mutant, cwd, retryDelayMs) {
|
|
104
|
-
const userPrompt = buildUserPrompt({ ...mutant, file: group.file }, cwd)
|
|
105
|
-
let lastError = null
|
|
106
|
-
|
|
107
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
108
|
-
try {
|
|
109
|
-
const response = await client.messages.create({
|
|
110
|
-
model: MODEL,
|
|
111
|
-
max_tokens: 1024,
|
|
112
|
-
system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
|
|
113
|
-
messages: [{ role: 'user', content: userPrompt }]
|
|
114
|
-
})
|
|
115
|
-
const text = response?.content?.[0]?.text ?? ''
|
|
116
|
-
return parseVerdict(text)
|
|
117
|
-
} catch (error) {
|
|
118
|
-
lastError = error
|
|
119
|
-
if (attempt < MAX_RETRIES && retryDelayMs > 0) {
|
|
120
|
-
await setTimeout(retryDelayMs * 2 ** attempt)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
console.warn(
|
|
126
|
-
`⚠ coverage classify: ${group.file}:${mutant.line}:${mutant.col} failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message ?? 'unknown'}`
|
|
127
|
-
)
|
|
128
|
-
return { ...FALLBACK_VERDICT }
|
|
129
|
-
}
|
package/scripts/coverage-fix.mjs
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n-cursor coverage --fix`: запускає
|
|
2
|
+
* `n-cursor coverage --fix`: запускає pi-агента для написання тестів
|
|
3
3
|
* по вцілілих мутантах Stryker. Агент отримує список мутантів з контекстом
|
|
4
4
|
* (file, line, оригінальний код, вцілілий варіант, тип мутації) і самостійно
|
|
5
5
|
* знаходить або створює відповідні test-файли.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Модель: CLOUD_MAX (складна агентна задача) або N_CURSOR_COVERAGE_FIX_MODEL.
|
|
8
8
|
*/
|
|
9
9
|
import { readFile } from 'node:fs/promises'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
|
|
13
|
+
import { resolveModel } from '../lib/models.mjs'
|
|
14
|
+
|
|
15
|
+
const MODEL = process.env.N_CURSOR_COVERAGE_FIX_MODEL ?? resolveModel('max')
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* @typedef {{line:number, col:number, mutantType:string, original:string, replacement:string}} MutantDetail
|
|
@@ -15,12 +20,13 @@ import { join } from 'node:path'
|
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
|
-
* Запускає
|
|
23
|
+
* Запускає pi-агента для написання тестів по вцілілих мутантах.
|
|
19
24
|
* @param {SurvivedFileGroup[]} survived вцілілі мутанти, згруповані по файлах
|
|
20
25
|
* @param {string} projectRoot абсолютний шлях до кореня проєкту
|
|
26
|
+
* @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => void }} [opts] ін'єкції для тестів
|
|
21
27
|
* @returns {Promise<void>}
|
|
22
28
|
*/
|
|
23
|
-
export async function fixSurvivedMutants(survived, projectRoot) {
|
|
29
|
+
export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
|
|
24
30
|
const totalMutants = survived.reduce((s, g) => s + g.mutants.length, 0)
|
|
25
31
|
if (totalMutants === 0) {
|
|
26
32
|
console.log('✓ Всі мутанти вбиті — доповнення тестів не потрібне')
|
|
@@ -30,26 +36,23 @@ export async function fixSurvivedMutants(survived, projectRoot) {
|
|
|
30
36
|
const prompt = await buildFixPrompt(survived, projectRoot)
|
|
31
37
|
console.log(`\n🤖 coverage --fix: запускаю агента для ${totalMutants} вцілілих мутантів...\n`)
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
const callPiFn = opts.callPi ?? callPi
|
|
40
|
+
callPiFn(prompt, MODEL, { cwd: projectRoot })
|
|
41
|
+
}
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
})
|
|
50
|
-
if (msg.type === 'text') process.stdout.write(msg.text)
|
|
51
|
-
}
|
|
52
|
-
process.stdout.write('\n')
|
|
43
|
+
/**
|
|
44
|
+
* Викликає pi в агентному режимі з live-output до stdout.
|
|
45
|
+
* @param {string} prompt
|
|
46
|
+
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
47
|
+
* @param {{ cwd?: string }} [piOpts]
|
|
48
|
+
*/
|
|
49
|
+
function callPi(prompt, model, { cwd } = {}) {
|
|
50
|
+
const modelArgs = model ? ['--model', model] : []
|
|
51
|
+
spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
|
|
52
|
+
cwd,
|
|
53
|
+
stdio: 'inherit',
|
|
54
|
+
timeout: 900_000
|
|
55
|
+
})
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/**
|
|
@@ -1,122 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SubagentRunner
|
|
3
|
-
*
|
|
4
|
-
* 1. `claude-agent-sdk` (програмний, потребує `ANTHROPIC_API_KEY`);
|
|
5
|
-
* 2. `claude -p` (CLI-auth користувача);
|
|
6
|
-
* 3. `cursor-agent -p` (CLI-auth).
|
|
7
|
-
* Нема жодного → throw (polyfill без runner-а не стартує, §2.2).
|
|
2
|
+
* SubagentRunner — спавн субагента через pi (провайдер-нейтрально).
|
|
3
|
+
* Модель обирається через resolveModel('avg') (каскад local→cloud) або через deps.model.
|
|
8
4
|
*
|
|
9
|
-
* pi
|
|
10
|
-
*
|
|
5
|
+
* Контракт runner-а: { backend: 'pi', runStep(prompt, { cwd }) → Promise<{ ok, output }> }.
|
|
6
|
+
* Усі callers (planner, executor, plan-panel, review, budget) використовують саме цей контракт.
|
|
11
7
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* pi НЕ спавниться рекурсивно коли pi — зовнішній драйвер (§9.1).
|
|
9
|
+
* У цьому проєкті зовнішній драйвер — Claude Code; pi як субагент — безпечно.
|
|
14
10
|
*/
|
|
15
11
|
import { spawnSync } from 'node:child_process'
|
|
16
|
-
import { env as processEnv } from 'node:process'
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
'SubagentRunner: ні claude-agent-sdk (з ANTHROPIC_API_KEY), ні `claude`/`cursor-agent` у PATH — ' +
|
|
20
|
-
'субагентів спавнити нічим. Встанови CLI-runner або задай ANTHROPIC_API_KEY.'
|
|
13
|
+
import { resolveModel } from '../../../lib/models.mjs'
|
|
21
14
|
|
|
22
15
|
/**
|
|
23
|
-
*
|
|
24
|
-
* @param {string}
|
|
25
|
-
* @param {
|
|
26
|
-
* @
|
|
16
|
+
* Викликає pi і повертає { ok, output }.
|
|
17
|
+
* @param {string} prompt
|
|
18
|
+
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
19
|
+
* @param {{ cwd?: string }} [opts]
|
|
20
|
+
* @returns {{ ok: boolean, output: string }}
|
|
27
21
|
*/
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
22
|
+
function callPi(prompt, model, { cwd } = {}) {
|
|
23
|
+
const modelArgs = model ? ['--model', model] : []
|
|
24
|
+
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
|
|
25
|
+
cwd,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
timeout: 600_000
|
|
28
|
+
})
|
|
29
|
+
const ok = !r.error && r.status === 0
|
|
30
|
+
const output = (r.stdout ?? '') + (r.error ? r.error.message : !ok ? (r.stderr ?? '') : '')
|
|
31
|
+
return { ok, output }
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
* @param {{
|
|
36
|
-
* @returns {
|
|
35
|
+
* Створює pi-runner. Повертає { backend: 'pi', runStep }.
|
|
36
|
+
* @param {{ model?: string, callPi?: Function }} [deps] ін'єкції для тестів
|
|
37
|
+
* @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => Promise<{ ok: boolean, output: string }> }>}
|
|
37
38
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (isInPath('cursor-agent')) return 'cursor'
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* CLI-runner (`claude -p` / `cursor-agent -p`) — CLI-auth, без API key.
|
|
47
|
-
* @param {'claude' | 'cursor-agent'} bin виконуваний
|
|
48
|
-
* @param {{ spawn?: typeof import('node:child_process').spawnSync }} [deps] ін'єкція
|
|
49
|
-
* @returns {{ backend: string, runStep: (prompt: string, opts?: { cwd?: string }) => { ok: boolean, output: string } }} runner
|
|
50
|
-
*/
|
|
51
|
-
export function cliRunner(bin, deps = {}) {
|
|
52
|
-
const spawn = deps.spawn ?? spawnSync
|
|
53
|
-
return {
|
|
54
|
-
backend: bin,
|
|
55
|
-
runStep(prompt, { cwd } = {}) {
|
|
56
|
-
const r = spawn(bin, ['-p'], { input: prompt, cwd, encoding: 'utf8' })
|
|
57
|
-
return { ok: (r.status ?? 1) === 0, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
39
|
+
export async function createRunner(deps = {}) {
|
|
40
|
+
const model = deps.model ?? resolveModel('avg')
|
|
41
|
+
const callPiFn = deps.callPi ?? callPi
|
|
61
42
|
|
|
62
|
-
/**
|
|
63
|
-
* SDK-runner (`claude-agent-sdk`). `query` ін'єктується; за замовчуванням —
|
|
64
|
-
* динамічний import (optional dependency).
|
|
65
|
-
* @param {{ query?: (input: object) => object }} [deps] ін'єкція (query повертає async-iterable повідомлень)
|
|
66
|
-
* @returns {{ backend: string, runStep: (prompt: string, opts?: { cwd?: string }) => Promise<{ ok: boolean, output: string }> }} runner
|
|
67
|
-
*/
|
|
68
|
-
export function sdkRunner(deps = {}) {
|
|
69
43
|
return {
|
|
70
|
-
backend: '
|
|
71
|
-
async runStep(prompt,
|
|
72
|
-
let query = deps.query
|
|
73
|
-
if (!query) {
|
|
74
|
-
const mod = await import('@anthropic-ai/claude-agent-sdk')
|
|
75
|
-
query = mod.query
|
|
76
|
-
}
|
|
77
|
-
let output = ''
|
|
78
|
-
let ok = true
|
|
44
|
+
backend: 'pi',
|
|
45
|
+
async runStep(prompt, opts = {}) {
|
|
79
46
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})) {
|
|
84
|
-
if (typeof msg?.text === 'string') output += msg.text
|
|
85
|
-
if (msg?.type === 'result') ok = msg.is_error !== true
|
|
86
|
-
}
|
|
87
|
-
} catch (error) {
|
|
88
|
-
return { ok: false, output: String(error?.message ?? error) }
|
|
47
|
+
return callPiFn(prompt, model, opts)
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return { ok: false, output: String(e?.message ?? e) }
|
|
89
50
|
}
|
|
90
|
-
return { ok, output }
|
|
91
51
|
}
|
|
92
52
|
}
|
|
93
53
|
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Створює runner за доступним backend-ом. `backend`/probe-и можна задати явно
|
|
97
|
-
* (тести); інакше визначаються з env/PATH/SDK.
|
|
98
|
-
* @param {{ backend?: string, env?: Record<string, string | undefined>, isInPath?: (name: string) => boolean, canImportSdk?: boolean, spawn?: (cmd: string, args: string[], opts: object) => object, query?: (input: object) => object }} [deps] ін'єкції
|
|
99
|
-
* @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => object }>} runner
|
|
100
|
-
*/
|
|
101
|
-
export async function createRunner(deps = {}) {
|
|
102
|
-
const env = deps.env ?? processEnv
|
|
103
|
-
const isInPath = deps.isInPath ?? (name => isBinaryInPath(name, deps.spawn))
|
|
104
|
-
const canImportSdk = deps.canImportSdk ?? (await probeSdk())
|
|
105
|
-
const backend = deps.backend ?? selectBackend({ hasApiKey: Boolean(env.ANTHROPIC_API_KEY), canImportSdk, isInPath })
|
|
106
|
-
if (!backend) throw new Error(NO_BACKEND)
|
|
107
|
-
if (backend === 'sdk') return sdkRunner(deps)
|
|
108
|
-
return cliRunner(backend === 'claude' ? 'claude' : 'cursor-agent', deps)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Чи імпортується `claude-agent-sdk` (optional dependency).
|
|
113
|
-
* @returns {Promise<boolean>} true, якщо доступний
|
|
114
|
-
*/
|
|
115
|
-
async function probeSdk() {
|
|
116
|
-
try {
|
|
117
|
-
await import('@anthropic-ai/claude-agent-sdk')
|
|
118
|
-
return true
|
|
119
|
-
} catch {
|
|
120
|
-
return false
|
|
121
|
-
}
|
|
122
|
-
}
|
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { basename } from 'node:path'
|
|
4
4
|
import { request } from 'node:http'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
5
6
|
import { env } from 'node:process'
|
|
6
|
-
import
|
|
7
|
+
import { LOCAL_MIN, resolveModel } from '../../../lib/models.mjs'
|
|
7
8
|
import { extractFacts } from './docgen-extract.mjs'
|
|
8
9
|
import { sectionMessages, oneShotMessages, STYLE, oneShotPromptText } from './docgen-prompts.mjs'
|
|
9
10
|
|
|
11
|
+
/** Strips provider prefix from tier string for direct ollama HTTP (ollama/gemma3:4b → gemma3:4b). */
|
|
12
|
+
function localModelId(tier) {
|
|
13
|
+
if (!tier) return 'gemma3:4b'
|
|
14
|
+
const i = tier.indexOf('/')
|
|
15
|
+
return i === -1 ? tier : tier.slice(i + 1)
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
const QUALITY_THRESHOLD = 70
|
|
11
19
|
|
|
12
20
|
/** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
|
|
@@ -137,73 +145,20 @@ function scoreDoc(md, facts) {
|
|
|
137
145
|
return { score: Math.max(0, score), issues }
|
|
138
146
|
}
|
|
139
147
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
Відповідай ТІЛЬКИ JSON без пояснень:
|
|
148
|
-
{"огляд":N,"поведінка":N,"гарантії":N,"стиль":N,"issues":["коротко про кожен мінус 1-5 слів"]}`
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Stage 2.5 cloud: Claude Haiku оцінює якість доку проти коду + фактів.
|
|
152
|
-
* Використовує найдешевшу хмарну модель — haiku — для мінімальної вартості судді.
|
|
153
|
-
* @returns {{ score: number, scores: object, issues: string[], tok: number }}
|
|
154
|
-
*/
|
|
155
|
-
async function cloudScoreDoc(md, facts, src, model = 'claude-haiku-4-5-20251001') {
|
|
156
|
-
const client = new Anthropic()
|
|
157
|
-
const factsTxt = [
|
|
158
|
-
facts.exports?.length ? `Публічні функції: ${facts.exports.map(e => e.name).join(', ')}` : '',
|
|
159
|
-
facts.internalSymbols?.length ? `Внутрішні (не публічні): ${facts.internalSymbols.join(', ')}` : '',
|
|
160
|
-
facts.markers?.caches ? 'Кешування: є' : 'Кешування: немає',
|
|
161
|
-
facts.markers?.network ? 'Мережа: є' : 'Мережа: немає',
|
|
162
|
-
facts.markers?.readOnly ? 'Read-only (не змінює файли/стан)' : ''
|
|
163
|
-
]
|
|
164
|
-
.filter(Boolean)
|
|
165
|
-
.join('\n')
|
|
166
|
-
|
|
167
|
-
const msg = await client.messages.create({
|
|
168
|
-
model,
|
|
169
|
-
max_tokens: 256,
|
|
170
|
-
system: SCORE_RUBRIC,
|
|
171
|
-
messages: [
|
|
172
|
-
{
|
|
173
|
-
role: 'user',
|
|
174
|
-
content: [
|
|
175
|
-
{ type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
|
|
176
|
-
{ type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
|
|
177
|
-
{ type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
|
|
178
|
-
]
|
|
179
|
-
}
|
|
180
|
-
]
|
|
148
|
+
/** Tier 2: виклик через pi (провайдер-нейтрально). model — рядок `provider/model-id`. */
|
|
149
|
+
function piOneShot(facts, src, model) {
|
|
150
|
+
const fullPrompt = `${STYLE}\n\n${oneShotPromptText(facts, src)}`
|
|
151
|
+
const modelArgs = model ? ['--model', model] : []
|
|
152
|
+
const r = spawnSync('pi', ['-p', fullPrompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
timeout: 120_000
|
|
181
155
|
})
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const total = (((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12) * 100
|
|
186
|
-
return { score: Math.round(total), scores: j, issues: j.issues ?? [], tok }
|
|
187
|
-
} catch {
|
|
188
|
-
return { score: 50, scores: {}, issues: ['parse-error'], tok }
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Tier 2: хмарний fallback через Claude коли local-score < QUALITY_THRESHOLD. */
|
|
193
|
-
async function claudeOneShot(facts, src, model = 'claude-sonnet-4-6') {
|
|
194
|
-
const client = new Anthropic()
|
|
195
|
-
const prompt = oneShotPromptText(facts, src)
|
|
196
|
-
const msg = await client.messages.create({
|
|
197
|
-
model,
|
|
198
|
-
max_tokens: 1500,
|
|
199
|
-
system: STYLE,
|
|
200
|
-
messages: [{ role: 'user', content: prompt }]
|
|
201
|
-
})
|
|
202
|
-
const text = msg.content[0]?.text ?? ''
|
|
203
|
-
const genTok = msg.usage?.output_tokens ?? 0
|
|
156
|
+
if (r.error) throw new Error(`pi Tier 2 error: ${r.error.message}`)
|
|
157
|
+
if (r.status !== 0) throw new Error(`pi Tier 2 exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
|
|
158
|
+
const text = r.stdout?.trim() ?? ''
|
|
204
159
|
let md = stripSignatures(stripSection(text))
|
|
205
160
|
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
206
|
-
return { md: md + '\n', genTok }
|
|
161
|
+
return { md: md + '\n', genTok: 0 }
|
|
207
162
|
}
|
|
208
163
|
|
|
209
164
|
/** Stage 3: фіксовані заголовки у фіксованому порядку. */
|
|
@@ -246,6 +201,10 @@ async function generateOneShot(facts, src, model) {
|
|
|
246
201
|
const DEFAULT_SYM_THRESHOLD = 4
|
|
247
202
|
/** Максимальний час локальної генерації на один файл перед ескалацією у Tier 2. */
|
|
248
203
|
const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
|
|
204
|
+
/** Дефолтна Tier 1 модель: N_CURSOR_DOCGEN_MODEL → LOCAL_MIN → ollama gemma3:4b. */
|
|
205
|
+
const DEFAULT_LOCAL_MODEL = localModelId(env.N_CURSOR_DOCGEN_MODEL ?? LOCAL_MIN)
|
|
206
|
+
/** Дефолтна Tier 2 модель (provider/model-id для pi): N_CURSOR_DOCGEN_CLOUD_MODEL → resolveModel('avg'). */
|
|
207
|
+
const DEFAULT_CLOUD_MODEL = env.N_CURSOR_DOCGEN_CLOUD_MODEL ?? resolveModel('avg')
|
|
249
208
|
|
|
250
209
|
/** Повертає promise, що відхиляється через `ms` мс з повідомленням про timeout. */
|
|
251
210
|
function withTimeout(promise, ms) {
|
|
@@ -268,9 +227,9 @@ function withTimeout(promise, ms) {
|
|
|
268
227
|
export async function generateDoc(
|
|
269
228
|
file,
|
|
270
229
|
{
|
|
271
|
-
model =
|
|
230
|
+
model = DEFAULT_LOCAL_MODEL,
|
|
272
231
|
mode = 'orchestrated',
|
|
273
|
-
cloudModel =
|
|
232
|
+
cloudModel = DEFAULT_CLOUD_MODEL,
|
|
274
233
|
threshold = QUALITY_THRESHOLD,
|
|
275
234
|
symThreshold = DEFAULT_SYM_THRESHOLD
|
|
276
235
|
} = {}
|
|
@@ -281,8 +240,8 @@ export async function generateDoc(
|
|
|
281
240
|
|
|
282
241
|
// Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2, не витрачаємо local-час
|
|
283
242
|
const complexity = facts.internalSymbols?.length ?? 0
|
|
284
|
-
if (complexity >= symThreshold &&
|
|
285
|
-
const r2 =
|
|
243
|
+
if (complexity >= symThreshold && cloudModel) {
|
|
244
|
+
const r2 = piOneShot(facts, src, cloudModel)
|
|
286
245
|
return {
|
|
287
246
|
...r2,
|
|
288
247
|
ms: Date.now() - t0,
|
|
@@ -302,8 +261,8 @@ export async function generateDoc(
|
|
|
302
261
|
: generateOrchestrated(facts, src, model)
|
|
303
262
|
r = await withTimeout(localPromise, LOCAL_TIMEOUT_MS)
|
|
304
263
|
} catch (e) {
|
|
305
|
-
if (
|
|
306
|
-
const r2 =
|
|
264
|
+
if (cloudModel) {
|
|
265
|
+
const r2 = piOneShot(facts, src, cloudModel)
|
|
307
266
|
return {
|
|
308
267
|
...r2,
|
|
309
268
|
ms: Date.now() - t0,
|
|
@@ -319,8 +278,8 @@ export async function generateDoc(
|
|
|
319
278
|
// Stage 2.5: детермінований скоринг (0 токенів) — gate перед Tier 2
|
|
320
279
|
const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
|
|
321
280
|
|
|
322
|
-
if (detScore < threshold &&
|
|
323
|
-
const r2 =
|
|
281
|
+
if (detScore < threshold && cloudModel) {
|
|
282
|
+
const r2 = piOneShot(facts, src, cloudModel)
|
|
324
283
|
return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2, model: cloudModel }
|
|
325
284
|
}
|
|
326
285
|
|
|
@@ -335,7 +294,7 @@ if (isRunAsCli(import.meta.url)) {
|
|
|
335
294
|
const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
|
|
336
295
|
const tierOnly = args.includes('--tier-only')
|
|
337
296
|
const mi = args.indexOf('--model')
|
|
338
|
-
const model = mi >= 0 ? args[mi + 1] :
|
|
297
|
+
const model = mi >= 0 ? args[mi + 1] : DEFAULT_LOCAL_MODEL
|
|
339
298
|
const si = args.indexOf('--sym-threshold')
|
|
340
299
|
const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
|
|
341
300
|
if (!file) {
|
|
@@ -4,12 +4,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { env } from 'node:process'
|
|
7
|
-
import {
|
|
7
|
+
import { resolveModel } from '../../../lib/models.mjs'
|
|
8
8
|
|
|
9
|
-
// Тир за замовчуванням:
|
|
9
|
+
// Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
|
|
10
10
|
// Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
|
|
11
|
-
export const MODEL = env.N_CURSOR_FIX_MODEL ??
|
|
12
|
-
export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ??
|
|
11
|
+
export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
|
|
12
|
+
export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Витягує відносні шляхи файлів із violation output.
|