@7n/llm-lib 2.1.1 → 2.2.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 +6 -0
- package/lib/agent-fix.mjs +37 -5
- package/lib/anchored-edit.mjs +198 -0
- package/lib/docs/agent-fix.md +3 -1
- package/lib/docs/anchored-edit.md +34 -0
- package/lib/docs/write-guard.md +2 -1
- package/lib/write-guard.mjs +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.0] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- anchored-edit (Фаза A2): строгі hash-anchored read_anchored/edit_anchored tools, opts.anchoredEdits у runAgentFix (toolset-профіль без built-in read/edit), edit_anchored під write-guard veto/snapshot
|
|
8
|
+
|
|
3
9
|
## [2.1.1] - 2026-07-11
|
|
4
10
|
|
|
5
11
|
### Changed
|
package/lib/agent-fix.mjs
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { env } from 'node:process'
|
|
34
34
|
import { homedir } from 'node:os'
|
|
35
|
+
import { createAnchoredTools } from './anchored-edit.mjs'
|
|
35
36
|
import { getRegistry, resolveModelSpec } from './internal/registry.mjs'
|
|
36
37
|
import { isLocalModel, thinkingLevelForTier } from './model-tiers.mjs'
|
|
37
38
|
import { createWriteGuard, gitRoot } from './write-guard.mjs'
|
|
@@ -152,7 +153,7 @@ async function runVerifyLoop({ session, verify, verifyMax, timeoutMs, startedAt,
|
|
|
152
153
|
* єдині наявні файли, які дозволено редагувати (порожньо/відсутнє — без переліку).
|
|
153
154
|
* @returns {string} промпт
|
|
154
155
|
*/
|
|
155
|
-
export function buildFixPrompt({ ruleId, violation, ruleText, feedback, targetFiles }) {
|
|
156
|
+
export function buildFixPrompt({ ruleId, violation, ruleText, feedback, targetFiles, anchoredEdits = false }) {
|
|
156
157
|
const parts = [`Виправ порушення правила "${ruleId}" у цьому проєкті.`]
|
|
157
158
|
if (ruleText) parts.push(`## Правило\n${ruleText}`)
|
|
158
159
|
parts.push(`## Порушення\n${violation}`)
|
|
@@ -176,6 +177,13 @@ export function buildFixPrompt({ ruleId, violation, ruleText, feedback, targetFi
|
|
|
176
177
|
'Після правок виклич `self_check`, щоб підтвердити, що порушення зникло. ' +
|
|
177
178
|
'Редагуй лише потрібне, не чіпай стороннє.'
|
|
178
179
|
)
|
|
180
|
+
if (anchoredEdits) {
|
|
181
|
+
parts.push(
|
|
182
|
+
'Наявні файли читай і редагуй ЛИШЕ через `read_anchored` → `edit_anchored` ' +
|
|
183
|
+
'(кожна правка = {anchor, line, newText} з якорями від read_anchored; ' +
|
|
184
|
+
'stale anchor = перечитай файл і повтори). `write` — лише для НОВИХ файлів.'
|
|
185
|
+
)
|
|
186
|
+
}
|
|
179
187
|
return parts.join('\n\n')
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -187,7 +195,17 @@ export function buildFixPrompt({ ruleId, violation, ruleText, feedback, targetFi
|
|
|
187
195
|
* selfCheck: (files: string[]) => Promise<{ ok: boolean, output: string }> | { ok: boolean, output: string } }} args параметри сесії.
|
|
188
196
|
* @returns {Promise<object>} pi AgentSession
|
|
189
197
|
*/
|
|
190
|
-
async function defaultCreateSession({
|
|
198
|
+
async function defaultCreateSession({
|
|
199
|
+
registry,
|
|
200
|
+
model,
|
|
201
|
+
thinkingLevel,
|
|
202
|
+
cwd,
|
|
203
|
+
factory,
|
|
204
|
+
astContext,
|
|
205
|
+
selfCheck,
|
|
206
|
+
chain,
|
|
207
|
+
anchoredEdits = false
|
|
208
|
+
}) {
|
|
191
209
|
const { createAgentSession, SessionManager, DefaultResourceLoader, SettingsManager, defineTool } =
|
|
192
210
|
await import('@earendil-works/pi-coding-agent')
|
|
193
211
|
const agentDir = `${homedir()}/.pi/agent`
|
|
@@ -226,13 +244,23 @@ async function defaultCreateSession({ registry, model, thinkingLevel, cwd, facto
|
|
|
226
244
|
})
|
|
227
245
|
})
|
|
228
246
|
|
|
247
|
+
// A2: anchored-профіль заміняє built-in read/edit на строгі read_anchored/edit_anchored
|
|
248
|
+
// (write лишається для НОВИХ файлів, під тим самим write-guard).
|
|
249
|
+
let tools = ['read', 'grep', 'find', 'edit', 'write', 'ls', 'ast_facts', 'self_check']
|
|
250
|
+
const customTools = [astTool, checkTool]
|
|
251
|
+
if (anchoredEdits) {
|
|
252
|
+
const { readTool, editTool } = createAnchoredTools({ cwd, defineTool })
|
|
253
|
+
tools = ['grep', 'find', 'write', 'ls', 'ast_facts', 'self_check', 'read_anchored', 'edit_anchored']
|
|
254
|
+
customTools.push(readTool, editTool)
|
|
255
|
+
}
|
|
256
|
+
|
|
229
257
|
const { session } = await createAgentSession({
|
|
230
258
|
modelRegistry: registry,
|
|
231
259
|
model,
|
|
232
260
|
thinkingLevel,
|
|
233
261
|
cwd,
|
|
234
|
-
tools
|
|
235
|
-
customTools
|
|
262
|
+
tools,
|
|
263
|
+
customTools,
|
|
236
264
|
sessionManager: SessionManager.inMemory(),
|
|
237
265
|
resourceLoader: loader
|
|
238
266
|
})
|
|
@@ -250,6 +278,7 @@ async function defaultCreateSession({ registry, model, thinkingLevel, cwd, facto
|
|
|
250
278
|
* targetFiles?: string[],
|
|
251
279
|
* verify?: (args: { touchedFiles: string[] }) => Promise<{ ok: boolean, output?: string }> | { ok: boolean, output?: string },
|
|
252
280
|
* verifyMax?: number,
|
|
281
|
+
* anchoredEdits?: boolean,
|
|
253
282
|
* deps?: { createSession?: (args: object) => Promise<object>, getRegistry?: () => Promise<object>,
|
|
254
283
|
* registry?: object, root?: string|null,
|
|
255
284
|
* astContext?: (path: string) => object,
|
|
@@ -270,6 +299,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
270
299
|
targetFiles,
|
|
271
300
|
verify = null,
|
|
272
301
|
verifyMax = VERIFY_MAX_DEFAULT,
|
|
302
|
+
anchoredEdits = false,
|
|
273
303
|
deps = {}
|
|
274
304
|
} = opts
|
|
275
305
|
const createSession = deps.createSession ?? defaultCreateSession
|
|
@@ -328,6 +358,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
328
358
|
factory: guard.factory,
|
|
329
359
|
astContext,
|
|
330
360
|
selfCheck,
|
|
361
|
+
anchoredEdits,
|
|
331
362
|
// Заголовки кореляції — лише локальним моделям (myllm стоїть тільки перед ними).
|
|
332
363
|
chain: modelSpec && isLocalModel(modelSpec) ? chain : null
|
|
333
364
|
})
|
|
@@ -376,7 +407,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
376
407
|
}
|
|
377
408
|
})
|
|
378
409
|
|
|
379
|
-
const fixPrompt = buildFixPrompt({ ruleId, violation, ruleText, feedback, targetFiles })
|
|
410
|
+
const fixPrompt = buildFixPrompt({ ruleId, violation, ruleText, feedback, targetFiles, anchoredEdits })
|
|
380
411
|
const pHash = promptHash(fixPrompt)
|
|
381
412
|
const startedAt = clock()
|
|
382
413
|
let error = null
|
|
@@ -439,6 +470,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
439
470
|
backstopHit,
|
|
440
471
|
verifyAttempts: verifyAttempts.length,
|
|
441
472
|
verifyOk: verifyAttempts.length > 0 ? verifyAttempts.at(-1).ok : null,
|
|
473
|
+
anchoredEdits,
|
|
442
474
|
wallMs: clock() - startedAt,
|
|
443
475
|
error,
|
|
444
476
|
...chain?.traceFields()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** @see ./docs/anchored-edit.md */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hash-anchored line-editing (Фаза A2 run-harness, дизайн 2026-07-11).
|
|
5
|
+
*
|
|
6
|
+
* Строга заміна built-in `read`/`edit` для fix-профілів: кожен рядок файлу має
|
|
7
|
+
* 3-символьний base36-якір від хешу вмісту; правка застосовується ЛИШЕ якщо якір
|
|
8
|
+
* збігається з поточним вмістом рядка. Жодного fuzzy-match, мовчазного переміщення
|
|
9
|
+
* чи автокорекції (клас collateral слабких моделей: переписаний файл, літеральний
|
|
10
|
+
* `\n\n`). Будь-який mismatch → структурована відмова `stale anchor` БЕЗ часткового
|
|
11
|
+
* застосування (атомарно на файл) — агенту лишається перечитати файл і повторити.
|
|
12
|
+
*
|
|
13
|
+
* Референс патерну — pi-hashline-edit-pro (вендоримо ідею, не пакет: рішення Б спеки
|
|
14
|
+
* pi-harness-mt-fix-graph). Хеш — `node:crypto` sha256-префікс (нуль нових залежностей;
|
|
15
|
+
* контекст не sensitive — якір лише детектує розбіжність вмісту рядка).
|
|
16
|
+
*
|
|
17
|
+
* Модуль pi-free: чисті функції над рядками + фабрика tool-дефініцій, якій caller
|
|
18
|
+
* (agent-fix) передає pi `defineTool` через lazy import. Запис на диск робить
|
|
19
|
+
* tool `edit_anchored` — він у WRITE_TOOLS write-guard, тож підлягає тому самому
|
|
20
|
+
* scope/denylist veto, pre-image snapshot і rollback, що й built-in `edit`/`write`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
26
|
+
|
|
27
|
+
/** Довжина якоря у base36-символах (36³ = 46656 значень — на рядок, не на файл). */
|
|
28
|
+
const ANCHOR_LEN = 3
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Якір рядка: перші 3 base36-символи sha256 від вмісту рядка.
|
|
32
|
+
* Навмисно БЕЗ номера рядка у хеші: якір «прив'язаний до вмісту», а номер іде
|
|
33
|
+
* окремим полем правки — розбіжність будь-якого з двох означає stale-стан.
|
|
34
|
+
* @param {string} text вміст рядка (без завершального \n)
|
|
35
|
+
* @returns {string} 3-символьний base36-якір
|
|
36
|
+
*/
|
|
37
|
+
export function lineAnchor(text) {
|
|
38
|
+
const h = createHash('sha256').update(text, 'utf8').digest()
|
|
39
|
+
return (h.readUInt32BE(0) % 36 ** ANCHOR_LEN).toString(36).padStart(ANCHOR_LEN, '0')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Рендерить вміст файлу у anchored-форматі: `якір|номер|текст`, нумерація з 1.
|
|
44
|
+
* @param {string} content повний вміст файлу
|
|
45
|
+
* @param {{ from?: number, to?: number }} [range] діапазон рядків (включно, 1-based)
|
|
46
|
+
* @returns {string} anchored-подання (по рядку на кожен рядок діапазону)
|
|
47
|
+
*/
|
|
48
|
+
export function renderAnchored(content, { from = 1, to = Infinity } = {}) {
|
|
49
|
+
const lines = content.split('\n')
|
|
50
|
+
const out = []
|
|
51
|
+
for (let i = from; i <= Math.min(to, lines.length); i++) {
|
|
52
|
+
const text = lines[i - 1]
|
|
53
|
+
out.push(`${lineAnchor(text)}|${i}|${text}`)
|
|
54
|
+
}
|
|
55
|
+
return out.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Атомарно застосовує anchored-правки до вмісту файлу.
|
|
60
|
+
*
|
|
61
|
+
* Валідація ПЕРЕД застосуванням: кожна правка перевіряється на (а) існування рядка,
|
|
62
|
+
* (б) збіг якоря з поточним вмістом. Хоч одна stale → нічого не застосовується.
|
|
63
|
+
* Семантика правки: `newText` — заміна рядка (може бути багаторядковим), `null` —
|
|
64
|
+
* видалення рядка. Дублікати номера рядка у списку правок — теж відмова (двозначно).
|
|
65
|
+
* @param {string} content поточний вміст файлу
|
|
66
|
+
* @param {Array<{ anchor: string, line: number, newText: string|null }>} edits правки
|
|
67
|
+
* @returns {{ ok: true, content: string } | { ok: false, stale: Array<{ line: number, reason: string }> }}
|
|
68
|
+
* новий вміст або перелік розбіжностей (для структурованої відповіді агенту)
|
|
69
|
+
*/
|
|
70
|
+
export function applyAnchoredEdits(content, edits) {
|
|
71
|
+
const lines = content.split('\n')
|
|
72
|
+
const stale = []
|
|
73
|
+
const seen = new Set()
|
|
74
|
+
for (const e of edits) {
|
|
75
|
+
if (!Number.isSafeInteger(e.line) || e.line < 1 || e.line > lines.length) {
|
|
76
|
+
stale.push({ line: e.line, reason: `рядка ${e.line} не існує (у файлі ${lines.length})` })
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (seen.has(e.line)) {
|
|
80
|
+
stale.push({ line: e.line, reason: `рядок ${e.line} правиться двічі в одному виклику` })
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
seen.add(e.line)
|
|
84
|
+
const actual = lineAnchor(lines[e.line - 1])
|
|
85
|
+
if (e.anchor !== actual) {
|
|
86
|
+
stale.push({ line: e.line, reason: `stale anchor ${e.anchor} (актуальний ${actual}) — перечитай файл` })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (stale.length > 0) return { ok: false, stale }
|
|
90
|
+
|
|
91
|
+
// Застосування знизу вгору — номери рядків вище не зсуваються.
|
|
92
|
+
const sorted = edits.toSorted((a, b) => b.line - a.line)
|
|
93
|
+
for (const e of sorted) {
|
|
94
|
+
if (e.newText === null) lines.splice(e.line - 1, 1)
|
|
95
|
+
else lines.splice(e.line - 1, 1, ...e.newText.split('\n'))
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, content: lines.join('\n') }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Резолвить шлях tool-виклику відносно cwd сесії.
|
|
102
|
+
* @param {string} cwd робоча директорія сесії
|
|
103
|
+
* @param {string} raw шлях із tool-input
|
|
104
|
+
* @returns {string} абсолютний шлях
|
|
105
|
+
*/
|
|
106
|
+
function resolvePath(cwd, raw) {
|
|
107
|
+
return isAbsolute(raw) ? raw : resolve(cwd, raw)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Структурована текстова відмова tool-виклику (JSON-текст — слабкі моделі бачать
|
|
112
|
+
* точну причину і наступний крок).
|
|
113
|
+
* @param {object} payload обʼєкт з `error` (+ опційні деталі)
|
|
114
|
+
* @returns {{ content: Array<{ type: string, text: string }>, details: object }} tool-результат
|
|
115
|
+
*/
|
|
116
|
+
function toolFail(payload) {
|
|
117
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }], details: {} }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Фабрика пари pi-tools `read_anchored`/`edit_anchored`.
|
|
122
|
+
*
|
|
123
|
+
* `defineTool` передається caller-ом (lazy pi-import в agent-fix) — модуль лишається
|
|
124
|
+
* pi-free. Обидва tools повертають результат текстом (JSON для помилок), щоб слабкі
|
|
125
|
+
* моделі бачили точну причину відмови і мали інструкцію наступного кроку.
|
|
126
|
+
* @param {{ cwd: string, defineTool: (def: object) => object,
|
|
127
|
+
* fs?: { existsSync?: typeof existsSync, readFileSync?: typeof readFileSync, writeFileSync?: typeof writeFileSync } }} args
|
|
128
|
+
* контекст: cwd сесії, pi defineTool, опційні fs-інжекції для тестів
|
|
129
|
+
* @returns {{ readTool: object, editTool: object }} tool-дефініції для customTools
|
|
130
|
+
*/
|
|
131
|
+
export function createAnchoredTools({ cwd, defineTool, fs = {} }) {
|
|
132
|
+
const exists = fs.existsSync ?? existsSync
|
|
133
|
+
const read = fs.readFileSync ?? readFileSync
|
|
134
|
+
const write = fs.writeFileSync ?? writeFileSync
|
|
135
|
+
|
|
136
|
+
const readTool = defineTool({
|
|
137
|
+
name: 'read_anchored',
|
|
138
|
+
label: 'Read (anchored)',
|
|
139
|
+
description:
|
|
140
|
+
'Read a file as anchored lines "anchor|lineNo|text". To edit an existing file you MUST read it with this tool first and use the returned anchors in edit_anchored.',
|
|
141
|
+
parameters: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
path: { type: 'string', description: 'file path' },
|
|
145
|
+
from: { type: 'number', description: 'first line (1-based, optional)' },
|
|
146
|
+
to: { type: 'number', description: 'last line (inclusive, optional)' }
|
|
147
|
+
},
|
|
148
|
+
required: ['path']
|
|
149
|
+
},
|
|
150
|
+
execute: (_id, { path, from, to }) => {
|
|
151
|
+
const abs = resolvePath(cwd, path)
|
|
152
|
+
if (!exists(abs)) return toolFail({ error: `файл не існує: ${path}` })
|
|
153
|
+
const text = renderAnchored(read(abs, 'utf8'), { from: from ?? 1, to: to ?? Infinity })
|
|
154
|
+
return { content: [{ type: 'text', text }], details: {} }
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const editTool = defineTool({
|
|
159
|
+
name: 'edit_anchored',
|
|
160
|
+
label: 'Edit (anchored)',
|
|
161
|
+
description:
|
|
162
|
+
'Strictly edit a file by anchored lines. Each edit = {anchor, line, newText}; newText replaces the whole line (may be multiline), null deletes the line. Anchors come from read_anchored. Any stale anchor rejects the WHOLE call — re-read the file and retry. No fuzzy matching.',
|
|
163
|
+
parameters: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
path: { type: 'string', description: 'file path' },
|
|
167
|
+
edits: {
|
|
168
|
+
type: 'array',
|
|
169
|
+
items: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
anchor: { type: 'string', description: '3-char anchor from read_anchored' },
|
|
173
|
+
line: { type: 'number', description: '1-based line number' },
|
|
174
|
+
newText: { type: ['string', 'null'], description: 'replacement text (null = delete line)' }
|
|
175
|
+
},
|
|
176
|
+
required: ['anchor', 'line']
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
required: ['path', 'edits']
|
|
181
|
+
},
|
|
182
|
+
execute: (_id, { path, edits }) => {
|
|
183
|
+
const abs = resolvePath(cwd, path)
|
|
184
|
+
if (!exists(abs)) return toolFail({ error: `файл не існує: ${path} — для нового файлу використовуй write` })
|
|
185
|
+
if (!Array.isArray(edits) || edits.length === 0) return toolFail({ error: 'edits порожній' })
|
|
186
|
+
const normalized = edits.map(e => ({ anchor: e.anchor, line: e.line, newText: e.newText ?? null }))
|
|
187
|
+
const res = applyAnchoredEdits(read(abs, 'utf8'), normalized)
|
|
188
|
+
if (!res.ok) return toolFail({ error: 'stale anchors — нічого не застосовано', stale: res.stale })
|
|
189
|
+
write(abs, res.content)
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, applied: normalized.length }) }],
|
|
192
|
+
details: {}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
return { readTool, editTool }
|
|
198
|
+
}
|
package/lib/docs/agent-fix.md
CHANGED
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: agent-fix.mjs
|
|
4
4
|
resource: llm-lib/lib/agent-fix.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 730809af
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -18,6 +18,8 @@ buildFixPrompt готує текстовий промпт, що містить
|
|
|
18
18
|
runAgentFix виконує повний агентний цикл для спроби виправлення порушення правила, включаючи взаємодію з інструментами, застосування патча та фіксацію телеметрії.
|
|
19
19
|
Виклик сесії огорнутий timeout-гонкою: `opts.timeoutMs` (дефолт 300s, коли consumer не передав значення) на спрацюванні abort-ить сесію і повертає помилку `fix timeout …` — зависла LLM-сесія (напр. мертва SSE) не блокує виклик назавжди.
|
|
20
20
|
|
|
21
|
+
Anchored-профіль (опційний `opts.anchoredEdits`, Фаза A2): toolset сесії заміняє built-in `read`/`edit` на строгі `read_anchored`/`edit_anchored` (див. anchored-edit.md; `write` лишається для нових файлів, під тим самим write-guard), а промпт отримує інструкцію anchored-циклу. Прапорець фіксується у трейсі (`anchoredEdits`) для A/B-аналізу; дефолт вимкнено.
|
|
22
|
+
|
|
21
23
|
Evidence-гейт (опційний `opts.verify`, Фаза A1 run-harness): після prompt-у модуль сам запускає canonical-перевірку consumer-а; провал інʼєктиться фідбеком у ту саму сесію — до `opts.verifyMax` додаткових ітерацій (дефолт 2), у межах того самого `timeoutMs` (залишок бюджету < 5s — чесна зупинка без ітерації). Гейт структурний: заяви агента про успіх не важать, джерелом правди лишається зовнішня перевірка. Помилка самої перевірки — інфраструктурна: ітерації не витрачаються, повертається `error` з префіксом `verify:`. Без `verify` — поведінка попередня (один прохід). Спроби фіксуються у `telemetry.verifyAttempts` і у trace (`verifyAttempts`, `verifyOk`).
|
|
22
24
|
|
|
23
25
|
## Публічний API
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: anchored-edit.mjs
|
|
4
|
+
resource: llm-lib/lib/anchored-edit.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 6ac40e43
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Огляд
|
|
10
|
+
|
|
11
|
+
Строге hash-anchored редагування рядків (Фаза A2 run-harness): заміна built-in `read`/`edit` для fix-профілів. Кожен рядок файлу отримує 3-символьний base36-якір від sha256 вмісту; правка застосовується лише при точному збігу якоря з поточним станом рядка. Мета — прибрати collateral слабких моделей (переписаний файл, літеральний `\n\n`): жодного fuzzy-match, мовчазного переміщення чи автокорекції. Референс патерну — pi-hashline-edit-pro (вендориться ідея, не пакет).
|
|
12
|
+
|
|
13
|
+
## Поведінка
|
|
14
|
+
|
|
15
|
+
Рендер (`renderAnchored`) віддає рядки у форматі `якір|номер|текст` (нумерація з 1, опційний діапазон включно). Застосування (`applyAnchoredEdits`) атомарне на файл: усі правки спершу валідуються (існування рядка, збіг якоря, відсутність дублю номера) — хоч одна розбіжність означає повну відмову з переліком `stale`-причин, файл не змінюється. Валідні правки застосовуються знизу вгору, тож номери рядків у пакеті правок не зсуваються; `newText` заміняє рядок (може бути багаторядковим), `null` — видаляє.
|
|
16
|
+
|
|
17
|
+
Tool-фабрика (`createAnchoredTools`) створює пару pi-tools: `read_anchored` (файл або діапазон в anchored-форматі; неіснуючий файл — структурована помилка) і `edit_anchored` (правки `{anchor, line, newText}`; stale → JSON-відмова з інструкцією перечитати файл; для нових файлів чесно відсилає до `write`). `defineTool` передається caller-ом — модуль лишається pi-free; fs-операції інжектовані для тестів.
|
|
18
|
+
|
|
19
|
+
## Публічний API
|
|
20
|
+
|
|
21
|
+
lineAnchor — 3-символьний base36-якір від sha256 вмісту рядка (детермінований, чутливий до будь-якої зміни тексту).
|
|
22
|
+
renderAnchored — anchored-подання вмісту файлу для читання агентом.
|
|
23
|
+
applyAnchoredEdits — атомарне застосування пакету правок або структурована відмова `{ok:false, stale:[…]}`.
|
|
24
|
+
createAnchoredTools — фабрика tool-дефініцій `read_anchored`/`edit_anchored` для customTools pi-сесії.
|
|
25
|
+
|
|
26
|
+
## Де використовується
|
|
27
|
+
|
|
28
|
+
`agent-fix.mjs`: `opts.anchoredEdits` перемикає toolset сесії (built-in `read`/`edit` прибираються, `write` лишається для нових файлів). `edit_anchored` внесений у WRITE_TOOLS write-guard — той самий scope/denylist veto, pre-image snapshot і rollback, що й у built-in write-tools. Opt-in у cursor: env `N_LLM_FIX_ANCHORED` (default-worker).
|
|
29
|
+
|
|
30
|
+
## Гарантії поведінки
|
|
31
|
+
|
|
32
|
+
- Атомарність на файл: часткове застосування пакету правок неможливе.
|
|
33
|
+
- Якір прив'язаний лише до вмісту рядка; номер рядка звіряється окремо — розбіжність будь-якого з двох дає відмову.
|
|
34
|
+
- Чисті функції не торкаються диска; запис робить лише `edit_anchored` (під write-guard).
|
package/lib/docs/write-guard.md
CHANGED
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: write-guard.mjs
|
|
4
4
|
resource: llm-lib/lib/write-guard.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: b5110048
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -16,6 +16,7 @@ docgen:
|
|
|
16
16
|
NEW_FILE позначає файл, якого до запису не існувало.
|
|
17
17
|
gitRoot визначає кореневий каталог репозиторію Git, якщо він є.
|
|
18
18
|
createWriteGuard створює механізм захисту записів до файлової системи, який перевіряє, чи запис знаходиться в межах кореневого каталогу Git, чи не стосується він директорій `.git` або ігнорованих файлів Git, і робить резервні копії файлів перед будь-якою зміною.
|
|
19
|
+
Під veto/snapshot підпадають tool-виклики `edit`, `write` і `edit_anchored` (anchored-профіль Фази A2, див. anchored-edit.md) — кастомний anchored-tool проходить той самий контроль, що й built-in write-tools.
|
|
19
20
|
|
|
20
21
|
## Публічний API
|
|
21
22
|
|
package/lib/write-guard.mjs
CHANGED
|
@@ -27,8 +27,8 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
27
27
|
/** Sentinel pre-image для файлу, якого до запису не існувало (rollback = видалити). */
|
|
28
28
|
export const NEW_FILE = Symbol('new-file')
|
|
29
29
|
|
|
30
|
-
/** Tool-и, що пишуть на диск і підлягають veto. */
|
|
31
|
-
const WRITE_TOOLS = new Set(['edit', 'write'])
|
|
30
|
+
/** Tool-и, що пишуть на диск і підлягають veto (включно з anchored-варіантом A2). */
|
|
31
|
+
const WRITE_TOOLS = new Set(['edit', 'write', 'edit_anchored'])
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* git-root для cwd (`git rev-parse --show-toplevel`) або null, якщо не git-репо.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/llm-lib",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Тонкий шар роботи з LLM (локальні omlx + хмарні провайдери) поверх pi: model tiers, one-shot, agentic-раннери, write-guard, trace, telemetry, prompt-budget",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nitra",
|