@7n/test 0.12.2 → 0.14.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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] - 2026-07-11
4
+
5
+ ### Added
6
+
7
+ - Ланцюжки (chains) @nitra/llm-lib ^1.1.0: mutant-classify — chain per mutant (tier1/tier2 = кроки, cache hit без chain), test-generate — chain per file (header + local/cloud спроби + vitest/length-retry = кроки), test-fix — chain per прогін (batch-виклики = кроки); адаптер callText/callAgent прокидає opts.chain. Аналітика ланцюжків — n-llm-chains-report і вкладка «Ланцюжки» в myllm.
8
+
9
+ ### Changed
10
+
11
+ - підняти @7n/llm-lib до ^2.0.2 (rename @nitra/llm-lib → @7n/llm-lib уже застосовано в dacc051; нова мажорна версія без змін API — chains/компресія/body-capture, лише перейменування пакета)
12
+
13
+ ## [0.13.0] - 2026-07-05
14
+
15
+ ### Changed
16
+
17
+ - lock
18
+ - Ф3 виносу LLM-шару: @7n/test переведено на @nitra/llm-lib. pi-client.mjs (власний SDK-плюмбінг, exponential backoff, memory-retry 15/30/60с) і prompt-budget.mjs видалено; замість них тонкий адаптер lib/llm.mjs (callText/callAgent) поверх runOneShot/runAgentSkill пакета і budgetFor/capText/packBatch з @nitra/llm-lib/prompt-budget. Політика строго fail-fast: knobs N_PI_RETRY_*/N_PI_MEMORY_RETRY_* видалено, лишилось одноразове подвоєння maxTokens на stopReason:'length'. coverage-fix переведено зі spawnSync pi CLI на SDK-embed агента. Тир-резолюцію централізовано (LOCAL_MIN/CLOUD_MIN/CLOUD_MAX з @nitra/llm-lib/model-tiers; classify приймає інжектовні tier1/tier2). УВАГА: потребує опублікованого @nitra/llm-lib >= 1.0.0 (мердж cursor#llm-lib-extraction перед релізом).
19
+
3
20
  ## [0.12.2] - 2026-07-05
4
21
 
5
22
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -41,6 +41,7 @@
41
41
  "dependencies": {
42
42
  "@earendil-works/pi-coding-agent": "^0.80.3",
43
43
  "@nitra/check-env": "^4.2.1",
44
+ "@7n/llm-lib": "^2.0.2",
44
45
  "@vitest/coverage-v8": "^4.1.9",
45
46
  "rollup": "^4.62.2",
46
47
  "vitest": "^4.1.9",
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import { join } from 'node:path'
9
- import { callText } from './lib/pi-client.mjs'
9
+ import { callText } from './lib/llm.mjs'
10
10
 
11
11
  const MAX_CONTENT_BYTES = 6000
12
12
 
@@ -36,13 +36,12 @@ const WIRING_RE = /^(import\b|export\s+(?:\{[^}]*\}|\*|type\b|interface\b|enum\b
36
36
  * @returns {string}
37
37
  */
38
38
  function stripComments(src) {
39
- return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, '')
39
+ return src.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/\/\/[^\n]*/g, '')
40
40
  }
41
41
 
42
42
  /**
43
43
  * Fast local classifier — no I/O, no LLM.
44
44
  * Returns a result object for obvious cases, or null when ambiguous.
45
- *
46
45
  * @param {string} content file source
47
46
  * @returns {{ needsTests: boolean, reason: string } | null}
48
47
  */
@@ -109,7 +108,6 @@ async function assessOne(fileInfo, dir, callTextFn) {
109
108
  * Assess a list of uncovered files: do they need tests?
110
109
  * Obvious cases (re-exports, functions-with-branches) are resolved locally.
111
110
  * Only ambiguous files trigger an LLM call.
112
- *
113
111
  * @param {Array<{file: string, pct: number}>} files
114
112
  * @param {string} dir project root
115
113
  * @param {{ callText?: Function }} [opts]
@@ -3,11 +3,8 @@ type: JS Module
3
3
  title: index.mjs
4
4
  resource: npm/src/coverage-classify/index.mjs
5
5
  docgen:
6
- crc: b7eff232
6
+ crc: 6f120c02
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.99
10
- judgeModel: openai-codex/gpt-5.4-mini
11
8
  ---
12
9
 
13
10
  ## Огляд
@@ -7,10 +7,11 @@
7
7
  * 3. Tier 1 fail → Tier 2 (N_CLOUD_MIN_MODEL через pi) → parseVerdict.
8
8
  * 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
9
9
  */
10
- import { env } from 'node:process'
11
10
  import { join } from 'node:path'
12
11
 
13
- import { callText } from '../lib/pi-client.mjs'
12
+ import { callText } from '../lib/llm.mjs'
13
+ import { CLOUD_MIN, LOCAL_MIN } from '@7n/llm-lib/model-tiers'
14
+ import { startChain } from '@7n/llm-lib/chain'
14
15
  import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
15
16
  import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
16
17
  import { parseVerdict } from './verdict-schema.mjs'
@@ -26,52 +27,79 @@ const FALLBACK_VERDICT = {
26
27
  * @param {string} prompt
27
28
  * @param {string} model provider/model-id або '' для pi-дефолту
28
29
  * @param {string} cwd
30
+ * @param {{chain?: object}} [callOpts] chain handle поточного мутанта
29
31
  * @returns {Promise<string>}
30
32
  */
31
- function callModel(prompt, model, cwd) {
32
- return callText(prompt, { cwd, ...(model ? { model } : {}) })
33
+ function callModel(prompt, model, cwd, { chain } = {}) {
34
+ return callText(prompt, { cwd, chain, ...(model && { model }) })
33
35
  }
34
36
 
35
37
  /**
36
- * Два тири: N_LOCAL_MIN_MODELN_CLOUD_MIN_MODEL → FALLBACK_VERDICT.
38
+ * Два тири: tier1 (local-min) tier2 (cloud-min) → FALLBACK_VERDICT.
39
+ * Кожен мутант — окремий ланцюжок (kind: mutant-classify): tier1 = крок 1,
40
+ * tier2 = крок 2; fallback-вердикт = outcome:'fail' (LLM не впорався).
37
41
  * @param {{file: string, mutants: object[]}} group
38
42
  * @param {object} mutant
39
43
  * @param {string} cwd
40
- * @param {(prompt: string, model: string, cwd: string) => Promise<string>} callModelFn
44
+ * @param {(prompt: string, model: string, cwd: string, callOpts?: {chain?: object}) => Promise<string>} callModelFn
45
+ * @param {string} tier1 model-spec першого тиру ('' = pi-дефолт)
46
+ * @param {string} tier2 model-spec другого тиру ('' = pi-дефолт)
47
+ * @param {typeof startChain} makeChain фабрика ланцюжка
41
48
  * @returns {Promise<object>} verdict
42
49
  */
43
- async function classifyOne(group, mutant, cwd, callModelFn) {
50
+ async function classifyOne(group, mutant, cwd, callModelFn, tier1, tier2, makeChain) {
44
51
  const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
45
52
  const loc = `${group.file}:${mutant.line}:${mutant.col}`
46
- const tier1 = env.N_LOCAL_MIN_MODEL ?? ''
47
- const tier2 = env.N_CLOUD_MIN_MODEL ?? ''
53
+ const chain = makeChain({ kind: 'mutant-classify', unit: loc, cwd })
48
54
 
49
55
  try {
50
- const text = await callModelFn(prompt, tier1, cwd)
51
- return parseVerdict(text)
56
+ const text = await callModelFn(prompt, tier1, cwd, { chain })
57
+ const verdict = parseVerdict(text)
58
+ chain.end({ outcome: 'success', extra: verdictExtra(verdict, mutant) })
59
+ return verdict
52
60
  } catch {
53
61
  try {
54
- const text = await callModelFn(prompt, tier2, cwd)
55
- return parseVerdict(text)
62
+ const text = await callModelFn(prompt, tier2, cwd, { chain })
63
+ const verdict = parseVerdict(text)
64
+ chain.end({ outcome: 'success', extra: verdictExtra(verdict, mutant) })
65
+ return verdict
56
66
  } catch (error) {
57
67
  console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${error.message}`)
68
+ chain.end({ outcome: 'fail', extra: { error: String(error.message ?? error).slice(0, 200) } })
58
69
  return { ...FALLBACK_VERDICT }
59
70
  }
60
71
  }
61
72
  }
62
73
 
74
+ /**
75
+ * Extra-поля фінального chain-запису мутанта.
76
+ * @param {{verdict: string, confidence: number}} verdict розпарсений вердикт
77
+ * @param {{replacement?: string}} mutant мутант
78
+ * @returns {object} extra
79
+ */
80
+ function verdictExtra(verdict, mutant) {
81
+ return {
82
+ verdict: verdict.verdict,
83
+ confidence: verdict.confidence,
84
+ replacement: String(mutant.replacement ?? '').slice(0, 120)
85
+ }
86
+ }
87
+
63
88
  /**
64
89
  * Класифікує survived мутантів через pi (N_LOCAL_MIN_MODEL → N_CLOUD_MIN_MODEL → fallback).
65
90
  * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived
66
91
  * @param {string} cwd
67
- * @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string) => Promise<string>}} [opts]
92
+ * @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string, callOpts?: {chain?: object}) => Promise<string>,
93
+ * tier1?: string, tier2?: string, startChain?: typeof startChain}} [opts] `tier1`/`tier2` — явні model-specs (дефолт: LOCAL_MIN/CLOUD_MIN пакета;
94
+ * інжектовні, бо тир-константи фіксуються при імпорті й у тестах не стабляться через env); `startChain` — фабрика ланцюжка (інжект для тестів)
68
95
  * @returns {Promise<Array<{key: string, verdict: object}>>}
69
96
  */
70
97
  export async function classify(survived, cwd, opts = {}) {
71
98
  const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
72
99
  const callModelFn = opts.callModel ?? callModel
73
- const tier1 = env.N_LOCAL_MIN_MODEL ?? ''
74
- const tier2 = env.N_CLOUD_MIN_MODEL ?? ''
100
+ const makeChain = opts.startChain ?? startChain
101
+ const tier1 = opts.tier1 ?? LOCAL_MIN
102
+ const tier2 = opts.tier2 ?? CLOUD_MIN
75
103
  const cacheModel = `${tier1 || 'default'}+${tier2 || 'cloud'}`
76
104
 
77
105
  const cache = readCache(cachePath)
@@ -93,11 +121,11 @@ export async function classify(survived, cwd, opts = {}) {
93
121
  verdict: cached.verdict,
94
122
  confidence: cached.confidence,
95
123
  reason: cached.reason,
96
- ...(cached.suggestedTest ? { suggestedTest: cached.suggestedTest } : {})
124
+ ...(cached.suggestedTest && { suggestedTest: cached.suggestedTest })
97
125
  }
98
126
  }
99
127
  if (!verdict) {
100
- verdict = await classifyOne(group, mutant, cwd, callModelFn)
128
+ verdict = await classifyOne(group, mutant, cwd, callModelFn, tier1, tier2, makeChain)
101
129
  if (cacheKey) {
102
130
  cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
103
131
  }
@@ -8,10 +8,11 @@
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
11
  import { env } from 'node:process'
12
+ import { CLOUD_MAX } from '@7n/llm-lib/model-tiers'
13
+ import { callAgent } from './lib/llm.mjs'
13
14
 
14
- const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? env.N_CLOUD_MAX_MODEL ?? ''
15
+ const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? CLOUD_MAX
15
16
 
16
17
  /**
17
18
  * @typedef {{line:number, col:number, mutantType:string, original:string, replacement:string}} MutantDetail
@@ -22,7 +23,7 @@ const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? env.N_CLOUD_MAX_MODEL ?? ''
22
23
  * Запускає pi-агента для написання тестів по вцілілих мутантах.
23
24
  * @param {SurvivedFileGroup[]} survived вцілілі мутанти, згруповані по файлах
24
25
  * @param {string} projectRoot абсолютний шлях до кореня проєкту
25
- * @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => void }} [opts] ін'єкції для тестів
26
+ * @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => Promise<void> }} [opts] ін'єкції для тестів
26
27
  * @returns {Promise<void>}
27
28
  */
28
29
  export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
@@ -36,22 +37,19 @@ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
36
37
  console.log(`\n🤖 coverage --fix: запускаю агента для ${totalMutants} вцілілих мутантів...\n`)
37
38
 
38
39
  const callPiFn = opts.callPi ?? callPi
39
- callPiFn(prompt, MODEL, { cwd: projectRoot })
40
+ await callPiFn(prompt, MODEL, { cwd: projectRoot })
40
41
  }
41
42
 
42
43
  /**
43
- * Викликає pi в агентному режимі з live-output до stdout.
44
+ * Викликає агента через `@7n/llm-lib` (SDK-embed, live-output до stdout) —
45
+ * заміна колишнього spawnSync pi CLI.
44
46
  * @param {string} prompt текст промпта
45
47
  * @param {string} model provider/model-id або '' для pi-дефолту
46
48
  * @param {{ cwd?: string }} [piOpts] опційні параметри (cwd)
49
+ * @returns {Promise<void>} завершується після виконання агента
47
50
  */
48
51
  function callPi(prompt, model, { cwd } = {}) {
49
- const modelArgs = model ? ['--model', model] : []
50
- spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
51
- cwd,
52
- stdio: 'inherit',
53
- timeout: 900_000
54
- })
52
+ return callAgent(prompt, cwd, { model })
55
53
  }
56
54
 
57
55
  /**
@@ -3,11 +3,8 @@ type: JS Module
3
3
  title: assess-need.mjs
4
4
  resource: npm/src/assess-need.mjs
5
5
  docgen:
6
- crc: 02dd5f01
6
+ crc: 20c3d440
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 90
9
- issues: internal-name:callText,judge:inaccurate:0.98
10
- judgeModel: openai-codex/gpt-5.4-mini
11
8
  ---
12
9
 
13
10
  ## Огляд
@@ -3,11 +3,8 @@ type: JS Module
3
3
  title: coverage-fix.mjs
4
4
  resource: npm/src/coverage-fix.mjs
5
5
  docgen:
6
- crc: 8b198273
6
+ crc: db577cf1
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.98
10
- judgeModel: openai-codex/gpt-5.4-mini
11
8
  ---
12
9
 
13
10
  ## Огляд
@@ -3,12 +3,8 @@ type: JS Module
3
3
  title: fix-tests.mjs
4
4
  resource: npm/src/fix-tests.mjs
5
5
  docgen:
6
- crc: f82de3c4
6
+ crc: 713e7889
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- tier: local-min-retry
9
- score: 90
10
- issues: internal-name:callText,judge:inaccurate:0.99
11
- judgeModel: openai-codex/gpt-5.4-mini
12
8
  ---
13
9
 
14
10
  ## Огляд
@@ -3,11 +3,8 @@ type: JS Module
3
3
  title: gen-tests.mjs
4
4
  resource: npm/src/gen-tests.mjs
5
5
  docgen:
6
- crc: 0d24dc95
6
+ crc: 0183f9b9
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.99
10
- judgeModel: openai-codex/gpt-5.4-mini
11
8
  ---
12
9
 
13
10
  ## Огляд
package/src/fix-tests.mjs CHANGED
@@ -19,13 +19,15 @@ import { mkdtemp, rm } from 'node:fs/promises'
19
19
  import { tmpdir } from 'node:os'
20
20
  import { join, relative } from 'node:path'
21
21
  import { env } from 'node:process'
22
- import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
23
- import { budgetFor, capText, packBatch } from './lib/prompt-budget.mjs'
22
+ import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
23
+ import { CLOUD_MAX } from '@7n/llm-lib/model-tiers'
24
+ import { startChain } from '@7n/llm-lib/chain'
25
+ import { budgetFor, capText, packBatch } from '@7n/llm-lib/prompt-budget'
24
26
  import { findTestRules } from './gen-tests.mjs'
25
27
  import { parseFailingTests } from './coverage-per-file.mjs'
26
28
  import { resolveVitestRun } from './lib/vitest-shim.mjs'
27
29
 
28
- const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ?? env.N_CLOUD_MAX_MODEL ?? undefined
30
+ const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ?? (CLOUD_MAX || undefined)
29
31
  const MAX_SRC_BYTES = 4000
30
32
  const TEST_DIR_MARKERS = ['/tests/', '\\tests\\']
31
33
  const TEST_FILE_SUFFIX = '.test.mjs'
@@ -321,17 +323,28 @@ function writeFixedFiles(fixed, remaining, dir) {
321
323
  * @param {string} dir project root
322
324
  * @param {{
323
325
  * failures?: Array<{file: string, errors: string[]}>,
324
- * callTextFn?: (prompt: string, opts?: object) => Promise<string>
325
- * }} [opts] overrides for callText and pre-fetched failures
326
+ * callTextFn?: (prompt: string, opts?: object) => Promise<string>,
327
+ * startChain?: typeof startChain
328
+ * }} [opts] overrides for callText, pre-fetched failures і фабрика ланцюжка (інжект для тестів)
326
329
  * @returns {Promise<{count: number, fixed: number, remaining: number}>} fix result summary
327
330
  */
328
331
  export async function fixFailingTests(dir, opts = {}) {
329
- const callTextFn =
330
- opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir, maxTokens: budgetFor('fix').maxTokens }))
331
332
  const failures = opts.failures ?? (await getFailingTests(dir))
332
333
 
333
334
  if (failures.length === 0) return { count: 0, fixed: 0, remaining: 0 }
334
335
 
336
+ // Один ланцюжок на прогін: батчі змішують файли, per-file chain неможливий
337
+ // без ламання батчингу; кожен batch-виклик = крок.
338
+ const chain = (opts.startChain ?? startChain)({
339
+ kind: 'test-fix',
340
+ unit: `fix:${failures.length}files`,
341
+ cwd: dir
342
+ })
343
+ let batches = 0
344
+ const callTextFn =
345
+ opts.callTextFn ??
346
+ (prompt => callText(prompt, { model: MODEL, cwd: dir, maxTokens: budgetFor('fix').maxTokens, chain }))
347
+
335
348
  console.log(`\n🔧 Виправляю ${failures.length} падаючих test-файлів (pi text mode)...\n`)
336
349
  for (const f of failures) {
337
350
  console.log(` • ${f.file} (${f.errors.length} помилок)`)
@@ -341,50 +354,64 @@ export async function fixFailingTests(dir, opts = {}) {
341
354
  let remaining = failures
342
355
  const attempts = new Map()
343
356
  let prevDeferred = new Set()
344
- for (;;) {
345
- // Спроби рахуються per-file (лише коли файл реально був у батчі),
346
- // тож deferred-черга не з'їдає ліміт файлів, які ще не пробували
347
- const eligible = remaining
348
- .filter(f => (attempts.get(f.file) ?? 0) < MAX_FIX_ATTEMPTS)
349
- // Анти-starvation: відкладені минулого разу файли йдуть першими
350
- .toSorted((a, b) => (prevDeferred.has(b.file) ? 1 : 0) - (prevDeferred.has(a.file) ? 1 : 0))
351
- if (eligible.length === 0) break
352
-
353
- const batch = buildFixTestsBatch(eligible, dir)
354
- if (batch.deferred.length) {
355
- console.log(
356
- ` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`
357
+ try {
358
+ for (;;) {
359
+ // Спроби рахуються per-file (лише коли файл реально був у батчі),
360
+ // тож deferred-черга не з'їдає ліміт файлів, які ще не пробували
361
+ const eligible = remaining
362
+ .filter(f => (attempts.get(f.file) ?? 0) < MAX_FIX_ATTEMPTS)
363
+ // Анти-starvation: відкладені минулого разу файли йдуть першими
364
+ .toSorted((a, b) => (prevDeferred.has(b.file) ? 1 : 0) - (prevDeferred.has(a.file) ? 1 : 0))
365
+ if (eligible.length === 0) break
366
+
367
+ const batch = buildFixTestsBatch(eligible, dir)
368
+ if (batch.deferred.length) {
369
+ console.log(
370
+ ` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`
371
+ )
372
+ }
373
+ for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
374
+ prevDeferred = new Set(batch.deferred)
375
+
376
+ let response
377
+ try {
378
+ batches++
379
+ response = await callTextFn(batch.prompt)
380
+ } catch (error) {
381
+ // memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
382
+ // до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
383
+ if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
384
+ console.error(` ✗ pi помилка: ${error.message}`)
385
+ break
386
+ }
387
+
388
+ const fixed = parseFixedFiles(response)
389
+
390
+ if (fixed.length === 0) {
391
+ console.error(' ✗ pi не повернула виправлений код')
392
+ break
393
+ }
394
+
395
+ const includedSet = new Set(batch.included)
396
+ writeFixedFiles(
397
+ fixed,
398
+ eligible.filter(f => includedSet.has(f.file)),
399
+ dir
357
400
  )
358
- }
359
- for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
360
- prevDeferred = new Set(batch.deferred)
361
401
 
362
- let response
363
- try {
364
- response = await callTextFn(batch.prompt)
365
- } catch (error) {
366
- // memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
367
- // до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
368
- if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
369
- console.error(` ✗ pi помилка: ${error.message}`)
370
- break
402
+ remaining = await getFailingTests(dir)
371
403
  }
372
-
373
- const fixed = parseFixedFiles(response)
374
-
375
- if (fixed.length === 0) {
376
- console.error(' ✗ pi не повернула виправлений код')
377
- break
378
- }
379
-
380
- const includedSet = new Set(batch.included)
381
- writeFixedFiles(
382
- fixed,
383
- eligible.filter(f => includedSet.has(f.file)),
384
- dir
385
- )
386
-
387
- remaining = await getFailingTests(dir)
404
+ } finally {
405
+ const fixedSoFar = failures.length - remaining.length
406
+ chain.end({
407
+ outcome: remaining.length === 0 ? 'success' : fixedSoFar > 0 ? 'partial' : 'fail',
408
+ extra: {
409
+ files: failures.map(f => f.file),
410
+ batches,
411
+ fixed: fixedSoFar,
412
+ remaining: remaining.length
413
+ }
414
+ })
388
415
  }
389
416
 
390
417
  const fixedCount = failures.length - remaining.length
package/src/gen-tests.mjs CHANGED
@@ -16,13 +16,14 @@
16
16
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
17
  import { spawnSync } from 'node:child_process'
18
18
  import { join, relative, dirname } from 'node:path'
19
- import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
20
- import { budgetFor } from './lib/prompt-budget.mjs'
19
+ import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
20
+ import { env } from 'node:process'
21
+ import { budgetFor } from '@7n/llm-lib/prompt-budget'
22
+ import { startChain } from '@7n/llm-lib/chain'
21
23
  import { resolveVitestRun } from './lib/vitest-shim.mjs'
22
24
  import { extractExportsWithComplexity } from './classify-exports.mjs'
23
25
  import { analyzeModule } from './lib/ast-analyze.mjs'
24
26
  import { probeModule, probeFetchCalls, probeTimeVariants, probeHelpers } from './lib/runtime-probe.mjs'
25
- import { env } from 'node:process'
26
27
 
27
28
  const MAX_SRC_BYTES = 6000
28
29
 
@@ -1149,9 +1150,10 @@ function resolveLocalModel(opts) {
1149
1150
  * @param {PiCallFn} callTextFn cloud LLM caller
1150
1151
  * @param {PiCallFn | null} localFn local LLM caller
1151
1152
  * @param {GenerateOneFn | undefined} generateOne custom single-file generator
1153
+ * @param {typeof startChain} [makeChain] фабрика ланцюжка (інжект для тестів)
1152
1154
  * @returns {Promise<void>} resolves after generation for this file completes
1153
1155
  */
1154
- async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generateOne) {
1156
+ async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generateOne, makeChain = startChain) {
1155
1157
  console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
1156
1158
 
1157
1159
  if (generateOne) {
@@ -1159,13 +1161,26 @@ async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generate
1159
1161
  return
1160
1162
  }
1161
1163
 
1162
- const exportsInfo = extractExportsWithComplexity(readSourceSnippet(join(dir, fileInfo.file)))
1163
- if (localFn && exportsInfo.length > 0) {
1164
- await generatePerExport(fileInfo, dir, callTextFn, localFn)
1165
- return
1166
- }
1164
+ // Ланцюжок файлу: усі виклики (header, per-export local/cloud спроби,
1165
+ // vitest-retry, length-retry) кроки одного chain.
1166
+ const chain = makeChain({ kind: 'test-generate', unit: fileInfo.file, cwd: dir })
1167
+ const chainedCloud = (prompt, callOpts = {}) => callTextFn(prompt, { ...callOpts, chain })
1168
+ const chainedLocal = localFn ? (prompt, callOpts = {}) => localFn(prompt, { ...callOpts, chain }) : null
1169
+ let failed = null
1170
+ try {
1171
+ const exportsInfo = extractExportsWithComplexity(readSourceSnippet(join(dir, fileInfo.file)))
1172
+ if (chainedLocal && exportsInfo.length > 0) {
1173
+ await generatePerExport(fileInfo, dir, chainedCloud, chainedLocal)
1174
+ return
1175
+ }
1167
1176
 
1168
- await generateOneTest(fileInfo, dir, callTextFn)
1177
+ await generateOneTest(fileInfo, dir, chainedCloud)
1178
+ } catch (error) {
1179
+ failed = String(error.message ?? error).slice(0, 200)
1180
+ throw error
1181
+ } finally {
1182
+ chain.end({ outcome: failed ? 'fail' : 'success', extra: failed ? { error: failed } : {} })
1183
+ }
1169
1184
  }
1170
1185
 
1171
1186
  /**
package/src/index.js CHANGED
@@ -11,9 +11,22 @@ export async function run(args) {
11
11
  console.log(' Runs coverage analysis, generates missing tests, then mutation testing.')
12
12
  console.log(' --no-mutation Skip mutation testing phase.')
13
13
  console.log(' Defaults to current directory when no argument is given.')
14
+ console.log('Usage: n coverage [--fix] [--changed]')
15
+ console.log(' Coverage + mutation testing only (no test generation) — writes COVERAGE.md.')
16
+ console.log(' --fix Agent fixes survived mutants, then re-runs coverage.')
17
+ console.log(' --changed Scope to files changed vs merge-base with main — no COVERAGE.md write.')
14
18
  return 0
15
19
  }
16
20
 
21
+ if (first === 'coverage') {
22
+ const { runCoverageCli } = await import('./coverage/coverage.mjs')
23
+ return runCoverageCli({
24
+ cwd: getCwd(),
25
+ fix: flags.includes('--fix'),
26
+ changed: flags.includes('--changed')
27
+ })
28
+ }
29
+
17
30
  const dir = first ? resolve(first) : getCwd()
18
31
  const noMutation = flags.includes('--no-mutation')
19
32
  const { runAutoTest } = await import('./run.mjs')
@@ -9,7 +9,6 @@ resource: npm/src/lib/
9
9
  | Файл | Тип |
10
10
  | ------------------------------------- | --------- |
11
11
  | [ast-analyze.mjs](ast-analyze.md) | JS Module |
12
- | [pi-client.mjs](pi-client.md) | JS Module |
13
- | [prompt-budget.mjs](prompt-budget.md) | JS Module |
12
+ | [llm.mjs](llm.md) | JS Module |
14
13
  | [runtime-probe.mjs](runtime-probe.md) | JS Module |
15
14
  | [vitest-shim.mjs](vitest-shim.md) | JS Module |
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: JS Module
3
+ title: llm.mjs
4
+ resource: npm/src/lib/llm.mjs
5
+ docgen:
6
+ crc: b126a1aa
7
+ ---
8
+
9
+ ## Огляд
10
+
11
+ Тонкий адаптер @7n/test над пакетом `@7n/llm-lib` (Ф3 спеки llm-lib-extraction у репо cursor): зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів (gen-tests, fix-tests, assess-need, coverage-classify, coverage-fix), а транспорт, registry і трасування повністю живуть у пакеті. Замінив колишній `pi-client.mjs` з власним SDK-плюмбінгом, retry/backoff і прямим omlx-HTTP.
12
+
13
+ ## Поведінка
14
+
15
+ callText — одноразовий text-виклик без tools через `runOneShot`: повертає текст відповіді; кидає Error на будь-якій помилці виклику (fail-fast, без retry/backoff). Єдина локальна політика — одноразове подвоєння `maxTokens` (до стелі 32768) при обрізаній відповіді (`stopReason: 'length'`), без пауз.
16
+ callAgent — агентний виклик через `runAgentSkill` з повним tool-set (read/write/edit/bash/grep/find/ls): агент пише файли напряму, текст стрімиться у stdout; таймаут 900с (паритет зі старим spawnSync pi CLI); без стелі відповіді (`maxTokens: 0`). Кидає Error на помилці.
17
+ MEMORY_ERROR_RE — реекспорт з пакета: колери класифікують memory-guard помилку локального model-сервера (пробити нагору й завершити процес, а не ковтати як per-file помилку).
18
+
19
+ ## Гарантії поведінки
20
+
21
+ - Жодних вбудованих retry/backoff: колишні knobs N_PI_RETRY_ATTEMPTS/N_PI_MEMORY_RETRY_ATTEMPTS тощо видалено (fail-fast політика пакета).
22
+ - Подвоєння maxTokens відбувається щонайбільше один раз на виклик.
23
+ - `opts.deps` прокидається у раннери пакета — тести інжектять фейк-сесії без pi.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Тонкий адаптер `@7n/test` над `@7n/llm-lib` (Ф3 спеки llm-lib-extraction):
3
+ * зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів,
4
+ * але транспорт/registry/trace повністю живуть у пакеті.
5
+ *
6
+ * Політика fail-fast успадковується від пакета: жодних retry/backoff на
7
+ * connection- чи memory-помилках (колишні knobs N_PI_RETRY_ATTEMPTS,
8
+ * N_PI_MEMORY_RETRY_ATTEMPTS тощо видалено разом із withRetry).
9
+ * Єдина локальна політика — одноразове
10
+ * подвоєння `maxTokens` на обрізаній відповіді (`stopReason: 'length'`):
11
+ * це семантичний повтор без пауз, а не очікування зайнятого сервера.
12
+ */
13
+ import { runOneShot } from '@7n/llm-lib/one-shot'
14
+ import { runAgentSkill } from '@7n/llm-lib/agent-skill'
15
+
16
+
17
+
18
+ /**
19
+ * Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
20
+ * `maxTokens` реєстру для локальної моделі (див. `~/.pi/agent/models.json`).
21
+ */
22
+ const MAX_TOKENS_CEILING = 32_768
23
+
24
+ /** Таймаут агентного виклику (паритет зі старим spawnSync pi CLI). */
25
+ const AGENT_TIMEOUT_MS = 900_000
26
+
27
+ /**
28
+ * Одноразовий text-виклик (без tools). Кидає Error на будь-якій помилці
29
+ * виклику (колери класифікують memory-guard через `MEMORY_ERROR_RE`).
30
+ * @param {string} prompt текст запиту для моделі
31
+ * @param {object} [opts] додаткові параметри виклику
32
+ * @param {string} [opts.cwd] робоча директорія для session
33
+ * @param {string} [opts.model] provider/model-id (напр. "openai/gpt-5.5"); без значення — default pi
34
+ * @param {number} [opts.maxTokens] стеля відповіді для цього виклику; на
35
+ * `stopReason: 'length'` виклик повторюється один раз із подвоєною стелею
36
+ * @param {object} [opts.chain] chain handle (`@7n/llm-lib/chain`) — виклик стає кроком ланцюжка
37
+ * @param {object} [opts.deps] інжекти для тестів (прокидаються у runOneShot)
38
+ * @returns {Promise<string>} текстова відповідь моделі
39
+ */
40
+ export async function callText(prompt, opts = {}) {
41
+ const r = await runOneShot({
42
+ messages: [{ role: 'user', content: prompt }],
43
+ modelSpec: opts.model ?? '',
44
+ maxTokens: opts.maxTokens,
45
+ timeoutMs: 0,
46
+ cwd: opts.cwd,
47
+ caller: '7n-test:text',
48
+ chain: opts.chain ?? null,
49
+ deps: opts.deps
50
+ })
51
+ if (r.error) throw new Error(r.error)
52
+
53
+ // Обрізана генерація зі зниженою стелею — не палимо retry-цикли колера
54
+ // на «invalid block», а один раз повторюємо з подвоєною стелею.
55
+ if (r.stopReason === 'length' && opts.maxTokens && opts.maxTokens < MAX_TOKENS_CEILING && !opts._lengthRetried) {
56
+ const doubled = Math.min(opts.maxTokens * 2, MAX_TOKENS_CEILING)
57
+ console.log(` ⚠ відповідь обрізана (stopReason: length) — повтор із maxTokens ${opts.maxTokens} → ${doubled}`)
58
+ return callText(prompt, { ...opts, maxTokens: doubled, _lengthRetried: true })
59
+ }
60
+ return r.content
61
+ }
62
+
63
+ /**
64
+ * Агентний виклик із повним tool-set (read/write/edit/bash/grep/find/ls):
65
+ * агент пише файли напряму, текст стрімиться у stdout. Кидає Error на
66
+ * помилці виклику. Заміна колишнього `spawnSync('pi', ['-p', ...])`.
67
+ * @param {string} prompt текст завдання для агента
68
+ * @param {string} cwd робоча директорія, куди агент може писати файли
69
+ * @param {object} [opts] додаткові параметри
70
+ * @param {string} [opts.model] provider/model-id або '' для pi-дефолту
71
+ * @param {object} [opts.chain] chain handle (`@7n/llm-lib/chain`) — виклик стає кроком ланцюжка
72
+ * @param {object} [opts.deps] інжекти для тестів (прокидаються у runAgentSkill)
73
+ * @returns {Promise<void>} проміс завершується після виконання агента
74
+ */
75
+ export async function callAgent(prompt, cwd, opts = {}) {
76
+ const r = await runAgentSkill(prompt, {
77
+ skillId: '7n-test',
78
+ modelSpec: opts.model ?? '',
79
+ cwd,
80
+ timeoutMs: AGENT_TIMEOUT_MS,
81
+ maxTokens: 0, // без стелі: агент пише цілі тест-файли (паритет зі старим CLI-шляхом)
82
+ caller: 'agent:7n-test',
83
+ chain: opts.chain ?? null,
84
+ deps: opts.deps
85
+ })
86
+ if (r.error) throw new Error(r.error)
87
+ }
88
+
89
+ export {MEMORY_ERROR_RE} from '@7n/llm-lib/one-shot'
@@ -1,29 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: pi-client.mjs
4
- resource: npm/src/lib/pi-client.mjs
5
- docgen:
6
- crc: e7f3471e
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.98
10
- judgeModel: openai-codex/gpt-5.4-mini
11
- ---
12
-
13
- ## Огляд
14
-
15
- Цей модуль надає прямий, інпроцесний програмний інтерфейс для взаємодії з агентами pi SDK, замінюючи синхронні виклики зовнішнього процесу. Він реалізує два режими роботи: текстовий режим, де функція `callText` повертає рядок на основі текстового запиту без активації інструментів, та режим агента, де функція `callAgent` активує кодові інструменти для прямого внесення змін у файли у заданій робочій директорії. Зверніть увагу, що у цьому інтерфейсі відсутній механізм кешування.
16
-
17
- ## Поведінка
18
-
19
- callText відправляє текстовий запит до моделі pi та повертає текстову відповідь.
20
- callAgent відправляє завдання до агента pi з активними інструментами кодування та виконує зміни у файлах у вказаній директорії.
21
-
22
- ## Публічний API
23
-
24
- callText — надсилає одноразовий запит у текстовому режимі, не використовуючи інструменти, і повертає відповідь, зчитуючи конфігурацію з ~/.pi/.
25
- callAgent — надсилає запит у режимі агента, надаючи доступ до повного набору інструментів кодування (читання, запис, bash, редагування); агент сам створює тестові файли.
26
-
27
- ## Гарантії поведінки
28
-
29
- - (специфічних машинно-виведених гарантій немає)
@@ -1,34 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: prompt-budget.mjs
4
- resource: npm/src/lib/prompt-budget.mjs
5
- docgen:
6
- crc: 010265bd
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.99
10
- judgeModel: openai-codex/gpt-5.4-mini
11
- ---
12
-
13
- ## Огляд
14
-
15
- Слугує єдиною точкою правди для визначення ліміту символів промпту та стелі відповіді (`maxTokens`) залежно від типу задачі (наприклад, `gen-tests`, `fix-tests`). Це механізм захисту від надмірного розширення промпту. Надає функції для внутрішнього обрізання тексту (`fitToBudget`), що виключає низькопріоритетні частини промпту, щоб він вмістився у встановлений ліміт, а також для ефективного групування цілих одиниць вмісту (`packBatch`) для подальшої пакетної обробки.
16
-
17
- ## Поведінка
18
-
19
- Поведінка
20
- budgetFor повертає встановлений ліміт символів для промпту та максимальну кількість токенів для заданого типу LLM-задачі.
21
- capText обрізає вхідний текст до заданого максимального розміру, зберігаючи його структуру з головною та хвостовою частинами, розділеними маркером.
22
- fitToBudget збирає частини промпту, обрізаючи або відкидаючи нижчопріоритетні частини доти, доки сумарний обсяг не вкладеться у заданий ліміт символів, при цьому найвищий пріоритет завжди захищено.
23
- packBatch групує одиниці (файли) у батчи, сортуючи їх за розміром, щоб вмістити якомога більше у межах заданого бюджету; одиниці, що не вмістилися, відкладаються для наступного проходу.
24
-
25
- ## Публічний API
26
-
27
- budgetFor — надає бюджет, необхідний для певної категорії завдання.
28
- capText — безпечно укорочує текст, зберігаючи його початок, маркер та кінець.
29
- fitToBudget — розміщує блоки у межах заданого бюджету: спочатку зменшує вміст, а потім відкидає найнижчі за пріоритетом, поки обсяг не втиснеться. Найважливіший елемент завжди залишається повним.
30
- packBatch — згруповує файли для обробки: спочатку обирає найменші для оптимізації кількості виправлень за один раз. Якщо елемент занадто великий, він відкладається для окремої обробки, де застосовується жорсткіше обрізання.
31
-
32
- ## Гарантії поведінки
33
-
34
- - Read-only: не виконує операцій запису (ФС/БД).
@@ -1,243 +0,0 @@
1
- /**
2
- * Programmatic pi client via `@earendil-works/pi-coding-agent` SDK.
3
- * Replaces spawnSync('pi', ...) with direct in-process calls.
4
- *
5
- * Two modes:
6
- * callText(prompt, model?) — text-only, no tools, returns string
7
- * callAgent(prompt, cwd) — coding tools enabled, writes files directly
8
- *
9
- * Both retry transient connection failures (e.g. a shared local model server
10
- * that's momentarily busy under concurrent load) with exponential backoff
11
- * instead of failing the caller's file on the first hiccup. A memory-guard
12
- * rejection gets its own longer bounded backoff: oMLX (local model server)
13
- * одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
14
- * запиту, яку сервер вивільняє сам за десятки секунд. Лише після
15
- * `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
16
- * помилка прокидається як термінальна (виклики-власники процесу мають дати
17
- * їй впасти, не ковтати як звичайну per-file помилку).
18
- */
19
- import { createAgentSession, SessionManager, ModelRegistry, AuthStorage } from '@earendil-works/pi-coding-agent'
20
- import { randomInt } from 'node:crypto'
21
- import { env } from 'node:process'
22
- import { setTimeout as sleep } from 'node:timers/promises'
23
-
24
- let _registry = null
25
- /**
26
- * Повертає або ініціалізує спільний model registry для поточного процесу.
27
- * @returns {object} спільний registry
28
- */
29
- function getRegistry() {
30
- if (_registry) return _registry
31
- _registry = ModelRegistry.create(AuthStorage.create())
32
- return _registry
33
- }
34
-
35
- const RETRYABLE_ERROR_RE = /connection error|ECONNREFUSED|ETIMEDOUT|fetch failed|network/i
36
- const OMLX_CODE_RE = /"(?:omlx_)?code"\s*:\s*"([^"]+)"/
37
- const OMLX_ESTIMATED_BYTES_RE = /"estimated_bytes"\s*:\s*(\d+)/
38
- const OMLX_LIMIT_BYTES_RE = /"limit_bytes"\s*:\s*(\d+)/
39
- /** Matches a local model server (e.g. oMLX) rejecting a prompt for lack of RAM. */
40
- export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
41
- const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
42
- const BASE_DELAY_MS = Number(env.N_PI_RETRY_DELAY_MS) || 1500
43
- const MAX_DELAY_MS = 15_000
44
- /** Скільки разів пробуємо промпт, відкинутий memory guard (включно з першою спробою). */
45
- const MEMORY_MAX_ATTEMPTS = Number(env.N_PI_MEMORY_RETRY_ATTEMPTS) || 3
46
- /** Базова затримка перед повтором після memory-guard відмови (експоненційно: 15s → 30s → …). */
47
- const MEMORY_BASE_DELAY_MS = Number(env.N_PI_MEMORY_RETRY_DELAY_MS) || 15_000
48
- const MEMORY_MAX_DELAY_MS = 60_000
49
-
50
- /**
51
- * Витягує структуровані поля oMLX-помилки з тексту повідомлення, якщо тіло
52
- * 400 (`omlx_code`/`estimated_bytes`/`limit_bytes`) потрапило в message.
53
- * `estimated_bytes`/`limit_bytes` — байти GPU/Metal-пам'яті сервера,
54
- * ТІЛЬКИ для діагностики й логів (не «скільки символів промпту зрізати»).
55
- * @param {string} message текст помилки від SDK/сервера
56
- * @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
57
- */
58
- function parseOmlxErrorDetails(message) {
59
- const code = OMLX_CODE_RE.exec(message)?.[1]
60
- const estimated = OMLX_ESTIMATED_BYTES_RE.exec(message)?.[1]
61
- const limit = OMLX_LIMIT_BYTES_RE.exec(message)?.[1]
62
- if (!code && !estimated && !limit) return null
63
- const details = {}
64
- if (code) details.omlxCode = code
65
- if (estimated) details.estimatedBytes = Number(estimated)
66
- if (limit) details.limitBytes = Number(limit)
67
- return details
68
- }
69
-
70
- /**
71
- * Розпізнає memory-guard відмову і збагачує помилку структурованими полями
72
- * (`omlxCode`, `estimatedBytes`, `limitBytes`), коли вони доступні в message.
73
- * @param {Error} error помилка з виклику моделі
74
- * @returns {boolean} `true`, якщо це memory-guard відмова
75
- */
76
- function isMemoryGuardError(error) {
77
- const details = parseOmlxErrorDetails(error.message ?? '')
78
- if (details) Object.assign(error, details)
79
- return error.omlxCode === 'prefill_memory_exceeded' || MEMORY_ERROR_RE.test(error.message ?? '')
80
- }
81
-
82
- /**
83
- * Термінальний вихід після вичерпання memory-retry: друкує тіло запиту в
84
- * stdout (лише тут — не на проміжних спробах) і кидає помилку далі,
85
- * зберігаючи структуровані поля й оригінал у `cause`. Виклики-власники
86
- * процесу (CLI) мають дати їй впасти, не ковтати як per-file помилку.
87
- * @param {Error} error memory-guard помилка останньої спроби
88
- * @param {string} requestBody промпт, надісланий моделі
89
- * @returns {never} завжди кидає
90
- */
91
- function failOnMemoryGuard(error, requestBody) {
92
- console.log('--- omlx memory-guard: тіло запиту ---')
93
- console.log(requestBody)
94
- console.log(`✗ omlx memory-guard: ${error.message}`)
95
- const wrapped = new Error(`omlx memory-guard: ${error.message}`, { cause: error })
96
- for (const key of ['omlxCode', 'estimatedBytes', 'limitBytes']) {
97
- if (error[key] !== undefined) wrapped[key] = error[key]
98
- }
99
- throw wrapped
100
- }
101
-
102
- /**
103
- * Retries `fn` with exponential backoff + jitter when it throws a transient
104
- * connection-ish error (shared local model server busy/restarting); other
105
- * errors (auth, malformed request) are re-thrown immediately. A memory-guard
106
- * rejection has its own bounded schedule (`MEMORY_MAX_ATTEMPTS` спроб,
107
- * затримки від `MEMORY_BASE_DELAY_MS` експоненційно): одно-слотова черга
108
- * oMLX вивільняє залишкову пам'ять попереднього запиту сама, тож повтор
109
- * того самого промпту після паузи легітимний. Після вичерпання —
110
- * `failOnMemoryGuard` (друк тіла + термінальний throw).
111
- * @template T
112
- * @param {() => Promise<T>} fn operation to retry
113
- * @param {string} requestBody prompt sent to the model, printed if a memory-guard error exhausts its retries
114
- * @returns {Promise<T>} result of the first successful attempt
115
- */
116
- async function withRetry(fn, requestBody) {
117
- let memoryAttempts = 0
118
- let connAttempts = 0
119
- for (;;) {
120
- try {
121
- return await fn()
122
- } catch (error) {
123
- if (isMemoryGuardError(error)) {
124
- memoryAttempts++
125
- if (memoryAttempts >= MEMORY_MAX_ATTEMPTS) failOnMemoryGuard(error, requestBody)
126
- const delay = Math.min(MEMORY_BASE_DELAY_MS * 2 ** (memoryAttempts - 1), MEMORY_MAX_DELAY_MS)
127
- await sleep(delay * (0.5 + randomInt(5000) / 10000))
128
- continue
129
- }
130
- connAttempts++
131
- if (connAttempts >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
132
- const delay = Math.min(BASE_DELAY_MS * 2 ** (connAttempts - 1), MAX_DELAY_MS)
133
- const jitter = delay * (0.5 + randomInt(5000) / 10000)
134
- await sleep(jitter)
135
- }
136
- }
137
- }
138
-
139
- /**
140
- * Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
141
- * `maxTokens` реєстру для локальної моделі (див. `~/.pi/agent/models.json`).
142
- */
143
- const MAX_TOKENS_CEILING = 32_768
144
-
145
- /**
146
- * Обгортає `session.agent.streamFn`, додаючи per-call `maxTokens` у
147
- * options LLM-виклику. SDK не має публічного per-call параметра
148
- * (спайк: `session.prompt(text, options)` не прокидає options у loop
149
- * config), але `options.maxTokens` у streamFn перекриває дефолт моделі —
150
- * перевірено на дроті через myllm-проксі (`max_completion_tokens`
151
- * змінюється).
152
- * @param {object} session сесія з `createAgentSession`
153
- * @param {number} maxTokens стеля відповіді для всіх викликів цієї сесії
154
- * @returns {void}
155
- */
156
- function applyMaxTokens(session, maxTokens) {
157
- const orig = session.agent.streamFn
158
- session.agent.streamFn = (model, context, options) => orig(model, context, { ...options, maxTokens })
159
- }
160
-
161
- /**
162
- * Sends a single prompt to pi in text mode (no tools) and returns the response.
163
- * Reads auth/model config from ~/.pi/ same as the CLI.
164
- * @param {string} prompt текст запиту для моделі
165
- * @param {object} [opts] додаткові параметри виклику
166
- * @param {string} [opts.cwd] робоча директорія для session
167
- * @param {string} [opts.model] provider/model-id для pi (наприклад, "openai/gpt-4o"); без значення — default pi
168
- * @param {number} [opts.maxTokens] стеля відповіді для цього виклику (перекриває дефолт моделі);
169
- * на `stopReason: 'length'` (обрізана генерація) виклик повторюється один раз із подвоєною стелею
170
- * @returns {Promise<string>} текстова відповідь моделі
171
- */
172
- export async function callText(prompt, opts = {}) {
173
- const result = await withRetry(async () => {
174
- const cwd = opts.cwd ?? process.cwd()
175
- const sessionOpts = {
176
- tools: [],
177
- sessionManager: SessionManager.inMemory(cwd),
178
- cwd
179
- }
180
- if (opts.model) {
181
- const registry = await getRegistry()
182
- const slashIdx = opts.model.indexOf('/')
183
- const provider = slashIdx === -1 ? null : opts.model.slice(0, slashIdx)
184
- const modelId = slashIdx === -1 ? opts.model : opts.model.slice(slashIdx + 1)
185
- const resolved = provider ? Reflect.apply(registry.find, registry, [provider, modelId]) : null
186
- sessionOpts.modelRegistry = registry
187
- sessionOpts.model = resolved ?? opts.model
188
- }
189
- const { session } = await createAgentSession(sessionOpts)
190
- if (opts.maxTokens) applyMaxTokens(session, opts.maxTokens)
191
- try {
192
- await session.prompt(prompt)
193
-
194
- const state = session.state
195
- const last = state.messages.at(-1)
196
- if (!last || last.role !== 'assistant') return { text: '', truncated: false }
197
- if (last.stopReason === 'error' || last.stopReason === 'aborted') {
198
- throw new Error(`pi error: ${last.errorMessage ?? last.stopReason}`)
199
- }
200
- return {
201
- text: last.content
202
- .filter(c => c.type === 'text')
203
- .map(c => c.text)
204
- .join(''),
205
- truncated: last.stopReason === 'length'
206
- }
207
- } finally {
208
- session.dispose()
209
- }
210
- }, prompt)
211
-
212
- // Обрізана генерація зі зниженою стелею — не палимо retry-цикли колера
213
- // на «invalid block», а один раз повторюємо з подвоєною стелею.
214
- if (result.truncated && opts.maxTokens && opts.maxTokens < MAX_TOKENS_CEILING && !opts._lengthRetried) {
215
- const doubled = Math.min(opts.maxTokens * 2, MAX_TOKENS_CEILING)
216
- console.log(` ⚠ відповідь обрізана (stopReason: length) — повтор із maxTokens ${opts.maxTokens} → ${doubled}`)
217
- return callText(prompt, { ...opts, maxTokens: doubled, _lengthRetried: true })
218
- }
219
- return result.text
220
- }
221
-
222
- /**
223
- * Sends a prompt to pi in agent mode with full coding tools (read/write/bash/edit).
224
- * The agent writes test files directly — no need to parse output.
225
- * @param {string} prompt текст завдання для агента
226
- * @param {string} cwd робоча директорія, куди агент може писати файли
227
- * @returns {Promise<void>} проміс завершується після виконання агента
228
- */
229
- export function callAgent(prompt, cwd) {
230
- return withRetry(async () => {
231
- const { session } = await createAgentSession({
232
- tools: ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'],
233
- sessionManager: SessionManager.inMemory(cwd),
234
- cwd
235
- })
236
-
237
- try {
238
- await session.prompt(prompt)
239
- } finally {
240
- session.dispose()
241
- }
242
- }, prompt)
243
- }
@@ -1,131 +0,0 @@
1
- /**
2
- * Спільний бюджет LLM-промптів (фаза 2 спеки
3
- * `docs/specs/2026-07-04-omlx-prompt-budget.md`, Д3): єдина точка правди
4
- * для ліміту символів промпту та стелі відповіді (`maxTokens`) за типом
5
- * задачі. Захищає від класу проблем «безлімітна секція роздула промпт до
6
- * memory guard» — кожен колер (gen-tests, fix-tests) бере бюджет звідси
7
- * замість власних розрізнених констант.
8
- *
9
- * Дві незалежні утиліти:
10
- * - `fitToBudget` — внутрішньопромптове обрізання: дропає/вкорочує
11
- * низькопріоритетні chunks, поки промпт не влізе в ліміт;
12
- * - `packBatch` — батчинг цілих одиниць (файлів): скільки влазить зараз,
13
- * решта — у наступний прохід.
14
- */
15
-
16
- /** Бюджети за типом задачі: ліміт символів промпту і стеля відповіді. */
17
- const BUDGETS = {
18
- header: { maxPromptChars: 8000, maxTokens: 2048 },
19
- block: { maxPromptChars: 40_000, maxTokens: 8192 },
20
- 'single-file': { maxPromptChars: 60_000, maxTokens: 16_384 },
21
- fix: { maxPromptChars: 60_000, maxTokens: 16_384 }
22
- }
23
-
24
- /** Частка голови chunk-а, що лишається при обрізанні (решта — хвіст + маркер). */
25
- const TRUNCATE_HEAD_RATIO = 0.7
26
- /** Мінімальний розмір, до якого має сенс обрізати chunk (менше — просто дроп). */
27
- const MIN_TRUNCATED_CHARS = 400
28
-
29
- /**
30
- * Повертає бюджет для типу задачі.
31
- * @param {'header'|'block'|'single-file'|'fix'} taskKind тип LLM-задачі
32
- * @returns {{maxPromptChars: number, maxTokens: number}} копія бюджету
33
- */
34
- export function budgetFor(taskKind) {
35
- const budget = BUDGETS[taskKind]
36
- if (!budget) throw new Error(`prompt-budget: невідомий taskKind "${taskKind}"`)
37
- return { ...budget }
38
- }
39
-
40
- /**
41
- * Символьно-безпечне обрізання середини: голова + маркер + хвіст.
42
- * @param {string} text вихідний текст
43
- * @param {number} maxChars цільовий розмір
44
- * @returns {string} обрізаний текст із маркером
45
- */
46
- export function capText(text, maxChars) {
47
- return truncateMiddle(text, maxChars)
48
- }
49
-
50
- /**
51
- * Внутрішня реалізація `capText` (окреме ім'я — щоб `fitToBudget` не
52
- * залежав від публічного контракту).
53
- * @param {string} text вихідний текст
54
- * @param {number} maxChars цільовий розмір
55
- * @returns {string} обрізаний текст із маркером
56
- */
57
- function truncateMiddle(text, maxChars) {
58
- const chars = [...text]
59
- if (chars.length <= maxChars) return text
60
- const head = Math.floor(maxChars * TRUNCATE_HEAD_RATIO)
61
- const tail = Math.max(0, maxChars - head)
62
- const dropped = chars.length - head - tail
63
- return `${chars.slice(0, head).join('')}\n...[обрізано ${dropped} символів]...\n${chars.slice(chars.length - tail).join('')}`
64
- }
65
-
66
- /**
67
- * Вкладає chunks у бюджет: спершу обрізає, потім дропає найнижчі
68
- * пріоритети, поки сумарний текст не влізе. Chunk із НАЙВИЩИМ
69
- * пріоритетом (сама задача / останній user-запит) захищений — його
70
- * текст не ріжеться і не дропається ніколи.
71
- * @param {Array<{text: string, priority: number, label?: string}>} chunks частини промпту; нижчий priority ріжеться першим
72
- * @param {number} maxChars бюджет символів на весь результат
73
- * @returns {{text: string, dropped: string[]}} зібраний промпт + мітки скорочених/викинутих частин
74
- */
75
- export function fitToBudget(chunks, maxChars) {
76
- const parts = chunks.map((c, i) => ({ ...c, label: c.label ?? `chunk#${i}`, kept: true, out: c.text }))
77
- const maxPriority = Math.max(...parts.map(p => p.priority))
78
- const total = () => parts.filter(p => p.kept).reduce((sum, p) => sum + p.out.length + 1, 0)
79
- const dropped = []
80
-
81
- const candidates = parts.filter(p => p.priority < maxPriority).toSorted((a, b) => a.priority - b.priority)
82
- // Прохід 1: обрізати кандидатів (від найнижчого пріоритету)
83
- for (const part of candidates) {
84
- if (total() <= maxChars) break
85
- const overflow = total() - maxChars
86
- const target = Math.max(MIN_TRUNCATED_CHARS, part.out.length - overflow)
87
- if (part.out.length > target) {
88
- part.out = truncateMiddle(part.out, target)
89
- dropped.push(`${part.label} (обрізано)`)
90
- }
91
- }
92
- // Прохід 2: дропнути кандидатів цілком, якщо обрізання не вистачило
93
- for (const part of candidates) {
94
- if (total() <= maxChars) break
95
- part.kept = false
96
- dropped.push(`${part.label} (видалено)`)
97
- }
98
-
99
- return {
100
- text: parts
101
- .filter(p => p.kept)
102
- .map(p => p.out)
103
- .join('\n'),
104
- dropped
105
- }
106
- }
107
-
108
- /**
109
- * Пакує одиниці (файли) у бюджет: найменші першими, щоб максимізувати
110
- * кількість виправлень за один виклик. Одиниця, що сама-одна перевищує
111
- * бюджет, потрапляє в `deferred` — колер робить для неї соло-виклик із
112
- * жорсткішим внутрішнім обрізанням (`fitToBudget`), а не мовчазний skip.
113
- * @param {Array<{key: string, size: number}>} items одиниці з розмірами
114
- * @param {number} maxChars бюджет символів на батч
115
- * @returns {{included: string[], deferred: string[]}} ключі включених і відкладених одиниць
116
- */
117
- export function packBatch(items, maxChars) {
118
- const sorted = items.toSorted((a, b) => a.size - b.size)
119
- const included = []
120
- const deferred = []
121
- let used = 0
122
- for (const item of sorted) {
123
- if (item.size + (included.length === 0 ? 0 : used) <= maxChars) {
124
- included.push(item.key)
125
- used += item.size
126
- } else {
127
- deferred.push(item.key)
128
- }
129
- }
130
- return { included, deferred }
131
- }