#!/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 "$@"10 views