# 7. Скрипты (JavaScript и Rhai)

🔵 Инструменты и хуки можно писать как скрипты, исполняемые встроенным
движком и подгружаемые из YAML — без пересборки. Два движка, выбор по
расширению файла:

- **JavaScript** (`.js`, `.mjs`, `.cjs`) — настоящий JS через
  [QuickJS](https://bellard.org/quickjs/). **Рекомендуемый выбор**: полный язык
  и стандартная библиотека (`JSON`, `Math`, `String`, regex, template-литералы,
  `Array`, …), знакомый синтаксис.
- **[Rhai](https://rhai.rs)** (`.rhai`) — компактный встраиваемый язык для
  совсем маленьких скриптов. См. §7.5.

Оба движка дают одинаковый контракт и одинаковые функции-помощники (см. §7.3).
Путь `script:` на `.js` запускается на QuickJS; любое другое расширение — на Rhai.

- **Скриптовый инструмент** (`kind: script_tool`) — LLM вызывает его как обычный
  инструмент; скрипт определяет `run(args)`.
- **Скриптовый хук** (`kind: script_hook`) — перехватывает жизненный цикл агента.

Готовые примеры в `config/scripts/`: `text_stats` (Rhai и JS), `currency`
(Rhai), `wordcount` (JS), `audit` и `moderation` (Rhai).

---

## 7.1 Скриптовый инструмент

`config/scripts/tools/text_stats.yaml`:

```yaml
kind: script_tool
name: text_stats
description: "Считать символы, слова, предложения."
script: text_stats.js           # относительно этого YAML
parameters:
  type: object
  properties:
    text: { type: string, description: "Текст для анализа" }
  required: [text]
ui:
  icon: 📝
warnings:
  - failed: true
    message: "Не удалось проанализировать текст"
```

`text_stats.js`:

```javascript
function run(args) {
    const text = (args.text ?? "").trim();
    if (text.length === 0) {
        return { content: "текст пуст", success: false, error: "empty text" };
    }
    const words = text.split(" ").filter((w) => w.length > 0).length;
    return { content: `Слов: ${words}`, success: true };
}
```

### Поля YAML

| Поле | Обязат. | Описание |
|---|---|---|
| `kind` | да | `script_tool` |
| `name` | нет | имя инструмента (по умолчанию — имя файла) |
| `description` | нет | показывается LLM |
| `parameters` | нет | JSON Schema аргументов |
| `script` | да | путь к `.js`/`.rhai`, относительно YAML |
| `ui` | нет | иконка + call/result-шаблоны |
| `max_operations` | нет | лимит операций (по умолчанию 10 000 000; 0 = без лимита) |
| `warnings` | нет | декларативные правила предупреждений |

### Контракт `run(args [, config])`

```javascript
function run(args, config) {
    // args — объект JSON-аргументов вызова
    // config — сырой YAML-конфиг инструмента (все поля, включая свои)
    return { content: "текст для LLM", success: true };
    // при ошибке:
    // return { content: "причина", success: false, error: "короткая причина" };
}
```

- Второй аргумент `config` опционален: если функция объявляет только `run(args)`,
  он не передаётся (обратная совместимость). Если объявлен `run(args, config)`,
  в него попадает весь YAML-конфиг инструмента — так настройки можно задавать
  прямо в YAML (напр. `repos: [...]`) и читать в скрипте, не заводя внешний файл.

- `content` — строка, попадающая в контекст агента.
- `success` — опционально (по умолчанию `true`).
- `error` — опционально; если задан, а `success` нет — результат неуспешен,
  `content` берётся из `error`.
- `visual` — опционально; визуальный блок или список блоков (`text` / `chart` /
  `table`), показывается пользователю, но **никогда** не попадает в контекст LLM.
  Скрипт может держать `content` компактной сводкой, а полные данные отдавать
  сюда:

  ```javascript
  function run(args) {
      const page = args.page ?? 1;
      return {
          content: `страница ${page}`,
          success: true,
          visual: {
              type: "table",
              columns: [ { key: "id", title: "ID" } ],
              rows: [ { id: 1 }, { id: 2 } ],
              continuation: page < 3 ? { args: { page: page + 1 } } : undefined,
          },
      };
  }
  ```

  `continuation.args` блока `table` включает ленивую пагинацию (см.
  [общие поля](05-tools-core.md#51-общие-поля) и `POST /v1/continuations`).
- Возврат простой строки разрешён → `content` с `success=true`.
- Выброшенное исключение → `success=false` с текстом ошибки.

---

## 7.2 Скриптовый хук

`config/scripts/hooks/audit.yaml`:

```yaml
kind: script_hook
name: audit
script: audit.js
events: [before_user_message, after_turn]   # опущено = все определённые функции
```

`audit.js`:

```javascript
function before_user_message(ctx, msg) {
    return { notice: `🔍 audit: ${msg.content.length} символов` };
}

function after_turn(ctx, question, answer) {
    return { notice: `✅ audit: ${question.content.length} → ${answer.content.length} символов` };
}
```

### Поля YAML

| Поле | Обязат. | Описание |
|---|---|---|
| `kind` | да | `script_hook` |
| `name` | нет | имя хука (по умолчанию — имя файла) |
| `script` | да | путь к `.js`/`.rhai`, относительно YAML |
| `events` | нет | какие события запускать; пусто = все определённые функции |
| `max_operations` | нет | лимит операций |
| любые свои поля | нет | доступны в скрипте как `ctx.config.<field>` |

### Контекст `ctx`

Каждая функция получает объект:

```javascript
{
    pid: "...",            // PID процесса (строка UUID)
    agent: "...",          // имя процесса
    template: "...",       // имя шаблона (или null)
    session_id: "...",     // или null
    user_id: "...",        // или null
    config: {...}          // сырой YAML-конфиг, включая свои поля
}
```

### Функции хука

```javascript
function before_user_message(ctx, msg) {
    // msg: { role: "user", content: "..." }
    // возвращаемый объект — все поля опциональны:
    return {
        notice: "⚡ заметка в чат",                       // стримится, НЕ в контекст
        reply: "готовый ответ",                          // полный ответ, пропускает LLM
        suppress: true,                                  // отбросить сообщение
        inject: [{ role: "system", content: "подсказка" }], // доп. сообщения
        replace_message: { role: "user", content: "..." },
    };
}

function after_turn(ctx, question, answer) {
    // question / answer: { role, content } или null
    // возвращает { notice: "..." } или простую строку
}

function after_loop(ctx, reason) {
    // reason: "job_done" | "terminated" | "blocked" | "budget_exhausted" | ...
    // возвращаемое значение игнорируется
}
```

Ошибка скрипта никогда не ломает ход агента.

---

## 7.3 Встроенные помощники

### HTTP

```javascript
const r = http_get("https://api.example.com/data");
// r = { status: 200, body: "<body>", error: "" }
// сетевая ошибка → { status: 0, body: "", error: "..." } — не бросает

const r2 = http_post("https://api.example.com/submit", "payload");
const r3 = http_post(url, body, "application/json");
```

Все HTTP-методы с одинаковой сигнатурой
`(url, body [, content_type [, timeout [, headers]]])`:

```javascript
const r = http_put("https://api.example.com/item/1", `{"title":"x"}`, "application/json");
const r = http_patch("https://api.example.com/item/1", `{"title":"y"}`);
const r = http_delete("https://api.example.com/item/1", "");
```

Или универсальная форма `http(method, url, …)`:

```javascript
const r = http("PATCH", url, `{"status":"closed"}`, "application/json");
```

Таймаут по умолчанию 30с; перекрывается доп. аргументом:

```javascript
const r = http_get(url, 5);                       // таймаут 5с
const r = http_post(url, body, "application/json", 5);
```

Свои заголовки (объект последним аргументом — например `Referer`):

```javascript
const token = env_var("MY_API_TOKEN");   // "" если не задан (пишет предупреждение)
const r = http_get(url, 5, { Authorization: `Bearer ${token}` });
```

### LLM

```javascript
const a = llm("Что такое фотосинтез?");                              // только user
const a = llm("Ты — краткий ассистент.", "Что такое фотосинтез?");   // system + user
const a = llm({
    user: "Вопрос",
    system: "Инструкция",     // опционально
    max_tokens: 500,          // опционально (по умолчанию 1024, максимум 4096)
    temperature: 0.3,         // опционально
});
```

- Инференс идёт через провайдер ядра; **токены списываются на вызывающий
  процесс** (и его бюджетную группу).
- Уважает лимит конкурентности ядра.
- Бюджет исчерпан → ошибка `caller budget exhausted`.
- LLM не подключён → вызов бросает; оборачивай в `try ... catch`.

**Структурированный вывод** — `llm_json({ ... output_schema })` возвращает
распарсенный JSON-объект. Ядро ретраит инференс (до 3 попыток), пока ответ не
провалидируется против `output_schema`, подмешивая текст ошибки в следующую
попытку:

```javascript
const r = llm_json({
    user: "Извлеки имя и цену из текста",
    output_schema: {
        type: "object",
        properties: { name: { type: "string" }, price: { type: "number" } },
        required: ["name", "price"],
    },
});
// r = { name: "...", price: 123 }
```

Если после всех попыток ответ не валиден — бросок
`llm: structured output did not validate...`.

### Cron

Скрипты могут планировать запуски агентов (контракт общий для JS и Rhai). Эти
задания — **динамические**: персистятся в `logs/cron.db` и переживают рестарт:

```javascript
const r = schedule_cron({
    name: "watch-price",
    template: "hello-agent",
    interval_secs: 3600,            // или schedule: "0 0 9 * * *", или oneshot_at
    prompt: "Проверь цену и сообщи.",
    notify: [{ type: "log" }],
});
// r = { name: "watch-price", ok: true }

const gone = cancel_cron("watch-price");   // -> true/false
const jobs = list_cron();                  // -> массив статусов заданий
```

Полный справочник полей, sink'и и мониторинг — в `11-cron-jobs`. Без
подключённого cron-сервиса (например `hook_tool` без сервера) вызовы бросают.

### State store

Скрипты могут читать/писать scoped state store (`scope` резолвится из
вызывающего, id не нужен): `state_get(scope, key)`, `state_set(scope, key, value)`,
`state_delete(scope, key)`, `state_keys(scope)`, `state_push`, `state_range`,
`state_sadd`, `state_smembers`, `state_srem`. Семантика операций — как у
[`state_tool`](05-tools-state.md#511-state_tool). Требует подключённого
`logging.state_db_path`.

### DuckDB (SQL-базы)

Скрипты могут читать/писать DuckDB-базы через три помощника. Соединения к одному
файлу пулятся процесс-глобально (один писатель на файл), временны́е типы
(`TIMESTAMP`/`DATE`/`TIME`) возвращаются строками ISO 8601:

```javascript
db_execute("data/dbs/{user}.db",
    "CREATE TABLE IF NOT EXISTS log (id BIGINT, text TEXT)");
db_execute("data/dbs/{user}.db", "INSERT INTO log VALUES (1, 'hi')");

const q = db_query("data/dbs/{user}.db", "SELECT * FROM log", 100);
// q = { columns: [...], rows: [...], count: N, has_more: bool }

const s = db_schema("data/dbs/{user}.db");          // таблицы + колонки текстом
const s = db_schema("data/dbs/{user}.db", "log");   // одна таблица
```

| Функция | Описание |
|---|---|
| `db_query(path, sql [, limit])` | SELECT-запрос → `{ columns, rows, count, has_more }` (лимит по умолч. 100) |
| `db_execute(path, sql)` | DDL/DML → число затронутых строк (бросает на ошибке) |
| `db_schema(path [, table])` | интроспекция таблиц/колонок текстом |

`path` поддерживает плейсхолдеры `{user}`, `{session}`, `{template}`/`{agent}`,
резолвящиеся из контекста вызывающего (как у
[`duckdb_tool`](05-tools-state.md#517-duckdb_tool)) — так скрипт получает «свою»
базу на пользователя, не видя чужих id.

### Shell (CLI)

Скрипты могут запускать команду через оболочку платформы (`cmd /C` на Windows,
`sh -c` в остальных). Вызов никогда не бросает — ошибки возвращаются в карте:

```javascript
const r = shell("git log --oneline -n 5");
// r = { ok: true, exit_code: 0, stdout: "...", stderr: "" }
// таймаут / несуществующая команда → { ok: false, exit_code: -1, stderr: "..." }

const r2 = shell("sleep 60", 5);   // жёсткий таймаут 5с (по умолч. 30с)
```

`shell(...)` идёт через оболочку (`cmd /C` / `sh -c`), поэтому двойные кавычки и
`%VAR%` (на Windows) интерпретируются оболочкой. Для путей с пробелами, кавычек
внутри аргументов и `%` в `git --pretty=format:...` используй `exec(...)` — он
запускает процесс **напрямую**, без оболочки, и аргументы передаются дословно:

```javascript
const r = exec("git", ["-C", ".", "log", "--pretty=format:%H%x1f%an", "-n", "5"]);
const r2 = exec("git", ["log", "--author=Alice Bob"], 30);  // третий аргумент — таймаут
```

### Файлы

`fs_read(path)` читает UTF-8 текстовый файл, `fs_write(path, content)` пишет
его, `fs_list(path)` возвращает список записей каталога (каталоги — с `/` в
конце). Все три бросают на ошибке:

```javascript
fs_write("report.csv", "a,b\n1,2\n");   // относительный путь → в scratch сессии
const cfg = fs_read("data/git/repos.json");  // чтение — по путям сервера
const entries = fs_list(caller().temp_dir);  // ["report.csv", "sub/", ...]
```

**Запись ограничена scratch-каталогом сессии** (`caller().temp_dir`):
- относительный путь (`"report.csv"`, `"sub/report.csv"`) резолвится от `temp_dir`;
- абсолютный путь допускается только внутри `temp_dir`;
- `..` не может вывести за пределы `temp_dir`; символические ссылки наружу тоже
  отклоняются. Иначе — ошибка `fs_write: path ... outside the session temp dir`.
- Без temp-базы (скрипт вне сессии) `fs_write` бросает `no temp dir ...`.

Чтение (`fs_read`/`fs_list`) остаётся read-only и не ограничено — им можно
читать серверные данные (`data/...`, конфиги).

**Бинарные файлы** — `fs_read_bytes(path)` возвращает содержимое как base64,
`fs_write_bytes(path, b64)` пишет байты (base64) обратно. Запись так же
ограничена scratch-каталогом сессии:

```javascript
const b64 = fs_read_bytes("photo.png");          // base64
fs_write_bytes("copy.png", b64);                  // → temp_dir/copy.png
```

### Контекст вызывающего (`caller`)

`caller()` возвращает идентичность процесса, от имени которого выполняется
скрипт (асимметрия с хуками устранена — `script_tool` тоже видит «кто я»):

```javascript
const me = caller();
// { pid, session_id, user_id, template, temp_dir }  — null, если неизвестно
fs_write(`${me.temp_dir}/notes.txt`, `user=${me.user_id}`);
```

### Вызов других инструментов (`call_tool`)

Скрипт может вызвать любой зарегистрированный инструмент по имени — ядро
диспетчеризует его точно так же, как вызов от LLM (проверка доступа, полный
контекст, защита от паники, усечение результата, учёт бюджета и запись в
историю результатов):

```javascript
const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" });
// r = { content: "...", success: true, data: ..., visual: ... }

const filled = call_tool("fill_contract", { name: "dogovor.docx", data: { client: "ООО Ромашка" } });
```

- Результат — карта `{ content, success, data, visual }` (поля `data`/`visual`
  — `null`, если тул их не вернул).
- `visual` передаётся как есть: скрипт может **переслать файл пользователю**,
  вернув его в своём `visual` (например блок `file` от `office_tool`):

  ```javascript
  function run(args) {
      const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" });
      return { content: "готово", success: r.success, visual: r.visual };
  }
  ```

- Если ядро не передало сервис вызова тулов (например `hook_tool` без
  `--live`), вызов бросает `call_tool: no tool-call service attached...`.
- Рекурсивный вызов того же `script_tool` самим собой ведёт к рекурсии —
  ограничен `max_operations`; не делай этого.

### Список доступных инструментов (`list_tools`)

`list_tools()` возвращает схемы инструментов, доступных вызывающему процессу
(тот же набор, что видит LLM при function calling):

```javascript
const tools = list_tools();
// [{ name: "make_docx", description: "...", parameters: { ... } }, ...]
```

### Результат прошлого вызова тула (`get_tool_result`)

`get_tool_result(tool [, index])` читает результат тула, уже выполненного в
этой сессии (без повторного запуска) — композиция без лишних вызовов:

```javascript
const r = get_tool_result("make_docx");        // последний результат
const prev = get_tool_result("make_docx", 1);  // предпоследний
// { content, success, data, visual }  — как у call_tool
```

- `index` — 0-based от последнего вызова (`0` по умолчанию).
- Без результата на индексе — бросок `get_tool_result: no result ...`.
- Читает историю, которую ядро ведёт для композиции (`ToolContext::tool_results`);
  без истории — `no tool-result history attached...`.

### Запуск субагента (`run_subagent`)

Скрипт может делегировать задачу дочернему агенту (спавнится как ребёнок
вызывающего, тратит его бюджетную группу, one-shot — чистится после):

```javascript
const r = run_subagent({
    task: "Проанализируй отзыв и дай оценку",
    template: "review-agent",        // или instructions: "..."
    // name, tools, output_schema — опционально
});
// r = { pid, answer, data }  — data заполнен, если задан output_schema
```

Без подключённого сервиса субагентов — бросок `run_subagent: no subagent service...`.

### Транскрипт сессии (`memory_*`)

Скрипт может читать **собственный транскрипт сессии** (то, что уже сохранилось
в message store — сообщения пользователя/агента, tool-call, хуки, warning,
error):

```javascript
const recent = memory_history(20);        // последние 20 сообщений, по порядку
const hits   = memory_search("поступление", 5);  // поиск по словам, по релевантности
const n      = memory_count();
// каждая запись: { id, role, kind, content, meta, created_at, score }
```

- Скоуп — `session:<session_id>` вызывающего (резолвится ядром, id не нужен).
- Без session id → бросок `memory: no session id...`; без message store →
  `memory: no message store...`.
- Это read-only: скрипт не пишет в транскрипт (пишет только ядро/хуки).

### Прочее

| Функция | Описание |
|---|---|
| `now()` | текущее UTC-время (строка RFC 3339) |
| `sleep_ms(ms)` | блокирующая пауза (ограничена 60с) — примитив задержки/backoff |
| `env_var(name)` | переменная окружения (`""` если нет; пишет предупреждение) |
| `print(...)`, `debug(...)` | лог в сервер |
| `urlencode(s)` / `url_encode(s)` | percent-encoding UTF-8 |
| `urldecode(s)` / `url_decode(s)` | percent-декодирование |
| `base64_encode(s)` / `base64_decode(s)` | base64 UTF-8 (decode бросает на невалидном входе) |
| `ord(c)`, `chr(i)` | символ ↔ кодпоинт |
| `matches(s, regex)`, `replace_re(s, regex, repl)` | regex-матч / замена |
| `extract_script_json(html, contains)` | первый JSON-блоб `<script>` (напр. `__NEXT_DATA__`) → объект, или `null` |
| `json_get(data, pointer)` | разрешить JSON-указатель (`/a/b/0`, экраны `~0`/`~1`) → значение, или `null` |

Для JSON в JS используй нативные `JSON.parse` / `JSON.stringify`.

---

## 7.4 Лимиты и гарантии

- **Нет прямого доступа к ядру** (`spawn`, `inject`, …) — только HTTP/LLM/
  state/DuckDB/shell/exec/`fs_read`/`fs_write`/`fs_list`/`fs_read_bytes`/
  `fs_write_bytes`-помощники, `caller()`, вызов других инструментов через
  `call_tool(...)`/`list_tools()`/`get_tool_result(...)`, чтение транскрипта
  через `memory_*` и возвращаемые значения.
- `max_operations` обрывает runaway-циклы (`while(true){}` даёт ошибку, а не
  зависание). `shell(...)` дополнительно ограничен жёстким таймаутом.
- **Состояние не сохраняется между вызовами** — каждый вызов получает свежий
  скоуп. Для персистентности используй `state_*`/`db_*`-помощники, HTTP/внешние
  сервисы или встроенные хуки (`faq_cache`, `memory`).
- **Скрипты синхронны**: `llm(...)` блокирует рабочий поток на время инференса.
  Нормально для коротких вызовов; избегай десятков последовательных LLM-вызовов
  в одном скрипте.

---

## 7.5 Rhai — альтернативный язык

Для маленьких скриптов можно использовать Rhai (`.rhai`). Контракт идентичен,
отличается синтаксис:

```javascript
fn run(args) {
    let text = args.text ?? "";
    text.trim();                      // trim() меняет на месте и возвращает ()
    if text.len == 0 {                // длина — свойство `.len`
        return #{ content: "текст пуст", success: false, error: "empty text" };
    }
    let words = text.split(" ").filter(|w| w.len > 0).len();
    #{ content: `Слов: ${words}`, success: true }
}
```

Отличия от JS:

- Объекты — `#{ key: value }` (не `{ … }`); `()` — это null.
- Длина строки/массива — свойство `.len` (не `.length`).
- Строковые методы (`trim`, `split`, …) **меняют строку на месте и возвращают
  `()`**: пиши `s.trim();` и читай `s`, а не `let t = s.trim();`.
- JSON — `parse_json` / `to_json` (вместо `JSON.parse` / `JSON.stringify`).
- `max_operations` в Rhai — точный счётчик операций (в JS — грубый лимит
  инструкций QuickJS).

---

## 7.6 Отладка

```bash
hook_tool config/scripts/hooks/audit.yaml user "Привет, мир"
hook_tool config/scripts/hooks/moderation.yaml user "Привет, мир" --live
```

Скриптовые инструменты проверяются тестами в стиле `site_tool` или в живом чате
с агентом, к которому прикреплён инструмент.
