# 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`.

### 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 текстовый файл (бросает на ошибке). Относительные
пути разрешаются от рабочей директории сервера:

```javascript
const cfg = fs_read("data/git/repos.json");
```

### Прочее

| Функция | Описание |
|---|---|
| `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-декодирование |
| `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`-помощники и возвращаемые значения.
- `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` или в живом чате
с агентом, к которому прикреплён инструмент.
