Files
TencentDB-Agent-Memory/scripts/memory-tencentdb-ctl.sh
T

1031 lines
38 KiB
Bash
Raw Normal View History

2026-05-29 17:33:12 +08:00
#!/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