Files
TencentDB-Agent-Memory/scripts/memory-tencentdb-ctl.sh
2026-05-29 17:51:28 +08:00

1031 lines
38 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# memory-tencentdb-ctl.sh — memory_tencentdb (TDAI) 服务统一管理脚本
#
# 两种运行模式:
#
# 默认:standalone 模式
# Gateway 以独立 HTTP 服务方式运行,完全不触碰 ~/.hermes/
# 日志目录 : $TDAI_DATA_DIR/logs/
# 配置文件 : $TDAI_DATA_DIR/tdai-gateway.json llm / embedding / tcvdb
# Gateway 端: 127.0.0.1:8420
#
# --hermes 模式(需显式传 --hermes 或设 MEMORY_TENCENTDB_MODE=hermes
# 额外做 hermes 集成 —— 路径约定沿用 install_hermes_tdai_gateway.sh
# 日志目录 : ~/.hermes/logs/memory_tencentdb/
# env 片段文件 : ~/.hermes/env.d/memory-tencentdb-llm.sh config llm 会写入)
# hermes 主配置 : ~/.hermes/config.yaml enable-hermes-memory 会修改)
# 目的:hermes 的 supervisor 可通过 os.environ.copy() 把 LLM 凭据继承给
# 它自己托管起来的 Gateway 子进程;也方便 hermes 端排障。
#
# 命令:
# start | stop | restart | status | logs | health
# config llm --api-key <k> --base-url <u> --model <m>
# config embedding --provider <p> --api-key <k> --base-url <u> --model <m> --dimensions <d>
# [--proxy-url <u>]
# config vdb --url <u> --username <u> --api-key <k> --database <d> [--ca-pem <path>]
# config show
# enable-hermes-memory # 仅 --hermes 模式:把 hermes config.yaml 的 memory.provider
# # 置为 memory_tencentdb
#
# 绝大多数子命令支持 --dry-run;写入操作均使用临时文件 + rename 原子替换,
# 生成的敏感文件权限设为 0600。
set -euo pipefail
# ============================================================
# 常量 / 路径
# ============================================================
SCRIPT_NAME="memory-tencentdb-ctl"
USER_HOME="${HOME:-$(eval echo "~$(whoami)")}"
# memory-tencentdb 统一根目录(所有 tdai 相关数据/代码默认收纳在此)
# 可通过环境变量 MEMORY_TENCENTDB_ROOT 覆盖
MEMORY_TENCENTDB_ROOT="${MEMORY_TENCENTDB_ROOT:-$USER_HOME/.memory-tencentdb}"
TDAI_INSTALL_DIR="${TDAI_INSTALL_DIR:-$MEMORY_TENCENTDB_ROOT/tdai-memory-openclaw-plugin}"
TDAI_DATA_DIR="${TDAI_DATA_DIR:-$MEMORY_TENCENTDB_ROOT/memory-tdai}"
GATEWAY_CFG="$TDAI_DATA_DIR/tdai-gateway.json"
# 旧路径(仅做提示,不自动迁移;迁移由 install_hermes_memory_tencentdb.sh 负责)
_LEGACY_INSTALL_DIR="$USER_HOME/tdai-memory-openclaw-plugin"
_LEGACY_DATA_DIR="$USER_HOME/memory-tdai"
if [ -z "${TDAI_INSTALL_DIR_EXPLICIT:-}" ] && [ ! -e "$TDAI_INSTALL_DIR" ] && [ -e "$_LEGACY_INSTALL_DIR" ]; then
printf '[%s] WARN: legacy install dir detected at %s; new default is %s. Run install_hermes_memory_tencentdb.sh to migrate, or `export TDAI_INSTALL_DIR=%s` to keep old location.\n' \
"$SCRIPT_NAME" "$_LEGACY_INSTALL_DIR" "$TDAI_INSTALL_DIR" "$_LEGACY_INSTALL_DIR" >&2
fi
if [ -z "${TDAI_DATA_DIR_EXPLICIT:-}" ] && [ ! -e "$TDAI_DATA_DIR" ] && [ -e "$_LEGACY_DATA_DIR" ]; then
printf '[%s] WARN: legacy data dir detected at %s; new default is %s. Run install_hermes_memory_tencentdb.sh to migrate, or `export TDAI_DATA_DIR=%s` to keep old location.\n' \
"$SCRIPT_NAME" "$_LEGACY_DATA_DIR" "$TDAI_DATA_DIR" "$_LEGACY_DATA_DIR" >&2
fi
# hermes 路径仅在 --hermes 模式下使用;此处保留定义以便 helper 复用。
HERMES_HOME="${HERMES_HOME:-$USER_HOME/.hermes}"
HERMES_CONFIG="$HERMES_HOME/config.yaml"
HERMES_ENV_DIR="$HERMES_HOME/env.d"
GATEWAY_HOST="${MEMORY_TENCENTDB_GATEWAY_HOST:-127.0.0.1}"
GATEWAY_PORT="${MEMORY_TENCENTDB_GATEWAY_PORT:-8420}"
# 运行模式:standalone(默认)| hermes
MODE="${MEMORY_TENCENTDB_MODE:-standalone}"
# 这些会在 _apply_mode_paths 中根据 MODE 实际赋值
HERMES_LOG_DIR=""
PID_FILE=""
STDOUT_LOG=""
STDERR_LOG=""
DRY_RUN=0
# ============================================================
# 通用 helpers
# ============================================================
log() { printf '[%s] %s\n' "$SCRIPT_NAME" "$*"; }
warn() { printf '[%s:warn] %s\n' "$SCRIPT_NAME" "$*" >&2; }
die() { printf '[%s:error] %s\n' "$SCRIPT_NAME" "$*" >&2; exit "${2:-1}"; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" 127
}
# 安全 shell 引用,避免 api_key 等特殊字符破坏 source
shell_quote() {
printf '%s' "$1" | sed -e "s/'/'\\\\''/g" -e "1s/^/'/" -e "\$s/\$/'/"
}
# 原子写文件:write_file <path> <mode> <stdin 内容>
write_file_atomic() {
local path="$1" mode="$2"
local dir; dir="$(dirname "$path")"
mkdir -p "$dir"
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] would write $path (mode=$mode):"
sed 's/^/ /'
return 0
fi
local tmp; tmp="$(mktemp "$dir/.${SCRIPT_NAME}.XXXXXX")"
cat > "$tmp"
chmod "$mode" "$tmp"
mv -f "$tmp" "$path"
log "wrote $path (mode=$mode)"
}
# 端口上的监听 PID(优先 lsof,兜底 ss)
listening_pids() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null || true
elif command -v ss >/dev/null 2>&1; then
ss -ltnpH "sport = :$port" 2>/dev/null \
| sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u
fi
}
# 健康检查(不依赖 curl,用 python3
health_check() {
local timeout="${1:-3}"
python3 - "$GATEWAY_HOST" "$GATEWAY_PORT" "$timeout" <<'PYEOF' 2>/dev/null
import json, sys, urllib.request
host, port, timeout = sys.argv[1], int(sys.argv[2]), float(sys.argv[3])
url = f"http://{host}:{port}/health"
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
body = r.read().decode("utf-8", "replace")
print(body)
sys.exit(0)
except Exception as e:
print(f"health check failed: {e}", file=sys.stderr)
sys.exit(1)
PYEOF
}
# 根据 MODE 解析日志 / PID 目录。必须在解析完 --hermes 后、任何 ensure_paths
# / 启动逻辑之前调用。
_apply_mode_paths() {
case "$MODE" in
standalone)
HERMES_LOG_DIR="${MEMORY_TENCENTDB_LOG_DIR:-$TDAI_DATA_DIR/logs}"
;;
hermes)
HERMES_LOG_DIR="${MEMORY_TENCENTDB_LOG_DIR:-$HERMES_HOME/logs/memory_tencentdb}"
;;
*)
die "invalid MODE: $MODE (expected standalone | hermes)" 1
;;
esac
PID_FILE="$HERMES_LOG_DIR/gateway.pid"
STDOUT_LOG="$HERMES_LOG_DIR/gateway.stdout.log"
STDERR_LOG="$HERMES_LOG_DIR/gateway.stderr.log"
}
# 仅在 --hermes 模式下执行的守卫。非 hermes 模式调用 hermes 专属命令会直接退出。
require_hermes_mode() {
[[ "$MODE" == "hermes" ]] || die \
"'$1' 仅在 --hermes 模式下可用;请追加 --hermes 或设 MEMORY_TENCENTDB_MODE=hermes" 1
}
ensure_paths() {
mkdir -p "$HERMES_LOG_DIR" "$TDAI_DATA_DIR"
[[ "$MODE" == "hermes" ]] && mkdir -p "$HERMES_ENV_DIR"
return 0
}
# 在需要 source 用户 env 的命令里调用。standalone 模式下只读取顶层 /etc/profile.d/
# 的系统级配置(保持和 install 脚本兼容),不 source ~/.hermes/env.d/*。
source_user_envs() {
# 系统级:install_hermes_tdai_gateway.sh 写入的 /etc/profile.d/memory-tencentdb-env.sh
# 里只有 Gateway 自身需要的变量(port/host/cmd/llm env),两种模式都可安全 source。
if [[ -r /etc/profile.d/memory-tencentdb-env.sh ]]; then
# shellcheck disable=SC1091
source /etc/profile.d/memory-tencentdb-env.sh
fi
if [[ "$MODE" == "hermes" ]]; then
if [[ -r /etc/profile.d/hermes-env.sh ]]; then
# shellcheck disable=SC1091
source /etc/profile.d/hermes-env.sh
fi
# 用户级 env.d,优先级更高
if [[ -d "$HERMES_ENV_DIR" ]]; then
local f
for f in "$HERMES_ENV_DIR"/*.sh; do
[[ -r "$f" ]] || continue
# shellcheck disable=SC1090
source "$f"
done
fi
fi
}
# ============================================================
# 启动命令构造
#
# 优先级:
# 1. MEMORY_TENCENTDB_GATEWAY_CMDinstall 脚本写入的环境变量)
# 2. 本地 tsx: cd $TDAI_INSTALL_DIR && npx tsx src/gateway/server.ts
# ============================================================
resolve_gateway_cmd() {
if [[ -n "${MEMORY_TENCENTDB_GATEWAY_CMD:-}" ]]; then
printf '%s' "$MEMORY_TENCENTDB_GATEWAY_CMD"
return 0
fi
local entry="$TDAI_INSTALL_DIR/src/gateway/server.ts"
[[ -f "$entry" ]] || die "Gateway entry not found: $entry (是否已执行 install_hermes_tdai_gateway.sh)"
# 与 install 脚本相同风格:sh -c 'cd ... && exec npx tsx ...'
printf "sh -c 'cd %s && exec npx tsx src/gateway/server.ts'" "$TDAI_INSTALL_DIR"
}
# ============================================================
# 子命令:start / stop / restart / status / logs / health
# ============================================================
cmd_start() {
ensure_paths
source_user_envs
local pids; pids="$(listening_pids "$GATEWAY_PORT")"
if [[ -n "$pids" ]]; then
warn "Gateway 已在 :$GATEWAY_PORT 运行 (pid=$pids)"
return 0
fi
need_cmd node
need_cmd npx
local gw_cmd; gw_cmd="$(resolve_gateway_cmd)"
log "starting gateway: $gw_cmd"
log "stdout -> $STDOUT_LOG"
log "stderr -> $STDERR_LOG"
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] skip spawn"
return 0
fi
# setsid 让 Gateway 脱离当前 shell 的进程组;nohup 兜底保证终端断开不挂
# eval 是必要的:gw_cmd 是 "sh -c '...'" 这种带引号结构
if command -v setsid >/dev/null 2>&1; then
eval "setsid nohup $gw_cmd >>\"$STDOUT_LOG\" 2>>\"$STDERR_LOG\" </dev/null &"
else
eval "nohup $gw_cmd >>\"$STDOUT_LOG\" 2>>\"$STDERR_LOG\" </dev/null &"
fi
local bg_pid=$!
echo "$bg_pid" > "$PID_FILE"
log "spawned pid=$bg_pid (shell wrapper)"
# 等待端口监听起来 / 健康检查
local i
for i in $(seq 1 30); do
sleep 0.5
if [[ -n "$(listening_pids "$GATEWAY_PORT")" ]]; then
if health_check 2 >/dev/null 2>&1; then
log "gateway healthy on http://$GATEWAY_HOST:$GATEWAY_PORT"
return 0
fi
fi
done
warn "gateway 未在 15s 内通过健康检查,请查看 $STDERR_LOG"
return 1
}
cmd_stop() {
local pids; pids="$(listening_pids "$GATEWAY_PORT")"
if [[ -z "$pids" ]]; then
log "no gateway listening on :$GATEWAY_PORT"
# 同时清理可能残留的 shell wrapper
if [[ -f "$PID_FILE" ]]; then
local wpid; wpid="$(cat "$PID_FILE" 2>/dev/null || true)"
[[ -n "$wpid" ]] && kill -0 "$wpid" 2>/dev/null && kill -TERM "$wpid" 2>/dev/null || true
rm -f "$PID_FILE"
fi
return 0
fi
log "sending SIGTERM to: $pids"
[[ $DRY_RUN -eq 1 ]] && { log "[dry-run] skip kill"; return 0; }
# shellcheck disable=SC2086
kill -TERM $pids 2>/dev/null || true
local i
for i in $(seq 1 10); do
sleep 0.5
pids="$(listening_pids "$GATEWAY_PORT")"
[[ -z "$pids" ]] && break
done
if [[ -n "$pids" ]]; then
warn "SIGTERM 未生效,发送 SIGKILL: $pids"
# shellcheck disable=SC2086
kill -KILL $pids 2>/dev/null || true
fi
rm -f "$PID_FILE"
log "gateway stopped"
}
cmd_restart() {
cmd_stop || true
sleep 0.5
cmd_start
}
cmd_status() {
local pids; pids="$(listening_pids "$GATEWAY_PORT")"
echo "== memory_tencentdb Gateway =="
echo " mode : $MODE"
echo " host:port : $GATEWAY_HOST:$GATEWAY_PORT"
echo " install : $TDAI_INSTALL_DIR"
echo " data dir : $TDAI_DATA_DIR"
echo " log dir : $HERMES_LOG_DIR"
echo " config : $GATEWAY_CFG $([[ -f $GATEWAY_CFG ]] && echo '[exists]' || echo '[missing]')"
if [[ "$MODE" == "hermes" ]]; then
echo " hermes cfg: $HERMES_CONFIG $([[ -f $HERMES_CONFIG ]] && echo '[exists]' || echo '[missing]')"
fi
if [[ -n "$pids" ]]; then
echo " state : RUNNING (pid=$pids)"
if health_check 2 >/dev/null 2>&1; then
echo " health : OK"
else
echo " health : UNHEALTHY"
fi
else
echo " state : STOPPED"
fi
if [[ "$MODE" == "hermes" ]]; then
echo
echo "== hermes memory provider =="
if [[ -f "$HERMES_CONFIG" ]]; then
local prov
prov="$(sed -n '/^memory:/,/^[a-zA-Z]/p' "$HERMES_CONFIG" \
| sed -n 's/^[[:space:]]*provider:[[:space:]]*//p' | head -n1)"
echo " memory.provider = ${prov:-<unset>}"
else
echo " (hermes config 不存在)"
fi
echo
echo "== env files =="
if [[ -d "$HERMES_ENV_DIR" ]]; then
ls -l "$HERMES_ENV_DIR"/*.sh 2>/dev/null || echo " (none)"
else
echo " $HERMES_ENV_DIR not found"
fi
fi
}
cmd_logs() {
local which="${1:-all}" lines="${2:-200}"
case "$which" in
out|stdout) tail -n "$lines" -f "$STDOUT_LOG" ;;
err|stderr) tail -n "$lines" -f "$STDERR_LOG" ;;
all|*)
log "tail $STDOUT_LOG & $STDERR_LOG (Ctrl-C 退出)"
tail -n "$lines" -f "$STDOUT_LOG" "$STDERR_LOG"
;;
esac
}
cmd_health() {
if health_check 3; then
log "gateway healthy"
else
die "gateway unhealthy" 1
fi
}
# ============================================================
# 子命令:config <llm|embedding|vdb|show>
#
# 写入两处(与 sync_tdai_llm.sh 一致):
# - $HERMES_ENV_DIR/memory-tencentdb-<section>.sh 仅 llm 段需要(通过环境变量暴露)
# - $GATEWAY_CFG (JSON) 三段 llm / embedding / tcvdb 都合并写入
# ============================================================
# ---- JSON 合并 helper ----
# 用法:merge_gateway_json "<section>" <<<"$json_fragment"
# section: llm / embedding / tcvdb
merge_gateway_json() {
local section="$1"
ensure_paths
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] would merge '$section' into $GATEWAY_CFG"
sed 's/^/ /'
return 0
fi
need_cmd python3
local fragment; fragment="$(cat)"
SECTION="$section" FRAGMENT="$fragment" CFG="$GATEWAY_CFG" \
python3 - <<'PYEOF'
import json, os, tempfile
section = os.environ["SECTION"]
fragment = json.loads(os.environ["FRAGMENT"])
path = os.environ["CFG"]
cfg = {}
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f) or {}
except Exception:
cfg = {}
# memory.* 段嵌套在 "memory" 下(gateway config.ts 的 loadGatewayConfig 约定),
# 但 llm 是顶层段(见 src/gateway/config.ts 第 79 行 obj(fileConfig,"llm"))。
if section == "llm":
merged = cfg.get("llm") or {}
merged.update(fragment)
cfg["llm"] = merged
else:
mem = cfg.get("memory") or {}
sub = mem.get(section) or {}
sub.update(fragment)
mem[section] = sub
cfg["memory"] = mem
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".tdai-gateway.", dir=d)
os.close(fd)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
PYEOF
log "merged '$section' into $GATEWAY_CFG (0600)"
}
# ---- config llm ----
cmd_config_llm() {
local api_key="" base_url="" model="" restart=0
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) api_key="$2"; shift 2 ;;
--base-url) base_url="$2"; shift 2 ;;
--model) model="$2"; shift 2 ;;
--restart) restart=1; shift ;;
*) die "config llm: 未知参数 $1" 1 ;;
esac
done
[[ -n "$api_key" ]] || die "--api-key 必填"
[[ -n "$base_url" ]] || die "--base-url 必填"
[[ -n "$model" ]] || die "--model 必填"
case "$base_url" in
http://*|https://*) ;;
*) die "--base-url 必须以 http:// 或 https:// 开头: $base_url" 1 ;;
esac
log "configure LLM: model=$model base_url=$base_url api_key=<${#api_key} chars>"
# 1) env 文件(仅 --hermes 模式):供 hermes 启动时 source
# hermes supervisor 再通过 os.environ.copy() 把凭据注入它自己托管的 Gateway 子进程。
# standalone 模式下 Gateway 直接读 tdai-gateway.json,无需 env 文件。
if [[ "$MODE" == "hermes" ]]; then
local qk qu qm
qk="$(shell_quote "$api_key")"
qu="$(shell_quote "$base_url")"
qm="$(shell_quote "$model")"
write_file_atomic "$HERMES_ENV_DIR/memory-tencentdb-llm.sh" 600 <<EOF
# Auto-generated by $SCRIPT_NAME — do not edit by hand.
# Source this file in the shell that launches hermes so MemoryTencentdbProvider
# inherits the credentials via os.environ.copy().
export TDAI_LLM_BASE_URL=$qu
export TDAI_LLM_API_KEY=$qk
export TDAI_LLM_MODEL=$qm
# Legacy aliases used by the Python provider's get_config_schema()
export MEMORY_TENCENTDB_LLM_BASE_URL="\$TDAI_LLM_BASE_URL"
export MEMORY_TENCENTDB_LLM_API_KEY="\$TDAI_LLM_API_KEY"
export MEMORY_TENCENTDB_LLM_MODEL="\$TDAI_LLM_MODEL"
EOF
fi
# 2) gateway.json 合并(两种模式都写)
local frag
frag=$(API="$api_key" URL="$base_url" MDL="$model" python3 -c '
import json, os
print(json.dumps({"baseUrl": os.environ["URL"], "apiKey": os.environ["API"], "model": os.environ["MDL"]}))
')
printf '%s' "$frag" | merge_gateway_json llm
[[ $restart -eq 1 ]] && cmd_restart || log "tip: 追加 --restart 可立刻重启 Gateway 让新 LLM 生效"
}
# ---- config embedding ----
cmd_config_embedding() {
local provider="" api_key="" base_url="" model="" dimensions="" proxy_url="" restart=0
while [[ $# -gt 0 ]]; do
case "$1" in
--provider) provider="$2"; shift 2 ;;
--api-key) api_key="$2"; shift 2 ;;
--base-url) base_url="$2"; shift 2 ;;
--model) model="$2"; shift 2 ;;
--dimensions) dimensions="$2"; shift 2 ;;
--proxy-url) proxy_url="$2"; shift 2 ;;
--restart) restart=1; shift ;;
*) die "config embedding: 未知参数 $1" 1 ;;
esac
done
[[ -n "$provider" ]] || die "--provider 必填(none/openai/deepseek/qclaw/..."
# provider=none 只写 provider,其余置空,相当于关闭向量检索
if [[ "$provider" == "none" ]]; then
printf '%s' '{"provider":"none","enabled":false}' | merge_gateway_json embedding
log "embedding disabled (provider=none)"
[[ $restart -eq 1 ]] && cmd_restart
return 0
fi
[[ -n "$api_key" ]] || die "--api-key 必填"
[[ -n "$base_url" ]] || die "--base-url 必填"
[[ -n "$model" ]] || die "--model 必填"
[[ -n "$dimensions" ]] || die "--dimensions 必填 (例如 1024)"
case "$base_url" in
http://*|https://*) ;;
*) die "--base-url 必须以 http:// 或 https:// 开头: $base_url" 1 ;;
esac
[[ "$dimensions" =~ ^[0-9]+$ ]] || die "--dimensions 必须为正整数: $dimensions" 1
if [[ "$provider" == "qclaw" && -z "$proxy_url" ]]; then
die "provider=qclaw 需要额外的 --proxy-url" 1
fi
log "configure embedding: provider=$provider model=$model dims=$dimensions"
local frag
frag=$(
PROV="$provider" API="$api_key" URL="$base_url" MDL="$model" \
DIM="$dimensions" PROXY="$proxy_url" python3 -c '
import json, os
out = {
"enabled": True,
"provider": os.environ["PROV"],
"baseUrl": os.environ["URL"],
"apiKey": os.environ["API"],
"model": os.environ["MDL"],
"dimensions": int(os.environ["DIM"]),
}
proxy = os.environ.get("PROXY", "")
if proxy:
out["proxyUrl"] = proxy
print(json.dumps(out))
')
printf '%s' "$frag" | merge_gateway_json embedding
[[ $restart -eq 1 ]] && cmd_restart || log "tip: 追加 --restart 让 embedding 立即生效"
}
# ---- config vdb (Tencent Cloud VectorDB) ----
cmd_config_vdb() {
local url="" username="root" api_key="" database="" alias="" ca_pem="" embedding_model="" restart=0
local set_backend=1
while [[ $# -gt 0 ]]; do
case "$1" in
--url) url="$2"; shift 2 ;;
--username) username="$2"; shift 2 ;;
--api-key) api_key="$2"; shift 2 ;;
--database) database="$2"; shift 2 ;;
--alias) alias="$2"; shift 2 ;;
--ca-pem) ca_pem="$2"; shift 2 ;;
--embedding-model) embedding_model="$2"; shift 2 ;;
--no-set-backend) set_backend=0; shift ;;
--restart) restart=1; shift ;;
*) die "config vdb: 未知参数 $1" 1 ;;
esac
done
[[ -n "$url" ]] || die "--url 必填 (例如 http://xxx.tencentclb.com:8100)"
[[ -n "$api_key" ]] || die "--api-key 必填"
[[ -n "$database" ]] || die "--database 必填"
case "$url" in
http://*|https://*) ;;
*) die "--url 必须以 http:// 或 https:// 开头: $url" 1 ;;
esac
if [[ -n "$ca_pem" && ! -r "$ca_pem" ]]; then
die "--ca-pem 文件不可读: $ca_pem" 1
fi
log "configure VDB: url=$url database=$database user=$username"
local frag
frag=$(
URL="$url" USR="$username" API="$api_key" DB="$database" \
ALIAS="$alias" CA="$ca_pem" EM="$embedding_model" python3 -c '
import json, os
out = {
"url": os.environ["URL"],
"username": os.environ["USR"],
"apiKey": os.environ["API"],
"database": os.environ["DB"],
}
for k, env in [("alias","ALIAS"), ("caPemPath","CA"), ("embeddingModel","EM")]:
v = os.environ.get(env, "")
if v: out[k] = v
print(json.dumps(out))
')
printf '%s' "$frag" | merge_gateway_json tcvdb
# 默认同时把 storeBackend 切到 tcvdb(可用 --no-set-backend 关掉)
if [[ $set_backend -eq 1 ]]; then
printf '%s' '{"storeBackend":"tcvdb"}' | CFG="$GATEWAY_CFG" python3 -c '
import json, os, sys, tempfile
path = os.environ["CFG"]
fragment = json.loads(sys.stdin.read())
cfg = {}
if os.path.isfile(path):
try:
cfg = json.load(open(path, "r", encoding="utf-8")) or {}
except Exception:
cfg = {}
mem = cfg.get("memory") or {}
mem.update(fragment)
cfg["memory"] = mem
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".tdai-gateway.", dir=d); os.close(fd)
json.dump(cfg, open(tmp, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
os.chmod(tmp, 0o600); os.replace(tmp, path)
' || warn "设置 storeBackend 失败"
log "memory.storeBackend = tcvdb"
fi
[[ $restart -eq 1 ]] && cmd_restart || log "tip: 追加 --restart 让 VDB 配置立即生效"
}
# ---- config vdb-off ----
# 把 gateway.json 的 memory.storeBackend 切回 "sqlite"。
# 默认保留 memory.tcvdb 凭据(便于以后切回 vdb 不用重输);
# 传 --purge-creds 显式清掉 memory.tcvdb 整段。
cmd_config_vdb_off() {
local restart=0 purge=0
while [[ $# -gt 0 ]]; do
case "$1" in
--purge-creds) purge=1; shift ;;
--restart) restart=1; shift ;;
*) die "config vdb-off: 未知参数 $1" 1 ;;
esac
done
ensure_paths
local purge_note=""
[[ $purge -eq 1 ]] && purge_note=" (and remove memory.tcvdb)"
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] would set memory.storeBackend=sqlite in ${GATEWAY_CFG}${purge_note}"
[[ $restart -eq 1 ]] && log "[dry-run] would restart Gateway"
return 0
fi
if [[ ! -f "$GATEWAY_CFG" ]]; then
warn "$GATEWAY_CFG 不存在;写入仅含 storeBackend=sqlite 的最小配置"
fi
need_cmd python3
PURGE="$purge" CFG="$GATEWAY_CFG" python3 - <<'PYEOF' || die "回退到 sqlite 失败" 1
import json, os, sys, tempfile
path = os.environ["CFG"]
purge = os.environ.get("PURGE", "0") == "1"
cfg = {}
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f) or {}
except Exception as e:
sys.stderr.write(f"[memory-tencentdb-ctl:warn] 解析 {path} 失败,将以空配置重建: {e}\n")
cfg = {}
mem = cfg.get("memory") or {}
prev = mem.get("storeBackend")
mem["storeBackend"] = "sqlite"
if purge and "tcvdb" in mem:
mem.pop("tcvdb", None)
cfg["memory"] = mem
d = os.path.dirname(path) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".tdai-gateway.", dir=d); os.close(fd)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
sys.stderr.write(f"[memory-tencentdb-ctl] memory.storeBackend: {prev!r} -> 'sqlite'"
+ (" (tcvdb creds purged)" if purge else " (tcvdb creds kept)") + "\n")
PYEOF
if [[ $purge -eq 1 ]]; then
log "memory.storeBackend = sqlite (tcvdb creds purged)"
else
log "memory.storeBackend = sqlite (tcvdb creds kept; 加 --purge-creds 可清除)"
fi
[[ $restart -eq 1 ]] && cmd_restart || log "tip: 追加 --restart 让回退立即生效"
}
# ---- config show ----
cmd_config_show() {
echo "== $GATEWAY_CFG =="
if [[ -f "$GATEWAY_CFG" ]]; then
# 自动脱敏 apiKey
python3 - "$GATEWAY_CFG" <<'PYEOF'
import json, sys
cfg = json.load(open(sys.argv[1], "r", encoding="utf-8"))
def redact(d):
if isinstance(d, dict):
for k, v in d.items():
if isinstance(v, (dict, list)):
redact(v)
elif k.lower() in ("apikey","api_key","password","token") and isinstance(v, str) and v:
d[k] = f"<redacted:{len(v)} chars>"
elif isinstance(d, list):
for x in d: redact(x)
redact(cfg)
print(json.dumps(cfg, indent=2, ensure_ascii=False))
PYEOF
else
echo "(not found)"
fi
echo
if [[ "$MODE" == "hermes" ]]; then
echo "== env files =="
if [[ -d "$HERMES_ENV_DIR" ]]; then
local f
for f in "$HERMES_ENV_DIR"/memory-tencentdb-*.sh; do
[[ -r "$f" ]] || continue
echo "--- $f ---"
# 脱敏 API key 值
sed -E "s/(API_KEY=)'([^']{0,4})[^']*'/\1'\2<redacted>'/g" "$f"
done
fi
else
echo "(standalone 模式:不写 env.d;如需 hermes 集成请追加 --hermes)"
fi
}
# ============================================================
# 子命令:enable-hermes-memory (仅 --hermes 模式)
# 把 $HERMES_CONFIG 的 memory.provider 设置为 memory_tencentdb。
# 写入策略(按优先级,自动降级):
# 1) ruamel.yaml round-trip:完整保留注释、键序、引号、缩进风格
# 2) 最小化原位行编辑:只改 provider 行,缩进直接拷贝既有兄弟键
# 3) memory 段不存在 → 在末尾追加最小段
# ============================================================
cmd_enable_hermes_memory() {
require_hermes_mode "enable-hermes-memory"
[[ -f "$HERMES_CONFIG" ]] || die "hermes 配置不存在: $HERMES_CONFIG"
[[ $# -eq 0 ]] || die "enable-hermes-memory: 不支持额外参数: $*" 1
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] would set memory.provider=memory_tencentdb in $HERMES_CONFIG"
return 0
fi
need_cmd python3
# 写入策略(按优先级,自动降级):
# 1. ruamel.yaml round-trip:完整保留注释、键序、引号、缩进风格
# 2. 原位行编辑:只改 memory.provider 那一行;缩进直接拷贝同段兄弟键的前缀
# 3. memory 段不存在 → 在文件末尾追加最小段(此时无既有格式可破坏)
python3 - "$HERMES_CONFIG" <<'PYEOF'
import os, re, sys, tempfile
path = sys.argv[1]
TARGET = "memory_tencentdb"
def _atomic_write(text: str) -> None:
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".hermes-config.", dir=d)
os.close(fd)
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def try_ruamel() -> bool:
"""首选:用 ruamel.yaml round-trip 改写,完整保留注释/键序/缩进/引号。"""
try:
from ruamel.yaml import YAML # type: ignore
except Exception:
return False
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
# 不强制 default indentruamel 会沿用文档原有缩进风格
with open(path, "r", encoding="utf-8") as f:
data = yaml.load(f)
if data is None:
# 空文件:构造最小 mapping
from ruamel.yaml.comments import CommentedMap
data = CommentedMap()
if "memory" not in data or not hasattr(data.get("memory"), "__setitem__"):
from ruamel.yaml.comments import CommentedMap
data["memory"] = CommentedMap()
data["memory"]["provider"] = TARGET
import io
buf = io.StringIO()
yaml.dump(data, buf)
_atomic_write(buf.getvalue())
print(f"updated {path} (ruamel.yaml round-trip)")
return True
def fallback_inline_edit() -> None:
"""兜底:纯文本最小化原位编辑,永不重写整个文件结构。
规则:
- 找到顶级 `memory:` 段及其 block(直到下一个顶级键)
- 若 block 内已有 `^(\\s+)provider\\s*:` 行 → 用相同前缀替换该行
(缩进直接复用现有 provider 行,零猜测)
- 否则在 block 内"拓印"任意已存在兄弟键的缩进,紧跟 `memory:` 之后插入一行
- 若 block 完全为空(仅 memory: 一行)→ 在 `memory:` 后插入一行,
缩进 copy 自同文件中其他顶级 mapping 的子键缩进;找不到再退化为 2
- 若全文没有 `memory:` 顶级段 → 在文件末尾追加最小段
"""
with open(path, "r", encoding="utf-8") as f:
lines = f.readlines()
top_key_re = re.compile(r"^[A-Za-z_][\w\-]*\s*:")
memory_start_re = re.compile(r"^memory\s*:\s*(#.*)?$")
sibling_key_re = re.compile(r"^(\s+)[A-Za-z_][\w\-]*\s*:")
provider_line_re = re.compile(r"^(\s+)provider(\s*):(\s*)(.*)$")
def infer_indent_from_doc() -> str:
"""从文档中其他顶级 mapping 的首个子键提取缩进字符串。"""
in_top = False
for ln in lines:
if top_key_re.match(ln):
in_top = True
continue
if in_top:
if not ln.strip() or ln.lstrip().startswith("#"):
continue
m = sibling_key_re.match(ln)
if m:
return m.group(1)
if top_key_re.match(ln):
in_top = True
continue
in_top = False
return " " # 极端情况:整个文件只有 memory: 自己
# 定位 memory: 顶级段
mem_idx = -1
for idx, ln in enumerate(lines):
if memory_start_re.match(ln):
mem_idx = idx
break
if mem_idx == -1:
# 全文无 memory 段:直接追加(不存在格式破坏问题)
indent_str = infer_indent_from_doc()
if lines and not lines[-1].endswith("\n"):
lines.append("\n")
lines.append("memory:\n")
lines.append(f"{indent_str}provider: {TARGET}\n")
_atomic_write("".join(lines))
print(f"updated {path} (appended new memory section)")
return
# 圈定 block 范围:[mem_idx+1, end)
end = len(lines)
for j in range(mem_idx + 1, len(lines)):
if top_key_re.match(lines[j]):
end = j
break
block = lines[mem_idx + 1:end]
# 1) 若已有 provider 行,用相同前缀原位替换该行
for k, b in enumerate(block):
m = provider_line_re.match(b)
if m:
indent = m.group(1)
sp_before = m.group(2)
sp_after = m.group(3) or " "
# 保留行尾注释(# 后内容)
tail = m.group(4)
comment = ""
ci = tail.find("#")
if ci >= 0:
# 简单处理:value 是 # 之前的部分(去除右侧空格),后续保留注释
comment = " " + tail[ci:].rstrip("\n")
new_line = f"{indent}provider{sp_before}:{sp_after}{TARGET}{comment}\n"
lines[mem_idx + 1 + k] = new_line
_atomic_write("".join(lines))
print(f"updated {path} (replaced provider line in-place)")
return
# 2) 无 provider 行:拓印同 block 内其他兄弟键的缩进
sibling_indent = None
for b in block:
if not b.strip() or b.lstrip().startswith("#"):
continue
m = sibling_key_re.match(b)
if m:
sibling_indent = m.group(1)
break
if sibling_indent is None:
# block 内无任何兄弟键(如刚刚新建/只有注释)→ 从文档其它段推断
sibling_indent = infer_indent_from_doc()
insert_at = mem_idx + 1
new_line = f"{sibling_indent}provider: {TARGET}\n"
lines.insert(insert_at, new_line)
_atomic_write("".join(lines))
print(f"updated {path} (inserted provider line)")
if not try_ruamel():
sys.stderr.write(
"[memory-tencentdb-ctl:info] ruamel.yaml 未安装,使用最小化原位编辑回退方案 "
"(pip install ruamel.yaml 可获得最佳保真度)\n"
)
fallback_inline_edit()
PYEOF
log "hermes memory.provider = memory_tencentdb"
}
# ============================================================
# 命令分发
# ============================================================
usage() {
cat <<'USAGE'
memory-tencentdb-ctl.sh — memory_tencentdb (TDAI) Gateway 管理脚本
运行模式:
standalone (默认) Gateway 独立运行;日志落在 $TDAI_DATA_DIR/logs/;不触碰 ~/.hermes
--hermes 额外做 hermes 集成:日志落 ~/.hermes/logs/memory_tencentdb/
config llm 同步写 ~/.hermes/env.d/memory-tencentdb-llm.sh
并开放 enable-hermes-memory 子命令
(也可通过 MEMORY_TENCENTDB_MODE=hermes 全局生效)
常用:
memory-tencentdb-ctl start 启动 Gateway
memory-tencentdb-ctl stop 停止 Gateway
memory-tencentdb-ctl restart 重启 Gateway
memory-tencentdb-ctl status 查看状态
memory-tencentdb-ctl health 健康检查 (/health)
memory-tencentdb-ctl logs [out|err|all] [N=200] 滚动查看日志
配置 (默认只写 $TDAI_DATA_DIR/tdai-gateway.json,即 ~/.memory-tencentdb/memory-tdai/tdai-gateway.json
--hermes 时 LLM 额外写 env.d):
memory-tencentdb-ctl config llm --api-key K --base-url U --model M [--restart]
memory-tencentdb-ctl config embedding --provider P --api-key K --base-url U \
--model M --dimensions D [--proxy-url U] [--restart]
memory-tencentdb-ctl config embedding --provider none # 关闭 embedding
memory-tencentdb-ctl config vdb --url U --api-key K --database D \
[--username root] [--alias A] [--ca-pem /path] \
[--embedding-model bge-large-zh] [--no-set-backend] [--restart]
memory-tencentdb-ctl config vdb-off [--purge-creds] [--restart]
# 切回本地 sqlite 存储;默认保留 tcvdb 凭据
# (仅改 storeBackend),加 --purge-creds 才清除凭据
memory-tencentdb-ctl config show # 打印配置(apiKey 已脱敏)
Hermes 集成 (需 --hermes):
memory-tencentdb-ctl --hermes enable-hermes-memory
设置 ~/.hermes/config.yaml 的 memory.provider
优先 ruamel.yaml round-trip(保留注释/格式),
未安装时降级为最小化原位行编辑(缩进自动从既有键拓印)。
全局选项:
--hermes / --standalone 切换运行模式(默认 standalone
--dry-run 预演所有写操作,不真正落盘
-h, --help 显示本帮助
关键环境变量:
MEMORY_TENCENTDB_MODE standalone | hermes (等价于 --hermes / --standalone
MEMORY_TENCENTDB_GATEWAY_HOST / _PORT Gateway 监听地址 (default 127.0.0.1:8420)
MEMORY_TENCENTDB_GATEWAY_CMD 自定义启动命令(否则走 $TDAI_INSTALL_DIR
MEMORY_TENCENTDB_LOG_DIR 覆盖日志目录
MEMORY_TENCENTDB_ROOT 统一根目录(默认 ~/.memory-tencentdb
TDAI_INSTALL_DIR / TDAI_DATA_DIR 插件源码 / 数据目录
(默认分别位于 $MEMORY_TENCENTDB_ROOT 之下)
USAGE
}
# 拆掉全局 flag
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--hermes) MODE="hermes"; shift ;;
--standalone) MODE="standalone"; shift ;;
-h|--help) usage; exit 0 ;;
*) ARGS+=("$1"); shift ;;
esac
done
set -- "${ARGS[@]:-}"
# 根据 MODE 初始化日志 / PID 路径
_apply_mode_paths
[[ $# -ge 1 ]] || { usage; exit 1; }
SUB="$1"; shift || true
case "$SUB" in
start) cmd_start "$@" ;;
stop) cmd_stop "$@" ;;
restart) cmd_restart "$@" ;;
status) cmd_status "$@" ;;
health) cmd_health "$@" ;;
logs) cmd_logs "$@" ;;
config)
[[ $# -ge 1 ]] || die "config 需要子命令: llm | embedding | vdb | vdb-off | show" 1
SECTION="$1"; shift || true
case "$SECTION" in
llm) cmd_config_llm "$@" ;;
embedding) cmd_config_embedding "$@" ;;
vdb) cmd_config_vdb "$@" ;;
vdb-off) cmd_config_vdb_off "$@" ;;
show) cmd_config_show "$@" ;;
*) die "未知 config 子命令: $SECTION" 1 ;;
esac
;;
enable-hermes-memory) cmd_enable_hermes_memory "$@" ;;
*) usage; die "未知命令: $SUB" 1 ;;
esac