@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 +11 -0
- package/bin/7n-test.js +4 -1
- package/package.json +1 -1
- package/src/coverage-per-file.mjs +2 -0
- package/src/docs/coverage-per-file.md +1 -1
- package/src/docs/fix-tests.md +1 -1
- package/src/docs/gen-tests.md +1 -1
- package/src/fix-tests.mjs +118 -34
- package/src/gen-tests.mjs +6 -5
- package/src/lib/docs/runtime-probe.md +1 -1
- package/src/lib/docs/vitest-shim.md +3 -3
- package/src/lib/pi-client.mjs +78 -30
- package/src/lib/prompt-budget.mjs +125 -0
- package/src/lib/vitest-shim.mjs +2 -1
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
|
-
|
|
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
|
@@ -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',
|
package/src/docs/fix-tests.md
CHANGED
package/src/docs/gen-tests.md
CHANGED
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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: vitest-shim.mjs
|
|
4
4
|
resource: npm/src/lib/vitest-shim.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
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-синтаксисі декларацій).
|
package/src/lib/pi-client.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Programmatic pi client via
|
|
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
|
-
|
|
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 =
|
|
56
|
-
const estimated =
|
|
57
|
-
const limit =
|
|
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
|
|
142
|
-
* @
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
183
|
-
* @returns {Promise<void>}
|
|
225
|
+
* @param {string} prompt текст завдання для агента
|
|
226
|
+
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
227
|
+
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
184
228
|
*/
|
|
185
|
-
export
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/vitest-shim.mjs
CHANGED
|
@@ -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
|
-
|
|
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`
|