
Openclaw Memory Tencentdb Setup
- 9 installs
- 13.9k repo stars
- Updated August 3, 2026
- tencent/tencentdb-agent-memory
Helps with ai & agent building tasks during AI-assisted development.
About
openclaw-memory-tencentdb-setup is a Claude Code skill in the AI & Agent Building category.
- openclaw-memory-tencentdb-setup
- AI & Agent Building
- AI-coding skill
Openclaw Memory Tencentdb Setup by the numbers
- 9 all-time installs (skills.sh)
- Ranked #12,152 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tencent/tencentdb-agent-memory --skill openclaw-memory-tencentdb-setupAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 13.9k |
| Last updated | August 3, 2026 |
| Repository | tencent/tencentdb-agent-memory ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
目的
在不依赖外部托管记忆服务的前提下,为 OpenClaw 提供可持续的本地长期记忆能力(L0→L1→L2→L3),并完成从安装、配置到验收的一次性闭环。
适用场景
- 用户要求在 OpenClaw 中安装或启用
memory-tencentdb - 用户需要配置召回、提取、画像、清理等参数
- 用户反馈"插件已装但无记忆 / 无召回 / 无向量检索"
不适用场景
- 用户只需要解释 memory 理念,不要求实际落地
- 用户要接入非 OpenClaw 宿主(先确认目标框架)
标准工作流
1) 环境预检
先确认基础版本满足要求:
- OpenClaw:
>= 2026.3.13 - Node.js:
>= 22.16.0
执行:
openclaw --version
node -v若版本不满足,先升级再继续。
2) 安装插件
执行安装命令:
openclaw plugins install @tencentdb-agent-memory/memory-tencentdb如已安装则执行更新:
openclaw plugins update memory-tencentdb3) 写入最小配置
编辑 ~/.openclaw/openclaw.json,确保存在:
{
"memory-tencentdb": {
"enabled": true
}
}说明:该插件支持零配置启动;不补充其它字段也能运行基础能力。
4) 按需追加推荐配置(生产常用)
根据用户需求补充如下分组:
capture: 对话捕获与保留策略extraction: L1 提取与去重pipeline: L1→L2→L3 调度recall: 召回数量、阈值、策略persona: 场景与画像触发参数embedding: 向量检索配置(远端 OpenAI 兼容)
推荐模板:
{
"memory-tencentdb": {
"capture": {
"enabled": true,
"excludeAgents": [],
"l0l1RetentionDays": 90,
"cleanTime": "03:00"
},
"extraction": {
"enabled": true,
"enableDedup": true,
"maxMemoriesPerSession": 10,
"model": "provider/model"
},
"pipeline": {
"everyNConversations": 5,
"enableWarmup": true,
"l1IdleTimeoutSeconds": 600,
"l2DelayAfterL1Seconds": 10,
"l2MinIntervalSeconds": 900,
"l2MaxIntervalSeconds": 3600,
"sessionActiveWindowHours": 24
},
"recall": {
"enabled": true,
"maxResults": 5,
"scoreThreshold": 0.3,
"strategy": "hybrid"
},
"persona": {
"triggerEveryN": 50,
"maxScenes": 15,
"backupCount": 3,
"sceneBackupCount": 10,
"model": "provider/model"
},
"embedding": {
"enabled": true,
"provider": "openai",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "${EMBEDDING_API_KEY}",
"model": "text-embedding-3-small",
"dimensions": 1536,
"conflictRecallTopK": 5
}
}
}5) 关键配置规则(避免隐性失败)
embedding.provider = "none"时,向量能力会禁用,仅保留关键词路径。- 若配置远端
provider(如openai/deepseek),必须同时提供: apiKeybaseUrlmodeldimensions- 上述任一缺失时,插件会继续运行,但自动降级为非向量模式。
l0l1RetentionDays:0表示不清理- 非
0时建议>=3 - 若设为
1~2,需显式开启allowAggressiveCleanup
6) 重启并验证生效
执行:
openclaw gateway restart检查项:
- Gateway 日志中出现
[memory-tdai]前缀 - 数据目录已创建:
~/.openclaw/state/memory-tdai/ - 至少包含:
conversations/、records/、scene_blocks/、vectors.db
7) 功能冒烟测试
执行一次最小对话回路并验证:
1. 连续对话 2~3 轮,提供可记忆信息(偏好、约束、背景)。 2. 发起新一轮对话,观察是否出现召回上下文注入。 3. 在 Agent 中调用:
tdai_memory_searchtdai_conversation_search
4. 确认能检索到刚刚产生的内容。
故障排查速查
- 插件无日志:检查
openclaw.json中memory-tencentdb.enabled是否为true,并确认已重启 Gateway。 - 有记录无召回:检查
recall.enabled、scoreThreshold是否过高。 - 无向量结果:检查
embedding四元组(apiKey/baseUrl/model/dimensions)是否齐全。 - 清理过猛导致历史过少:检查
l0l1RetentionDays与allowAggressiveCleanup。 - 配置已改但行为不变:确认修改的是
~/.openclaw/openclaw.json,并再次重启 Gateway。
安全与合规约束
- 将
apiKey视为敏感信息;不在聊天、日志、截图中明文扩散。 - 优先使用环境变量注入密钥;配置示例中仅保留占位符。
- 仅修改
memory-tencentdb对应配置段,避免覆盖用户其它插件配置。
完成定义(Definition of Done)
在结束任务前,必须同时满足:
- 插件安装/更新命令执行成功
openclaw.json已存在有效memory-tencentdb配置- Gateway 已重启
[memory-tdai]日志可见- 数据目录与关键文件已生成
- 至少 1 次检索工具调用成功返回结果
交付话术模板
可在完成后向用户输出:
- 已完成
memory-tencentdb安装与配置,并重启 Gateway。 - 已验证日志与数据目录生效,记忆链路可用。
- 如需下一步优化,可继续调优
recall.scoreThreshold、pipeline.everyNConversations、persona.triggerEveryN与embedding模型参数。
name: 🐛 Bug Report
description: Report an issue with the plugin | 报告插件使用中的问题
title: "[Bug] "
labels: ["bug"]
assignees: []
body:
- type: input
id: openclaw-version
attributes:
label: OpenClaw Version | OpenClaw 版本
placeholder: e.g. 2026.3.13 | 例如:2026.3.13
validations:
required: true
- type: input
id: plugin-version
attributes:
label: Plugin Version | 插件版本
description: TencentDB-Agent-Memory
placeholder: e.g. 0.1.0 | 例如:0.1.0
validations:
required: true
- type: input
id: os
attributes:
label: Operating System | 操作系统
placeholder: e.g. Linux / Ubuntu 22.04 / macOS 14 / Windows 11 / CentOS 7
validations:
required: true
- type: input
id: system-spec
attributes:
label: System Specification | 系统配置
description: Optional, e.g. CPU model, memory size | 可选,如 CPU、内存大小等,不清楚可略过
placeholder: Intel i7 16GB RAM / 8C16G | Intel i7 16GB内存 / 8核16G
validations:
required: false
- type: textarea
id: bug-description
attributes:
label: Describe the bug | 问题描述
description: A clear and concise description of the problem | 清晰简洁地描述你遇到的问题
placeholder: Please describe the issue in detail | 请详细说明问题现象...
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: To Reproduce | 复现步骤
description: Steps to reproduce the behavior | 复现该问题的操作步骤
placeholder: |
1. Go to / Call API | 执行操作/调用接口
2. Set parameters | 传入参数/配置
3. See error | 出现报错/异常
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior | 预期行为
description: What you expected to happen | 你期望的正确结果
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs / Screenshots | 报错日志/截图
description: Please attach full logs or screenshots | 请附上完整报错日志或截图
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context | 补充信息
description: Any other relevant information | 其他相关信息
validations:
required: falsename: ✨ Feature Request
description: Propose a feature or suggestion | 提出一个功能需求或建议
title: "[Feature] "
labels: ["enhancement"]
assignees: []
body:
- type: textarea
id: related-problem
attributes:
label: Is this feature related to a problem? | 该功能需求是否与某个问题相关?请描述
description: A clear and concise description of the problem | 清晰简洁地描述问题是什么
placeholder: e.g. I'm frustrated when... | 例如:我总是在使用 XX 功能时感到不便...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like | 描述你期望的解决方案
description: A clear and concise description of what you want to happen | 清晰简洁地说明你希望实现的效果
placeholder: Describe the feature you want to add or improve | 请描述你希望新增或改进的功能...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered | 描述你考虑过的其他方案
description: A clear and concise description of any alternative solutions | 清晰简洁地描述你考虑过的其他替代方案或功能
placeholder: Optional | 可选
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context | 补充说明
description: Add any other context or screenshots about the feature | 在此添加与该功能需求相关的其他背景信息或截图
placeholder: Optional | 可选
validations:
required: falsename: ❓ Question / Consultation
description: Ask a question about usage or community | 使用咨询或社区相关问题
title: "[Question] "
labels: ["question"]
assignees: []
body:
- type: dropdown
id: question-type
attributes:
label: Question Category | 问题类别
description: Select the category that best describes your question | 选择最符合你问题的类别
options:
- Usage / How-to | 使用方式咨询
- Compatibility / Integration | 兼容性 / 集成确认
- Community / Group | 社区 / 群组相关
- Other | 其他
validations:
required: true
- type: textarea
id: question-description
attributes:
label: Your Question | 你的问题
description: Describe your question clearly | 请清晰描述你的问题
placeholder: e.g. Does the memory plugin work with OpenClaw? | 例如:记忆插件是否支持 OpenClaw?
validations:
required: true
- type: textarea
id: context
attributes:
label: Context / Background | 背景信息
description: Any context that helps us understand your question better | 有助于我们理解你问题的背景信息
placeholder: e.g. your use case, environment, what you've tried | 例如:你的使用场景、环境、已尝试的方案等
validations:
required: false
Description | 描述
<!-- Describe what this PR does | 请描述这个 PR 做了什么 -->
Related Issue | 关联 Issue
<!-- Example: Fix #123 | 例如:Fix #123 -->
Change Type | 修改类型
- [ ] Bug fix | Bug 修复
- [ ] New feature | 新功能
- [ ] Documentation update | 文档更新
- [ ] Code optimization | 代码优化
Self-test Checklist | 自测清单
- [ ] Verified locally | 本地验证通过
- [ ] No existing features affected | 无影响现有功能
Additional Notes | 其他说明
<!-- Optional | 可选 -->
name: CI
on:
pull_request:
branches: [main]
# Cancel previous runs for the same PR / branch
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── 1. Install dependencies ────────────────────────────────
install:
name: Install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: npm install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: npm install --ignore-scripts
# ── 2. Pack validation ─────────────────────────────────────
pack:
name: Pack
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: npm pack (dry-run)
run: npm pack --dry-run
- name: npm pack
run: npm pack
- name: Upload .tgz artifact
uses: actions/upload-artifact@v4
with:
name: package-tarball
path: "*.tgz"
retention-days: 7
# ── 5. Plugin manifest validation ──────────────────────────
manifest:
name: Manifest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate openclaw.plugin.json
run: |
echo "── Checking openclaw.plugin.json exists ──"
test -f openclaw.plugin.json || { echo "❌ openclaw.plugin.json not found"; exit 1; }
echo "── Checking valid JSON ──"
node -e "JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'))" || { echo "❌ Invalid JSON"; exit 1; }
echo "── Checking required fields ──"
node -e "
const m = JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8'));
const errors = [];
if (!m.id || typeof m.id !== 'string') errors.push('missing or invalid \"id\"');
if (m.configSchema && typeof m.configSchema !== 'object') errors.push('\"configSchema\" must be an object');
if (errors.length) { console.error('❌ Manifest errors:', errors.join(', ')); process.exit(1); }
console.log('✅ id:', m.id);
if (m.name) console.log(' name:', m.name);
if (m.configSchema) console.log(' configSchema: present (' + Object.keys(m.configSchema.properties || {}).length + ' top-level props)');
"
echo "── Checking package.json openclaw metadata ──"
node -e "
const pkg = JSON.parse(require('fs').readFileSync('package.json','utf8'));
const oc = pkg.openclaw || {};
const errors = [];
if (!oc.extensions || !oc.extensions.length) errors.push('missing openclaw.extensions');
if (!oc.compat?.pluginApi) errors.push('missing openclaw.compat.pluginApi');
if (!oc.build?.openclawVersion) errors.push('missing openclaw.build.openclawVersion');
if (errors.length) { console.error('❌ Package metadata errors:', errors.join(', ')); process.exit(1); }
console.log('✅ openclaw metadata OK');
console.log(' pluginApi:', oc.compat.pluginApi);
console.log(' openclawVersion:', oc.build.openclawVersion);
"
# ── 6. Package size guard ──────────────────────────────────
size:
name: Size Guard
needs: pack
runs-on: ubuntu-latest
steps:
- name: Download tarball
uses: actions/download-artifact@v4
with:
name: package-tarball
- name: Check package size
run: |
TGZ=$(ls *.tgz | head -1)
SIZE=$(stat -c%s "$TGZ" 2>/dev/null || stat -f%z "$TGZ")
SIZE_KB=$((SIZE / 1024))
MAX_KB=2048
echo "📦 Package: $TGZ"
echo " Size: ${SIZE_KB} KB (limit: ${MAX_KB} KB)"
if [ "$SIZE_KB" -gt "$MAX_KB" ]; then
echo "❌ Package exceeds ${MAX_KB} KB size limit!"
echo " Consider checking if large files were accidentally included."
exit 1
else
echo "✅ Size OK"
fi
# Dependencies
node_modules/
# Runtime workspace
workspace/
# Environment variables
.env
# Test caches
__tests__/soak/.model-cache/
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
# Migration build output
scripts/export-tencent-vdb/dist/
scripts/migrate-sqlite-to-tcvdb/dist/
scripts/read-local-memory/dist/
# Root-level build output (not used at runtime, not published)
dist/
node_modules/
benchmark-runs/
.codebuddy
.ai_assets
# Dev-only scripts & lockfile (not shipped in MR)
install-plugin.sh
package-lock.json
pnpm-lock.yaml
yarn.lock
test-offload.sh
test-offload-mmd.sh
test-offload-sessions.sh
# npm pack / release tarballs (never commit packaged outputs)
*.tgz
*.tar.gz
# log files
*.log# 测试文件
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__/
# 开发文档与辅助
docs/
benchmark-runs/
workspace/
# 环境与配置
.env
.env.*
.gitignore
.codebuddy/
.coding-ci.yaml
# 运行时产物
node_modules/
*.tgz#!/usr/bin/env node
// 薄启动器:加载预编译好的 VDB 导出脚本。
// 构建:npm run build:export-vdb
// 使用:npm run export:vdb -- [参数] 或 node ./bin/export-tencent-vdb.mjs [参数]
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const entryScript = path.resolve(thisDir, "../scripts/export-tencent-vdb/dist/export-tencent-vdb.js");
if (!fs.existsSync(entryScript)) {
console.error("❌ 预编译产物不存在: " + entryScript);
console.error(" 请先执行: npm run build:export-tencent-vdb");
process.exit(1);
}
import(entryScript);
#!/usr/bin/env node
// Thin wrapper: runs the pre-compiled migration CLI entry.
// Build first: npm run build:migrate-sqlite-to-vdb (or pnpm build:migrate-sqlite-to-vdb)
import path from "node:path";
import { fileURLToPath } from "node:url";
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const entryScript = path.resolve(thisDir, "../scripts/migrate-sqlite-to-tcvdb/dist/scripts/migrate-sqlite-to-tcvdb/cli-entry.js");
import(entryScript);
#!/usr/bin/env node
// 薄启动器:加载预编译好的本地 Memory 数据查询脚本。
// 构建:npm run build:read-local-memory
// 使用:npm run read-local-memory -- [参数] 或 node ./bin/read-local-memory.mjs [参数]
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const entryScript = path.resolve(thisDir, "../scripts/read-local-memory/dist/read-local-memory.js");
if (!fs.existsSync(entryScript)) {
console.error("❌ 预编译产物不存在: " + entryScript);
console.error(" 请先执行: npm run build:read-local-memory");
process.exit(1);
}
import(entryScript);
Changelog
本文件记录 @tencentdb-agent-memory/memory-tencentdb 插件的所有显著变更,格式遵循 Keep a Changelog,版本号遵循 Semantic Versioning。
---
[Unreleased]
✨ 新功能
- 时区可配置 (#75 / #87):新增顶层
timezone配置项,支持 IANA 时区名(Asia/Shanghai、Europe/Berlin)和 UTC 偏移串(+08:00、-05:30)。默认"system"(跟随进程系统时区),升级零感。 - 暴露给 LLM 的时间戳统一为带显式 offset 的 ISO 8601(如
2026-04-07T11:04:45+08:00),修复 #87 报告的 UTC/本地时区混用导致 LLM 误算时间差的问题。 - L1 / L2 prompt 顶部自动插入时区声明,指引 LLM 按正确时区推算"昨天"、"上周"等相对时间。
- L0 JSONL 分片日和 cleaner 清理边界跟随配置时区(默认仍为系统时区)。
- 存储层(SQLite / TCVDB)时间戳始终为 UTC instant,无需数据迁移。
- 统一收敛原有 4 处分散的时间格式化 helper 到
src/utils/time.ts,减少代码重复。
⚠️ 升级注意(仅在显式配置 timezone 时生效)
如果你显式设置了 IANA 时区(如 "Asia/Shanghai"):
1. L0 JSONL 分片日:将以配置时区的午夜为界。如果你的服务器系统时区与配置时区不同,升级当天的分片文件名可能与之前一天有重叠——不会丢数据(cursor 按 instant 比较)。如有外部工具按文件名做去重/归档,请确认其能正确处理同一日期出现两次的情况。 2. cleaner `cleanTime` 触发时机:从"系统时区的指定时刻"改为"配置时区的指定时刻"。 3. scene/persona META 头部时间戳格式:新写入将使用 YYYY-MM-DDTHH:mm:ss±HH:MM 完整 ISO 8601。老数据保持原样,召回展示时由系统统一换算。
不动配置 = 行为完全不变。
---
[0.3.6] - 2026-05-27
✨ 新功能
- Recall 上下文预算控制 (#71 / #70):新增
recall.maxCharsPerMemory与recall.maxTotalRecallChars两项配置,默认0不改变现有行为;设置为正整数后,会在 L1 召回完成、注入<relevant-memories>之前按分数顺序裁剪超长条目并丢弃溢出部分,避免长会话因记忆膨胀挤占上下文。已在 README、README_CN 与openclaw.plugin.json同步说明。 - L1 / L2 / L3 提示词按用户输入语言自适应 (#38):
l1-extraction、l1-dedup、scene-extraction、persona-generation四个 prompt 中所有自由文本字段(scene_name、记忆content、scene.md标题/正文、persona.md各章节)现在跟随用户消息的主导语言书写;JSON 字段名、枚举值、ISO 时间戳、persona.md等结构化文件名继续保持英文作为稳定契约。无需配置locale,任意语言(en / fr / ja / es / …)均可直接使用。 - Embedding `sendDimensions` 可选关闭:
OpenAIEmbeddingService默认仍会在请求体携带dimensions字段(兼容 OpenAItext-embedding-3-*Matryoshka 截断);新增embedding.sendDimensions配置项,设置为false时省略该字段,可对接 BGE-M3 等不支持自定义维度的固定维度模型(原会被服务端 HTTP 400 拒绝does not support matryoshka representation)。 - Gateway 可选 Bearer 鉴权 + CORS 白名单:新增
server.apiKey/TDAI_GATEWAY_API_KEY配置项,设置后所有非/health路由需携带Authorization: Bearer <key>(crypto.timingSafeEqual防时序攻击);新增server.corsOrigins/TDAI_CORS_ORIGINS配置项,显式指定允许的 CORS 来源列表(空列表 = 不发送 CORS 头,"*"= 保留旧版宽松行为)。启动时打印安全态势摘要,非回环地址 + 无 apiKey 时输出 WARN。两项均默认关闭,现有部署无需改动。Hermes Python 客户端同步支持MEMORY_TENCENTDB_GATEWAY_API_KEY环境变量自动附加 Bearer 头。 - Offload `collect` 模式:新增
offload.mode: "collect"配置,仅执行数据采集(L0 捕获 + 向量写入)而不触发 L3 压缩,适用于纯数据积累阶段或调试场景。
🐛 修复
数据安全 / 数据隔离
- L2 LLM 提取失败导致 `scene_blocks/` 被清空 / 半写入 (#88):Phase 1 已对
scene_blocks/做完整快照,但 LLM 抛错时catch直接return,沙箱里的部分写入 / 删除不会回滚,后续 recall 因此看不到场景导航,降级为碎片召回。新增BackupManager.findLatestBackup+restoreLatestDirectory,在 LLM 失败时自动从最新备份恢复;采用 fail-soft 设计:无备份时不动目标目录,恢复过程自身的错误也不会替换原始 LLM 错误。 - Cleaner 安全加固:
computeCutoffMsByLocalDay拒绝无效 cutoff(未来时间 / 距今不足 24h);SQLite 与 TCVDB 在expired/total > 80%时阻止删除;runOnce增加最小保留护栏(L0:50 / L1:20)并产出cleaner_summaryJSON 审计日志;新增__tests__/cleaner/verify-cleaner-safety.tsE2E 校验。 - 场景文件名含空格导致 Persona Scene Navigation 引用失效:LLM 在 L2 偶发用
Daily Rhythm in Shanghai.md这类含空格的名字创建 scene block,导致persona.md中### Path: scene_blocks/<name>.md引用无法被下游\S+\.md风格解析器(health-checker 等)识别,soak 后健康检查必报Scene Navigation 存在但无场景引用。修复:(1)scene-extractionprompt 增加"📛 文件命名规范(强制)"段,禁止空格 / 括号 / 引号等标点;(2) 新增core/scene/filename-normalizer.ts,在SceneExtractor.extractPhase 5b(cleanup 后、syncSceneIndex前)自动归一化文件名(空格 →-、剥离危险标点、冲突时追加-2后缀),下游 PersonaGenerator / recall / profile-sync 自动使用干净名,无需改动。
OpenClaw 宿主兼容
- `api.runtime.state` 在新版 OpenClaw 上为 `undefined` 导致注册时崩溃 (#78 / #85 / #79):为两处调用点加可选链 + fallback,优先调用宿主
runtimeState.resolveStateDir(),缺失时退到OPENCLAW_STATE_DIR环境变量,再退到~/.openclaw。同时修复 cli-metadata 注册模式下runtime为空对象{}仍会触发TypeError的问题(在该模式下提前 return,只调用registerCli)。 - `contextEngine` slot ID 与插件名不一致:
registerContextEngine的 ID 从openclaw-context-offload改为memory-tencentdb,避免openclaw doctor --fix把 slot 重置;setup-offload.sh中的CONTEXT_ENGINE_ID同步更新。 - L1.5 settle 永不返回导致 L2 卡死:在 L2 poll 中为 L1.5 settle 增加 60s 超时,当未配置 Context Engine slot 导致
assemble永不被调用时,自动 force-settle 解锁 L2。 - Standalone 文本任务仍暴露工具导致 DeepSeek 等后端 L1 抽取不稳定 (#58 / #59):
enableTools=false时彻底不传工具列表(此前即便只读子集也会鼓励 OpenAI 兼容后端尝试 tool calling,DeepSeek 上尤其明显)。 - L2 cold-start skip 被错误地更新 `l2LastRunTime`:导致首次 skip 后必须等满
l2MaxInterval才会真正运行 L2;现在仅在确实跑过的情况下更新时间戳。
Offload(Context Engine)稳定性
- `sanitizeText` 误删 emoji / CJK Extension B / Math Bold 等非 BMP 字符 (#30 / #31):
UNSAFE_CHAR_RE含[\uD800-\uDFFF]但缺uflag,JS 按 UTF-16 code unit 处理时会把每个非 BMP 码点的两个 surrogate 各自 strip 掉。加上uflag 后只匹配孤立(畸形)surrogate,emoji / 扩展 CJK / 数学加粗等正常恢复;新增 vitest 套件覆盖保留与剥离两类 case。 - Emergency 截断在 `MIN_KEEP` 拒绝下死锁:
EMERGENCY_MIN_MESSAGES_TO_KEEP由 4 降到 2;新增_emergencyTruncateOversized,当 head/tail 删除均被阻塞时就地截断超大消息(保留tool_use块结构),最后兜底强制删除并配对清理toolResult,在 LLM 可见的内容里加截断告示。 - 多轮 aggressive compression 累计耗时:由 6 轮
O(N × rounds)全量 tiktoken 改为单趟O(N × 1)直接计算到目标阈值的精确切点。615 条消息从 84s 降到 ~14s;tool 配对、user 消息保护、MMD 保留、stall 检测等安全机制全部保留。 - FP-HEAD-DELETE 在多轮 FAST-SKIP 后误删新消息:移除该路径,改用
FP-BOUNDARY-DELETE(基于上一轮 aggressive 边界的 O(1) 头部删除,index + fingerprint 双重验证、tool-pair 安全)+BOUNDARY-INCR-SKIP(增量估算低于阈值时跳过 tiktoken)。重放场景下assemble38s → 122ms(310×)。 - 首次 assemble 慢:为
TAIL-ACCUMULATE增加 fast-token-estimate(基于字符,~51× 快于 tiktoken)前置短路,无边界且 fast estimate 明显高于阈值时跳过全量 tiktoken;同时为TAIL-ACCUMULATE增加向后 tool-pair 校正、user 消息保护、最少保留 10 条等安全检查。首次 assemble 29s → ~1.4s。 - Token 计算精度:
details字段加入INTERNAL_KEYS(框架在送 LLM 前 strip 掉,不应计入 token);新增_stripLargeFields()移除非内容大字段;就地截断后调用invalidateTokenCache(msg)修正 WeakMap 缓存陈旧问题;bestTokens < 600时跳过截断防反向膨胀;stub 文本简化为纯英文避免触发模型内容过滤;l3TiktokenEncoding默认从o200k_base改为cl100k_base(匹配 DeepSeek / GLM / MiniMax 分词器)。 - Offload 日志降级:
AGGRESSIVE/EMERGENCY等多数日志降到 debug,仅当超过 10s 时才输出SLOWwarn;Opik tracer 初始化、after_tool_call无 session 等"正常 fallback"场景日志同步降级,减少plugins list噪音。
Hermes / Docker 部署
- `Dockerfile.hermes` 生成的 `config.yaml` 缺 `api_key` (#77 / #81):
provider: custom模式下 Hermes 从config.yaml.model.api_key读密钥,而原 CMD 脚本仅写入.env的OPENAI_API_KEY,造成容器内首条对话即报 401。修复为同步写入model.api_key: "${MODEL_API_KEY}"。 - 安装脚本多处问题 (#18 / #19 / #20 / #54 / #55):
- 支持
HERMES_AGENT_DIR环境变量覆盖,适配 FHS 布局(把 hermes-agent 装在/usr/local/lib/hermes-agent等)。 - root 用户执行时不再
su - root无限递归。 MEMORY_TENCENTDB_GATEWAY_CMD在 systemd 环境下用command -v node解析的绝对路径,npx tsx改为node --import tsx/esm,避免 nvm PATH 不可见时找不到 node。
✨ 改进
- L1.5 settle 60s 超时保护(详见上)。
- OpenAI-style standalone runner 在
enableTools=false时不再传任何工具(详见上)。 - `l3TiktokenEncoding` 默认改为 `cl100k_base`,匹配主流国产/开源模型分词器。
- Docker 文档:补充
cd docker/opensource前置步骤;新增 question/consultation issue 模板。
🧪 测试 / 内部
- 新增
src/utils/backup.test.ts:findLatestBackup4 个用例 +restoreLatestDirectory5 个用例,真 fs 沙箱。 - 新增
src/core/scene/scene-extractor.restore.integration.test.ts:用真临时目录 + 真 BackupManager 验证 LLM 失败时scene_blocks/被恢复(含 step 日志,沙箱保留供人工 inspect)。 - 新增
src/utils/openclaw-state-dir.test.ts+index.test.tscli-metadata 模式安全性用例。 - 新增
__tests__/cleaner/verify-cleaner-safety.tsE2E(SQLite + live VDB)。 - 新增
src/offload/fast-token-estimate.ts+benchmark-token-estimate.ts基线脚本。
⚠️ 配置项变化(向后兼容)
| Key | 默认值 | 说明 |
|---|---|---|
recall.maxCharsPerMemory | 0 | 0/未设置 = 不裁剪 |
recall.maxTotalRecallChars | 0 | 0/未设置 = 不裁剪 |
embedding.sendDimensions | true | false 时不在请求体携带 dimensions,适配 BGE-M3 等 |
l3TiktokenEncoding | cl100k_base(原 o200k_base) | 仅在显式依赖 o200k_base 时需手动覆盖回去 |
---
[0.3.5] - 2026-05-15
🐛 修复
- 兼容 OpenClaw v2026.5.7 zod v4 子路径:显式声明
zod@^4.4.3依赖,解决@ai-sdk/provider-utils@4.x需要zod/v4子路径导出但宿主环境可能 hoist zod@3.x 引发Cannot find module zod/v4的运行时错误。
✨ 改进
- L1→L2 延迟从 90s 降至 10s:
l2DelayAfterL1Seconds默认值 90→10,冷启动用户不再需要等待 ~90s 才能看到 L2 场景提取结果,体感更及时。
📖 文档
- README 新增 Docker Quick Start 章节,说明模型 URL/Name 环境变量配置方式。
---
[0.3.4] - 2026-05-12
🐛 修复
- 兼容 OpenClaw v2026.4.7 以下版本 L1 抽取空输出:旧宿主不支持
systemPromptOverride,通过extraSystemPrompt回退注入系统提示,确保 LLM 按数据提取助手身份工作。 - TCVDB hybrid 召回冗余双重 HTTP 调用:
auto-recall对 TCVDB 发两次相同的hybridSearch请求(且 keyword 路径将 FTS5 OR 表达式错误传入 BM25 编码器)。新增nativeHybridSearch短路,TCVDB 单次调用即可完成 dense + sparse + RRF,recall 耗时减半(~50-120ms)。 - L2 parser 对齐 Go 后端:增加 mermaid fallback,修复
first{...last}JSON 提取逻辑。
✨ 改进
- VDB HTTP 请求级计时:
tcvdb-client每次请求打一条 info 计时日志(/document/hybridSearch 85ms),retry/失败细节保持 debug 级别。 - 启动路径误导性日志降级为 DEBUG:store manifest 不一致、sqlite schema migration、profile-sync MD5 mismatch 等正常场景不再打 warn/info,避免 AI 误判。
- L1 提取调试日志:新增
[l1-debug]系列(RESOLVE / INVOKE / RESULT / EMPTY_DUMP / ENTRY / NO_JSON),方便定位 LLM 调用链问题。
🔧 兼容性适配
- OC 2026.4.23 Zod schema 兼容 patch 脚本(
scripts/bugfix-20260423/):一键修复allowConversationAccess被.strict()拒绝的问题,含轻量版脚本、全自动脚本、手动 SOP 文档。 - Offload 日志去掉
Backend前缀,默认超时为 120s。
📦 新功能
- Offload Local Mode:支持本地模式运行 offload(不依赖远端后端)。
- Docker 一体化镜像(
Dockerfile.hermes):单容器捆绑 Hermes Agent + memory_tencentdb 插件 + TDAI Memory Gateway,统一MODEL_*环境变量驱动。
✅ 测试
- 修复
fault-injectionFI-05 mock config 缺embedding字段 - 修复
cli.testdependencies 断言适配新增依赖 - 跳过
patch-effectiveness已删除的install-plugin.sh测试
---
[0.3.3] - 2026-05-08
🐛 修复
- 加固 hook-policy 版本决策逻辑:仅当宿主版本为严格
x.y.z语义化版本、且>= 2026.4.24时才自动写入hooks.allowConversationAccess;无法解析(如unknown、beta、snapshot 等非标准版本)时一律跳过,避免对旧版本或非预期版本误写配置导致启动失败。 - hook-policy 关键路径补充 debug 日志(原始版本串、解析后版本、最小要求版本、是否 patch 的决策),方便线上排查。
✅ 测试
- 新增
src/utils/ensure-hook-policy.test.ts,覆盖标准版本、预发布、unknown、边界值等决策 case。
[0.3.2] - 2026-05-08
🐛 修复
- 兼容 OpenClaw v2026.4.23 前的版本,防止写入的 hook 配置导致无法启动
- 修改 allowConversationAccess 到 2026.4.24+ 添加。
[0.3.1-beta.1] - 2026-05-07
🐛 修复
- 兼容 OpenClaw v2026.4.23+ hook 权限策略:该版本引入
allowConversationAccess安全门控(openclaw#70786),导致非 bundled 插件的agent_endhook 被静默拦截,整个 capture pipeline 失效。新增ensurePluginHookPolicy()自动检测并补全配置,优先通过 SDK 触发 gateway 自动重启,fallback 手动写入配置文件。 - 兼容 OpenClaw 2026.5.3+ 安装校验:新增 tsdown 构建配置生成
dist/index.mjs,满足新版安装时对编译产物的强制校验(不再允许纯 TypeScript 入口)。 - 声明 `activation.onStartup`:确保 gateway 在启动时加载本插件。
- 声明 `contracts.tools`:注册
tdai_memory_search、tdai_conversation_search工具名,满足 tool registration contract 要求。
---
[0.3.0] - 2026-05-06
🚀 新功能
运维管理工具(CTL)
- 新增
memory-tencentdb-ctl命令行管理工具,支持 standalone 与 hermes 两种运行模式 - 新增
install-memory-tencentdb一键安装脚本 - CTL 新增
config vdb-off命令,支持将 Gateway 存储从 VDB 回退到 SQLite - Gateway 安装脚本支持将环境变量写入
~/.hermes/.env(systemd 场景)
Offload 增强
- Offload 启动时自动应用
after_tool_callpatch,patch 失败时自动禁用 offload - 新增
setup-offload.sh一键启用/禁用 offload 脚本,支持--backend-api-key参数 - L0 捕获过滤:排除 offload 注入的 MMD 上下文块,避免将压缩中间产物误存为记忆
Gateway 自愈与稳定性
- Hermes 插件新增 watchdog + lazy probe 机制,Gateway 异常时自动恢复
- Gateway YAML 配置解析支持任意深度嵌套
✨ 改进
- 数据目录与安装目录统一整合至
~/.memory-tencentdb/ - 引入
$HERMES_HOME环境变量约定,移除硬编码~/.hermes路径 - CTL hermes 配置编辑改为缩进感知,保持原始文件格式
- 运维脚本保留在 tarball 中但不再注册为 bin 命令(减少全局命令污染)
- init/destroy 生命周期日志降级为 debug 级别
- patch 脚本兼容 pnpm 安装环境,使用 Node.js 动态解析 openclaw 安装路径
🐛 修复
Core 稳定性
- 修复
ensureSchedulerStarted并发调用下的竞态问题 - 修复
/session/end错误销毁全局 scheduler 的问题(改为按 session_key 作用域) - 修复关闭 store 时未等待后台 fire-and-forget 任务完成的问题
- 修复
disable_offload未正确删除slots.contextEngine配置的问题
Offload
- 修复 slot 占用检测逻辑:仅在
ok=false(slot 被占用)时拒绝,API 异常不再误判为冲突 - 修复
registerContextEngine抛异常时未禁用 offload 的问题 - 修复 slot 被占用时未完全禁用所有 offload 功能的问题
L3 压缩
- 修复 aggressive/emergency 压缩在用户消息位于队首时卡死的问题
- 修复消息被大量 offload 后压缩停滞的问题
迁移工具
- 修复源数据目录或 SQLite 不存在时迁移脚本崩溃的问题(改为优雅跳过)
- 修复源数据为空时 config/manifest 未写入的问题
脚本与运维
- 修复
set -e环境下((VAR++))在 VAR=0 时导致脚本退出的问题 - 修复 patch 脚本误报 FAILED 计数的问题(跳过无 after_tool_call 上下文的候选项)
- 修复 Hermes 退出时未终止 Gateway 子进程的问题
♻️ 重构
- 统一 patch 检测逻辑:始终委托给 patch 脚本并通过退出码判定结果
---
[0.3.0-beta.1] - 2026-04-23
🚀 新功能
短期记忆压缩(Context Offload)
- 新增 Offload 模块,支持长对话场景下的上下文压缩与记忆卸载
架构重构:Core + Gateway 多框架支持
- 重构为
TdaiCore宿主无关的核心层 + 适配器模式,解耦 OpenClaw 框架依赖 - 新增
HostAdapter/LLMRunner/LLMRunnerFactory抽象接口,支持不同宿主的 LLM 调用 - 新增 Hermes Gateway 适配器(
memory_tencentdbHermes Plugin),支持通过 Hermes 框架独立运行 TdaiCore提供统一的handleBeforeRecall()/handleTurnCommitted()/searchMemories()等 API- Gateway 零配置自动发现:Hermes 插件自动检测配置和数据目录
- 数据目录所有权从插件移至 Gateway 层管理
Recall 注入优化(Cache 友好)
- L1 召回记忆从
appendSystemContext移到prependContext(用户消息前缀),避免每轮系统提示词变化导致 prompt cache bust - Persona / Scene Navigation / Tools Guide 保持在
appendSystemContext(稳定内容,连续多轮 cache 命中) - 注册
before_message_write钩子,在 user message 持久化到 JSONL 前 strip<relevant-memories>标签,防止历史消息中累积旧的召回内容
分场景 Embedding 超时
- 新增
embedding.recallTimeoutMs(recall 路径)和embedding.captureTimeoutMs(capture 路径)配置 - recall 超时时 hybrid 策略自动降级为纯关键词搜索;capture 超时时 L1 dedup 降级为 FTS
- 向前兼容:不配置时 fallback 到全局
embedding.timeoutMs
✨ 改进
- CleanContextRunner 通过
systemPromptOverride替换 OpenClaw 默认系统提示词,每次 L1/L2/L3 调用节省 ~4500 input tokens - L2(场景提取)和 L3(画像生成)prompt 拆分为
systemPrompt+userPrompt,角色划分更清晰 - Pipeline 默认参数调整:
l1IdleTimeoutSeconds60→600s,l2MinIntervalSeconds300→900s,l2MaxIntervalSeconds1800→3600s
🐛 修复
- 修复
pullProfilesToLocal并发竞争导致ENOTEMPTY错误(乐观无锁修法:rename 竞争失败时静默使用对方结果) - 修复
originalUserMessageCount数据链路断裂导致 L0 recorder 无法定位被污染的 user message - 修复
RecallResult类型定义缺少prependContext字段(types.ts与auto-recall.ts不一致)
---
[0.2.2] - 2026-04-17
🐛 修复
- 修复因未声明
undici依赖导致 TCVDB 客户端加载失败的问题(开发环境之前依赖 monorepo 根node_modules的传递解析) - 将插件注册阶段的大量 INFO 日志降级为 DEBUG,避免 CLI 模式下输出过多无关日志
[0.2.1] - 2026-04-16 (deprecated)
NOTE: 此版本由于存在 undici 依赖导致插件启动失败的问题,已废弃
相关问题在 0.2.2 及以后版本中已修复
🚀 新功能
- TCVDB 新增 HTTPS 连接支持,可通过插件配置
caPemPath或迁移脚本参数--tcvdb-ca-pem指定自定义 CA 证书 PEM 文件 read-local-memory脚本新增 L2 单文件查询,并将 L0 / L1 查询切换为直接从vectors.db读取,支持 SQL 层过滤、排序与分页
✨ 改进
- TCVDB 的 L0 / L1 向量索引默认调整为
DISK_FLAT,并在不支持该索引类型的实例上自动回退到HNSW - 默认服务端 embedding 模型调整为
bge-large-zh - TCVDB 所有读接口统一启用
readConsistency: "strongConsistency",消除 read-after-write 不一致 - 健康检测脚本 VDB 连接支持 HTTPS 自签证书
🐛 修复
- 修复 L3 persona sync 因未拉取远端 baseline 导致版本冲突跳过写入的问题
- 修复
memories_since_last_persona被 L0 和 L1 双重计数导致 persona 触发阈值膨胀的问题 - 移除
CheckpointManager中已被captureAtomically()替代的废弃方法
---
[0.2.0] - 2026-04-15
🚀 新功能
腾讯云向量数据库(TCVDB)存储后端
- 新增腾讯云向量数据库存储后端,支持向量 + BM25 混合召回
- 支持 SQLite 与 TCVDB 之间的索引结构同步
- L2 场景 / L3 画像支持在本地缓存与向量数据库之间双向同步
- 插件配置(manifest)暴露
storeBackend、tcvdb、bm25、embedding.timeoutMs等配置项
本地 BM25 关键字检索
- 使用本地 tcvdb-text 编码器替代原有的 BM25 HTTP sidecar 服务,消除外部依赖
Seed 数据导入工具
- 新增 CLI
seed命令,支持从外部数据批量导入记忆 - 提取共享的 pipeline-factory,供 seed 和正常运行时复用
- 支持 ISO 8601 时间戳格式(移除 JSONL 支持)
数据迁移与运维工具
- 新增 SQLite → 腾讯云向量数据库迁移脚本,支持
--help/-h展示完整参数说明和使用示例 - 新增 VDB 数据导出脚本(含预编译 JS 和 CLI 启动器)
- 新增本地 Memory 数据查询脚本
- 注册全部 CLI bin 入口:
migrate-sqlite-to-tcvdb、export-tencent-vdb、read-local-memory
记忆搜索工具调用限制
tdai_memory_search+tdai_conversation_search增加每轮合计最多 3 次的调用次数限制,通过 tool description 和召回引导提示词约束模型行为,防止陷入无效重复搜索
🐛 修复
- 修复 L2 场景合并(MERGE)无法删除旧文件的问题:OpenClaw 4.1+ 的 write 工具拒绝空白内容,改用
[DELETED]标记实现软删除,SceneExtractor cleanup 阶段同步识别并清理 - 修复 L2 抽取产生孤立 BATCH/ARCHIVE 文件的问题,统一 maxScenes 上限为 15
- 修复 L3 启动时重复拉取 profile 的问题
- 过滤 skill wrapper 噪声标记(
¥¥[...]¥¥) - 处理
createCollection并发竞态(错误码 15202)
♻️ 重构
- Pipeline checkpoint 游标语义从 timestamp 改为 update_at
- Runner 改用
api.runtime.agent.runEmbeddedPiAgent,避免跨环境导入失败 - 统一脚本构建流程:新增
build:scripts一键编译命令,prepack钩子确保npm pack前自动编译全部脚本产物
📚 文档
- 新增 AI Agent 长期记忆插件设计与实现技术文档
- 新增项目指南、研发系统分层架构文档
- 新增 VDB 存储设计文档及迁移指南
---
<details> <summary>预发布版本</summary>
[0.2.0-beta.1] - 2026-04-14
此版本的内容已合并至 [0.2.0] 正式版。
</details>
[0.1.4] - 2026-04-10
🚀 Features
- (auto-recall) Add recall hint text before memories
[0.1.3] - 2026-04-09
🚀 功能
- (memory-tdai) 用 reporter 抽象替换 emitMetric
- (L3) L3 使用读写工具,防止模型输出 CoT
- (memory) 添加 embedding 截断、召回超时,以及从 L0 捕获中剔除代码块
- (config) Embedding 超时支持配置
- (report) 在 schema 中暴露 report 配置项,默认值改为 false
🐛 修复
- (capture) 跳过心跳/定时任务/自动化/调度类消息
- (recall) 召回完成时清除超时定时器,避免误报超时警告
💼 Other
- 重命名包名为 memory-tencentdb
- (deps) 将 node-llama-cpp 改为可选依赖
⚡ 性能
- (auto-capture) 将 L0 向量嵌入移至后台以降低延迟
📚 文档
- 添加 allowPromptInjection 配置警告说明
[0.1.2] — 2026-03-26
更新内容
1. 优化对话捕获与记忆抽取过滤机制
[0.1.1] — 2026-03-25
更新内容
1. 兼容 openclaw 2026.3.23 更新
[0.1.0] — 2026-03-25
首个正式发布版本。本地优先的四层记忆系统(L0→L1→L2→L3),基于 SQLite + LLM 实现对话捕获、记忆提取、场景归纳与用户画像。
更新内容
1. 关键字检索增加 FTS5 全文索引,采用 jieba 分词 2. 未配置远程 embedding 服务时,默认不开启 embedding 能力(不自动使用本地 embedding,且封禁主动使用本地 embedding 的配置入口) 3. 优化 L2、L3 生成 prompt 以控制生成内容大小(减少 token 开销) 4. Pipeline 调度器优化文件锁用法 5. 避免全量读取 L0、L1 数据
贡献指南
感谢你对 TencentDB Agent Memory 项目的关注!我们欢迎来自社区的各种贡献——无论是报告问题、改进文档还是提交代码。
贡献方式
- 报告 Bug:在 GitHub Issues 中描述问题并提供复现步骤。
- 请求功能:在 Issues 中描述使用场景和你期望的解决方案。
- 改进文档:修复错别字、完善说明或补充示例。
- 提交代码:修复 Bug、实现新功能或优化性能。
开发入门
前置条件
- Node.js >= 22.16.0
- npm 或 pnpm
- OpenClaw >= 2026.3.13
从源码开发
本项目无需编译。Node.js 22.16+ 原生支持 TypeScript 类型剥离,OpenClaw 直接加载 .ts 源码运行。
# 克隆仓库
git clone https://github.com/Tencent/TencentDB-Agent-Memory.git
cd TencentDB-Agent-Memory
# 安装依赖
npm install
# 将当前目录作为本地插件注册到 OpenClaw
openclaw plugins install --link .install --link 会将当前目录作为本地插件注册到 OpenClaw,修改源码后重启 Gateway 即可生效。
项目结构
├── index.ts # 插件入口
├── openclaw.plugin.json # OpenClaw 插件清单
├── src/
│ ├── config.ts # 配置管理
│ ├── conversation/ # L0 对话层 — 原始对话捕获
│ ├── record/ # L1 记录层 — 结构化信息提取
│ ├── scene/ # L2 场景层 — 场景归纳与聚合
│ ├── persona/ # L3 画像层 — 用户画像构建
│ ├── store/ # 存储层 — SQLite/向量数据库
│ ├── hooks/ # OpenClaw 钩子集成
│ ├── prompts/ # LLM 提示词模板
│ ├── tools/ # 工具函数
│ ├── utils/ # 通用工具
│ └── report/ # 健康检测与报告
├── hermes-plugin/ # Hermes 智能体插件适配
├── scripts/ # 辅助脚本(Gateway 控制等)
├── CHANGELOG.md # 变更日志
└── README.md # 项目说明提交 Pull Request
1. Fork 本仓库并基于 main 分支创建你的特性分支。 2. 进行修改 — 保持每个提交专注且原子化。 3. 测试 — 确保现有功能不受影响。 4. 更新文档 — 如果更改涉及用户可见行为,请同步更新 README 或相关文档。 5. 提交 PR — 描述修改动机、变更内容,并关联相关 Issue。
分支说明
| 分支 | 用途 |
|---|---|
main | 默认分支,PR 提交目标 |
提交信息规范
使用以下格式编写 commit message:
<类型>(<范围>): <简要描述>
<详细说明(可选)>
Closes #123
Signed-off-by: Your Name <your-email@example.com>类型
与 PR 模板中的 Change Type 对应:
| 类型 | 说明 | 对应 PR Change Type |
|---|---|---|
fix | Bug 修复 | Bug fix |
feat | 新功能 | New feature |
docs | 文档更新 | Documentation update |
perf | 代码优化 | Code optimization |
refactor | 代码重构(不影响功能) | Code optimization |
test | 测试相关 | — |
chore | 构建/工具/依赖变更 | — |
范围示例
store、hooks、persona、scene、record、conversation、gateway、hermes
代码风格
- TypeScript:使用项目已有的代码风格,保持一致性。
- 命名:使用有意义的变量名和函数名,优先使用英文。
- 注释:关键逻辑处添加注释,说明"为什么"而非"做了什么"。
- 导入顺序:Node.js 内置模块 → 第三方依赖 → 项目内部模块。
开发者来源证书 (DCO)
所有提交必须包含 Signed-off-by 行,表示你同意 开发者来源证书:
git commit -s -m "feat(store): add batch insert support"没有有效 Signed-off-by 的提交将不会被合并。
安全问题
如果你发现安全漏洞,请通过邮箱 agentmemory@tencent.com 报告,我们会尽快处理。
许可证
提交贡献即表示你同意你的代码将在 MIT License 下许可。
---
再次感谢你的贡献!如有任何问题,欢迎在 Issues 中讨论。
Contributing Guide
Thank you for your interest in the TencentDB Agent Memory project! We welcome all kinds of contributions from the community — whether it's reporting issues, improving documentation, or submitting code.
How to Contribute
- Report Bugs: Describe the issue in GitHub Issues and provide steps to reproduce.
- Request Features: Describe your use case and proposed solution in Issues.
- Improve Documentation: Fix typos, clarify explanations, or add examples.
- Submit Code: Fix bugs, implement new features, or optimize performance.
Getting Started
Prerequisites
- Node.js >= 22.16.0
- npm or pnpm
- OpenClaw >= 2026.3.13
Developing from Source
This project requires no build step. Node.js 22.16+ natively supports TypeScript type stripping, and OpenClaw directly loads .ts source files at runtime.
# Clone the repository
git clone https://github.com/Tencent/TencentDB-Agent-Memory.git
cd TencentDB-Agent-Memory
# Install dependencies
npm install
# Register the current directory as a local plugin in OpenClaw
openclaw plugins install --link .install --link registers the current directory as a local plugin in OpenClaw. After modifying source code, simply restart the Gateway for changes to take effect.
Project Structure
├── index.ts # Plugin entry point
├── openclaw.plugin.json # OpenClaw plugin manifest
├── src/
│ ├── config.ts # Configuration management
│ ├── conversation/ # L0 Conversation layer — raw dialogue capture
│ ├── record/ # L1 Record layer — structured information extraction
│ ├── scene/ # L2 Scene layer — scene summarization & aggregation
│ ├── persona/ # L3 Persona layer — user profile construction
│ ├── store/ # Storage layer — SQLite / vector database
│ ├── hooks/ # OpenClaw hooks integration
│ ├── prompts/ # LLM prompt templates
│ ├── tools/ # Tool functions
│ ├── utils/ # General utilities
│ └── report/ # Health check & reporting
├── hermes-plugin/ # Hermes agent plugin adapter
├── scripts/ # Helper scripts (Gateway control, etc.)
├── CHANGELOG.md # Changelog
└── README.md # Project documentationSubmitting a Pull Request
1. Fork this repository and create your feature branch from main. 2. Make changes — keep each commit focused and atomic. 3. Test — ensure existing functionality is not affected. 4. Update documentation — if changes affect user-facing behavior, update the README or related docs. 5. Open a PR — describe the motivation, changes, and link related Issues.
Branch Information
| Branch | Purpose |
|---|---|
main | Default branch, PR target |
Commit Message Convention
Use the following format for commit messages:
<type>(<scope>): <short summary>
<detailed description (optional)>
Closes #123
Signed-off-by: Your Name <your-email@example.com>Types
Aligned with the PR template Change Types:
| Type | Description | PR Change Type |
|---|---|---|
fix | Bug fix | Bug fix |
feat | New feature | New feature |
docs | Documentation update | Documentation update |
perf | Performance optimization | Code optimization |
refactor | Code refactoring (no behavior change) | Code optimization |
test | Test related | — |
chore | Build / tooling / dependency changes | — |
Scope Examples
store, hooks, persona, scene, record, conversation, gateway, hermes
Code Style
- TypeScript: Follow the existing code style in the project for consistency.
- Naming: Use meaningful variable and function names, prefer English.
- Comments: Add comments at critical logic points explaining "why" rather than "what".
- Import order: Node.js built-in modules → third-party dependencies → internal project modules.
Developer Certificate of Origin (DCO)
All commits must include a Signed-off-by line, indicating your agreement to the Developer Certificate of Origin:
git commit -s -m "feat(store): add batch insert support"Commits without a valid Signed-off-by will not be merged.
Security Issues
If you discover a security vulnerability, please report it via the email agentmemory@tencent.com and we will address it promptly.
License
By submitting a contribution, you agree that your code will be licensed under the MIT License.
---
Thank you again for contributing! If you have any questions, feel free to discuss them in Issues.
# TDAI Memory + Hermes Agent — All-in-One Open Source Image
#
# Based on install_hermes_ubuntu24.04_hongkong.sh installation logic.
# Pre-installs Hermes Agent + memory_tencentdb plugin + TDAI Memory Gateway.
#
# Build: docker build -f Dockerfile.hermes -t hermes-memory .
# Run: docker run -it -p 8420:8420 \
# -e MODEL_API_KEY=xxx \
# hermes-memory
#
# Hermes and TDAI share a single set of model config via MODEL_* env vars.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
# ============================================================
# System dependencies (mirrors install script)
# ============================================================
RUN apt-get update && \
apt-get install -y git curl ripgrep ffmpeg python3-pip python3-venv \
build-essential python3-dev make g++ && \
# Node.js 22
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# ============================================================
# Install TDAI Memory Gateway (npm)
# ============================================================
WORKDIR /opt/tdai-gateway
RUN echo '{"name":"tdai-memory-standalone","version":"1.0.0","type":"module","private":true}' > package.json && \
npm install @tencentdb-agent-memory/memory-tencentdb@latest tsx && \
npm cache clean --force
# ============================================================
# Install Hermes Agent (official installer, skip browser)
# ============================================================
ENV HERMES_SKIP_BROWSER_SETUP=1
RUN curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh -o /tmp/install-hermes.sh && \
# Patch: skip browser setup
sed -i '/^install_node_deps() {/a\ if [ "${HERMES_SKIP_BROWSER_SETUP:-0}" = "1" ]; then echo "Skipping browser deps"; return 0; fi' /tmp/install-hermes.sh && \
# Patch: shallow clone
sed -i 's/git clone --branch/git clone --depth 1 --branch/' /tmp/install-hermes.sh && \
HOME=/root bash /tmp/install-hermes.sh --skip-setup && \
rm -f /tmp/install-hermes.sh
# Hermes data dir (user-independent)
ENV HERMES_HOME=/opt/data
# Remove installer-generated config so HERMES_HOME takes sole precedence
RUN rm -f /root/.hermes/config.yaml /root/.hermes/.env
# Link memory_tencentdb plugin into Hermes bundled plugin discovery path
# (plugins/memory/__init__.py scans <hermes-repo>/plugins/memory/<name>/)
RUN ln -sf /opt/tdai-gateway/node_modules/@tencentdb-agent-memory/memory-tencentdb/hermes-plugin/memory/memory_tencentdb \
/usr/local/lib/hermes-agent/plugins/memory/memory_tencentdb
# ============================================================
# Configuration — Unified model config (shared by Hermes + TDAI)
# ============================================================
RUN mkdir -p /opt/data/tdai-memory
# -- Unified model config (users only need to set these) --
# MODEL_API_KEY must be passed at runtime via -e (not baked into image)
ENV MODEL_NAME=deepseek-v3.2
ENV MODEL_BASE_URL=https://api.lkeap.cloud.tencent.com/v1
ENV MODEL_PROVIDER=custom
# -- Gateway port --
ENV TDAI_GATEWAY_PORT=8420
ENV TDAI_GATEWAY_HOST=0.0.0.0
ENV TDAI_DATA_DIR=/opt/data/tdai-memory
# -- Plugin connection (Hermes -> Gateway) --
ENV MEMORY_TENCENTDB_GATEWAY_HOST=127.0.0.1
ENV MEMORY_TENCENTDB_GATEWAY_PORT=8420
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -sf http://localhost:8420/health || exit 1
EXPOSE 8420
VOLUME ["/opt/data"]
# ============================================================
# CMD: Sync MODEL_* -> generate configs -> start Gateway (foreground)
# Use: docker exec hermes-memory hermes (on-demand)
# ============================================================
CMD ["bash", "-c", "\
mkdir -p /opt/data/tdai-memory && \
CFG=/opt/data/config.yaml && \
ENVFILE=/opt/data/.env && \
export TDAI_LLM_MODEL=\"${MODEL_NAME}\" && \
export TDAI_LLM_BASE_URL=\"${MODEL_BASE_URL}\" && \
export TDAI_LLM_API_KEY=\"${MODEL_API_KEY}\" && \
printf \"model:\\n default: \\\"${MODEL_NAME}\\\"\\n provider: \\\"${MODEL_PROVIDER}\\\"\\n base_url: \\\"${MODEL_BASE_URL}\\\"\\n api_key: \\\"${MODEL_API_KEY}\\\"\\n\\nmemory:\\n provider: memory_tencentdb\\n\" > \"$CFG\" && \
printf \"OPENAI_API_KEY=${MODEL_API_KEY}\\n\" > \"$ENVFILE\" && \
echo \"=== Config: model=${MODEL_NAME}, provider=${MODEL_PROVIDER}, url=${MODEL_BASE_URL} ===\" && \
echo \"=== Gateway starting... ===\" && \
echo \"=== Use: docker exec -it <container> hermes to start a conversation ===\" && \
exec node --import tsx/esm \
/opt/tdai-gateway/node_modules/@tencentdb-agent-memory/memory-tencentdb/src/gateway/server.ts \
"]
Hermes + TDAI Memory — 一体化开源镜像
预装 Hermes Agent + TDAI Memory 插件,单容器同时运行两个服务。 只需配置一个 API Key 即可启用 Hermes 对话 + 四层记忆系统。
架构
┌──────────────────────────────────────────────────────┐
│ 容器内部 │
│ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ Hermes Agent │ │ TDAI Memory │ │
│ │ (Python) │───▶│ Gateway (Node.js) │ │
│ │ │HTTP│ :8420 │ │
│ │ memory_tencentdb │ │ │ │
│ │ plugin (内置) │ │ SQLite 本地存储 │ │
│ └──────────────────────┘ └─────────────────────┘ │
│ │
│ 统一模型配置(Hermes + TDAI 共用一套 MODEL_* 变量) │
└──────────────────────────────────────────────────────┘快速开始
# 构建(不依赖项目源码,任意目录均可)
docker build -f Dockerfile.hermes -t hermes-memory .
# 运行(后台常驻,Gateway 自动启动)
docker run -d \
--name hermes-memory \
--restart unless-stopped \
-p 8420:8420 \
-e MODEL_API_KEY="your-api-key" \
-e MODEL_BASE_URL="https://api.lkeap.cloud.tencent.com/v1" \
-e MODEL_NAME="deepseek-v3.2" \
-e MODEL_PROVIDER="custom" \
-v hermes_data:/opt/data \
hermes-memory
# 验证 Gateway
curl http://localhost:8420/health
# 进入 Hermes 对话
docker exec -it hermes-memory hermes镜像内置了腾讯云 DeepSeek-V3.2 的默认值,如果你使用该模型,MODEL_BASE_URL/MODEL_NAME/MODEL_PROVIDER可以省略,只传MODEL_API_KEY即可。
工作原理
容器启动时(CMD)自动执行以下步骤:
1. 将 MODEL_* 环境变量同步到 Gateway(export TDAI_LLM_*) 2. 生成 /opt/data/config.yaml(Hermes 配置,含模型参数和 memory.provider: memory_tencentdb) 3. 生成 /opt/data/.env(写入 OPENAI_API_KEY,供 Hermes 读取) 4. 前台启动 TDAI Memory Gateway(Node.js,监听 :8420,保持容器常驻)
通过 docker exec -it hermes-memory hermes 进入对话时,Hermes 从 $HERMES_HOME(/opt/data)读取上述配置文件,自动连接已运行的 Gateway。memory_tencentdb 插件通过 HTTP 与本地 Gateway 通信,完成对话采集、记忆提取、场景构建和用户画像生成(L0→L1→L2→L3 四层 pipeline)。
环境变量
统一模型配置(Hermes + TDAI 共用)
| 变量 | 默认值 | 说明 |
|---|---|---|
MODEL_API_KEY | - | LLM API Key(必填,运行时通过 `-e` 传入) |
MODEL_BASE_URL | https://api.lkeap.cloud.tencent.com/v1 | LLM API 地址 |
MODEL_NAME | deepseek-v3.2 | 模型名称 |
MODEL_PROVIDER | custom | 模型 provider: custom/openrouter/anthropic/openai/gemini |
用户只需配置上述 MODEL_* 变量,容器启动时自动同步到 Hermes(config.yaml + .env)和 Gateway(TDAI_LLM_* 环境变量)。
服务配置
| 变量 | 默认值 | 说明 |
|---|---|---|
TDAI_GATEWAY_PORT | 8420 | Gateway 端口 |
TDAI_GATEWAY_HOST | 0.0.0.0 | Gateway 绑定地址 |
TDAI_DATA_DIR | /opt/data/tdai-memory | 记忆数据目录 |
HERMES_HOME | /opt/data | Hermes 数据目录 |
数据持久化
所有数据存储在 /opt/data volume 中:
/opt/data/
├── tdai-memory/ # TDAI 记忆数据 (SQLite + 场景文件)
│ ├── memories.sqlite # L0/L1 数据
│ ├── scene_blocks/ # L2 场景文件
│ ├── persona.md # L3 用户画像
│ └── checkpoint.json # Pipeline 状态
├── sessions/ # Hermes 会话记录
├── skills/ # Hermes 技能
├── config.yaml # Hermes 配置(启动时自动生成)
├── .env # 环境变量(启动时自动生成)
└── gateway.log # Gateway 日志故障排查
# 查看 Gateway 日志
docker exec hermes-memory cat /opt/data/tdai-memory/gateway.log
# 查看 Gateway 健康状态
docker exec hermes-memory curl -s http://localhost:8420/health | python3 -m json.tool
# 手动测试记忆召回
docker exec hermes-memory curl -s -X POST http://localhost:8420/recall \
-H "Content-Type: application/json" \
-d '{"query":"test","session_key":"debug"}'
# 查看生成的 Hermes 配置
docker exec hermes-memory cat /opt/data/config.yaml
# 查看环境变量同步结果
docker exec hermes-memory env | grep -E '(MODEL_|TDAI_LLM_)'
# 进入容器调试
docker exec -it hermes-memory bash构建说明
Dockerfile 不依赖本地源码 COPY,也不依赖 root 用户:
- TDAI Memory Gateway:通过
npm install @tencentdb-agent-memory/memory-tencentdb@latest从 npm registry 获取 - Hermes Agent:通过官方安装脚本从 GitHub 获取,安装到
/usr/local/lib/hermes-agent/ - memory_tencentdb 插件:npm 包内已包含
hermes-plugin/目录,构建时自动 symlink 到 Hermes 内置插件路径(/usr/local/lib/hermes-agent/plugins/memory/)
容器内所有运行时路径均为绝对路径,不依赖 $HOME 或特定用户,可以非 root 用户运行。
"""memory-tencentdb Memory Provider — MemoryProvider interface for Hermes.
Four-layer memory system (L0 conversation, L1 extraction, L2 scene blocks,
L3 persona synthesis) accessed via local Node.js Gateway sidecar.
The Gateway runs the memory-tencentdb Core engine (the same engine used by
the OpenClaw plugin) as an HTTP service. This provider translates Hermes
lifecycle events into Gateway API calls.
Config via environment variables:
MEMORY_TENCENTDB_GATEWAY_HOST — Gateway host (default: 127.0.0.1)
MEMORY_TENCENTDB_GATEWAY_PORT — Gateway port (default: 8420)
MEMORY_TENCENTDB_GATEWAY_CMD — Command to start the Gateway (optional; if
unset, the provider auto-discovers
``src/gateway/server.ts`` next to the plugin
checkout or under ``$HOME``)
The on-disk data directory (L0~L3 storage) is owned by the Gateway, not by
this provider. Point the Gateway at a custom location with ``TDAI_DATA_DIR``
(read directly by ``src/gateway/config.ts``); otherwise it falls back to
``~/.memory-tencentdb/memory-tdai`` (with legacy fallback to ``~/memory-tdai``
if it still exists). This provider no longer carries its own data-dir default
or env var — a single source of truth prevents the two layers from drifting
apart.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from .client import MemoryTencentdbSdkClient
from .supervisor import GatewaySupervisor
logger = logging.getLogger(__name__)
# Circuit breaker: after N consecutive failures, pause API calls
_BREAKER_THRESHOLD = 5
_BREAKER_COOLDOWN_SECS = 60
# Gateway resurrect throttle: minimum seconds between two consecutive
# ensure_running() attempts triggered by in-flight request failures.
# Chosen smaller than _BREAKER_COOLDOWN_SECS so we can try to revive the
# Gateway *within* a breaker-open window (otherwise the breaker would mask
# the outage for a full minute before we'd even attempt recovery).
# Chosen larger than supervisor's HEALTH_CHECK_MAX_WAIT (30s) so a failed
# revive never overlaps with the next attempt.
_RECOVER_COOLDOWN_SECS = 15
# Background sync thread limits.
# _MAX_INFLIGHT_SYNCS caps concurrent capture threads: once reached we wait
# on the oldest one with _SYNC_JOIN_TIMEOUT_SECS before spawning a new one,
# so a hung Gateway can't cause unbounded thread growth.
_MAX_INFLIGHT_SYNCS = 4
_SYNC_JOIN_TIMEOUT_SECS = 5.0
# _SHUTDOWN_JOIN_TIMEOUT_SECS bounds how long shutdown will wait on *each*
# still-alive sync thread. Kept per-thread rather than global because one
# stuck thread shouldn't starve the rest.
_SHUTDOWN_JOIN_TIMEOUT_SECS = 5.0
# Watchdog: a daemon thread that periodically inspects the Gateway and
# resurrects it on death. This is the *only* mechanism that can recover from
# the "stuck-in-False" state where _gateway_available has been flipped to
# False (initial start failed or breaker-open path swallowed all errors) and
# every business request short-circuits before reaching the failure path that
# would otherwise call _try_recover_gateway().
#
# _WATCHDOG_INTERVAL_SECS controls the polling cadence. Kept smaller than
# _BREAKER_COOLDOWN_SECS so we can detect death and re-enable the provider
# well before the breaker would naturally expire.
# _WATCHDOG_SHUTDOWN_TIMEOUT_SECS bounds how long shutdown waits for the
# watchdog to exit cleanly; the thread is daemonized so a hang would not
# block interpreter exit, but a bounded join keeps logs orderly.
_WATCHDOG_INTERVAL_SECS = 10.0
_WATCHDOG_SHUTDOWN_TIMEOUT_SECS = 2.0
# Gateway networking defaults (kept here so is_available/initialize stay in sync)
_DEFAULT_GATEWAY_HOST = "127.0.0.1"
_DEFAULT_GATEWAY_PORT = 8420
def _resolve_gateway_port(default: int = _DEFAULT_GATEWAY_PORT) -> int:
"""Resolve MEMORY_TENCENTDB_GATEWAY_PORT with validation.
Accepts surrounding whitespace. Falls back to ``default`` and logs a
warning when the env var is unset, empty, not a valid integer, or
outside the valid TCP port range (1..65535). This keeps ``is_available``
exception-safe (required by the provider registration contract) and
gives users a clear diagnostic instead of a raw ValueError stack.
"""
raw = os.environ.get("MEMORY_TENCENTDB_GATEWAY_PORT")
if raw is None or not raw.strip():
return default
try:
port = int(raw.strip())
except ValueError:
logger.warning(
"Invalid MEMORY_TENCENTDB_GATEWAY_PORT=%r (not an integer); "
"falling back to default %d.",
raw, default,
)
return default
if not (1 <= port <= 65535):
logger.warning(
"MEMORY_TENCENTDB_GATEWAY_PORT=%d is out of range (1..65535); "
"falling back to default %d.",
port, default,
)
return default
return port
def _resolve_gateway_host(default: str = _DEFAULT_GATEWAY_HOST) -> str:
"""Resolve MEMORY_TENCENTDB_GATEWAY_HOST, trimming whitespace."""
raw = os.environ.get("MEMORY_TENCENTDB_GATEWAY_HOST")
if raw is None:
return default
host = raw.strip()
return host or default
def _resolve_gateway_api_key() -> Optional[str]:
"""Read the optional Gateway Bearer token from the environment.
Looks at ``MEMORY_TENCENTDB_GATEWAY_API_KEY`` (Hermes-namespaced) first;
falls back to ``TDAI_GATEWAY_API_KEY`` so an operator who already wired
up the Gateway-side env var does not have to set two names. Returns
``None`` when neither is set, which means "do not attach an
Authorization header" — exactly matching the Gateway's own legacy
default. Whitespace-only values are treated as unset to guard against
shells that quote ``\\n`` into env vars.
Important: this is purely the **client-side** secret. Whether the
Gateway actually enforces a Bearer check is decided on the Gateway
side (its own ``TDAI_GATEWAY_API_KEY`` / ``server.apiKey``); the
plugin does not propagate this value across to the spawned Gateway.
The operator must configure the same secret on both ends if they
want auth enforcement.
"""
for var in ("MEMORY_TENCENTDB_GATEWAY_API_KEY", "TDAI_GATEWAY_API_KEY"):
raw = os.environ.get(var)
if raw is None:
continue
value = raw.strip()
if value:
return value
return None
# Candidate locations searched by _discover_gateway_cmd() when the user has not
# set MEMORY_TENCENTDB_GATEWAY_CMD. Order matters: in-tree checkout (next to
# this file) wins over ad-hoc clones in ``$HOME``.
_GATEWAY_DISCOVERY_RELATIVE_PATHS = (
# hermes-plugin/memory/memory_tencentdb/__init__.py → plugin root
Path("src") / "gateway" / "server.ts",
)
_GATEWAY_DISCOVERY_HOME_PATHS = (
# New canonical install location (managed by install_hermes_memory_tencentdb.sh
# and memory-tencentdb-ctl.sh): ~/.memory-tencentdb/tdai-memory-openclaw-plugin/...
Path(".memory-tencentdb") / "tdai-memory-openclaw-plugin" / "src" / "gateway" / "server.ts",
# Legacy locations (kept for backward compatibility with installations done
# before the ~/.memory-tencentdb/ consolidation):
Path("tdai-memory-openclaw-plugin") / "src" / "gateway" / "server.ts",
Path(".hermes") / "plugins" / "tdai-memory-openclaw-plugin" / "src" / "gateway" / "server.ts",
)
def _discover_gateway_cmd() -> Optional[str]:
"""Best-effort fallback to locate the Node Gateway entry point.
Called only when ``MEMORY_TENCENTDB_GATEWAY_CMD`` is unset, so that a fresh
checkout works out-of-the-box without the user having to hand-craft an
absolute launch command. Resolution order:
1. ``<plugin-root>/src/gateway/server.ts`` (in-tree: this file lives at
``<plugin-root>/hermes-plugin/memory/memory_tencentdb/__init__.py``).
2. Well-known paths under ``$HOME`` (preferred:
``~/.memory-tencentdb/tdai-memory-openclaw-plugin``; legacy:
``~/tdai-memory-openclaw-plugin`` and
``~/.hermes/plugins/tdai-memory-openclaw-plugin``).
Returns a ready-to-``Popen`` command string wrapping a ``sh -c`` that
``cd``-s into the plugin root before exec-ing ``pnpm exec tsx
src/gateway/server.ts``. The ``cd`` is required because ``tsx`` is
installed under ``<plugin-root>/node_modules`` and Node's ESM resolver
searches ``package.json`` from the cwd upward — if we launched ``tsx``
with the hermes-agent cwd, resolution would fail with
``ERR_MODULE_NOT_FOUND``. Using ``sh -c`` keeps the supervisor's
``shlex.split`` + ``Popen(argv)`` contract intact (no ``shell=True``).
Returns ``None`` if no ``server.ts`` candidate exists. The function never
raises: supervisor-side validation will surface a friendly warning if the
discovered path later fails to start.
"""
import shlex
here = Path(__file__).resolve()
# hermes-plugin/memory/memory_tencentdb/__init__.py → parents[3] = plugin root
plugin_root_candidates: List[Path] = []
try:
plugin_root_candidates.append(here.parents[3])
except IndexError: # pragma: no cover - defensive; __file__ depth is stable
pass
home_raw = os.environ.get("HOME") or os.environ.get("USERPROFILE")
home = Path(home_raw) if home_raw else None
searched: List[Path] = []
for root in plugin_root_candidates:
for rel in _GATEWAY_DISCOVERY_RELATIVE_PATHS:
searched.append(root / rel)
if home is not None:
for rel in _GATEWAY_DISCOVERY_HOME_PATHS:
searched.append(home / rel)
for candidate in searched:
try:
if candidate.is_file():
# candidate = <plugin-root>/src/gateway/server.ts
# -> parents[2] = <plugin-root>
plugin_root = candidate.parents[2]
logger.info(
"memory-tencentdb Gateway command auto-discovered: %s "
"(override with MEMORY_TENCENTDB_GATEWAY_CMD)",
candidate,
)
# shlex.quote guards against spaces / shell metachars in paths.
# The inner command mirrors start-memory-tencentdb-gateway.sh:
# cd <plugin-root> && exec pnpm exec tsx src/gateway/server.ts
inner = (
f"cd {shlex.quote(str(plugin_root))} && "
"exec pnpm exec tsx src/gateway/server.ts"
)
return f"sh -c {shlex.quote(inner)}"
except OSError: # pragma: no cover - e.g. permission errors on is_file
continue
logger.debug(
"memory-tencentdb Gateway auto-discovery found no server.ts under: %s",
", ".join(str(p) for p in searched) or "<no candidates>",
)
return None
# Search tool limit bounds (shared by memory_search and conversation_search).
_DEFAULT_SEARCH_LIMIT = 5
_MAX_SEARCH_LIMIT = 20
def _coerce_limit(
raw: Any,
*,
default: int = _DEFAULT_SEARCH_LIMIT,
maximum: int = _MAX_SEARCH_LIMIT,
) -> int:
"""Coerce a tool-call ``limit`` arg into a valid int in ``[1, maximum]``.
LLM tool calls don't always honor the JSON Schema ``type: integer``
declaration — we regularly see strings ("10"), floats ("10.5"), None,
or booleans. A bare ``int(x)`` either raises ValueError (string "abc",
"10.5") or silently coerces True/False to 1/0, which would surface as
a useless ``Tool call failed: invalid literal for int()`` back to the
model. Instead we:
* accept None / empty string -> return ``default``;
* reject bool explicitly (bool is an ``int`` subclass in Python, and
``int(True) == 1`` is almost never what the caller meant);
* accept int / float / numeric-looking strings via float() then int();
* clamp the result to ``[1, maximum]``;
* on any failure, log a warning and fall back to ``default``.
"""
if raw is None or raw == "":
return default
if isinstance(raw, bool):
logger.warning(
"memory-tencentdb: ignoring non-numeric limit=%r (bool); "
"falling back to default %d.",
raw, default,
)
return default
try:
# float() handles int, float, and numeric strings uniformly;
# int() then truncates toward zero.
value = int(float(raw))
except (TypeError, ValueError):
logger.warning(
"memory-tencentdb: ignoring invalid limit=%r (not numeric); "
"falling back to default %d.",
raw, default,
)
return default
if value < 1:
return 1
if value > maximum:
return maximum
return value
# ---------------------------------------------------------------------------
# Tool schemas
# ---------------------------------------------------------------------------
MEMORY_SEARCH_SCHEMA = {
"name": "memory_tencentdb_memory_search",
"description": (
"Search through the user's long-term memories. Use this when you need to "
"recall specific information about the user's preferences, past events, "
"instructions, or context from previous conversations. Returns relevant "
"memory records ranked by relevance."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query describing what you want to recall about the user.",
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default: 5, max: 20).",
},
"type": {
"type": "string",
"enum": ["persona", "episodic", "instruction"],
"description": "Optional filter by memory type.",
},
},
"required": ["query"],
},
}
CONVERSATION_SEARCH_SCHEMA = {
"name": "memory_tencentdb_conversation_search",
"description": (
"Search through past conversation history (raw dialogue records). "
"Use when memory_tencentdb_memory_search doesn't have the information "
"you need, or when you want to find specific past conversations or "
"exact words the user said before."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query describing what conversation content you want to find.",
},
"limit": {
"type": "integer",
"description": "Maximum number of messages to return (default: 5, max: 20).",
},
},
"required": ["query"],
},
}
# ---------------------------------------------------------------------------
# MemoryProvider implementation
# ---------------------------------------------------------------------------
class MemoryTencentdbProvider(MemoryProvider):
"""memory-tencentdb four-layer memory via local Gateway sidecar."""
def __init__(self):
self._supervisor: Optional[GatewaySupervisor] = None
self._client: Optional[MemoryTencentdbSdkClient] = None
self._session_id = ""
self._user_id = ""
self._gateway_available = False
self._initialized = False # Track if initialize() has been called
# Background sync threads.
# We allow at most _MAX_INFLIGHT_SYNCS in-flight sync threads at any
# time. Stuck threads (e.g. Gateway hung mid-capture) are tracked in
# _active_syncs so shutdown can still join them and we never lose
# references to spawned threads. _sync_lock guards both fields.
self._sync_lock = threading.Lock()
self._active_syncs: List[threading.Thread] = []
# Circuit breaker
self._consecutive_failures = 0
self._breaker_open_until = 0.0
# Gateway auto-resurrect state.
# _recover_lock ensures only one thread at a time actually calls
# supervisor.ensure_running() (which can block up to 30s). Other
# threads that see a failure will try the lock non-blockingly and
# fall through — they never wait, so recovery attempts never add
# latency to business calls.
# _last_recover_attempt gates how often we retry when revival keeps
# failing (e.g. gateway binary missing, node not installed).
# Initialized to -inf (rather than 0.0) because time.monotonic()'s
# reference point is undefined — on some platforms (notably macOS)
# it starts near zero at process start, which would make the
# ``now - 0.0 < _RECOVER_COOLDOWN_SECS`` check swallow the very
# first recovery attempt. Using -inf guarantees the first attempt
# always passes the throttle.
self._recover_lock = threading.Lock()
self._last_recover_attempt = float("-inf")
# Watchdog state.
# The watchdog runs as a daemon thread that periodically (every
# _WATCHDOG_INTERVAL_SECS) verifies the Gateway is alive and, on
# failure, calls _try_recover_gateway(). This breaks the
# "stuck-in-False" deadlock where business requests short-circuit on
# _gateway_available == False and never reach the failure path that
# would trigger recovery. _watchdog_stop is an Event so shutdown can
# signal a clean exit without waiting a full polling interval.
self._watchdog_thread: Optional[threading.Thread] = None
self._watchdog_stop = threading.Event()
# -- Properties -----------------------------------------------------------
@property
def name(self) -> str:
return "memory_tencentdb"
# -- Circuit breaker ------------------------------------------------------
def _is_breaker_open(self) -> bool:
if self._consecutive_failures < _BREAKER_THRESHOLD:
return False
if time.monotonic() >= self._breaker_open_until:
self._consecutive_failures = 0
return False
return True
def _record_success(self):
self._consecutive_failures = 0
def _record_failure(self):
self._consecutive_failures += 1
if self._consecutive_failures >= _BREAKER_THRESHOLD:
self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
logger.warning(
"memory-tencentdb circuit breaker tripped after %d failures. Pausing for %ds.",
self._consecutive_failures, _BREAKER_COOLDOWN_SECS,
)
# -- Gateway auto-resurrect ----------------------------------------------
def _try_recover_gateway(self, *, bypass_cooldown: bool = False) -> bool:
"""Best-effort: re-probe and, if needed, re-launch the Gateway.
Called from the *failure* path of prefetch / sync_turn / handle_tool_call
so a transient Gateway crash during an active Hermes session is not
stuck behind the 60s circuit breaker. Also called from the watchdog
thread (``bypass_cooldown=True``) which has its own cadence and must
not be throttled by the request-driven 15s gate.
Guarantees (do not break these without revisiting callers):
* Never raises — exceptions are logged and swallowed.
* Never blocks a losing thread: uses ``acquire(blocking=False)``.
If another thread is already attempting recovery, we return
``False`` immediately.
* Throttled by ``_RECOVER_COOLDOWN_SECS`` so a Gateway that
refuses to start does not burn CPU on every failed request.
The watchdog opts out of this throttle via ``bypass_cooldown``.
* Refuses to run after ``shutdown()`` (detected via
``self._supervisor is None``) so we never resurrect a provider
that the host has released.
* On success: refreshes ``self._client`` / ``self._gateway_available``
and resets the circuit breaker so the very next request isn't
falsely blocked.
* On failure: records the attempt timestamp; does NOT touch the
circuit breaker (the caller already recorded a failure).
"""
supervisor = self._supervisor
if supervisor is None:
# Either initialize() was never called, or shutdown() already ran.
return False
if not bypass_cooldown:
now = time.monotonic()
if now - self._last_recover_attempt < _RECOVER_COOLDOWN_SECS:
return False
if not self._recover_lock.acquire(blocking=False):
# Another thread is already attempting recovery — let it work.
return False
try:
# Re-check supervisor under the lock: shutdown() could have set it
# to None between our first read and acquiring the lock.
supervisor = self._supervisor
if supervisor is None:
return False
# Double-check the cooldown under the lock too: another recovery
# may have completed between our read and the acquire().
if not bypass_cooldown:
now = time.monotonic()
if now - self._last_recover_attempt < _RECOVER_COOLDOWN_SECS:
return False
# Fast path: maybe the Gateway is already back (someone else
# restarted it, or it was a transient blip).
if supervisor.is_running():
logger.info(
"memory-tencentdb Gateway is reachable again; restoring provider state."
)
ok = True
else:
logger.warning(
"memory-tencentdb Gateway appears down; attempting to resurrect."
)
ok = supervisor.ensure_running()
self._last_recover_attempt = time.monotonic()
if ok:
# Reattach the client (supervisor owns the authoritative one).
self._client = supervisor.client
self._gateway_available = True
# Clear the breaker so the next request can proceed
# immediately instead of being blocked by the 60s cooldown.
self._consecutive_failures = 0
self._breaker_open_until = 0.0
logger.info("memory-tencentdb Gateway recovery succeeded.")
return True
logger.warning(
"memory-tencentdb Gateway recovery failed; will retry no sooner than %ds.",
_RECOVER_COOLDOWN_SECS,
)
return False
except Exception as e: # defensive: never propagate to caller
self._last_recover_attempt = time.monotonic()
logger.warning("memory-tencentdb Gateway recovery raised: %s", e)
return False
finally:
self._recover_lock.release()
# -- Watchdog & lazy probe -----------------------------------------------
def _ensure_alive_for_request(self) -> bool:
"""Lazy probe used by the request short-circuit guards.
Problem this solves: prefetch / sync_turn / handle_tool_call all
return early when ``_gateway_available`` is False, which means a
provider that failed to start (or was tripped by the 60s breaker
and never re-enabled) can never recover via the request path —
recovery only runs in the failure ``except`` branch, but the guard
prevents requests from ever reaching that branch.
This method gives the guards a way out: when the breaker is closed
but ``_gateway_available`` is False, attempt a single recovery
synchronously (subject to the same lock + cooldown as the failure
path). On success the caller can proceed with the real request; on
failure it returns the same empty / disabled response as before.
Safe to call from any thread. Never raises. Returns the value of
``_gateway_available`` after the attempt.
"""
if self._gateway_available:
return True
if self._is_breaker_open():
# Breaker takes precedence: respect its 60s cooldown so we do
# not turn every request into a Gateway-restart attempt during
# an outage.
return False
# Try to bring the Gateway back. This is throttled by the same
# 15s cooldown as the failure path, so a flood of requests won't
# cause a recovery storm.
self._try_recover_gateway()
return self._gateway_available
def _start_watchdog(self) -> None:
"""Start the background watchdog thread (idempotent).
The watchdog is the only mechanism that can recover from the
"Gateway dies while no requests are in flight" scenario. It also
breaks the deadlock where _gateway_available is stuck False and
every request short-circuits before triggering recovery.
"""
if self._watchdog_thread is not None and self._watchdog_thread.is_alive():
return
self._watchdog_stop.clear()
thread = threading.Thread(
target=self._watchdog_loop,
daemon=True,
name="memory-tencentdb-watchdog",
)
self._watchdog_thread = thread
thread.start()
def _watchdog_loop(self) -> None:
"""Periodically verify Gateway health and resurrect on death.
Runs until ``_watchdog_stop`` is set (by ``shutdown()``) or until
the supervisor reference is dropped. Each iteration:
1. Snapshot the supervisor reference. If None → exit (provider
was shut down).
2. Cheap path: if our own child PID is alive AND ``_gateway_available``
is True, do nothing. Skips the HTTP round-trip in the common
happy path.
3. Otherwise, perform a real health check via supervisor.is_running().
On success and ``_gateway_available`` is False (e.g. someone
externally restarted the Gateway), reattach the client.
4. On failure, call ``_try_recover_gateway(bypass_cooldown=True)``.
The watchdog has its own pacing (``_WATCHDOG_INTERVAL_SECS``)
so it must not be subject to the request-driven cooldown.
All exceptions are logged and swallowed — the watchdog must never
crash and leave the provider unsupervised.
"""
logger.debug(
"memory-tencentdb watchdog started (interval=%.1fs)",
_WATCHDOG_INTERVAL_SECS,
)
while not self._watchdog_stop.wait(timeout=_WATCHDOG_INTERVAL_SECS):
try:
supervisor = self._supervisor
if supervisor is None:
# Provider was shut down between ticks.
break
# Cheap happy path: child is alive and we're already marked
# available. Nothing to do.
if self._gateway_available and supervisor.is_process_alive():
continue
# Either we never marked available, the child died, or the
# Gateway was started externally (no Popen handle but maybe
# listening on the port). Do a real health check.
healthy = False
try:
healthy = supervisor.is_running()
except Exception as e: # pragma: no cover - defensive
logger.debug(
"memory-tencentdb watchdog health probe raised: %s", e,
)
if healthy:
if not self._gateway_available:
# Externally revived (or first-time success after a
# bumpy start): reattach without re-spawning.
logger.info(
"memory-tencentdb watchdog: Gateway is reachable; "
"restoring provider state."
)
self._client = supervisor.client
self._gateway_available = True
self._consecutive_failures = 0
self._breaker_open_until = 0.0
continue
# Truly down. Attempt resurrection, bypassing the request-path
# cooldown — the watchdog itself enforces pacing.
logger.warning(
"memory-tencentdb watchdog: Gateway unreachable; "
"attempting to resurrect."
)
self._try_recover_gateway(bypass_cooldown=True)
except Exception as e: # pragma: no cover - defensive
logger.warning(
"memory-tencentdb watchdog iteration raised (continuing): %s", e,
)
logger.debug("memory-tencentdb watchdog exiting")
def _stop_watchdog(self) -> None:
"""Signal the watchdog to exit and join briefly. Safe if not started."""
self._watchdog_stop.set()
thread = self._watchdog_thread
self._watchdog_thread = None
if thread is None:
return
thread.join(timeout=_WATCHDOG_SHUTDOWN_TIMEOUT_SECS)
if thread.is_alive():
# Daemon thread, will not block interpreter exit; just log so
# users can correlate with Gateway hangs in the health probe.
logger.debug(
"memory-tencentdb watchdog did not exit within %.1fs; "
"abandoning (daemon).",
_WATCHDOG_SHUTDOWN_TIMEOUT_SECS,
)
# -- Core lifecycle -------------------------------------------------------
def is_available(self) -> bool:
"""Check if the Gateway is configured or already running.
Prefers local config checks (env vars) to avoid blocking network calls.
Only falls back to health check when no env config is present.
"""
# Fast path: env var configured → assume available (will verify in initialize)
if os.environ.get("MEMORY_TENCENTDB_GATEWAY_CMD"):
return True
if os.environ.get("MEMORY_TENCENTDB_GATEWAY_PORT"):
return True
# Slow path: no env config, try a quick health check.
# Use validated resolvers so a malformed env var never raises here
# (is_available must never throw: it's called during provider
# registration and an exception would break the whole plugin).
host = _resolve_gateway_host()
port = _resolve_gateway_port()
api_key = _resolve_gateway_api_key()
client = MemoryTencentdbSdkClient(
base_url=f"http://{host}:{port}",
timeout=2,
api_key=api_key,
)
try:
result = client.health(timeout=2)
return result.get("status") in ("ok", "degraded")
except Exception:
return False
def initialize(self, session_id: str, **kwargs) -> None:
"""Start or connect to the Gateway sidecar.
Gateway startup is performed in a background thread so that
``initialize()`` returns immediately and does not block the
Hermes agent ``__init__`` (which would add up to 30 s latency
before the first prompt is accepted).
While the background thread is still running:
* ``prefetch`` / ``sync_turn`` / ``handle_tool_call`` see
``_gateway_available == False`` and gracefully return empty
results or no-ops — no data is lost because capture will
succeed once the Gateway comes up and subsequent turns will
work normally.
* ``get_tool_schemas`` already returns schemas optimistically
(gated on ``_initialized``, not ``_gateway_available``),
so the tools appear in the LLM surface even before the
Gateway is ready.
"""
self._session_id = session_id
self._user_id = kwargs.get("user_id", "default")
host = _resolve_gateway_host()
port = _resolve_gateway_port()
# Priority: explicit env var → auto-discovery (in-tree / $HOME fallbacks).
# Auto-discovery lets fresh checkouts work without manual CMD wiring;
# it only runs when the env var is not set, so existing deployments
# are unaffected.
gateway_cmd = os.environ.get("MEMORY_TENCENTDB_GATEWAY_CMD") or _discover_gateway_cmd()
# Optional Bearer token attached to outbound Gateway requests
# (off by default). The plugin only handles the client side — if
# the operator wants the Gateway to enforce auth, they must
# configure ``TDAI_GATEWAY_API_KEY`` / ``server.apiKey`` on the
# Gateway side directly so both ends agree on the secret.
api_key = _resolve_gateway_api_key()
self._supervisor = GatewaySupervisor(
host=host,
port=port,
gateway_cmd=gateway_cmd,
api_key=api_key,
)
# Mark as initialized immediately so tools are registered
# (get_tool_schemas checks _initialized, not _gateway_available).
self._initialized = True
def _background_start():
"""Start / connect to the Gateway in the background."""
try:
available = self._supervisor.ensure_running()
if available:
self._client = self._supervisor.client
self._gateway_available = True
logger.info(
"memory-tencentdb Gateway ready (background start, %s:%d)",
host, port,
)
else:
logger.warning(
"memory-tencentdb Gateway not available after background start. "
"Memory features will be disabled until the Gateway is reachable. "
"Set MEMORY_TENCENTDB_GATEWAY_CMD to auto-start the Gateway, "
"or place the plugin checkout at ~/tdai-memory-openclaw-plugin "
"for auto-discovery."
)
except Exception as e:
logger.warning(
"memory-tencentdb background Gateway start failed (non-fatal): %s", e
)
# Fast path: if the Gateway is *already* running (e.g. started by
# systemd, memory-tencentdb-ctl, or a previous session), skip the
# thread overhead and attach synchronously. The health check takes
# <100ms for a local Gateway, so this doesn't block meaningfully.
if self._supervisor.is_running():
self._client = self._supervisor.client
self._gateway_available = True
logger.info(
"memory-tencentdb Gateway already running (%s:%d)",
host, port,
)
else:
# Gateway is not up yet — start it in the background.
t = threading.Thread(
target=_background_start, daemon=True,
name="tdai-gateway-init",
)
t.start()
# Start the watchdog regardless of the initial start outcome.
# Even if _background_start fails (e.g. tdai binary missing on
# first launch), the watchdog will keep retrying so a later
# external fix (operator installs node, drops the plugin into
# the discovery path, etc.) is picked up automatically without
# requiring a hermes restart.
self._start_watchdog()
def system_prompt_block(self) -> str:
if not self._gateway_available:
return ""
return (
"# memory-tencentdb Memory\n"
f"Active. User: {self._user_id}.\n"
"Four-layer memory system (L0→L1→L2→L3) with automatic conversation "
"capture, structured memory extraction, scene blocks, and persona synthesis.\n"
"Use memory_tencentdb_memory_search to find specific memories, "
"memory_tencentdb_conversation_search to search raw conversation history."
)
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Synchronous recall — fetch memories in real-time for the current turn."""
if not query:
return ""
# Lazy probe before the short-circuit guard. If the Gateway died but
# the breaker has not yet tripped (or has since cooled down), this
# gives the request path a chance to revive it instead of silently
# returning "" forever. See _ensure_alive_for_request() for the
# guarantees and rationale.
if not self._ensure_alive_for_request() or not self._client:
return ""
effective_session = session_id or self._session_id
try:
result = self._client.recall(
query=query,
session_key=effective_session,
user_id=self._user_id,
)
context = result.get("context", "")
self._record_success()
if context:
return f"## memory-tencentdb Memory\n{context}"
return ""
except Exception as e:
self._record_failure()
logger.debug("memory-tencentdb prefetch failed: %s", e)
# Fire-and-forget attempt to bring the Gateway back for the next
# call. Never blocks more than supervisor.ensure_running()'s own
# timeout, and only one thread at a time actually does the work.
self._try_recover_gateway()
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""No-op — recall is done synchronously in prefetch()."""
pass
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Send the turn to Gateway for capture (non-blocking).
Threading model:
* Each call spawns a daemon thread that performs one ``capture``.
* ``_active_syncs`` retains references to all still-alive threads so
they are never orphaned when a new sync starts.
* If ``_MAX_INFLIGHT_SYNCS`` is reached (e.g. Gateway is hung),
we wait on the oldest thread for ``_SYNC_JOIN_TIMEOUT_SECS`` before
spawning a new one. If that thread is still alive afterwards we
still spawn, but keep the stuck thread tracked so ``shutdown`` can
try to reap it later.
* All mutations of ``_active_syncs`` are serialized by
``_sync_lock`` so concurrent callers (future async entry points)
cannot leak references via a read/modify/write race.
"""
# Lazy probe — same rationale as prefetch(). Without this, a
# provider stuck in the False/closed-breaker state would silently
# drop every captured turn until the watchdog (or a manual
# restart) revived it.
if not self._ensure_alive_for_request() or not self._client:
return
effective_session = session_id or self._session_id
client = self._client
def _sync():
try:
client.capture(
user_content=user_content,
assistant_content=assistant_content,
session_key=effective_session,
user_id=self._user_id,
)
self._record_success()
except Exception as e:
self._record_failure()
logger.warning("memory-tencentdb sync failed: %s", e)
# Trigger recovery from a background thread — safe because
# _try_recover_gateway itself is non-blocking under
# contention and swallows all exceptions.
self._try_recover_gateway()
# Reap finished threads and, if at capacity, wait on the oldest one.
# We pick the oldest non-finished candidate *outside* the lock so the
# join() call doesn't hold _sync_lock (holding a lock across a
# potentially slow join would serialize every incoming turn).
oldest_to_join: Optional[threading.Thread] = None
with self._sync_lock:
self._active_syncs = [t for t in self._active_syncs if t.is_alive()]
if len(self._active_syncs) >= _MAX_INFLIGHT_SYNCS:
oldest_to_join = self._active_syncs[0]
if oldest_to_join is not None:
oldest_to_join.join(timeout=_SYNC_JOIN_TIMEOUT_SECS)
if oldest_to_join.is_alive():
logger.warning(
"memory-tencentdb sync backlog: oldest sync thread still "
"running after %.1fs; %d in-flight threads tracked. "
"Continuing with a new sync; Gateway may be hung.",
_SYNC_JOIN_TIMEOUT_SECS, len(self._active_syncs),
)
thread = threading.Thread(
target=_sync, daemon=True, name="memory-tencentdb-sync",
)
with self._sync_lock:
# Reap again in case the join above freed slots, then register.
self._active_syncs = [t for t in self._active_syncs if t.is_alive()]
self._active_syncs.append(thread)
thread.start()
def shutdown(self) -> None:
"""Clean shutdown — flush and release resources."""
# Stop the watchdog FIRST so it does not race with shutdown by
# spawning a fresh recovery attempt while we're tearing the
# supervisor down. Idempotent + non-blocking-bounded.
self._stop_watchdog()
# Wait for every background sync thread we ever spawned (not just the
# most recent one). Taking a snapshot under the lock first means new
# calls to sync_turn during shutdown can't race with our iteration.
with self._sync_lock:
pending = list(self._active_syncs)
self._active_syncs.clear()
for t in pending:
if not t.is_alive():
continue
t.join(timeout=_SHUTDOWN_JOIN_TIMEOUT_SECS)
if t.is_alive():
# Threads are daemon, so they won't block interpreter exit —
# but log so users can correlate with Gateway issues.
logger.warning(
"memory-tencentdb shutdown: sync thread %s still alive "
"after %.1fs; abandoning (daemon).",
t.name, _SHUTDOWN_JOIN_TIMEOUT_SECS,
)
# Send session end if Gateway is available
if self._client and self._gateway_available:
try:
self._client.end_session(
session_key=self._session_id,
user_id=self._user_id,
)
except Exception as e:
logger.debug("memory-tencentdb session end failed: %s", e)
# Note: do NOT shut down the supervisor/Gateway here — it may serve
# other sessions. The Gateway manages its own lifecycle.
# We *do* drop our reference to the supervisor so any in-flight
# _try_recover_gateway() call sees self._supervisor is None and
# bails out instead of resurrecting a released provider.
self._client = None
self._gateway_available = False
self._initialized = False
self._supervisor = None
# -- Tools ----------------------------------------------------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
# Optimistically return tool schemas if Gateway is configured or running.
# This is critical because MemoryManager.add_provider() calls
# get_tool_schemas() BEFORE initialize() to build the _tool_to_provider
# routing table. If we return [] here, tools won't be routable
# even after initialize() succeeds (despite _refresh_tool_registration).
if self._gateway_available or self._initialized:
return [MEMORY_SEARCH_SCHEMA, CONVERSATION_SEARCH_SCHEMA]
# Pre-init: check if Gateway is likely to be available
if os.environ.get("MEMORY_TENCENTDB_GATEWAY_CMD") or os.environ.get("MEMORY_TENCENTDB_GATEWAY_PORT"):
return [MEMORY_SEARCH_SCHEMA, CONVERSATION_SEARCH_SCHEMA]
return []
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
# Lazy probe — gives tool-call path the same self-heal opportunity
# as prefetch / sync_turn. Without this, an LLM-issued memory_search
# call could see "Gateway is not connected" forever even after the
# Gateway came back up, because nothing else would flip
# _gateway_available back to True.
self._ensure_alive_for_request()
if not self._client:
return json.dumps({
"error": "memory-tencentdb Gateway is not connected. Memory search is temporarily unavailable.",
"hint": "The Gateway may still be starting up. Try again in a moment.",
})
if self._is_breaker_open():
return json.dumps({"error": "memory-tencentdb Gateway temporarily unavailable (circuit breaker open)."})
try:
if tool_name == "memory_tencentdb_memory_search":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
result = self._client.search_memories(
query=query,
limit=_coerce_limit(args.get("limit")),
type_filter=args.get("type", ""),
)
self._record_success()
return json.dumps(result)
if tool_name == "memory_tencentdb_conversation_search":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
result = self._client.search_conversations(
query=query,
limit=_coerce_limit(args.get("limit")),
)
self._record_success()
return json.dumps(result)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
except Exception as e:
self._record_failure()
# Same fire-and-forget recovery as prefetch(); the error
# returned to the LLM below is unchanged.
self._try_recover_gateway()
return json.dumps({"error": f"Tool call failed: {e}"})
# -- Optional hooks -------------------------------------------------------
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Mirror built-in memory writes to memory-tencentdb for indexing."""
# TODO: Implement mirroring of Hermes builtin MEMORY.md/USER.md writes
# to memory-tencentdb's recall index for conflict suppression and dedup.
pass
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""Trigger session-level flush on the Gateway."""
if self._client and self._gateway_available:
try:
self._client.end_session(
session_key=self._session_id,
user_id=self._user_id,
)
except Exception as e:
logger.debug("memory-tencentdb on_session_end failed: %s", e)
# -- Config ---------------------------------------------------------------
def get_config_schema(self) -> List[Dict[str, Any]]:
return [
{
"key": "gateway_cmd",
"description": "Command to start the memory-tencentdb Gateway (e.g. 'node --import tsx /path/to/server.ts')",
"env_var": "MEMORY_TENCENTDB_GATEWAY_CMD",
"required": False,
},
{
"key": "gateway_host",
"description": "Gateway host",
"default": "127.0.0.1",
"env_var": "MEMORY_TENCENTDB_GATEWAY_HOST",
},
{
"key": "gateway_port",
"description": "Gateway port",
"default": "8420",
"env_var": "MEMORY_TENCENTDB_GATEWAY_PORT",
},
{
"key": "gateway_api_key",
"description": (
"Optional Bearer token attached to outbound Gateway "
"requests. Set this to the same secret you configure on "
"the Gateway side (``TDAI_GATEWAY_API_KEY`` / "
"``server.apiKey``) so the Bearer comparison succeeds. "
"Leave unset to skip the Authorization header entirely "
"(legacy default; matches an open Gateway)."
),
"secret": True,
"required": False,
"env_var": "MEMORY_TENCENTDB_GATEWAY_API_KEY",
},
{
"key": "llm_api_key",
"description": "LLM API key (for Gateway's standalone LLM calls)",
"secret": True,
"required": True,
"env_var": "MEMORY_TENCENTDB_LLM_API_KEY",
},
{
"key": "llm_base_url",
"description": "LLM API base URL",
"default": "https://api.openai.com/v1",
"env_var": "MEMORY_TENCENTDB_LLM_BASE_URL",
},
{
"key": "llm_model",
"description": "LLM model name",
"default": "gpt-4o",
"env_var": "MEMORY_TENCENTDB_LLM_MODEL",
},
]
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
def register(ctx) -> None:
"""Register memory-tencentdb as a memory provider plugin."""
ctx.register_memory_provider(MemoryTencentdbProvider())
"""MemoryTencentdbSdkClient — HTTP client for the memory-tencentdb Gateway.
Wraps all Gateway API endpoints with timeout, retry, and error handling.
Thread-safe — can be shared across prefetch/sync threads.
"""
from __future__ import annotations
import json
import logging
import urllib.request
import urllib.error
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10 # seconds
class MemoryTencentdbSdkClient:
"""HTTP client for the memory-tencentdb Gateway sidecar."""
def __init__(
self,
base_url: str = "http://127.0.0.1:8420",
timeout: int = DEFAULT_TIMEOUT,
api_key: Optional[str] = None,
):
"""Construct the client.
Args:
base_url: Gateway base URL.
timeout: Default request timeout in seconds.
api_key: Optional Bearer token. When non-empty, every request
attaches ``Authorization: Bearer <api_key>``. When ``None``
or empty, no auth header is sent — this preserves the
pre-existing open-Gateway behaviour and is the right default
for any deployment where the Gateway has not opted into
``TDAI_GATEWAY_API_KEY`` yet.
The provider sources this value from
``MEMORY_TENCENTDB_GATEWAY_API_KEY`` (with
``TDAI_GATEWAY_API_KEY`` as a fallback). The Gateway must
be configured with the matching secret independently —
this client does not (and should not) propagate the value
across to the Gateway process.
"""
self._base_url = base_url.rstrip("/")
self._timeout = timeout
# Strip whitespace defensively — env vars often pick up trailing
# newlines from `echo` or YAML quoting; an exact-match Bearer
# comparison would otherwise reject a key that "looks right".
self._api_key = (api_key or "").strip() or None
def _build_headers(self, *, content_type: bool) -> Dict[str, str]:
"""Build request headers, conditionally adding Authorization.
Centralised so the auth header logic is stated once: every method
below goes through ``_post`` / ``_get`` which call this helper. If
you ever add a new HTTP verb, route it here.
"""
headers: Dict[str, str] = {}
if content_type:
headers["Content-Type"] = "application/json"
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
return headers
def _post(self, path: str, body: Dict[str, Any], timeout: Optional[int] = None) -> Dict[str, Any]:
"""Make a POST request to the Gateway."""
url = f"{self._base_url}{path}"
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers=self._build_headers(content_type=True),
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout or self._timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body_text = ""
try:
body_text = e.read().decode("utf-8", errors="replace")
except Exception:
pass
logger.warning("memory-tencentdb Gateway %s returned %d: %s", path, e.code, body_text[:500])
raise
except Exception as e:
logger.debug("memory-tencentdb Gateway %s failed: %s", path, e)
raise
def _get(self, path: str, timeout: Optional[int] = None) -> Dict[str, Any]:
"""Make a GET request to the Gateway."""
url = f"{self._base_url}{path}"
req = urllib.request.Request(
url,
headers=self._build_headers(content_type=False),
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout or self._timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
logger.debug("memory-tencentdb Gateway GET %s failed: %s", path, e)
raise
# -- API methods ----------------------------------------------------------
def health(self, timeout: int = 3) -> Dict[str, Any]:
"""Check if the Gateway is healthy."""
return self._get("/health", timeout=timeout)
def recall(self, query: str, session_key: str, user_id: str = "") -> Dict[str, Any]:
"""Recall memories for a query (prefetch)."""
body: Dict[str, Any] = {"query": query, "session_key": session_key}
if user_id:
body["user_id"] = user_id
return self._post("/recall", body)
def capture(
self,
user_content: str,
assistant_content: str,
session_key: str,
session_id: str = "",
user_id: str = "",
) -> Dict[str, Any]:
"""Capture a conversation turn (sync_turn)."""
body: Dict[str, Any] = {
"user_content": user_content,
"assistant_content": assistant_content,
"session_key": session_key,
}
if session_id:
body["session_id"] = session_id
if user_id:
body["user_id"] = user_id
return self._post("/capture", body)
def search_memories(self, query: str, limit: int = 5, type_filter: str = "", scene: str = "") -> Dict[str, Any]:
"""Search L1 structured memories."""
body: Dict[str, Any] = {"query": query, "limit": limit}
if type_filter:
body["type"] = type_filter
if scene:
body["scene"] = scene
return self._post("/search/memories", body)
def search_conversations(self, query: str, limit: int = 5, session_key: str = "") -> Dict[str, Any]:
"""Search L0 raw conversations."""
body: Dict[str, Any] = {"query": query, "limit": limit}
if session_key:
body["session_key"] = session_key
return self._post("/search/conversations", body)
def end_session(self, session_key: str, user_id: str = "") -> Dict[str, Any]:
"""End a session and trigger flush."""
body: Dict[str, Any] = {"session_key": session_key}
if user_id:
body["user_id"] = user_id
return self._post("/session/end", body)
def seed(
self,
data: Any,
session_key: str = "",
strict_round_role: bool = False,
auto_fill_timestamps: bool = True,
config_override: Optional[Dict[str, Any]] = None,
timeout: int = 300,
) -> Dict[str, Any]:
"""Batch seed historical conversations into the memory pipeline.
Args:
data: Seed input — Format A ``{"sessions": [...]}`` or Format B ``[...]``.
session_key: Fallback session key when input sessions lack one.
strict_round_role: Require each round to have both user and assistant.
auto_fill_timestamps: Auto-fill missing timestamps (default True).
config_override: Plugin config overrides (deep-merged).
timeout: Request timeout in seconds (seed can be slow, default 300s).
Returns:
Summary dict with sessions_processed, rounds_processed, etc.
"""
body: Dict[str, Any] = {"data": data}
if session_key:
body["session_key"] = session_key
if strict_round_role:
body["strict_round_role"] = True
if not auto_fill_timestamps:
body["auto_fill_timestamps"] = False
if config_override:
body["config_override"] = config_override
return self._post("/seed", body, timeout=timeout)
name: memory_tencentdb
display_name: memory-tencentdb
version: 1.0.0
description: "memory-tencentdb four-layer memory — L0 conversation recording, L1 episodic extraction, L2 scene blocks, L3 persona synthesis via local Node.js Gateway."
hooks:
- on_memory_write
- on_session_end
# Legacy provider name — kept so that users whose config still says
# `memory.provider: tdai` continue to resolve to this provider.
aliases:
- tdai
- memory-tencentdb
Tencent is pleased to support the open source community by making TencentDB Agent Memory available.
Copyright (C) 2026 Tencent. All rights reserved.
TencentDB Agent Memory is licensed under the MIT.
Terms of the MIT:
--------------------------------------------------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.Bugfix-20260423 打镜像 SOP
适用版本: OpenClaw 2026.4.23
修复问题: Issue #73806 — Zod schema.strict()拒绝hooks.allowConversationAccess,导致非捆绑插件无法注册会话钩子
脚本位置: scripts/bugfix-20260423.sh---
步骤一:停止 Gateway
openclaw gateway stop确认已停止:
ps aux | grep gateway确保没有 openclaw-gateway 进程在运行。
---
步骤二:执行 Patch
cd /path/to/memory-tdai/scripts
bash bugfix-20260423.sh---
步骤三:验证
3.1 验证 openclaw.json 配置
cat ~/.openclaw/openclaw.json | python3 -m json.tool | grep allowConversationAccess预期输出:
"allowConversationAccess": true确认在 plugins.entries.memory-tencentdb.hooks 下。
3.2 验证 Zod Schema dist 文件
先定位 OpenClaw 安装目录(路径因环境而异,以下仅为示例):
# 方式一:通过 which 自动定位
OC_DIR=$(node -e "const p=require('path'),f=require('fs'); \
const bin=require('child_process').execSync('which openclaw',{encoding:'utf8'}).trim(); \
let d=p.dirname(f.realpathSync(bin)); \
while(d!=p.dirname(d)){if(f.existsSync(p.join(d,'package.json'))){console.log(d);break;}d=p.dirname(d);}")
echo "$OC_DIR"
# 方式二:手动指定(示例路径,请根据实际环境替换)
# OC_DIR=~/.local/share/pnpm/global/5/.pnpm/openclaw@2026.4.23_@napi-rs+canvas@0.1.100/node_modules/openclaw然后检查 zod-schema-BhKK4qYw.js:
cat "$OC_DIR/dist/zod-schema-BhKK4qYw.js" | grep allowConversationAccess -n验证要点:
1. allowConversationAccess 已出现在输出中 2. 只出现了一次(只有一行匹配) 3. 所在行的上下文形如:allowPromptInjection:z.boolean().optional(),allowConversationAccess:z.boolean().optional()}).strict().optional()
<!-- TODO: 贴验证截图 -->
---
验证通过后
两项验证均通过即可重新启动 Gateway:
openclaw gateway run{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node"],
"declaration": false,
"sourceMap": false
},
"include": ["export-tencent-vdb.ts"],
"exclude": ["dist", "node_modules", "docs"]
}
import { runMigrationCli } from "./sqlite-to-tcvdb.js";
const TAG = "[memory-tdai][migrate-cli]";
try {
const summary = await runMigrationCli(process.argv.slice(2));
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`${TAG} ${message}\n`);
process.exitCode = 1;
}