Дизайн расширения web-search

Расширение из двух инструментов: web_search (найти кандидатов-URL в открытом вебе) и
extract_page (прочитать ОДИН URL и дистиллировать ответ на конкретную задачу).

Расположение: ~/.pi/agent/extensions/web-search/

1
2
3
4
5
6
7
8
9
web-search/
├── index.ts              # точка входа: регистрирует оба инструмента
├── tools/
   ├── web_search.ts     # инструмент web_search
   └── extract_page.ts   # инструмент extract_page
├── subagent.ts           # runExtractor: изолированный model-call для дистилляции
└── scripts/
    ├── search.py         # поиск: ddgs → fallback на HTML-скрейп DDG
    └── fetch.py          # fetch страницы + trafilatura → чистый текст

Ключевые дизайн-решения

  1. Разделение «найти» и «прочитать». web_search возвращает ТОЛЬКО список ссылок
    (title + URL + snippet) — компактный «реестр кандидатов», по которому модель сама решает,
    какие страницы кормить в extract_page. Контент не тащится через поиск.
  2. Сырой HTML никогда не попадает в главную сессию. extract_page сам (детерминированно,
    внутри расширения) скачивает страницу и выжимает читаемый текст через trafilatura
    (fetch.py), затем отдаёт текст одному изолированному model-call'у (runExtractor),
    который возвращает только дистиллированный ответ. Главная сессия получает результат, а не
    простыню страницы — дёшево по контексту и без риска рекурсии агентов.
  3. Один изолированный model-call вместо вложенного agent-loop'а. Дешевле и
    детерминированнее, чем полный цикл «воркера»; рассуждения по извлечению остаются вне
    главной сессии.
  4. Бесплатный поисковый бэкенд без ключей. Только DuckDuckGo: сначала библиотека
    ddgs, при её недоступности — fallback-скрейп html.duckduckgo.com/html/ (без новых
    зависимостей, только lxml). Никакого провайдерского зоопарка.
  5. Python-скрипты как «планирование», а не «самодеятельность». Скрипты только ищут/фетчат
    и печатают готовый LLM-friendly вывод; вся интерпретация — на стороне модели.
  6. Windows-безопасность вывода. Оба скрипта оборачивают stdout в UTF-8
    (errors="replace"), чтобы не ловить UnicodeEncodeError/mojibake на cp1252.
  7. Сайт-специфичный парсинг — вне ядра. Никакого «зоопарка» под Ozon/2ch/etc. — такое
    живёт в скилле manuals, а ядро остаётся универсальным.

Поток данных

web_search                                    extract_page
──────────                                    ────────────
query ──► python search.py                    url + task
        ├─ ddgs.DDGS().text()                 └─► python fetch.py <url>
        │    или HTML-скрейп DDG                    ├─ urllib + gzip/deflate + charset
        └─► "1. Title\n   URL\n   Snippet"          ├─ trafilatura → чистый текст (≤20k)
                                                    │    (fallback: title + text_content)
        модель выбирает URL и                       └─► pageText (обрезан до 24 000 симв.)
        вызывает extract_page ────────────────►          │
                                              runExtractor(model = ctx.model)
                                                systemPrompt: «information extraction
                                                worker» (формат ANSWER/SOURCES/RAW_DATA)
                                                user: Task + URL + pageText
                                              в главную сессию: только дистиллированный
                                              ответ + details{url, source, error?}

Назначение

Найти кандидатов-ссылки в открытом вебе. Используется, когда факты могут быть
устаревшими/неизвестными: версии, изменения API, цены, новости, даты релизов, тексты ошибок,
всё после cutoff'а модели. Только находит URL — за содержимым идёт extract_page. Поиск
бесплатный (DuckDuckGo).

Схема параметров (typebox, additionalProperties: false)

Параметр Тип Описание
query string Поисковый запрос. Поддерживает site:, intitle:, -exclude, "точная фраза"
limit number? Сколько результатов вернуть (1–10, по умолчанию 5)
time_filter enum? Свежесть: h=час, d=сутки (по умолчанию), w=неделя, m=месяц, y=год. Для новостей/версий/цен

Выполнение

  1. onUpdate → промежуточный статус Searching: <query>.
  2. Запуск python scripts/search.py <query> -n <limit> (+ --tbs qdr:<time_filter>).
  3. exit != 0 → throw с хвостом stderr/stdout (до 500 символов).
  4. Возврат stdout как текстового контента; details: { source: "duckduckgo" }.

search.py

  • ddgs_search(): предпочитает ddgs.DDGS().text(query, max_results, timelimit)
    структурированные результаты; timelimit = time_filter без префикса qdr:.
  • Fallback: POST на https://html.duckduckgo.com/html/ (UA Firefox, df = маппинг
    qdr:*d|w|m|y), XPath по div.web-resulth2/a.result__a (title, href) +
    a.result__snippet. Лимит 15 c, результат обрезается до limit.
  • CLI: query, -n/--count (1–10, default 5), -t/--tbs (qdr:h|d|w|m|y).
  • Если результатов нет → No results found. и exit 1.
  • Формат вывода (LLM-friendly, без JSON):
    1
    2
    3
    1. Title
       URL
       Snippet
    

Инструмент extract_page

Назначение

Прочитать одну страницу и вернуть конкретный ответ на задачу. Даёшь URL + точную
одноцелевую задачу — он скачивает страницу, извлекает читаемый текст и дистиллирует только
запрошенное (никогда не dumps всей страницы). Использовать ПОСЛЕ web_search по выбранным
URL. Лучше всего работает на статических страницах: статьи, документация, changelog'и,
release notes, блоги, посты форумов.

Схема параметров (additionalProperties: false)

Параметр Тип Описание
url string URL страницы
task string Конкретная инструкция, что вытащить. Точная и одноцелевая, напр. «какая последняя стабильная версия и её дата релиза», «извлеки все цены и опции на странице»

Выполнение

  1. onUpdateReading: <url>.
  2. python scripts/fetch.py <url> (с прокидыванием signal для отмены).
  3. Ошибки: exit != 0 → throw; stdout начинается с FETCH_ERROR → throw; пустой текст →
    throw «likely JS-heavy or blocked».
  4. runExtractor(ctx, { url, task, pageText: pageText.slice(0, 24_000) }) — pageText
    жёстко обрезается до 24 000 символов перед отправкой модели.
  5. Возврат: { content: [text], details: { url, source: "extract_page", error? } }.

fetch.py

  • fetch(): urllib.request с UA Firefox, Accept: text/html…, Accept-Encoding: gzip, deflate;
    timeout 25 c; _gunzip (gzip-магия \x1f\x8b, deflate \x78), _decode по
    Content-Type: charset=…, fallback utf-8cp1252 → replace.
  • Приоритет — trafilatura.extract(..., include_links/images/tables=False, output_format="txt"),
    печать первых 20 000 символов.
  • Fallback: lxml → <title> + text_content() со схлопыванием \n{2,} → префикс TITLE: ….
  • Совсем при неудаче — сырой HTML (первые 20 000 символов).

subagent.ts — runExtractor

  • Берёт активную модель сессии: ctx.model; если модели/ключа нет →
    ERROR: no active model available to extract. / ERROR: no API key for the active model.
  • Один вызов complete(model, { systemPrompt, messages }, { apiKey, headers, env }) из
    @earendil-works/pi-ai/compat; все text-блоки ответа склеиваются и trim'ятся.
  • Исключение → isError: true, текст EXTRACTOR_ERROR: <message>.
  • «Предзнание» окружения зашито в промпт: python-библиотеки (requests 2.34, trafilatura 2.1,
    lxml 6.1, bs4 4.15, ddgs 9.14, regex) уже стоят; воркер ничего не ставит и не проверяет.
    Фетчит главная сессия (fetch.py), воркер только рассуждает и отвечает.

Промпты (дословно)

1
2
3
4
Search the open web and return a short list of candidate links (title + URL + snippet). Use
whenever facts may be stale/unknown: versions, API changes, prices, news, release dates,
error messages, anything post-cutoff. This only finds URLsif you need the actual
content/answer, hand a promising URL to extract_page. Search is free (DuckDuckGo).

promptSnippet: Search the web for candidate links (title+URL+snippet)

promptGuidelines:

  1. Use web_search when the answer depends on info newer than your training cutoff (versions, prices, news, docs, error fixes).
  2. web_search returns links/snippets only. To actually read and answer from a page, follow up with extract_page on the chosen URL.

Tool description — extract_page

1
2
3
4
5
Read a single web page and return the concrete answer to a task. Given a URL + a specific
task, fetches the page, extracts its readable text, and distills just the requested
information (never dumps the whole page). Use AFTER web_search to read the promising URLs it
found. Works best on static pages: articles, docs, changelogs, release notes, blogs, forum
posts.

promptSnippet: Extract the answer to a specific task from a single web page (URL + task)

promptGuidelines:

  1. Use extract_page after web_search returns candidate URLs — give it the URL and a precise task so it returns only the answer.
  2. extract_page works on static pages. For JS-heavy or login/anti-bot sites (marketplaces, imageboards) it may fail — tell the user rather than looping.

System prompt экстрактора (subagent.ts)

You are an information extraction worker.

## Rule 1 — reporting format (STRICT)
End your reply with EXACTLY these three sections, in this order:

ANSWER
<the concrete fact(s) extracted, 1-3 sentences. Answer the task directly, no hedging, no "here is what I found".>

SOURCES
- <source URL that actually supplied the ANSWER>

RAW_DATA
<optional: only if the task asks for structured detail (prices, specs, lists). Compact key: value or list lines. Omit the section entirely if not asked.>

## Rule 2 — scope
- Use ONLY the page text provided below. If the task cannot be answered from it, say so in ANSWER explicitly.
- Never invent facts, prices, versions, or numbers not present in the text.
- Do not return raw HTML or dump the whole page — only the distilled result.

## Environment note
The python environment is already set up (requests 2.34, trafilatura 2.1, lxml 6.1, bs4 4.15, ddgs 9.14, regex). Do not pip-install or verify.

User-сообщение экстрактора (шаблон)

1
2
3
4
5
6
7
Task: ${task}
URL: ${url}

Page text (clean, from trafilatura):
${pageText}

Extract per the rules and reply in the ANSWER / SOURCES / [RAW_DATA] format.

(pageText обрезан до 24 000 символов на стороне extract_page.ts.)

Промежуточные статусы (onUpdate)

  • web_search: Searching: ${query}
  • extract_page: Reading: ${url}

Ограничения и поведения по ошибкам

  • Статические страницы — да; JS-тяжёлые / login / антибот — скорее всего провал (пустой
    текст или FETCH_ERROR) → модель сообщает пользователю, а не зацикливается.
  • pageText ≤ 24k символов модели, читаемый текст ≤ 20k символов из fetch.py — верхние
    границы контекста осознанно жёсткие.
  • Отсутствие результатов поиска — явный exit 1 с текстом No results found.
  • Отмена — через signal в fetch; в web_search сигнал не прокидывается.
Edit

Pub: 12 Sep 2026 06:41 UTC

Views: 22