@7n/llm-lib 2.2.1 → 2.4.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.0] - 2026-07-11
4
+
5
+ ### Added
6
+
7
+ - harness (Фаза A4): createHarness — декларативний фасад над runOneShot/runAgentFix/runAgentSkill (профіль-обʼєкт {schema_version, kind, ...} → делегація в раннер, per-виклик поля перекривають); + subpath-експорти anchored-edit, web-tools
8
+
9
+ ## [2.3.0] - 2026-07-11
10
+
11
+ ### Added
12
+
13
+ - web-tools (Фаза A3): web_search/web_fetch для cloud-профілів — SSRF-guard (кожен redirect-hop), мінімальна html→text екстракція без нових залежностей, один search-провайдер за ключем (Brave/Tavily/Exa, N_LLM_SEARCH_PROVIDER); opts.webTools у runAgentFix (дефолт off)
14
+
3
15
  ## [2.2.1] - 2026-07-11
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -23,6 +23,7 @@ pi свідомо лишає на caller-а: model tiers, fail-fast політи
23
23
 
24
24
  | Імпорт | Що дає |
25
25
  | --- | --- |
26
+ | `@7n/llm-lib/harness` | `createHarness({profiles})` → `{run(spec), profileNames()}` — декларативний фасад над раннерами (профіль-обʼєкт `{schema_version, kind, ...}` → делегація); `validateProfile(p)`, `HARNESS_SCHEMA_VERSION` |
26
27
  | `@7n/llm-lib/one-shot` | `runOneShot({messages, modelTier?, modelSpec?, ...})` → `{content, usage, error, model, caller}` |
27
28
  | `@7n/llm-lib/agent-fix` | `runAgentFix(ruleId, violation, cwd, opts)` → `{applied, touchedFiles, telemetry, error, rollback}`; `buildFixPrompt(...)` |
28
29
  | `@7n/llm-lib/agent-skill` | `runAgentSkill(prompt, opts)` → `{ok, telemetry, error}` |
@@ -35,6 +36,8 @@ pi свідомо лишає на caller-а: model tiers, fail-fast політи
35
36
  | `@7n/llm-lib/with-timeout` | `withTimeout(promise, ms, {onTimeout?, label?})` |
36
37
  | `@7n/llm-lib/prompt-budget` | `budgetFor(kind)`, `fitToBudget(chunks, maxChars)`, `packBatch(items, maxChars)`, `capText(text, maxChars)` |
37
38
  | `@7n/llm-lib/body-capture` | `captureBody(record, opts?)` (opt-in, `N_LLM_TRACE_BODIES=1`), `bodiesDir()`, `bodyCaptureEnabled()` |
39
+ | `@7n/llm-lib/anchored-edit` | `createAnchoredTools({cwd, defineTool})`, `applyAnchoredEdits(content, edits)`, `lineAnchor(text)`, `renderAnchored(content, range?)` — hash-anchored строгі edit-tools (профіль `anchoredEdits`) |
40
+ | `@7n/llm-lib/web-tools` | `createWebTools({defineTool})`, `fetchPage(url, opts?)`, `assertPublicHttpUrl(url)`, `resolveSearchProvider(env)` — web_search/web_fetch із SSRF-guard (профіль `webTools`) |
38
41
 
39
42
  `lib/internal/` (registry, memory-guard, max-tokens, chain-headers, compress-context,
40
43
  apply-compression) — НЕ публічний API: не імпортувати зовні пакета, subpath-експортів
package/lib/agent-fix.mjs CHANGED
@@ -33,6 +33,7 @@
33
33
  import { env } from 'node:process'
34
34
  import { homedir } from 'node:os'
35
35
  import { createAnchoredTools } from './anchored-edit.mjs'
36
+ import { createWebTools } from './web-tools.mjs'
36
37
  import { getRegistry, resolveModelSpec } from './internal/registry.mjs'
37
38
  import { isLocalModel, thinkingLevelForTier } from './model-tiers.mjs'
38
39
  import { createWriteGuard, gitRoot } from './write-guard.mjs'
@@ -204,7 +205,8 @@ async function defaultCreateSession({
204
205
  astContext,
205
206
  selfCheck,
206
207
  chain,
207
- anchoredEdits = false
208
+ anchoredEdits = false,
209
+ webTools = false
208
210
  }) {
209
211
  const { createAgentSession, SessionManager, DefaultResourceLoader, SettingsManager, defineTool } =
210
212
  await import('@earendil-works/pi-coding-agent')
@@ -253,6 +255,12 @@ async function defaultCreateSession({
253
255
  tools = ['grep', 'find', 'write', 'ls', 'ast_facts', 'self_check', 'read_anchored', 'edit_anchored']
254
256
  customTools.push(readTool, editTool)
255
257
  }
258
+ // A3: web-доступ — лише за явним профілем (cloud-тири за дизайном); read-only tools.
259
+ if (webTools) {
260
+ const { searchTool, fetchTool } = createWebTools({ defineTool })
261
+ tools = [...tools, 'web_search', 'web_fetch']
262
+ customTools.push(searchTool, fetchTool)
263
+ }
256
264
 
257
265
  const { session } = await createAgentSession({
258
266
  modelRegistry: registry,
@@ -279,6 +287,7 @@ async function defaultCreateSession({
279
287
  * verify?: (args: { touchedFiles: string[] }) => Promise<{ ok: boolean, output?: string }> | { ok: boolean, output?: string },
280
288
  * verifyMax?: number,
281
289
  * anchoredEdits?: boolean,
290
+ * webTools?: boolean,
282
291
  * deps?: { createSession?: (args: object) => Promise<object>, getRegistry?: () => Promise<object>,
283
292
  * registry?: object, root?: string|null,
284
293
  * astContext?: (path: string) => object,
@@ -300,6 +309,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
300
309
  verify = null,
301
310
  verifyMax = VERIFY_MAX_DEFAULT,
302
311
  anchoredEdits = false,
312
+ webTools = false,
303
313
  deps = {}
304
314
  } = opts
305
315
  const createSession = deps.createSession ?? defaultCreateSession
@@ -359,6 +369,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
359
369
  astContext,
360
370
  selfCheck,
361
371
  anchoredEdits,
372
+ webTools,
362
373
  // Заголовки кореляції — лише локальним моделям (myllm стоїть тільки перед ними).
363
374
  chain: modelSpec && isLocalModel(modelSpec) ? chain : null
364
375
  })
@@ -471,6 +482,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
471
482
  verifyAttempts: verifyAttempts.length,
472
483
  verifyOk: verifyAttempts.length > 0 ? verifyAttempts.at(-1).ok : null,
473
484
  anchoredEdits,
485
+ webTools,
474
486
  wallMs: clock() - startedAt,
475
487
  error,
476
488
  ...chain?.traceFields()
@@ -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: 730809af
6
+ crc: 83a189d4
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
+ Web-профіль (опційний `opts.webTools`, Фаза A3): додає read-only tools `web_search`/`web_fetch` (див. web-tools.md; SSRF-guard, ліміти розміру) — для правил із зовнішнім знанням на cloud-тирах; прапорець у трейсі, дефолт вимкнено.
22
+
21
23
  Anchored-профіль (опційний `opts.anchoredEdits`, Фаза A2): toolset сесії заміняє built-in `read`/`edit` на строгі `read_anchored`/`edit_anchored` (див. anchored-edit.md; `write` лишається для нових файлів, під тим самим write-guard), а промпт отримує інструкцію anchored-циклу. Прапорець фіксується у трейсі (`anchoredEdits`) для A/B-аналізу; дефолт вимкнено.
22
24
 
23
25
  Evidence-гейт (опційний `opts.verify`, Фаза A1 run-harness): після prompt-у модуль сам запускає canonical-перевірку consumer-а; провал інʼєктиться фідбеком у ту саму сесію — до `opts.verifyMax` додаткових ітерацій (дефолт 2), у межах того самого `timeoutMs` (залишок бюджету < 5s — чесна зупинка без ітерації). Гейт структурний: заяви агента про успіх не важать, джерелом правди лишається зовнішня перевірка. Помилка самої перевірки — інфраструктурна: ітерації не витрачаються, повертається `error` з префіксом `verify:`. Без `verify` — поведінка попередня (один прохід). Спроби фіксуються у `telemetry.verifyAttempts` і у trace (`verifyAttempts`, `verifyOk`).
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: harness.mjs
4
+ resource: llm-lib/lib/harness.mjs
5
+ docgen:
6
+ crc: 3ff88af8
7
+ ---
8
+
9
+ ## Огляд
10
+
11
+ Run-harness фасад (Фаза A4): єдиний декларативний вхід над трьома раннерами пакета (`runOneShot` / `runAgentFix` / `runAgentSkill`). Consumer описує ЩО запустити профілем-обʼєктом, а не набором позиційних opts; той самий профіль серіалізується у JSON — це те, що дозволяє майбутньому MT-адаптеру мапити вузол графа на конфігурацію без коду. Фасад тонкий: резолвить профіль у opts і делегує в наявний раннер, не дублюючи їхньої логіки (write-guard, verify-loop, toolset-и лишаються в раннерах).
12
+
13
+ ## Поведінка
14
+
15
+ Профіль — обʼєкт `{ schema_version, kind, …налаштування }`, де `kind` (`fix`/`skill`/`one-shot`) привʼязує його до раннера, а решта полів (tier, model, timeoutMs, maxTokens, thinkingLevel, verifyMax, anchoredEdits, webTools) стають дефолтами opts. `schema_version` присутній з першої версії й перевіряється на сумісність — несумісний або невідомий `kind` дає структуровану помилку валідації ще до раннера.
16
+
17
+ `createHarness({ profiles })` повертає обʼєкт із `run(spec)` і `profileNames()`. У `run` профіль задається іменем (з мапи) або інлайн-обʼєктом, а per-виклик поля (динаміка: cwd, violation, verify, messages, prompt тощо) зливаються поверх дефолтів профілю й перекривають збіжні. Далі harness делегує з правильними позиційними аргументами кожного раннера: `fix` → `(ruleId, violation, cwd, opts)`, `skill` → `(prompt, opts)`, `one-shot` → `(opts)`. Поля `kind`/`schema_version` у opts раннера не потрапляють.
18
+
19
+ Раннери тягнуться lazy (динамічний import у гілці потрібного `kind`) — top-level модуль лишається вільним від pi; у тестах раннери інжектуються через `deps`.
20
+
21
+ ## Публічний API
22
+
23
+ HARNESS_SCHEMA_VERSION — поточна версія схеми профілю.
24
+ validateProfile — перевіряє `kind` і `schema_version`, повертає `{ok}` або `{ok:false, error}`.
25
+ createHarness — будує harness із іменованих профілів; `run(spec)` запускає задачу, `profileNames()` перелічує профілі.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Контракт раннерів не змінюється: harness лише перекладає профіль+виклик у їхні аргументи й повертає їхній результат як є.
30
+ - Невалідний/невідомий профіль зупиняється до виклику раннера (жодного часткового ефекту).
31
+ - Top-level pi-free: жодного pi-import, поки не викликано `run` відповідного kind.
@@ -0,0 +1,37 @@
1
+ ---
2
+ type: JS Module
3
+ title: web-tools.mjs
4
+ resource: llm-lib/lib/web-tools.mjs
5
+ docgen:
6
+ crc: 4242c966
7
+ ---
8
+
9
+ ## Огляд
10
+
11
+ Web-доступ для cloud-профілів run-harness (Фаза A3): пара pi-tools `web_search`/`web_fetch` — мінімальне ядро без нових залежностей (референс — pi-web-access, без його fallback-ланцюгів провайдерів і browser-режимів). Вмикається лише явним профілем consumer-а (agent-fix `opts.webTools`, за дизайном — cloud-тири); дефолт вимкнено.
12
+
13
+ ## Поведінка
14
+
15
+ `web_fetch` ходить лише на публічні http(s)-адреси: SSRF-guard блокує інші схеми, `localhost`/`*.local`/`*.internal` і літеральні приватні IP (v4-діапазони, v6 loopback/link-local/ULA); redirect-и проходяться вручну (до 3 hop-ів) з guard-перевіркою кожного hop-а. HTML зводиться до тексту власним мінімальним стрипером (script/style/noscript вирізаються ітеративно без regex-backtracking, блокові теги → переноси, entity декодуються); json/plain віддаються як є. Відповідь обрізається лімітом (`maxChars`, дефолт 20k символів) з чесним прапорцем `truncated`; таймаут запиту 20s.
16
+
17
+ `web_search` працює через ОДНОГО провайдера: явний `N_LLM_SEARCH_PROVIDER` або перший наявний ключ (`BRAVE_API_KEY` → `TAVILY_API_KEY` → `EXA_API_KEY`); результати нормалізуються до `{title, url, snippet}`. Без жодного ключа tool чесно повертає структуровану відмову з інструкцією конфігурації — не виняток.
18
+
19
+ Вміст сторінок повертається tool-result-ом (дані, не інструкції) — prompt-injection зі сторінок не отримує системного рівня; помилки обох tools — структурований JSON-текст із причиною.
20
+
21
+ ## Публічний API
22
+
23
+ assertPublicHttpUrl — SSRF-guard: розбирає URL або кидає Error з причиною відмови.
24
+ htmlToText — мінімальна html→text екстракція (без DOM-залежностей).
25
+ fetchPage — fetch з guard-ом на кожному redirect-hop-і, таймаутом і лімітом розміру.
26
+ resolveSearchProvider — вибір search-провайдера за env.
27
+ createWebTools — фабрика tool-дефініцій `web_search`/`web_fetch` (defineTool і fetch інжектяться — модуль pi-free).
28
+
29
+ ## Де використовується
30
+
31
+ `agent-fix.mjs`: `opts.webTools: true` додає обидва tools у сесію (перші споживачі — правила з зовнішнім знанням: pin-перевірки ga, taze-подібні). Прапорець фіксується у trace для аналізу.
32
+
33
+ ## Гарантії поведінки
34
+
35
+ - Жодного мережевого виклику без явного tool-виклику агента; лише http/https на публічні адреси.
36
+ - Відповіді обмежені за розміром; guard-відмови детерміновані й пояснені.
37
+ - Модуль pi-free; усі зовнішні ефекти (fetch, env) інжектовані — тести без мережі.
@@ -0,0 +1,119 @@
1
+ /** @see ./docs/harness.md */
2
+
3
+ /**
4
+ * Run-harness фасад (Фаза A4, дизайн 2026-07-11): єдиний декларативний вхід над
5
+ * трьома раннерами (`runOneShot` / `runAgentFix` / `runAgentSkill`).
6
+ *
7
+ * Мета — щоб consumer (inline-драбина n-cursor, майбутній MT-runner Фази B, 7n-test)
8
+ * описував ЩО запустити **профілем-обʼєктом**, а не набором позиційних opts, і щоб
9
+ * той самий профіль серіалізувався у JSON (Фаза B мапить `a.md`-вузол MT → профіль
10
+ * без коду). Фасад тонкий: він резолвить профіль у opts і делегує в наявний раннер,
11
+ * НЕ дублюючи їхню логіку (write-guard, verify-loop, toolset-и лишаються в раннерах).
12
+ *
13
+ * Профіль (усі поля опційні, крім прив'язки до раннера через `kind`):
14
+ * { schema_version: 1, kind: 'fix'|'skill'|'one-shot',
15
+ * tier, model, timeoutMs, maxTokens, thinkingLevel,
16
+ * verifyMax, anchoredEdits, webTools }
17
+ * `schema_version` присутній з дня 1 — Фаза B хоче стабільності контракту.
18
+ *
19
+ * Модуль pi-free на рівні top-level: раннери самі роблять lazy pi-import у fix/skill
20
+ * гілці; `createHarness` лише готує замикання.
21
+ */
22
+
23
+ /** Поточна версія схеми профілю. Несумісна зміна → bump + міграція consumer-ів. */
24
+ export const HARNESS_SCHEMA_VERSION = 1
25
+
26
+ /** Підтримувані види задач (прив'язка профілю до раннера). */
27
+ const KINDS = new Set(['fix', 'skill', 'one-shot'])
28
+
29
+ /**
30
+ * Валідує профіль: відомий `kind`, сумісний `schema_version`.
31
+ * @param {object} profile профіль-обʼєкт
32
+ * @returns {{ ok: true } | { ok: false, error: string }} результат валідації
33
+ */
34
+ export function validateProfile(profile) {
35
+ if (!profile || typeof profile !== 'object') return { ok: false, error: 'профіль має бути обʼєктом' }
36
+ const v = profile.schema_version ?? HARNESS_SCHEMA_VERSION
37
+ if (v !== HARNESS_SCHEMA_VERSION) {
38
+ return { ok: false, error: `несумісний schema_version ${v} (очікується ${HARNESS_SCHEMA_VERSION})` }
39
+ }
40
+ if (!KINDS.has(profile.kind)) {
41
+ return { ok: false, error: `невідомий kind "${profile.kind}" (допустимі: ${[...KINDS].join(', ')})` }
42
+ }
43
+ return { ok: true }
44
+ }
45
+
46
+ /**
47
+ * Резолвить профіль + per-виклик поля у opts конкретного раннера.
48
+ * Профіль задає дефолти конфігурації, `call` — динаміку виклику (cwd, violation,
49
+ * verify, chain тощо); `call` перекриває збіжні поля профілю.
50
+ * @param {object} profile профіль-обʼєкт (валідований)
51
+ * @param {object} call per-виклик поля
52
+ * @returns {object} opts для раннера
53
+ */
54
+ function resolveOpts(profile, call) {
55
+ const { schema_version: _sv, kind: _kind, ...profileOpts } = profile
56
+ return { ...profileOpts, ...call }
57
+ }
58
+
59
+ /**
60
+ * Створює harness із набором іменованих профілів.
61
+ * @param {{ profiles?: Record<string, object>,
62
+ * deps?: { runOneShot?: (opts: object) => Promise<object>, runAgentFix?: (...args: unknown[]) => Promise<object>, runAgentSkill?: (prompt: string, opts: object) => Promise<object> } }} [args]
63
+ * `profiles` — мапа імʼя→профіль; `deps` — інжекція раннерів (тести / кастомний wiring).
64
+ * @returns {{ run: (spec: object) => Promise<object>, profileNames: () => string[] }} harness
65
+ */
66
+ export function createHarness({ profiles = {}, deps = {} } = {}) {
67
+ /**
68
+ * Лениво тягне раннер (pi-free top-level: імпорт лише при першому виклику потрібного kind).
69
+ * @param {string} kind вид задачі
70
+ * @returns {Promise<(...args: unknown[]) => Promise<object>>} функція-раннер
71
+ */
72
+ async function runnerFor(kind) {
73
+ if (kind === 'fix') {
74
+ if (deps.runAgentFix) return deps.runAgentFix
75
+ const mod = await import('./agent-fix.mjs')
76
+ return mod.runAgentFix
77
+ }
78
+ if (kind === 'skill') {
79
+ if (deps.runAgentSkill) return deps.runAgentSkill
80
+ const mod = await import('./agent-skill.mjs')
81
+ return mod.runAgentSkill
82
+ }
83
+ if (deps.runOneShot) return deps.runOneShot
84
+ const mod = await import('./one-shot.mjs')
85
+ return mod.runOneShot
86
+ }
87
+
88
+ /**
89
+ * Запускає задачу за профілем.
90
+ * @param {object} spec `{ profile: string|object, ...call }` — профіль за іменем або
91
+ * інлайн-обʼєктом + per-виклик поля (`fix`: ruleId, violation, cwd, verify, targetFiles…;
92
+ * `skill`: prompt, cwd…; `one-shot`: messages…).
93
+ * @returns {Promise<object>} результат відповідного раннера (контракт не змінюється)
94
+ */
95
+ async function run(spec = {}) {
96
+ const { profile: profileRef, ...call } = spec
97
+ const profile = typeof profileRef === 'string' ? profiles[profileRef] : profileRef
98
+ if (!profile) throw new Error(`профіль не знайдено: ${JSON.stringify(profileRef)}`)
99
+ const valid = validateProfile(profile)
100
+ if (!valid.ok) throw new Error(`невалідний профіль: ${valid.error}`)
101
+
102
+ const run = await runnerFor(profile.kind)
103
+ const opts = resolveOpts(profile, call)
104
+ if (profile.kind === 'fix') {
105
+ // runAgentFix(ruleId, violation, cwd, opts) — позиційні + opts.
106
+ const { ruleId, violation, cwd, ...rest } = opts
107
+ return run(ruleId, violation, cwd, rest)
108
+ }
109
+ if (profile.kind === 'skill') {
110
+ // runAgentSkill(prompt, opts) — prompt позиційний.
111
+ const { prompt, ...rest } = opts
112
+ return run(prompt, rest)
113
+ }
114
+ // one-shot: усе в одному obj-arg.
115
+ return run(opts)
116
+ }
117
+
118
+ return { run, profileNames: () => Object.keys(profiles) }
119
+ }
@@ -0,0 +1,321 @@
1
+ /** @see ./docs/web-tools.md */
2
+
3
+ /**
4
+ * Web-доступ для cloud-профілів run-harness (Фаза A3, дизайн 2026-07-11).
5
+ *
6
+ * Мінімальне ядро без нових залежностей (референс — pi-web-access, без його
7
+ * fallback-ланцюгів провайдерів, browser-cookie режимів і video-екстракції):
8
+ * - `web_fetch` — global fetch → текстова екстракція (html→text власним
9
+ * мінімальним стрипером, json/plain — як є) з лімітом розміру.
10
+ * - `web_search` — ОДИН провайдер за конфігом: `N_LLM_SEARCH_PROVIDER` або
11
+ * перший наявний ключ (`BRAVE_API_KEY` → `TAVILY_API_KEY` → `EXA_API_KEY`).
12
+ *
13
+ * Безпека (нова поверхня довіри — URL формує модель):
14
+ * - SSRF-guard: лише http/https; блок localhost/*.local та літеральних IP
15
+ * приватних діапазонів; redirect-и проходяться вручну (до 3 hop-ів) із
16
+ * guard-перевіркою КОЖНОГО hop-а.
17
+ * - Вміст сторінок повертається як tool-result (дані, не інструкції) —
18
+ * prompt-injection зі сторінок не отримує системного рівня.
19
+ * - Ліміт відповіді (`maxChars`) — проти роздування контексту.
20
+ *
21
+ * Модуль pi-free: `defineTool` і fetch інжектяться; ввімкнення — профілем
22
+ * consumer-а (agent-fix `opts.webTools`, лише cloud-тири за дизайном).
23
+ */
24
+
25
+ import { env as processEnv } from 'node:process'
26
+
27
+ /** Дефолтний таймаут одного web-запиту. */
28
+ const FETCH_TIMEOUT_MS = 20_000
29
+ /** Дефолтний ліміт символів відповіді tool-а (проти роздування контексту). */
30
+ const DEFAULT_MAX_CHARS = 20_000
31
+ /** Максимум redirect-hop-ів при ручному проході. */
32
+ const MAX_REDIRECTS = 3
33
+ /** Кількість результатів пошуку за замовчуванням. */
34
+ const DEFAULT_SEARCH_COUNT = 5
35
+
36
+ const PRIVATE_V4 = /^(?:0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/
37
+ const PRIVATE_V6 = /^(?:::1|::|fe80:|f[cd][0-9a-f]{2}:)/i
38
+ const HTML_CONTENT_TYPE = /text\/html|application\/xhtml/i
39
+ const BLOCK_CLOSE_TAGS = /<\/(?:p|div|li|h[1-6]|tr|section|article|blockquote|pre)>/gi
40
+ const BR_HR_TAGS = /<(?:br|hr)\s*\/?>/gi
41
+ const SPACES = /[ \t]+/g
42
+ const BLANK_LINES = /\n{3,}/g
43
+
44
+ /**
45
+ * SSRF-guard: чи можна ходити на URL. Кидає Error з причиною при відмові.
46
+ * Блокує не-http(s), localhost/*.local/*.internal і літеральні приватні IP
47
+ * (v4-діапазони, v6 loopback/link-local/ULA). DNS-резолюція не робиться —
48
+ * захист від літералів; довірений периметр consumer-а лишається його політикою.
49
+ * @param {string} rawUrl URL з tool-input
50
+ * @returns {URL} розібраний URL (для подальшого fetch)
51
+ */
52
+ export function assertPublicHttpUrl(rawUrl) {
53
+ let url
54
+ try {
55
+ url = new URL(rawUrl)
56
+ } catch {
57
+ throw new Error(`невалідний URL: ${rawUrl}`)
58
+ }
59
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
60
+ throw new Error(`заборонена схема ${url.protocol} (лише http/https)`)
61
+ }
62
+ const host = url.hostname.toLowerCase()
63
+ if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) {
64
+ throw new Error(`заборонений хост: ${host}`)
65
+ }
66
+ const bare = host.replaceAll(/^\[|\]$/g, '')
67
+ if (PRIVATE_V4.test(bare)) throw new Error(`приватна IPv4-адреса заборонена: ${host}`)
68
+ if (PRIVATE_V6.test(bare)) throw new Error(`приватна IPv6-адреса заборонена: ${host}`)
69
+ return url
70
+ }
71
+
72
+ /**
73
+ * Ітеративно (без regex-backtracking) вирізає блоки `<tag …>…</tag>`.
74
+ * @param {string} html вихідний html
75
+ * @param {string} tag імʼя тега (lowercase)
76
+ * @returns {string} html без блоків тега
77
+ */
78
+ function stripTagBlocks(html, tag) {
79
+ const open = `<${tag}`
80
+ const close = `</${tag}>`
81
+ const lower = () => html.toLowerCase()
82
+ for (;;) {
83
+ const start = lower().indexOf(open)
84
+ if (start === -1) return html
85
+ const end = lower().indexOf(close, start)
86
+ if (end === -1) return html.slice(0, start)
87
+ html = `${html.slice(0, start)} ${html.slice(end + close.length)}`
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Char-scan стрип решти тегів (`<…>` → пробіл) без regex.
93
+ * @param {string} text html після блокових замін
94
+ * @returns {string} текст без тегів
95
+ */
96
+ function stripTags(text) {
97
+ let out = ''
98
+ let i = 0
99
+ while (i < text.length) {
100
+ const lt = text.indexOf('<', i)
101
+ if (lt === -1) {
102
+ out += text.slice(i)
103
+ break
104
+ }
105
+ out += text.slice(i, lt)
106
+ const gt = text.indexOf('>', lt + 1)
107
+ if (gt === -1) break
108
+ out += ' '
109
+ i = gt + 1
110
+ }
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * Мінімальна html→text екстракція: викидає script/style/noscript, блокові теги
116
+ * зводить до переносів, решту тегів стрипає, декодує базові entity, стискає
117
+ * порожні рядки. Це свідомо НЕ readability (без DOM-залежностей) — достатньо,
118
+ * щоб агент прочитав документацію/README/чейнджлог.
119
+ * @param {string} html сирий html
120
+ * @returns {string} текст
121
+ */
122
+ export function htmlToText(html) {
123
+ let text = html
124
+ for (const tag of ['script', 'style', 'noscript']) text = stripTagBlocks(text, tag)
125
+ text = text.replaceAll(BLOCK_CLOSE_TAGS, '\n').replaceAll(BR_HR_TAGS, '\n')
126
+ return stripTags(text)
127
+ .replaceAll('&nbsp;', ' ')
128
+ .replaceAll('&amp;', '&')
129
+ .replaceAll('&lt;', '<')
130
+ .replaceAll('&gt;', '>')
131
+ .replaceAll('&quot;', '"')
132
+ .replaceAll('&#39;', "'")
133
+ .replaceAll(SPACES, ' ')
134
+ .replaceAll(BLANK_LINES, '\n\n')
135
+ .trim()
136
+ }
137
+
138
+ /**
139
+ * Fetch з SSRF-guard на кожному redirect-hop-і, таймаутом і лімітом розміру.
140
+ * @param {string} rawUrl цільовий URL
141
+ * @param {{ timeoutMs?: number, maxChars?: number, fetchImpl?: typeof fetch }} [opts] параметри
142
+ * @returns {Promise<{ url: string, status: number, contentType: string, text: string, truncated: boolean }>} сторінка текстом
143
+ */
144
+ export async function fetchPage(
145
+ rawUrl,
146
+ { timeoutMs = FETCH_TIMEOUT_MS, maxChars = DEFAULT_MAX_CHARS, fetchImpl = fetch } = {}
147
+ ) {
148
+ let url = assertPublicHttpUrl(rawUrl)
149
+ const controller = new AbortController()
150
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
151
+ try {
152
+ let response
153
+ for (let hop = 0; ; hop++) {
154
+ response = await fetchImpl(url, { redirect: 'manual', signal: controller.signal })
155
+ if (response.status < 300 || response.status >= 400) break
156
+ if (hop >= MAX_REDIRECTS) throw new Error(`занадто багато redirect-ів (> ${MAX_REDIRECTS})`)
157
+ const location = response.headers.get('location')
158
+ if (!location) throw new Error(`redirect ${response.status} без Location`)
159
+ url = assertPublicHttpUrl(new URL(location, url).href)
160
+ }
161
+ const contentType = response.headers.get('content-type') ?? ''
162
+ const raw = await response.text()
163
+ const text = HTML_CONTENT_TYPE.test(contentType) ? htmlToText(raw) : raw
164
+ return {
165
+ url: url.href,
166
+ status: response.status,
167
+ contentType,
168
+ text: text.slice(0, maxChars),
169
+ truncated: text.length > maxChars
170
+ }
171
+ } finally {
172
+ clearTimeout(timer)
173
+ }
174
+ }
175
+
176
+ /** Search-адаптери: нормалізують відповідь до [{title, url, snippet}]. */
177
+ const PROVIDERS = {
178
+ brave: {
179
+ keyVar: 'BRAVE_API_KEY',
180
+ async search(query, count, key, fetchImpl) {
181
+ const u = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`
182
+ const r = await fetchImpl(u, { headers: { 'X-Subscription-Token': key, Accept: 'application/json' } })
183
+ if (!r.ok) throw new Error(`brave: HTTP ${r.status}`)
184
+ const data = await r.json()
185
+ return (data.web?.results ?? []).map(x => ({ title: x.title, url: x.url, snippet: x.description ?? '' }))
186
+ }
187
+ },
188
+ tavily: {
189
+ keyVar: 'TAVILY_API_KEY',
190
+ async search(query, count, key, fetchImpl) {
191
+ const r = await fetchImpl('https://api.tavily.com/search', {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
194
+ body: JSON.stringify({ query, max_results: count })
195
+ })
196
+ if (!r.ok) throw new Error(`tavily: HTTP ${r.status}`)
197
+ const data = await r.json()
198
+ return (data.results ?? []).map(x => ({ title: x.title, url: x.url, snippet: x.content ?? '' }))
199
+ }
200
+ },
201
+ exa: {
202
+ keyVar: 'EXA_API_KEY',
203
+ async search(query, count, key, fetchImpl) {
204
+ const r = await fetchImpl('https://api.exa.ai/search', {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json', 'x-api-key': key },
207
+ body: JSON.stringify({ query, numResults: count, contents: { text: { maxCharacters: 500 } } })
208
+ })
209
+ if (!r.ok) throw new Error(`exa: HTTP ${r.status}`)
210
+ const data = await r.json()
211
+ return (data.results ?? []).map(x => ({ title: x.title ?? x.url, url: x.url, snippet: x.text ?? '' }))
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Обирає search-провайдера: явний `N_LLM_SEARCH_PROVIDER` або перший наявний ключ.
218
+ * @param {Record<string, string|undefined>} env середовище
219
+ * @returns {{ name: string, key: string }|null} провайдер+ключ або null (жодного)
220
+ */
221
+ export function resolveSearchProvider(env = processEnv) {
222
+ const wanted = env.N_LLM_SEARCH_PROVIDER
223
+ if (wanted) {
224
+ const p = PROVIDERS[wanted]
225
+ const key = p ? env[p.keyVar] : undefined
226
+ return p && key ? { name: wanted, key } : null
227
+ }
228
+ for (const [name, p] of Object.entries(PROVIDERS)) {
229
+ if (env[p.keyVar]) return { name, key: env[p.keyVar] }
230
+ }
231
+ return null
232
+ }
233
+
234
+ /**
235
+ * Структурована текстова відмова tool-виклику.
236
+ * @param {object} payload обʼєкт з `error`
237
+ * @returns {{ content: Array<{ type: string, text: string }>, details: object }} tool-результат
238
+ */
239
+ function toolFail(payload) {
240
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }], details: {} }
241
+ }
242
+
243
+ /**
244
+ * Успішний текстовий tool-результат.
245
+ * @param {string} text текст відповіді
246
+ * @returns {{ content: Array<{ type: string, text: string }>, details: object }} tool-результат
247
+ */
248
+ function toolOk(text) {
249
+ return { content: [{ type: 'text', text }], details: {} }
250
+ }
251
+
252
+ /**
253
+ * Фабрика пари pi-tools `web_search`/`web_fetch`.
254
+ * @param {{ defineTool: (def: object) => object,
255
+ * deps?: { env?: Record<string, string|undefined>, fetchImpl?: typeof fetch } }} args контекст:
256
+ * pi defineTool + інжекції env/fetch для тестів
257
+ * @returns {{ searchTool: object, fetchTool: object }} tool-дефініції для customTools
258
+ */
259
+ export function createWebTools({ defineTool, deps = {} }) {
260
+ const env = deps.env ?? processEnv
261
+ const fetchImpl = deps.fetchImpl ?? fetch
262
+
263
+ const searchTool = defineTool({
264
+ name: 'web_search',
265
+ label: 'Web search',
266
+ description: 'Search the web. Returns a JSON list of {title, url, snippet}. Use web_fetch to read a result page.',
267
+ parameters: {
268
+ type: 'object',
269
+ properties: {
270
+ query: { type: 'string', description: 'search query' },
271
+ count: { type: 'number', description: `max results (default ${DEFAULT_SEARCH_COUNT})` }
272
+ },
273
+ required: ['query']
274
+ },
275
+ execute: async (_id, { query, count }) => {
276
+ const provider = resolveSearchProvider(env)
277
+ if (!provider) {
278
+ return toolFail({
279
+ error:
280
+ 'search-провайдер не сконфігуровано: потрібен BRAVE_API_KEY / TAVILY_API_KEY / EXA_API_KEY (опц. N_LLM_SEARCH_PROVIDER)'
281
+ })
282
+ }
283
+ try {
284
+ const results = await PROVIDERS[provider.name].search(
285
+ query,
286
+ count ?? DEFAULT_SEARCH_COUNT,
287
+ provider.key,
288
+ fetchImpl
289
+ )
290
+ return toolOk(JSON.stringify({ provider: provider.name, results }))
291
+ } catch (error) {
292
+ return toolFail({ error: `web_search: ${error.message}` })
293
+ }
294
+ }
295
+ })
296
+
297
+ const fetchTool = defineTool({
298
+ name: 'web_fetch',
299
+ label: 'Web fetch',
300
+ description:
301
+ 'Fetch a public http(s) URL and return its text content (html is stripped to text). Truncated to a size limit.',
302
+ parameters: {
303
+ type: 'object',
304
+ properties: {
305
+ url: { type: 'string', description: 'absolute http(s) URL' },
306
+ maxChars: { type: 'number', description: `truncate limit (default ${DEFAULT_MAX_CHARS})` }
307
+ },
308
+ required: ['url']
309
+ },
310
+ execute: async (_id, { url, maxChars }) => {
311
+ try {
312
+ const page = await fetchPage(url, { maxChars: maxChars ?? DEFAULT_MAX_CHARS, fetchImpl })
313
+ return toolOk(JSON.stringify(page))
314
+ } catch (error) {
315
+ return toolFail({ error: `web_fetch: ${error.message}` })
316
+ }
317
+ }
318
+ })
319
+
320
+ return { searchTool, fetchTool }
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/llm-lib",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Тонкий шар роботи з LLM (локальні omlx + хмарні провайдери) поверх pi: model tiers, one-shot, agentic-раннери, write-guard, trace, telemetry, prompt-budget",
5
5
  "keywords": [
6
6
  "nitra",
@@ -32,6 +32,9 @@
32
32
  "!**/*.test.mjs"
33
33
  ],
34
34
  "exports": {
35
+ "./harness": "./lib/harness.mjs",
36
+ "./anchored-edit": "./lib/anchored-edit.mjs",
37
+ "./web-tools": "./lib/web-tools.mjs",
35
38
  "./model-tiers": "./lib/model-tiers.mjs",
36
39
  "./chain": "./lib/chain.mjs",
37
40
  "./chains-report": "./lib/chains-report.mjs",