@7n/test 0.11.2 → 0.12.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-07-04
4
+
5
+ ### Added
6
+
7
+ - 🤖 feat(core): спільний prompt-budget (бюджет символів + per-call maxTokens за типом задачі через streamFn-обгортку), батчинг fix-tests під бюджет з анти-starvation, повтор на обрізану генерацію (stopReason length)
8
+
9
+ ### Fixed
10
+
11
+ - 🤖 fix(core): coverage-скан виключає `**/*.d.ts` — vitest 4 прибрав дефолтний exclude, і v8-remap падав із RolldownError на TS-синтаксисі декларацій у цільових проєктах (CLI-прапорець у coverage-per-file та exclude у shim-конфігу)
12
+ - CLI більше не висить після завершення роботи: явний process.exit у bin (незакриті pi-сесії тримали event loop) і session.dispose() у finally для callText/callAgent
13
+
3
14
  ## [0.11.2] - 2026-07-04
4
15
 
5
16
  ### Fixed
package/bin/7n-test.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from '../src/index.js'
3
3
 
4
- process.exitCode = await run(process.argv.slice(2))
4
+ // Незакриті pi-сесії (createAgentSession) тримають event loop, тож без явного
5
+ // process.exit процес висить після завершення роботи навіть з кодом 0.
6
+ // eslint-disable-next-line n/no-process-exit
7
+ process.exit(await run(process.argv.slice(2)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -112,6 +112,8 @@ export async function measureCoveragePerFile(dir) {
112
112
  '--coverage.reporter=lcov',
113
113
  // vitest 4 прибрав coverage.all — явний include, щоб файли без тестів лишались у lcov (0%)
114
114
  '--coverage.include=**/*.{js,mjs,ts,vue}',
115
+ // vitest 4 прибрав дефолтний exclude **/*.d.ts — без нього v8-remap падає на TS-синтаксисі декларацій
116
+ '--coverage.exclude=**/*.d.ts',
115
117
  `--coverage.reportsDirectory=${lcovDir}`,
116
118
  '--reporter=verbose',
117
119
  '--reporter=json',
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: coverage-per-file.mjs
4
4
  resource: npm/src/coverage-per-file.mjs
5
5
  docgen:
6
- crc: d167dff5
6
+ crc: 2b4a460d
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: fix-tests.mjs
4
4
  resource: npm/src/fix-tests.mjs
5
5
  docgen:
6
- crc: b807d1bc
6
+ crc: f198a4b3
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: gen-tests.mjs
4
4
  resource: npm/src/gen-tests.mjs
5
5
  docgen:
6
- crc: 243806c4
6
+ crc: 531f870e
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
package/src/fix-tests.mjs CHANGED
@@ -20,6 +20,7 @@ import { tmpdir } from 'node:os'
20
20
  import { join, relative } from 'node:path'
21
21
  import { env } from 'node:process'
22
22
  import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
23
+ import { budgetFor, capText, packBatch } from './lib/prompt-budget.mjs'
23
24
  import { findTestRules } from './gen-tests.mjs'
24
25
  import { parseFailingTests } from './coverage-per-file.mjs'
25
26
  import { resolveVitestRun } from './lib/vitest-shim.mjs'
@@ -108,6 +109,44 @@ function extractCode(text) {
108
109
  return text.slice(bodyStart + 1, end).trim()
109
110
  }
110
111
 
112
+ /**
113
+ * Читає складники секції одного падаючого файлу для промпту.
114
+ * @param {{file: string, errors: string[]}} failure падаючий тест-файл
115
+ * @param {string} [dir] project root
116
+ * @returns {{file: string, errBlock: string, testCode: string, sourceCode: string|null}} сирі частини секції
117
+ */
118
+ function readFailureParts({ file, errors }, dir) {
119
+ const absPath = dir ? join(dir, file) : file
120
+ const testCode = existsSync(absPath) ? readFileSync(absPath, 'utf8').slice(0, 6000) : '(файл не знайдено)'
121
+ // Heuristically find source file: strip tests/ prefix from path
122
+ const sourcePath = inferSourcePath(absPath)
123
+ const sourceCode =
124
+ sourcePath && existsSync(sourcePath) ? readFileSync(sourcePath, 'utf8').slice(0, MAX_SRC_BYTES) : null
125
+ return { file, errBlock: errors.join('\n\n'), testCode, sourceCode }
126
+ }
127
+
128
+ /**
129
+ * Складає секцію файлу з готових частин.
130
+ * @param {{file: string, errBlock: string, testCode: string, sourceCode: string|null}} parts частини з `readFailureParts`
131
+ * @returns {string} markdown-секція файлу
132
+ */
133
+ function buildFileSection({ file, errBlock, testCode, sourceCode }) {
134
+ return [
135
+ `### \`${file}\``,
136
+ '',
137
+ '**Помилки:**',
138
+ '```',
139
+ errBlock,
140
+ '```',
141
+ '',
142
+ '**Поточний тест-файл:**',
143
+ '```js',
144
+ testCode,
145
+ '```',
146
+ ...(sourceCode ? ['', '**Source (для довідки, не міняй):**', '```js', sourceCode, '```'] : [])
147
+ ].join('\n')
148
+ }
149
+
111
150
  /**
112
151
  * Builds a prompt to fix one failing test file.
113
152
  * Includes current file content so the LLM doesn't need file-read tools.
@@ -117,34 +156,17 @@ function extractCode(text) {
117
156
  */
118
157
  export function buildFixTestsPrompt(failures, dir) {
119
158
  const testRules = dir ? findTestRules(dir) : null
159
+ const sections = failures.map(f => buildFileSection(readFailureParts(f, dir)))
160
+ return buildPromptShell(sections, testRules)
161
+ }
120
162
 
121
- const sections = failures.map(({ file, errors }) => {
122
- const absPath = dir ? join(dir, file) : file
123
- const testCode = existsSync(absPath) ? readFileSync(absPath, 'utf8').slice(0, 6000) : '(файл не знайдено)'
124
-
125
- // Heuristically find source file: strip tests/ prefix from path
126
- const sourcePath = inferSourcePath(absPath)
127
- const sourceCode =
128
- sourcePath && existsSync(sourcePath) ? readFileSync(sourcePath, 'utf8').slice(0, MAX_SRC_BYTES) : null
129
-
130
- const errBlock = errors.join('\n\n')
131
-
132
- return [
133
- `### \`${file}\``,
134
- '',
135
- '**Помилки:**',
136
- '```',
137
- errBlock,
138
- '```',
139
- '',
140
- '**Поточний тест-файл:**',
141
- '```js',
142
- testCode,
143
- '```',
144
- ...(sourceCode ? ['', '**Source (для довідки, не міняй):**', '```js', sourceCode, '```'] : [])
145
- ].join('\n')
146
- })
147
-
163
+ /**
164
+ * Обгортає секції файлів спільними правилами й інструкцією формату відповіді.
165
+ * @param {string[]} sections markdown-секції падаючих файлів
166
+ * @param {string|null} testRules конвенції тестів проєкту
167
+ * @returns {string} повний текст промпту
168
+ */
169
+ function buildPromptShell(sections, testRules) {
148
170
  return [
149
171
  'Виправ падаючі unit-тести. Поверни ПОВНИЙ вміст КОЖНОГО виправленого тест-файлу.',
150
172
  '',
@@ -176,6 +198,54 @@ export function buildFixTestsPrompt(failures, dir) {
176
198
  ].join('\n')
177
199
  }
178
200
 
201
+ /**
202
+ * Складає промпт під бюджет `budgetFor('fix')`: скільки файлів влазить —
203
+ * стільки в батч (найменші першими), решта — у `deferred` на наступний
204
+ * прохід. Файл, що сам-один перевищує бюджет, отримує соло-промпт із
205
+ * внутрішнім обрізанням (`fitToBudget`: спершу ріжеться source-довідка,
206
+ * потім vitest-помилки; сам тест-код захищений) — ніколи не скипається.
207
+ * @param {Array<{file: string, errors: string[]}>} failures падаючі тест-файли
208
+ * @param {string} [dir] project root
209
+ * @returns {{prompt: string, included: string[], deferred: string[]}} промпт + розподіл файлів
210
+ */
211
+ export function buildFixTestsBatch(failures, dir) {
212
+ const testRules = dir ? findTestRules(dir) : null
213
+ const shellOverhead = buildPromptShell([], testRules).length
214
+ const sectionBudget = Math.max(1000, budgetFor('fix').maxPromptChars - shellOverhead)
215
+
216
+ const parts = failures.map(f => {
217
+ const p = readFailureParts(f, dir)
218
+ return { ...p, section: buildFileSection(p) }
219
+ })
220
+ const { included, deferred } = packBatch(
221
+ parts.map(p => ({ key: p.file, size: p.section.length })),
222
+ sectionBudget
223
+ )
224
+
225
+ if (included.length === 0) {
226
+ // Соло-режим: навіть найменший файл не влазить — жорстко обрізаємо
227
+ // нутрощі секції (source геть, помилки до чверті бюджету, тест-код —
228
+ // найцінніше — до половини), замість мовчазного skip
229
+ const smallest = parts.toSorted((a, b) => a.section.length - b.section.length)[0]
230
+ console.log(` ⚠ ${smallest.file}: секція завелика (${smallest.section.length} симв.) — соло-виклик з обрізанням`)
231
+ const section = buildFileSection({
232
+ file: smallest.file,
233
+ errBlock: capText(smallest.errBlock, Math.floor(sectionBudget / 4)),
234
+ testCode: capText(smallest.testCode, Math.floor(sectionBudget / 2)),
235
+ sourceCode: null
236
+ })
237
+ return {
238
+ prompt: buildPromptShell([section], testRules),
239
+ included: [smallest.file],
240
+ deferred: failures.map(f => f.file).filter(f => f !== smallest.file)
241
+ }
242
+ }
243
+
244
+ const includedSet = new Set(included)
245
+ const sections = parts.filter(p => includedSet.has(p.file)).map(p => p.section)
246
+ return { prompt: buildPromptShell(sections, testRules), included, deferred }
247
+ }
248
+
179
249
  /**
180
250
  * Parses fenced JavaScript blocks with file markers from LLM response.
181
251
  * @param {string} text LLM response text.
@@ -256,7 +326,8 @@ function writeFixedFiles(fixed, remaining, dir) {
256
326
  * @returns {Promise<{count: number, fixed: number, remaining: number}>} fix result summary
257
327
  */
258
328
  export async function fixFailingTests(dir, opts = {}) {
259
- const callTextFn = opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir }))
329
+ const callTextFn =
330
+ opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir, maxTokens: budgetFor('fix').maxTokens }))
260
331
  const failures = opts.failures ?? (await getFailingTests(dir))
261
332
 
262
333
  if (failures.length === 0) return { count: 0, fixed: 0, remaining: 0 }
@@ -268,15 +339,27 @@ export async function fixFailingTests(dir, opts = {}) {
268
339
  console.log()
269
340
 
270
341
  let remaining = failures
271
- for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS && remaining.length > 0; attempt++) {
272
- if (attempt > 1) {
273
- console.log(`\n🔄 Спроба ${attempt}/${MAX_FIX_ATTEMPTS}: залишилось ${remaining.length} файлів...\n`)
342
+ const attempts = new Map()
343
+ 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(` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`)
274
356
  }
357
+ for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
358
+ prevDeferred = new Set(batch.deferred)
275
359
 
276
- const prompt = buildFixTestsPrompt(remaining, dir)
277
360
  let response
278
361
  try {
279
- response = await callTextFn(prompt)
362
+ response = await callTextFn(batch.prompt)
280
363
  } catch (error) {
281
364
  // memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
282
365
  // до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
@@ -292,7 +375,8 @@ export async function fixFailingTests(dir, opts = {}) {
292
375
  break
293
376
  }
294
377
 
295
- writeFixedFiles(fixed, remaining, dir)
378
+ const includedSet = new Set(batch.included)
379
+ writeFixedFiles(fixed, eligible.filter(f => includedSet.has(f.file)), dir)
296
380
 
297
381
  remaining = await getFailingTests(dir)
298
382
  }
package/src/gen-tests.mjs CHANGED
@@ -17,6 +17,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
17
17
  import { spawnSync } from 'node:child_process'
18
18
  import { join, relative, dirname } from 'node:path'
19
19
  import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
20
+ import { budgetFor } from './lib/prompt-budget.mjs'
20
21
  import { resolveVitestRun } from './lib/vitest-shim.mjs'
21
22
  import { extractExportsWithComplexity } from './classify-exports.mjs'
22
23
  import { analyzeModule } from './lib/ast-analyze.mjs'
@@ -867,7 +868,7 @@ async function generateSharedHeader(ctx, dir, callTextFn) {
867
868
  try {
868
869
  const headerResp = await callTextFn(
869
870
  buildHeaderPrompt({ file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo }),
870
- { cwd: dir }
871
+ { cwd: dir, maxTokens: budgetFor('header').maxTokens }
871
872
  )
872
873
  const header = extractCode(headerResp)
873
874
  if (header) return header
@@ -892,7 +893,7 @@ async function generateLocalBlock(opts) {
892
893
  const result = await generateBlockWithLoop(
893
894
  blockPrompt,
894
895
  callLocalFn,
895
- {},
896
+ { maxTokens: budgetFor('block').maxTokens },
896
897
  header,
897
898
  dir,
898
899
  testDir,
@@ -920,7 +921,7 @@ async function generateCloudBlock(opts) {
920
921
  const result = await generateBlockWithLoop(
921
922
  blockPrompt,
922
923
  callTextFn,
923
- { cwd: dir },
924
+ { cwd: dir, maxTokens: budgetFor('block').maxTokens },
924
925
  header,
925
926
  dir,
926
927
  testDir,
@@ -1109,7 +1110,7 @@ async function generateOneTest(fileInfo, dir, callTextFn) {
1109
1110
  const prompt = buildSingleFilePrompt(fileInfo, dir)
1110
1111
  let response
1111
1112
  try {
1112
- response = await callTextFn(prompt, { cwd: dir })
1113
+ response = await callTextFn(prompt, { cwd: dir, maxTokens: budgetFor('single-file').maxTokens })
1113
1114
  } catch (error) {
1114
1115
  if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
1115
1116
  console.error(` ✗ pi помилка для ${fileInfo.file}: ${error.message}`)
@@ -1181,7 +1182,7 @@ export async function generateTests(files, dir, opts = {}) {
1181
1182
 
1182
1183
  const callTextFn = opts.callText ?? callText
1183
1184
  const localModel = resolveLocalModel(opts)
1184
- const localFn = localModel ? prompt => callTextFn(prompt, { model: localModel, cwd: dir }) : null
1185
+ const localFn = localModel ? (prompt, opts = {}) => callTextFn(prompt, { ...opts, model: localModel, cwd: dir }) : null
1185
1186
 
1186
1187
  const mode = localFn ? `per-export (local:${localModel} + cloud)` : 'single-file (cloud)'
1187
1188
  console.log(`\n🤖 Генерую тести для ${files.length} файлів [${mode}]...\n`)
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: runtime-probe.mjs
4
4
  resource: npm/src/lib/runtime-probe.mjs
5
5
  docgen:
6
- crc: 31dd4fd6
6
+ crc: 6455ded4
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: vitest-shim.mjs
4
4
  resource: npm/src/lib/vitest-shim.mjs
5
5
  docgen:
6
- crc: e08618cc
6
+ crc: 144cb026
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.97
@@ -18,7 +18,7 @@ docgen:
18
18
 
19
19
  VITEST_BIN — Абсолютний шлях до вбудованого виконуваного файлу Vitest.
20
20
  VITEST_SHIM_CONFIG — Абсолютний шлях до тимчасового файлу конфігурації Vitest, що забезпечує роботу збірки без встановлення Vitest у цільовому проекті.
21
- ensureVitestShim — Гарантує існування файлу конфігурації Vitest, записуючи мінімальний конфіг у тимчасову директорію, ігноруючи шляхи до `node_modules`.
21
+ ensureVitestShim — Гарантує існування файлу конфігурації Vitest, записуючи мінімальний конфіг у тимчасову директорію, ігноруючи шляхи до `node_modules` та декларації типів `**/*.d.ts`.
22
22
 
23
23
  ## Публічний API
24
24
 
@@ -28,4 +28,4 @@ ensureVitestShim — Гарантує існування файлу конфіг
28
28
 
29
29
  ## Гарантії поведінки
30
30
 
31
- - Свідомо пропускає шляхи: `node_modules`.
31
+ - Свідомо пропускає шляхи: `node_modules` і декларації типів `**/*.d.ts` (vitest 4 прибрав дефолтний exclude — без нього v8-remap падає на TS-синтаксисі декларацій).
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Programmatic pi client via @earendil-works/pi-coding-agent SDK.
2
+ * Programmatic pi client via `@earendil-works/pi-coding-agent` SDK.
3
3
  * Replaces spawnSync('pi', ...) with direct in-process calls.
4
4
  *
5
5
  * Two modes:
@@ -9,7 +9,7 @@
9
9
  * Both retry transient connection failures (e.g. a shared local model server
10
10
  * that's momentarily busy under concurrent load) with exponential backoff
11
11
  * instead of failing the caller's file on the first hiccup. A memory-guard
12
- * rejection gets its own longer bounded backoff: oMLX обслуговує один промпт
12
+ * rejection gets its own longer bounded backoff: oMLX (local model server)
13
13
  * одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
14
14
  * запиту, яку сервер вивільняє сам за десятки секунд. Лише після
15
15
  * `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
@@ -23,15 +23,19 @@ import { setTimeout as sleep } from 'node:timers/promises'
23
23
 
24
24
  let _registry = null
25
25
  /**
26
- *
26
+ * Повертає або ініціалізує спільний model registry для поточного процесу.
27
+ * @returns {object} спільний registry
27
28
  */
28
- async function getRegistry() {
29
+ function getRegistry() {
29
30
  if (_registry) return _registry
30
31
  _registry = ModelRegistry.create(AuthStorage.create())
31
32
  return _registry
32
33
  }
33
34
 
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+)/
35
39
  /** Matches a local model server (e.g. oMLX) rejecting a prompt for lack of RAM. */
36
40
  export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
37
41
  const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
@@ -52,9 +56,9 @@ const MEMORY_MAX_DELAY_MS = 60_000
52
56
  * @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
53
57
  */
54
58
  function parseOmlxErrorDetails(message) {
55
- const code = /"(?:omlx_)?code"\s*:\s*"([^"]+)"/.exec(message)?.[1]
56
- const estimated = /"estimated_bytes"\s*:\s*(\d+)/.exec(message)?.[1]
57
- const limit = /"limit_bytes"\s*:\s*(\d+)/.exec(message)?.[1]
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]
58
62
  if (!code && !estimated && !limit) return null
59
63
  const details = {}
60
64
  if (code) details.omlxCode = code
@@ -132,17 +136,41 @@ async function withRetry(fn, requestBody) {
132
136
  }
133
137
  }
134
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
+
135
161
  /**
136
162
  * Sends a single prompt to pi in text mode (no tools) and returns the response.
137
163
  * Reads auth/model config from ~/.pi/ same as the CLI.
138
- * @param {string} prompt
139
- * @param {object} [opts]
140
- * @param {string} [opts.cwd]
141
- * @param {string} [opts.model] provider/model-id passed to pi (e.g. "openai/gpt-4o"); omit for pi default
142
- * @returns {Promise<string>}
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>} текстова відповідь моделі
143
171
  */
144
172
  export async function callText(prompt, opts = {}) {
145
- return withRetry(async () => {
173
+ const result = await withRetry(async () => {
146
174
  const cwd = opts.cwd ?? process.cwd()
147
175
  const sessionOpts = {
148
176
  tools: [],
@@ -154,35 +182,51 @@ export async function callText(prompt, opts = {}) {
154
182
  const slashIdx = opts.model.indexOf('/')
155
183
  const provider = slashIdx === -1 ? null : opts.model.slice(0, slashIdx)
156
184
  const modelId = slashIdx === -1 ? opts.model : opts.model.slice(slashIdx + 1)
157
- const resolved = provider ? registry.find(provider, modelId) : null
185
+ const resolved = provider ? Reflect.apply(registry.find, registry, [provider, modelId]) : null
158
186
  sessionOpts.modelRegistry = registry
159
187
  sessionOpts.model = resolved ?? opts.model
160
188
  }
161
189
  const { session } = await createAgentSession(sessionOpts)
190
+ if (opts.maxTokens) applyMaxTokens(session, opts.maxTokens)
191
+ try {
192
+ await session.prompt(prompt)
162
193
 
163
- await session.prompt(prompt)
164
-
165
- const state = session.state
166
- const last = state.messages.at(-1)
167
- if (!last || last.role !== 'assistant') return ''
168
- if (last.stopReason === 'error' || last.stopReason === 'aborted') {
169
- throw new Error(`pi error: ${last.errorMessage ?? last.stopReason}`)
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()
170
209
  }
171
- return last.content
172
- .filter(c => c.type === 'text')
173
- .map(c => c.text)
174
- .join('')
175
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
176
220
  }
177
221
 
178
222
  /**
179
223
  * Sends a prompt to pi in agent mode with full coding tools (read/write/bash/edit).
180
224
  * The agent writes test files directly — no need to parse output.
181
- * @param {string} prompt
182
- * @param {string} cwd project root where files should be written
183
- * @returns {Promise<void>}
225
+ * @param {string} prompt текст завдання для агента
226
+ * @param {string} cwd робоча директорія, куди агент може писати файли
227
+ * @returns {Promise<void>} проміс завершується після виконання агента
184
228
  */
185
- export async function callAgent(prompt, cwd) {
229
+ export function callAgent(prompt, cwd) {
186
230
  return withRetry(async () => {
187
231
  const { session } = await createAgentSession({
188
232
  tools: ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'],
@@ -190,6 +234,10 @@ export async function callAgent(prompt, cwd) {
190
234
  cwd
191
235
  })
192
236
 
193
- await session.prompt(prompt)
237
+ try {
238
+ await session.prompt(prompt)
239
+ } finally {
240
+ session.dispose()
241
+ }
194
242
  }, prompt)
195
243
  }
@@ -0,0 +1,125 @@
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 { text: parts.filter(p => p.kept).map(p => p.out).join('\n'), dropped }
100
+ }
101
+
102
+ /**
103
+ * Пакує одиниці (файли) у бюджет: найменші першими, щоб максимізувати
104
+ * кількість виправлень за один виклик. Одиниця, що сама-одна перевищує
105
+ * бюджет, потрапляє в `deferred` — колер робить для неї соло-виклик із
106
+ * жорсткішим внутрішнім обрізанням (`fitToBudget`), а не мовчазний skip.
107
+ * @param {Array<{key: string, size: number}>} items одиниці з розмірами
108
+ * @param {number} maxChars бюджет символів на батч
109
+ * @returns {{included: string[], deferred: string[]}} ключі включених і відкладених одиниць
110
+ */
111
+ export function packBatch(items, maxChars) {
112
+ const sorted = items.toSorted((a, b) => a.size - b.size)
113
+ const included = []
114
+ const deferred = []
115
+ let used = 0
116
+ for (const item of sorted) {
117
+ if (item.size + (included.length === 0 ? 0 : used) <= maxChars) {
118
+ included.push(item.key)
119
+ used += item.size
120
+ } else {
121
+ deferred.push(item.key)
122
+ }
123
+ }
124
+ return { included, deferred }
125
+ }
@@ -51,7 +51,8 @@ export function ensureVitestShim() {
51
51
  ` include: ['**/*.{js,mjs,ts,vue}'],\n` +
52
52
  // Exclude test files that use non-vitest runners (bun:test, jest) from
53
53
  // the coverage scan so they don't cause import errors or skewed data.
54
- ` exclude: ['**/node_modules/**'],\n` +
54
+ // *.d.ts: vitest 4 прибрав дефолтний exclude — v8-remap падає на TS-синтаксисі декларацій.
55
+ ` exclude: ['**/node_modules/**', '**/*.d.ts'],\n` +
55
56
  ` },\n` +
56
57
  ` },\n` +
57
58
  `})\n`