JustPaste
HomeCategoriesAboutDonateContactTerms of UsePrivacy Policy
JustPaste

Free online notepad — write and share instantly

Navigate

  • Home
  • Timeline
  • Categories

Info

  • About
  • Donate
  • Contact

Legal

  • Terms of Use
  • Privacy Policy

© 2026 JustPaste.app. All rights reserved.

Made with ♥ by JustPaste

install-terraphim.sh | JustPaste.app
17 days ago10 views
👨‍💻Programming

install-terraphim.sh

#!/usr/bin/env bash
# =============================================================================
# install-terraphim.sh — установка Terraphim AI (terraphim-agent) на Fedora Linux
#                        + автоматическая настройка поиска по локальным файлам
#
# Источник: https://terraphim.ai/docs/installation/
#
# Что делает за один запуск:
#   1. ставит terraphim-agent (метод --method);
#   2. конвертирует книги из ваших каталогов (по умолчанию ~/Библиотека:
#      .fb2, .fb2.zip, .txt, .md) в Markdown-зеркало — Terraphim индексирует
#      только Markdown, поэтому .fb2/.txt напрямую не находятся;
#      оригиналы не трогаются;
#      создаёт роль "Library" с haystack (Ripgrep) на это зеркало —
#      без графа знаний, поэтому не нужны ни каталог kg, ни --role;
#   3. прописывает её в ~/.config/terraphim/settings.toml (role_config) и
#      делает ролью по умолчанию;
#   4. проверяет через `config show`, что агент реально использует именно этот
#      каталог (сохранённый в БД конфиг перекрывает role_config); при
#      расхождении делает `config reload`, а если не помогло — откладывает
#      файлы БД агента (.bak-<время>) и даёт ему заново прочитать role_config;
#   5. проверяет, что роль видна и что поиск реально находит текст.
#
# После добавления новых книг: ./install-terraphim.sh --sync-only
#
# Методы установки (--method):
#   binary   (по умолчанию) — готовый архив с downloads.terraphim.ai,
#                             версия и SHA-256 берутся из manifest stable-v2.json
#   official — официальный универсальный установщик (install.sh)
#   cargo    — cargo install terraphim_agent --features repl-full
#   source   — git clone terraphim-clients + cargo build --release
#
# Коды выхода (совпадают с официальным установщиком):
#   0 успех | 1 ошибка аргументов | 2 manifest недоступен
#   3 версия недоступна | 4 ошибка загрузки | 5 SHA-256 не совпал
#   6 ошибка установки | 7 ошибка настройки локального поиска
# =============================================================================

set -Eeuo pipefail

# --- Константы ---------------------------------------------------------------
readonly MANIFEST_URL="https://downloads.terraphim.ai/terraphim-agent/stable-v2.json"
readonly DOWNLOAD_BASE="https://downloads.terraphim.ai/terraphim-agent"
readonly OFFICIAL_INSTALLER="https://raw.githubusercontent.com/terraphim/terraphim-ai/main/scripts/install.sh"
readonly CLIENTS_REPO="https://github.com/terraphim/terraphim-clients.git"
readonly MIN_RUST="1.85.0"
readonly BIN_NAME="terraphim-agent"

# --- Параметры (меняются аргументами командной строки) -----------------------
METHOD="binary"                    # метод установки
INSTALL_DIR="${HOME}/.local/bin"   # куда класть бинарник
WANT_VERSION=""                    # пусто = текущая версия из manifest
USE_MUSL=0                         # 1 = статическая musl-сборка вместо GNU
WITH_CLI=0                         # только для method=official
WITH_GREP=0                        # только для method=official
SKIP_DEPS=0                        # 1 = не вызывать dnf
NO_LOCAL=0                         # 1 = не настраивать локальный поиск
ROLE_NAME="Library"                # имя роли для локального поиска
LIB_DIRS=()                        # каталоги для поиска (--library, можно несколько)
CONVERT=1                          # 1 = строить Markdown-зеркало библиотеки
SYNC_ONLY=0                        # 1 = только обновить зеркало и конфиг
RESET_STATE=0                      # 1 = принудительно сбросить сохранённый конфиг агента
PROBE_WORD=""                      # слово для самопроверки поиска

TMP_DIR=""                         # временный каталог (создаётся позже)
CFG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/terraphim"
DATA_DIR="${XDG_DATA_HOME:-${HOME}/.local/share}/terraphim"
ROLE_JSON="${CFG_DIR}/local_search.json"
MIRROR_DIR="${DATA_DIR}/library-md"
CONVERTER="${DATA_DIR}/library_to_md.py"

# --- Вспомогательные функции -------------------------------------------------
log()  { printf '\033[1;34m[*]\033[0m %s\n' "$*"; }
ok()   { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die()  { local code="$1"; shift; printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit "$code"; }

cleanup() { [[ -n "${TMP_DIR}" && -d "${TMP_DIR}" ]] && rm -rf "${TMP_DIR}"; return 0; }
trap cleanup EXIT

usage() {
  cat <<EOF
Использование: $0 [опции]

  --method M        binary | official | cargo | source   (по умолчанию: binary)
  --version X.Y.Z   требовать конкретную версию (иначе — текущая из manifest)
  --install-dir D   каталог установки (по умолчанию: ~/.local/bin)
  --musl            взять статическую musl-сборку (по умолчанию GNU на x86_64)
  --with-cli        (method=official) дополнительно поставить CLI
  --with-grep       (method=official) дополнительно поставить terraphim-grep
  --skip-deps       не устанавливать зависимости через dnf

Локальный поиск:
  --library D       каталог для поиска; можно указать несколько раз
                    (по умолчанию: ~/Библиотека)
  --role-name N     имя роли (по умолчанию: Library)
  --no-local-search не настраивать поиск по локальным файлам
  --no-convert      не конвертировать в Markdown (искать в каталогах как есть)
  --sync-only       не устанавливать агент: только обновить зеркало и конфиг
  --reset-state     принудительно сбросить сохранённый конфиг агента (резервная
                    копия остаётся рядом с файлами БД, суффикс .bak-<время>)

  -h, --help        эта справка

Примеры:
  $0
  $0 --library ~/Библиотека --library ~/Documents
  $0 --sync-only
  $0 --install-dir /usr/local/bin
  $0 --method source
EOF
}

# Выполнить команду с root-правами: напрямую, если мы root, иначе через sudo
as_root() {
  if [[ "${EUID}" -eq 0 ]]; then "$@"; else sudo "$@"; fi
}

# Проверка наличия команды
have() { command -v "$1" >/dev/null 2>&1; }

# Сравнение версий: возвращает 0, если $1 >= $2
version_ge() { [[ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" == "$2" ]]; }

# --- Разбор аргументов -------------------------------------------------------
parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --method)          METHOD="${2:?--method требует значение}"; shift 2 ;;
      --version)         WANT_VERSION="${2:?--version требует значение}"; shift 2 ;;
      --install-dir)     INSTALL_DIR="${2:?--install-dir требует значение}"; shift 2 ;;
      --musl)            USE_MUSL=1; shift ;;
      --with-cli)        WITH_CLI=1; shift ;;
      --with-grep)       WITH_GREP=1; shift ;;
      --skip-deps)       SKIP_DEPS=1; shift ;;
      --library)         LIB_DIRS+=("${2:?--library требует значение}"); shift 2 ;;
      --role-name)       ROLE_NAME="${2:?--role-name требует значение}"; shift 2 ;;
      --no-local-search) NO_LOCAL=1; shift ;;
      --no-convert)      CONVERT=0; shift ;;
      --sync-only)       SYNC_ONLY=1; shift ;;
      --reset-state)     RESET_STATE=1; shift ;;
      -h|--help)         usage; exit 0 ;;
      *)                 usage >&2; die 1 "Неизвестная опция: $1" ;;
    esac
  done

  case "${METHOD}" in
    binary|official|cargo|source) ;;
    *) die 1 "Неизвестный метод: ${METHOD}" ;;
  esac

  # Каталог по умолчанию
  [[ "${#LIB_DIRS[@]}" -gt 0 ]] || LIB_DIRS=("${HOME}/Библиотека")
}

# --- Проверка окружения ------------------------------------------------------
check_fedora() {
  if [[ -r /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    if [[ "${ID:-}" != "fedora" && "${ID_LIKE:-}" != *fedora* ]]; then
      warn "Система не похожа на Fedora (ID=${ID:-?}); продолжаю на свой риск."
    else
      log "Обнаружена ${PRETTY_NAME:-Fedora}"
    fi
  fi
}

# Установка пакетов через dnf (только недостающих)
install_deps() {
  [[ "${SKIP_DEPS}" -eq 1 ]] && return 0
  have dnf || { warn "dnf не найден — пропускаю установку зависимостей."; return 0; }

  local pkgs=("$@") missing=() p
  for p in "${pkgs[@]}"; do
    rpm -q "${p}" >/dev/null 2>&1 || missing+=("${p}")
  done

  if [[ "${#missing[@]}" -gt 0 ]]; then
    log "Устанавливаю зависимости: ${missing[*]}"
    as_root dnf install -y "${missing[@]}"
  fi
}

# Определение целевой платформы (target triple) по архитектуре
detect_target() {
  local arch
  arch="$(uname -m)"
  case "${arch}" in
    x86_64)
      if [[ "${USE_MUSL}" -eq 1 ]]; then
        TARGET="x86_64-unknown-linux-musl"
      else
        TARGET="x86_64-unknown-linux-gnu"
      fi ;;
    aarch64|arm64)
      # Для ARM64 на сайте опубликована только musl-сборка
      TARGET="aarch64-unknown-linux-musl" ;;
    *)
      die 1 "Архитектура ${arch} не поддерживается готовыми сборками. Используйте --method source." ;;
  esac
  log "Целевая платформа: ${TARGET}"
}

# --- Метод: binary -----------------------------------------------------------
install_binary() {
  install_deps curl tar gzip python3 coreutils findutils
  detect_target

  TMP_DIR="$(mktemp -d)"

  # 1. Загружаем manifest
  log "Загружаю manifest: ${MANIFEST_URL}"
  curl -fsSL "${MANIFEST_URL}" -o "${TMP_DIR}/manifest.json" \
    || die 2 "Manifest недоступен: ${MANIFEST_URL}"

  # 2. Из manifest берём версию и ожидаемый SHA-256 для нашей платформы.
  #    Структура: {"version": "...", "assets": {"<target>": {"sha256": "..."}}}
  local manifest_version expected_sha
  manifest_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' \
                      "${TMP_DIR}/manifest.json")" \
    || die 2 "Не удалось прочитать версию из manifest"
  expected_sha="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["assets"][sys.argv[2]]["sha256"])' \
                  "${TMP_DIR}/manifest.json" "${TARGET}")" \
    || die 3 "В manifest нет сборки для ${TARGET}"

  # 3. Если запрошена конкретная версия — она должна совпадать с каналом
  if [[ -n "${WANT_VERSION}" && "${WANT_VERSION}" != "${manifest_version}" ]]; then
    die 3 "Запрошена версия ${WANT_VERSION}, а канал отдаёт ${manifest_version}"
  fi
  log "Версия: ${manifest_version}"

  # 4. Скачиваем архив
  local archive="${BIN_NAME}-${manifest_version}-${TARGET}.tar.gz"
  log "Скачиваю ${archive}"
  curl -fsSL "${DOWNLOAD_BASE}/${archive}" -o "${TMP_DIR}/${archive}" \
    || die 4 "Не удалось скачать ${DOWNLOAD_BASE}/${archive}"

  # 5. Проверяем контрольную сумму (при несовпадении — прерываем)
  local actual_sha
  actual_sha="$(sha256sum "${TMP_DIR}/${archive}" | awk '{print $1}')"
  if [[ "${actual_sha,,}" != "${expected_sha,,}" ]]; then
    die 5 "SHA-256 не совпал! ожидалось ${expected_sha}, получено ${actual_sha}"
  fi
  ok "SHA-256 совпал"

  # 6. Распаковываем и ищем бинарник (на случай вложенного каталога в архиве)
  mkdir -p "${TMP_DIR}/unpacked"
  tar -xzf "${TMP_DIR}/${archive}" -C "${TMP_DIR}/unpacked" \
    || die 6 "Не удалось распаковать архив"
  local found
  found="$(find "${TMP_DIR}/unpacked" -type f -name "${BIN_NAME}" | head -n1)"
  [[ -n "${found}" ]] || die 6 "В архиве не найден ${BIN_NAME}"

  place_binary "${found}"
}

# Копирование бинарника в INSTALL_DIR (с sudo, если каталог недоступен на запись)
place_binary() {
  local src="$1"
  log "Устанавливаю в ${INSTALL_DIR}"

  if [[ -d "${INSTALL_DIR}" && -w "${INSTALL_DIR}" ]] \
     || { [[ ! -e "${INSTALL_DIR}" ]] && mkdir -p "${INSTALL_DIR}" 2>/dev/null; }; then
    install -m 0755 "${src}" "${INSTALL_DIR}/${BIN_NAME}" || die 6 "Ошибка установки"
  else
    as_root install -D -m 0755 "${src}" "${INSTALL_DIR}/${BIN_NAME}" || die 6 "Ошибка установки"
  fi
  ok "Установлен ${INSTALL_DIR}/${BIN_NAME}"
}

# --- Метод: official ---------------------------------------------------------
install_official() {
  install_deps curl bash
  TMP_DIR="$(mktemp -d)"

  log "Загружаю официальный установщик"
  curl -fsSL "${OFFICIAL_INSTALLER}" -o "${TMP_DIR}/install.sh" \
    || die 4 "Не удалось скачать ${OFFICIAL_INSTALLER}"

  # Собираем аргументы для официального скрипта
  local args=()
  [[ "${WITH_CLI}"  -eq 1 ]] && args+=(--with-cli)
  [[ "${WITH_GREP}" -eq 1 ]] && args+=(--with-grep)
  [[ -n "${WANT_VERSION}" ]] && args+=(--version "${WANT_VERSION}")
  args+=(--install-dir "${INSTALL_DIR}")

  # ВАЖНО: скрипт запускается как файл, а не через `curl | bash --опция`
  # (в последнем случае bash воспринимает --опцию как свою, а не скрипта).
  bash "${TMP_DIR}/install.sh" "${args[@]}" || die 6 "Официальный установщик завершился с ошибкой"
}

# --- Rust-тулчейн для cargo/source -------------------------------------------
ensure_rust() {
  install_deps gcc gcc-c++ make pkgconf-pkg-config openssl-devel git curl
  if ! have cargo || ! have rustc; then
    install_deps rust cargo
  fi

  local ver
  ver="$(rustc --version | awk '{print $2}')"
  if ! version_ge "${ver}" "${MIN_RUST}"; then
    die 6 "Rust ${ver} слишком старый (нужен >= ${MIN_RUST}). Установите rustup: 'sudo dnf install rustup && rustup-init && rustup update stable'"
  fi
  log "Rust ${ver}"
}

# --- Метод: cargo ------------------------------------------------------------
install_cargo() {
  ensure_rust
  log "cargo install terraphim_agent (может занять несколько минут)"
  if [[ -n "${WANT_VERSION}" ]]; then
    cargo install terraphim_agent --features repl-full --version "${WANT_VERSION}" --locked \
      || die 6 "cargo install не удался"
  else
    cargo install terraphim_agent --features repl-full --locked \
      || die 6 "cargo install не удался"
  fi
  # cargo кладёт бинарник в ~/.cargo/bin
  INSTALL_DIR="${CARGO_HOME:-${HOME}/.cargo}/bin"
  ok "Установлен ${INSTALL_DIR}/${BIN_NAME}"
}

# --- Метод: source -----------------------------------------------------------
install_source() {
  ensure_rust
  TMP_DIR="$(mktemp -d)"

  log "Клонирую ${CLIENTS_REPO}"
  git clone --depth 1 "${CLIENTS_REPO}" "${TMP_DIR}/terraphim-clients" \
    || die 4 "git clone не удался"

  log "Сборка release (может занять несколько минут)"
  ( cd "${TMP_DIR}/terraphim-clients" \
    && cargo build --release -p terraphim_agent --bin "${BIN_NAME}" ) \
    || die 6 "Сборка не удалась"

  place_binary "${TMP_DIR}/terraphim-clients/target/release/${BIN_NAME}"
}

# --- Конвертация библиотеки в Markdown ---------------------------------------
# Terraphim индексирует Markdown-файлы, поэтому .fb2/.txt собираем в зеркало.
# Инкрементально: уже сконвертированные и не изменившиеся файлы пропускаются.
write_converter() {
  mkdir -p "${DATA_DIR}"
  cat > "${CONVERTER}" <<'PY'
#!/usr/bin/env python3
"""Конвертер библиотеки в Markdown: library_to_md.py OUT_DIR SRC_DIR..."""
import hashlib, os, re, sys, zipfile
import xml.etree.ElementTree as ET

# Лимит ФС на одно имя — 255 БАЙТ (кириллица в UTF-8 = 2 байта на букву),
# поэтому длинные имена книг сокращаем с запасом и добавляем хеш от оригинала,
# чтобы разные книги с одинаковым началом не слились в один файл.
MAX_NAME = 200

def fit(name, suffix=""):
    full = name + suffix
    if len(os.fsencode(full)) <= MAX_NAME:
        return full
    tag = "~" + hashlib.sha1(os.fsencode(name)).hexdigest()[:8]
    budget = MAX_NAME - len(os.fsencode(tag + suffix))
    while name and len(os.fsencode(name)) > budget:
        name = name[:-1]
    return name.rstrip(" .") + tag + suffix

def mirror_path(out_dir, base, rel):
    parts = rel.split(os.sep)
    safe = [fit(p) for p in parts[:-1]] + [fit(parts[-1], ".md")]
    return os.path.join(out_dir, fit(base), *safe)

def local(tag):
    return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""

def text_of(el):
    return " ".join("".join(el.itertext()).split())

def fb2_to_md(data):
    try:
        root = ET.fromstring(data)
    except ET.ParseError:
        return fallback(data)
    title = authors = ""
    for ti in root.iter():
        if local(ti.tag) == "title-info":
            for c in ti:
                if local(c.tag) == "book-title":
                    title = text_of(c)
                elif local(c.tag) == "author" and not authors:
                    authors = " ".join(" ".join(c.itertext()).split())
            break
    out = []
    if title:
        out.append("# " + title)
    if authors:
        out.append("**" + authors + "**")

    def walk(el):
        t = local(el.tag)
        if t in ("binary", "description", "image"):
            return
        if t == "title":
            s = text_of(el)
            if s:
                out.append("## " + s)
            return
        if t in ("p", "v", "subtitle", "text-author", "th", "td"):
            s = text_of(el)
            if s:
                out.append(s)
            return
        for c in el:
            walk(c)

    for body in root:
        if local(body.tag) == "body":
            walk(body)
    return "\n\n".join(out) + "\n"

def fallback(data):
    for enc in ("utf-8", "cp1251"):
        try:
            s = data.decode(enc)
            break
        except UnicodeDecodeError:
            s = data.decode("utf-8", "replace")
    s = re.sub(r"<binary.*?</binary>", " ", s, flags=re.S)
    s = re.sub(r"</(p|v|title|subtitle)>", "\n\n", s)
    s = re.sub(r"<[^>]+>", "", s)
    return "\n".join(l.strip() for l in s.splitlines()) + "\n"

def plain_to_md(data):
    for enc in ("utf-8", "cp1251"):
        try:
            return data.decode(enc)
        except UnicodeDecodeError:
            pass
    return data.decode("utf-8", "replace")

def convert(path):
    low = path.lower()
    if low.endswith(".fb2.zip"):
        with zipfile.ZipFile(path) as z:
            for n in z.namelist():
                if n.lower().endswith(".fb2"):
                    return fb2_to_md(z.read(n))
        return None
    with open(path, "rb") as f:
        data = f.read()
    if low.endswith(".fb2"):
        return fb2_to_md(data)
    return plain_to_md(data)

def main():
    out_dir, *srcs = sys.argv[1:]
    done = skipped = failed = 0
    probe_src = None
    for src in srcs:
        base = os.path.basename(os.path.normpath(src)) or "lib"
        for dp, _, files in os.walk(src):
            for fn in files:
                if not fn.lower().endswith((".fb2", ".fb2.zip", ".txt", ".md")):
                    continue
                path = os.path.join(dp, fn)
                rel = os.path.relpath(path, src)
                dst = mirror_path(out_dir, base, rel)
                if os.path.exists(dst) and os.path.getmtime(dst) >= os.path.getmtime(path):
                    skipped += 1
                    probe_src = probe_src or dst
                    continue
                try:
                    md = convert(path)
                    if not md or not md.strip():
                        raise ValueError("пусто")
                    os.makedirs(os.path.dirname(dst), exist_ok=True)
                    with open(dst, "w", encoding="utf-8") as f:
                        f.write(md)
                    done += 1
                    probe_src = probe_src or dst
                except Exception as e:
                    failed += 1
                    print("пропущен %s: %s" % (path, e), file=sys.stderr)
    print("сконвертировано: %d, без изменений: %d, ошибок: %d" % (done, skipped, failed))
    # слово для самопроверки поиска: длинное слово из середины первого файла
    if probe_src:
        with open(probe_src, encoding="utf-8") as f:
            words = re.findall(r"[^\W\d_]{7,}", f.read())
        if words:
            print("PROBE:" + words[len(words) // 2].lower())

main()
PY
}

sync_library() {
  have python3 || die 7 "Для конвертации нужен python3"
  write_converter
  log "Конвертирую библиотеку в Markdown: ${MIRROR_DIR}"
  local res
  res="$(python3 "${CONVERTER}" "${MIRROR_DIR}" "$@")" || die 7 "Конвертация не удалась"
  printf '%s\n' "${res}" | grep -v '^PROBE:' || true
  PROBE_WORD="$(printf '%s\n' "${res}" | sed -n 's/^PROBE://p' | head -n1)"
  [[ -d "${MIRROR_DIR}" ]] || die 7 "Не нашлось ни одного поддерживаемого файла (.fb2/.fb2.zip/.txt/.md)"
}

# --- Сохранённое состояние агента --------------------------------------------
# Порядок загрузки конфига агентом: --config > конфиг, сохранённый в БД (SQLite)
# > role_config из settings.toml. role_config читается ТОЛЬКО пока в БД ещё нет
# ролей (первый запуск). Потом правка local_search.json и settings.toml на поиск
# не влияет, пока БД не сброшена или не выполнен `config reload`.
# Поэтому после записи конфига проверяем, какой каталог реально активен,
# и при расхождении чиним.

agent_bin() {
  local bin="${INSTALL_DIR}/${BIN_NAME}"
  [[ -x "${bin}" ]] || bin="$(command -v "${BIN_NAME}" || true)"
  [[ -n "${bin}" ]] || return 1
  printf '%s\n' "${bin}"
}

# Печатает haystack-пути роли ROLE_NAME из `config show` (по одному в строке).
# Код возврата: 0 ок | 1 вывод не разобран | 2 такой роли нет в активном конфиге
active_haystacks() {
  local bin="$1"
  RUST_LOG=error "${bin}" config show 2>/dev/null | python3 -c '
import json, re, sys
raw = sys.stdin.read()
dec = json.JSONDecoder()
cfg = None
for m in re.finditer(r"^\{", raw, re.M):
    try:
        obj, _ = dec.raw_decode(raw[m.start():])
    except ValueError:
        continue
    if isinstance(obj, dict) and "roles" in obj:
        cfg = obj
        break
if cfg is None:
    sys.exit(1)
name = sys.argv[1]
roles = cfg["roles"]
role = roles.get(name) if isinstance(roles, dict) else None
if role is None:
    for r in (roles.values() if isinstance(roles, dict) else roles):
        if isinstance(r, dict) and name in (r.get("name"), r.get("shortname")):
            role = r
            break
if role is None:
    sys.exit(2)
for h in role.get("haystacks", []):
    print(h.get("location", ""))
' "${ROLE_NAME}"
}

# Сравнивает активные haystack-пути роли с ожидаемыми ($2...).
# Возврат: 0 совпали | 1 не совпали | 2 проверить не удалось.
# Фактические пути кладёт в ACTIVE_NOW (для сообщений).
ACTIVE_NOW=""
haystacks_match() {
  local bin="$1"; shift
  local got rc=0 want
  got="$(active_haystacks "${bin}")" || rc=$?
  ACTIVE_NOW="${got//$'\n'/; }"
  case "${rc}" in
    0) ;;
    2) ACTIVE_NOW=""; return 1 ;;   # роли нет в сохранённом конфиге — это расхождение
    *) return 2 ;;
  esac
  want="$(printf '%s\n' "$@" | sed 's:/*$::' | sort)"
  got="$(printf '%s\n' "${got}" | sed 's:/*$::' | sort)"
  [[ "${got}" == "${want}" ]] || return 1
  return 0
}

# Откладывает в сторону файлы БД агента (с суффиксом .bak-<время>), чтобы
# при следующем запуске он заново прочитал role_config. Ничего не удаляет.
reset_persisted_config() {
  local stamp st="${CFG_DIR}/settings.toml" v r f
  stamp="$(date +%Y%m%d-%H%M%S)"
  local -a roots=("${DATA_DIR}" "${HOME}/.terraphim" /tmp/terraphim_sqlite /tmp/terraphim_redb)

  # пути хранилищ, явно указанные в settings.toml
  if [[ -f "${st}" ]]; then
    while IFS= read -r v; do
      v="${v/#\~/${HOME}}"
      [[ "${v}" == *'$'* ]] && continue
      roots+=("${v}")
    done < <(sed -nE 's/^[[:space:]]*(datadir|root|connection_string)[[:space:]]*=[[:space:]]*"([^"]+)".*/\2/p' "${st}")
  fi

  local -a found=()
  for r in "${roots[@]}"; do
    if [[ -f "${r}" ]]; then
      found+=("${r}")
    elif [[ -d "${r}" ]]; then
      while IFS= read -r -d '' f; do found+=("${f}"); done < <(
        find "${r}" -maxdepth 3 -path "${MIRROR_DIR}" -prune -o -type f \
             \( -name '*.db' -o -name '*.db-wal' -o -name '*.db-shm' \
                -o -name '*.sqlite' -o -name '*.sqlite3' -o -name '*.redb' \) \
             -print0 2>/dev/null)
    fi
  done

  [[ "${#found[@]}" -gt 0 ]] || { warn "Файлы сохранённого состояния агента не найдены."; return 1; }
  for f in "${found[@]}"; do
    [[ -e "${f}" ]] || continue            # мог уже уехать (дубликат в списке)
    mv -- "${f}" "${f}.bak-${stamp}" && log "Отложено: ${f} → ${f}.bak-${stamp}"
  done
  return 0
}

# Убеждается, что роль ROLE_NAME у агента действительно смотрит на каталоги $@.
activate_role_config() {
  local bin st=0
  bin="$(agent_bin)" || { warn "${BIN_NAME} не найден — проверку активного каталога пропускаю."; return 0; }

  if [[ "${RESET_STATE}" -eq 1 ]]; then
    log "Сбрасываю сохранённое состояние агента (--reset-state)"
    reset_persisted_config || true
    RUST_LOG=error "${bin}" config show >/dev/null 2>&1 || true   # заново читает role_config
  fi

  log "Проверяю, какой каталог поиска реально активен у агента"
  haystacks_match "${bin}" "$@" || st=$?
  case "${st}" in
    0) ok "Активный каталог поиска: $*"; return 0 ;;
    2) warn "Не удалось разобрать вывод «${BIN_NAME} config show» — проверку пропускаю."; return 0 ;;
  esac

  warn "Агент использует другой, ранее сохранённый конфиг."
  warn "  активно: ${ACTIVE_NOW:-(роли «${ROLE_NAME}» нет)}"
  warn "  нужно:   $*"

  # 1) штатный способ — перечитать role_config
  log "Пробую «${BIN_NAME} config reload»"
  { RUST_LOG=error "${bin}" config reload \
      || RUST_LOG=error "${bin}" config reload "${ROLE_JSON}"; } >/dev/null 2>&1 || true
  st=0; haystacks_match "${bin}" "$@" || st=$?
  if [[ "${st}" -eq 0 ]]; then ok "Конфиг перечитан, активный каталог: $*"; return 0; fi

  # 2) сброс сохранённого состояния — агент заново прочтёт role_config
  log "Сбрасываю сохранённое состояние агента"
  if reset_persisted_config; then
    RUST_LOG=error "${bin}" config show >/dev/null 2>&1 || true
    st=0; haystacks_match "${bin}" "$@" || st=$?
    if [[ "${st}" -eq 0 ]]; then ok "Состояние сброшено, активный каталог: $*"; return 0; fi
  fi

  warn "Не удалось сделать активным каталог $* (сейчас: ${ACTIVE_NOW:-?})."
  warn "Диагностика:  RUST_LOG=debug ${BIN_NAME} config show"
  warn "Запасной вариант (конфиг только из файла, без сохранённого):"
  warn "  ${BIN_NAME} --config ${ROLE_JSON} search \"слово\""
  return 0
}

# --- Локальный поиск ---------------------------------------------------------
# Создаёт JSON с одной ролью (Ripgrep-haystack на каждый каталог из LIB_DIRS)
# и прописывает его в settings.toml. Граф знаний не используется:
# relevance_function = bm25, kg = null — работает «из коробки» без kg-каталога.
configure_local_search() {
  [[ "${NO_LOCAL}" -eq 1 ]] && return 0

  install_deps python3
  have python3 || die 7 "Для настройки локального поиска нужен python3"

  mkdir -p "${CFG_DIR}" "${DATA_DIR}" || die 7 "Не удалось создать ${CFG_DIR}"

  # Раскрываем пути до абсолютных и предупреждаем о несуществующих
  local d abs dirs=()
  for d in "${LIB_DIRS[@]}"; do
    abs="$(realpath -m -- "${d/#\~/${HOME}}")"
    if [[ -d "${abs}" ]]; then
      dirs+=("${abs}")
    else
      warn "Каталог не существует, пропускаю: ${abs}"
    fi
  done
  [[ "${#dirs[@]}" -gt 0 ]] \
    || die 7 "Нет ни одного существующего каталога для поиска (см. --library)"

  if [[ "${CONVERT}" -eq 1 ]]; then
    sync_library "${dirs[@]}"
    dirs=("${MIRROR_DIR}")
  fi

  log "Настраиваю роль «${ROLE_NAME}»: ${dirs[*]}"

  python3 - "${ROLE_JSON}" "${ROLE_NAME}" "${dirs[@]}" <<'PY' || die 7 "Не удалось создать ${ROLE_JSON}"
import json, sys

out, name, *dirs = sys.argv[1:]
config = {
    "id": "Embedded",
    "global_shortcut": None,
    "roles": {
        name: {
            "shortname": name,
            "name": name,
            "relevance_function": "bm25",
            "terraphim_it": False,
            "theme": "spacelab",
            "kg": None,
            "haystacks": [
                {
                    "location": d,
                    "service": "Ripgrep",
                    "read_only": True,
                    "atomic_server_secret": None,
                    "extra_parameters": {},
                }
                for d in dirs
            ],
            "llm_provider": None,
            "llm_auto_summarize": False,
            "llm_system_prompt": "",
            "extra": {},
        }
    },
    "default_role": name,
    "selected_role": name,
}
with open(out, "w", encoding="utf-8") as f:
    json.dump(config, f, ensure_ascii=False, indent=2)
    f.write("\n")
PY
  ok "Конфиг роли: ${ROLE_JSON}"

  # settings.toml: role_config должен быть ключом верхнего уровня (до любых [таблиц]),
  # поэтому пишем его первой строкой, а старую строку role_config из файла выбрасываем.
  local st="${CFG_DIR}/settings.toml" tmp
  tmp="$(mktemp)"
  {
    printf 'role_config = "%s"\n' "${ROLE_JSON}"
    if [[ -f "${st}" ]]; then
      cp -n "${st}" "${st}.bak" 2>/dev/null || true
      grep -v -E '^[[:space:]]*role_config[[:space:]]*=' "${st}" || true
    else
      printf 'default_data_path = "%s"\n' "${DATA_DIR}"
    fi
  } > "${tmp}"
  install -m 0644 "${tmp}" "${st}" && rm -f "${tmp}"
  ok "Обновлён ${st}"

  # Сам по себе новый конфиг ничего не гарантирует: если у агента уже есть
  # сохранённый (с прежним haystack), он перекроет role_config.
  activate_role_config "${dirs[@]}"
}

# --- PATH и проверка ---------------------------------------------------------
ensure_path() {
  case ":${PATH}:" in
    *":${INSTALL_DIR}:"*) return 0 ;;
  esac

  warn "${INSTALL_DIR} отсутствует в PATH"
  # Дописываем в ~/.bashrc, только если там ещё нет такой строки
  local rc="${HOME}/.bashrc" line
  line="export PATH=\"\$PATH:${INSTALL_DIR}\""
  if ! grep -qsF "${line}" "${rc}"; then
    printf '\n# Terraphim AI\n%s\n' "${line}" >> "${rc}"
    log "Добавлено в ${rc}. Выполните: source ~/.bashrc  (или откройте новый терминал)"
  fi
  export PATH="${PATH}:${INSTALL_DIR}"
}

# Самопроверка: ищем слово из реально сконвертированной книги
probe_search() {
  local bin="$1"
  [[ -n "${PROBE_WORD}" ]] || { ok "Поиск: ${BIN_NAME} search \"слово\""; return 0; }
  log "Проверка поиска по слову «${PROBE_WORD}»"
  if [[ -n "$("${bin}" search "${PROBE_WORD}" --role "${ROLE_NAME}" 2>/dev/null | head -c 1)" ]]; then
    ok "Поиск работает: ${BIN_NAME} search \"коммунизм\""
  else
    warn "Поиск по «${PROBE_WORD}» ничего не вернул (файлы лежат в ${MIRROR_DIR})."
    warn "Диагностика: RUST_LOG=debug ${BIN_NAME} search \"${PROBE_WORD}\" --role ${ROLE_NAME}"
  fi
}

verify() {
  local bin="${INSTALL_DIR}/${BIN_NAME}"
  log "Проверка установки"
  "${bin}" --version || die 6 "Бинарник не запускается"
  # check-update — «мягкая» проверка: сбой не считаем фатальным
  "${bin}" check-update || warn "check-update завершился с ошибкой (сеть?)"

  if [[ "${NO_LOCAL}" -eq 0 ]]; then
    log "Проверка роли «${ROLE_NAME}»"
    if "${bin}" roles list 2>&1 | grep -qF "${ROLE_NAME}"; then
      ok "Роль «${ROLE_NAME}» активна"
      probe_search "${bin}"
    else
      warn "Роль «${ROLE_NAME}» не видна. Возможно, агент использует ранее сохранённый конфиг."
      warn "Запасной вариант: ${BIN_NAME} --config ${ROLE_JSON} search \"коммунизм\""
    fi
  fi
  ok "Готово. Запуск REPL: ${BIN_NAME} repl"
}

# --- main --------------------------------------------------------------------
main() {
  parse_args "$@"

  if [[ "${SYNC_ONLY}" -eq 1 ]]; then
    [[ "${NO_LOCAL}" -eq 0 ]] || die 1 "--sync-only несовместим с --no-local-search"
    local bin="${INSTALL_DIR}/${BIN_NAME}"
    [[ -x "${bin}" ]] || bin="$(command -v "${BIN_NAME}" || true)"
    [[ -n "${bin}" ]] || die 6 "${BIN_NAME} не найден — сначала запустите без --sync-only"
    configure_local_search
    probe_search "${bin}"
    exit 0
  fi

  [[ "$(uname -s)" == "Linux" ]] || die 1 "Скрипт предназначен для Linux (Fedora)"
  check_fedora

  case "${METHOD}" in
    binary)   install_binary ;;
    official) install_official ;;
    cargo)    install_cargo ;;
    source)   install_source ;;
  esac

  configure_local_search
  ensure_path
  verify
}

main "$@"
← Back to timeline