@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
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.29.0] - 2026-06-07
4
+
5
+ ### Added
6
+
7
+ - resolveModel(tier) — прозорий каскадний fallback local→cloud для всіх 3 тирів (min/avg/max)
8
+
3
9
  ## [3.28.0] - 2026-06-06
4
10
 
5
11
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.28.0",
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
- * Orchestration:
5
- * 1. Перевірка ANTHROPIC_API_KEY + dynamic import SDK (graceful skip).
6
- * 2. Для кожного мутанта: cache lookup класифікаціяcache write.
7
- * 3. На неуспішну класифікацію після retries conservative fallback worth-testing/confidence=0.
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
- * Класифікує survived мутантів через Claude API.
32
- * Без API key / без SDK / при критичних помилках — повертає [] (graceful skip).
33
- * @param {Array<{file: string, mutants: Array<object>, exampleTest?: object|null, recommendationText?: string|null}>} survived список survived груп (як у COVERAGE.md)
34
- * @param {string} cwd корінь проєкту
35
- * @param {{cachePath?: string, client?: object, retryDelayMs?: number}} [opts] ін'єкції для тестів
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
- export async function classify(survived, cwd, opts = {}) {
39
- const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
40
- const retryDelayMs = opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
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
- if (!env.ANTHROPIC_API_KEY) {
43
- console.warn('⚠ coverage classify: ANTHROPIC_API_KEY not set, classification skipped')
44
- return []
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
- let SDK
54
+ // Tier 1: resolveModel('min') — каскад local→cloud якщо локалі нема
48
55
  try {
49
- SDK = await import('@anthropic-ai/sdk')
56
+ const text = callPiFn(prompt, resolveModel('min'))
57
+ return parseVerdict(text)
50
58
  } catch {
51
- console.warn('⚠ coverage classify: @anthropic-ai/sdk not installed, classification skipped')
52
- return []
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
- const Anthropic = SDK.default
55
- const client = opts.client ?? new Anthropic()
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 !== MODEL) {
83
+ if (cache.model !== cacheModel) {
59
84
  cache.entries = {}
60
- cache.model = 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 = await classifyOne(client, group, mutant, cwd, retryDelayMs)
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
- }
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `n-cursor coverage --fix`: запускає Claude Code агента для написання тестів
2
+ * `n-cursor coverage --fix`: запускає pi-агента для написання тестів
3
3
  * по вцілілих мутантах Stryker. Агент отримує список мутантів з контекстом
4
4
  * (file, line, оригінальний код, вцілілий варіант, тип мутації) і самостійно
5
5
  * знаходить або створює відповідні test-файли.
6
6
  *
7
- * Залежить від `@anthropic-ai/claude-agent-sdk` (dependencies у npm/package.json).
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
- * Запускає Claude Code агента для написання тестів по вцілілих мутантах.
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
- // Dynamic import: @anthropic-ai/claude-agent-sdk завантажується лише при --fix,
34
- // щоб не гальмувати звичайний coverage-прогін за відсутності пакету.
35
- const { query } = await import('@anthropic-ai/claude-agent-sdk')
39
+ const callPiFn = opts.callPi ?? callPi
40
+ callPiFn(prompt, MODEL, { cwd: projectRoot })
41
+ }
36
42
 
37
- for await (const msg of query({
38
- prompt,
39
- options: {
40
- cwd: projectRoot,
41
- maxTurns: 20,
42
- allowedTools: ['Read', 'Edit', 'Bash'],
43
- // Без permissionMode headless-агент SDK не отримує дозволу на запис/Bash —
44
- // він крутиться, палить токени, але НЕ редагує файли (підтверджено пробом).
45
- // `bypassPermissions` потрібен, бо агент і пише тести (Edit), і ганяє `bun test` (Bash);
46
- // `acceptEdits` авто-підтверджує лише edits, а Bash лишився б заблокованим.
47
- permissionMode: 'bypassPermissions'
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 (spec §15.1) абстракція спавну сфокусованого субагента для
3
- * Активного Раннера (Ф3/Ф4). Backend обирається за доступністю:
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.dev для inner-спавну НЕ використовується: у автономному режимі pi.dev —
10
- * зовнішній драйвер, тож спавн ним внутрішніх субагентів = рекурсія (§9.1).
5
+ * Контракт runner-а: { backend: 'pi', runStep(prompt, { cwd }) Promise<{ ok, output }> }.
6
+ * Усі callers (planner, executor, plan-panel, review, budget) використовують саме цей контракт.
11
7
  *
12
- * Усі probe-залежності (`spawn`/`isInPath`/`canImportSdk`/`query`) ін'єктуються,
13
- * щоб тестувати без реальних процесів і без SDK.
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
- const NO_BACKEND =
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
- * Чи є бінарник у PATH (через `command -v`).
24
- * @param {string} name ім'я виконуваного
25
- * @param {typeof import('node:child_process').spawnSync} [spawn] ін'єкція для тестів
26
- * @returns {boolean} true, якщо знайдено
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
- export function isBinaryInPath(name, spawn = spawnSync) {
29
- const r = spawn('command', ['-v', name], { shell: true, encoding: 'utf8' })
30
- return (r.status ?? 1) === 0
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
- * Обирає backend субагентів за пріоритетом sdk > claude > cursor.
35
- * @param {{ hasApiKey: boolean, canImportSdk: boolean, isInPath: (name: string) => boolean }} probes доступність
36
- * @returns {'sdk' | 'claude' | 'cursor' | null} backend або null
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 selectBackend({ hasApiKey, canImportSdk, isInPath }) {
39
- if (hasApiKey && canImportSdk) return 'sdk'
40
- if (isInPath('claude')) return 'claude'
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: 'sdk',
71
- async runStep(prompt, { cwd } = {}) {
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
- for await (const msg of query({
81
- prompt,
82
- options: { cwd, maxTurns: 20, allowedTools: ['Read', 'Edit', 'Bash'] }
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 Anthropic from '@anthropic-ai/sdk'
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
- const SCORE_RUBRIC = `Оціни якість документації для JavaScript-модуля за 4 критеріями (1-3 кожен):
141
-
142
- - огляд: 3=описує роль модуля в системі (ЩО і НАВІЩО); 2=частково розмитий; 1=відсутній або перераховує функції
143
- - поведінка: 3=бізнес-терміни, без деталей реалізації; 2=деякі impl-деталі; 1=переважно реалізація або відсутня
144
- - гарантії: 3=лише реальні інваріанти підтверджені кодом, без галюцинацій; 2=частково правильні; 1=вигадані або відсутні
145
- - стиль: 3=без сигнатур/internal-імен, правильна markdown-структура; 2=дрібні порушення; 1=сигнатури/internal-імена/відсутні заголовки
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
- const tok = (msg.usage?.input_tokens ?? 0) + (msg.usage?.output_tokens ?? 0)
183
- try {
184
- const j = JSON.parse(msg.content[0]?.text ?? '{}')
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 = 'gemma3:4b',
230
+ model = DEFAULT_LOCAL_MODEL,
272
231
  mode = 'orchestrated',
273
- cloudModel = 'claude-sonnet-4-6',
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 && env.ANTHROPIC_API_KEY) {
285
- const r2 = await claudeOneShot(facts, src, cloudModel)
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 (env.ANTHROPIC_API_KEY) {
306
- const r2 = await claudeOneShot(facts, src, cloudModel)
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 && env.ANTHROPIC_API_KEY) {
323
- const r2 = await claudeOneShot(facts, src, cloudModel)
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] : 'gemma3:4b'
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 { CLOUD_MIN, CLOUD_AVG } from '../../../lib/models.mjs'
7
+ import { resolveModel } from '../../../lib/models.mjs'
8
8
 
9
- // Тир за замовчуванням: CLOUD_MINCLOUD_AVG при ескалації.
9
+ // Тир за замовчуванням: minavg при ескалації (каскад local→cloud).
10
10
  // Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
11
- export const MODEL = env.N_CURSOR_FIX_MODEL ?? CLOUD_MIN
12
- export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? CLOUD_AVG
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.