diff --git a/.gitignore b/.gitignore
index 9cde883..2a57051 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,10 @@ workspace/
# Test caches
__tests__/soak/.model-cache/
+# Migration build output
+scripts/export-tencent-vdb/dist/
+scripts/migrate-sqlite-to-tcvdb/dist/
+scripts/read-local-memory/dist/
+
node_modules/
-benchmark-runs/
\ No newline at end of file
+benchmark-runs/
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..6a9a7e9
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,22 @@
+# 测试文件
+*.test.ts
+*.test.js
+*.spec.ts
+*.spec.js
+__tests__/
+
+# 开发文档与辅助
+docs/
+benchmark-runs/
+workspace/
+
+# 环境与配置
+.env
+.env.*
+.gitignore
+.codebuddy/
+.coding-ci.yaml
+
+# 运行时产物
+node_modules/
+*.tgz
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74cbbfb..dc79017 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,101 @@
---
+## [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 存储设计文档及迁移指南
+
+---
+
+
+预发布版本
+
+## [0.2.0-beta.1] - 2026-04-14
+
+*此版本的内容已合并至 [0.2.0] 正式版。*
+
+
+
## [0.1.4] - 2026-04-10
### 🚀 Features
diff --git a/SKILL-DIAGNOSTIC-EXPORT.md b/SKILL-DIAGNOSTIC-EXPORT.md
new file mode 100644
index 0000000..09976ec
--- /dev/null
+++ b/SKILL-DIAGNOSTIC-EXPORT.md
@@ -0,0 +1,152 @@
+---
+name: openclaw-diagnostic-export
+description: 帮助用户导出 OpenClaw + memory-tencentdb(原 memory-tdai)记忆插件的现场诊断数据,用于排查问题。当用户提到"导出诊断数据""export diagnostic""现场数据""排查问题""导出日志""收集现场""打包现场数据"时应触发。
+version: 1.0.0
+---
+
+## 目的
+
+将 OpenClaw 日志、记忆插件数据(L0~L3)、脱敏后的配置打包为本地压缩包,由用户确认后手动发送给研发团队排查问题。
+
+> **名称说明**:插件已从 `@tdai/memory-tdai` 更名为 `@tencentdb-agent-memory/memory-tencentdb`,但数据目录始终为 `~/.openclaw/memory-tdai/`(代码中硬编码)。本 skill 中所有对 `memory-tdai` 目录的引用均指实际数据目录路径,与插件 ID 无关。
+
+## 导出工作流
+
+### Step 1: 确认环境
+
+在导出前,先确认 OpenClaw 工作目录存在且可访问:
+
+```bash
+# 探测工作目录(优先级:环境变量 > ~/.openclaw > ~/.clawdbot)
+OPENCLAW_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
+[ -d "$OPENCLAW_DIR" ] || OPENCLAW_DIR="$HOME/.clawdbot"
+ls -la "$OPENCLAW_DIR/" 2>/dev/null && echo "✅ 找到: $OPENCLAW_DIR" || echo "❌ 未找到 OpenClaw 工作目录"
+```
+
+确认 memory-tdai 子目录存在:
+
+```bash
+ls -la "$OPENCLAW_DIR/memory-tdai/" 2>/dev/null
+```
+
+### Step 2: 执行导出脚本
+
+运行项目 `scripts/` 目录下的导出脚本:
+
+```bash
+bash scripts/export-diagnostic.sh
+```
+
+> 脚本位于本项目的 `scripts/export-diagnostic.sh`,如果通过 `pnpm` 或其他方式运行,需确保工作目录在项目根目录下。
+
+脚本默认将压缩包输出到 `~/Downloads/openclaw-diagnostic-.tar.gz`。
+
+如需指定其他输出目录:
+
+```bash
+bash scripts/export-diagnostic.sh /tmp
+```
+
+### Step 3: 确认导出结果
+
+脚本执行完成后,检查输出:
+
+1. **确认压缩包已生成** — 脚本末尾会打印压缩包路径和大小
+2. **向用户说明包含内容**:
+
+| 文件/目录 | 内容 | 隐私风险 |
+|-----------|------|---------|
+| `env-info.txt` | 系统版本、OpenClaw 版本、目录结构、磁盘占用 | 低 |
+| `logs/` | OpenClaw 网关日志 + 滚动日志(最近 3 天,每文件最多 5000 行) | 低 |
+| `memory-tdai/` | 记忆插件全量数据:L0 对话、L1 记忆、L2 场景、L3 画像、SQLite 数据库、checkpoint | **高** — 包含用户对话原文 |
+| `openclaw-config-redacted.json` | 脱敏后的配置(已移除 API Key/Token/Password/Secret,models/channels/env 整体替换) | 低 |
+| `plugins-info.txt` | 已安装插件列表和版本 | 低 |
+
+3. **提醒用户**:
+ - 配置文件已自动脱敏,API Key、Token 等敏感信息已被替换为 `***REDACTED***`
+ - **记忆数据(memory-tdai/)包含用户对话原文**,请确认可以分享后再发送
+ - 压缩包存放在本地,**不会自动上传**,需要用户手动发送给研发团队
+
+### Step 4: 告知用户后续操作
+
+导出完成后,告知用户:
+
+1. 压缩包已保存在本地(打印具体路径)
+2. 请检查内容后,通过企微/邮件等方式手动发送给研发团队
+3. 如只需部分数据(如仅日志或仅配置),可解压后选择性发送
+
+## 导出内容详解
+
+### OpenClaw 日志位置
+
+| 日志类型 | 路径 | 说明 |
+|---------|------|------|
+| 网关 stdout | `~/.openclaw/logs/gateway.log` | 网关守护进程标准输出 |
+| 网关 stderr | `~/.openclaw/logs/gateway.err.log` | 网关守护进程错误输出 |
+| 滚动日志 | `/tmp/openclaw/openclaw-YYYY-MM-DD.log` | 按日期滚动,JSON Lines 格式,24h 自动清理 |
+| 配置审计 | `~/.openclaw/logs/config-audit.jsonl` | 配置写入审计记录 |
+| 命令日志 | `~/.openclaw/logs/commands.log` | 命令事件日志(hook 可选) |
+
+### 记忆插件数据结构
+
+```
+~/.openclaw/memory-tdai/
+├── conversations/ — L0 原始对话(每日 JSONL 分片)
+├── records/ — L1 结构化记忆(每日 JSONL 分片)
+├── scene_blocks/ — L2 场景 Markdown 文件
+├── persona.md — L3 用户画像
+├── vectors.db — SQLite 数据库(向量 + 全文索引)
+├── .metadata/ — checkpoint、scene_index.json
+└── .backup/ — 滚动备份
+```
+
+### 配置脱敏规则
+
+导出脚本对 `openclaw.json` 执行以下脱敏:
+
+| 规则 | 处理方式 |
+|------|---------|
+| 字段名匹配 `apiKey/token/password/secret/credential` 且值为字符串 | 替换为 `***REDACTED(Nchars)***` |
+| SecretRef 对象(含 source/provider/id) | id 替换为 `***REDACTED***` |
+| 顶层 `models`、`secrets`、`channels`、`env` 块 | 整体替换为 `***REDACTED_SECTION***` |
+| `gateway.auth` 下的 token/password | 替换为 `***REDACTED***` |
+| 其余字段(含 `plugins` 完整配置) | **保留原样**(插件配置是排查重点) |
+
+## 手动导出(脚本不可用时的备选方案)
+
+如果导出脚本无法执行(如 Node.js 不可用),按以下步骤手动收集:
+
+```bash
+# 1. 创建导出目录
+EXPORT_DIR=~/Downloads/openclaw-diagnostic-$(date +%Y%m%d-%H%M%S)
+mkdir -p "$EXPORT_DIR"
+
+# 2. 复制日志
+cp -r ~/.openclaw/logs/ "$EXPORT_DIR/logs/" 2>/dev/null
+cp /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log "$EXPORT_DIR/" 2>/dev/null
+
+# 3. 复制记忆插件数据
+cp -r ~/.openclaw/memory-tdai/ "$EXPORT_DIR/memory-tdai/" 2>/dev/null
+
+# 4. 手动脱敏配置(⚠️ 必须手动删除敏感字段!)
+# 复制配置并用编辑器删除 models/secrets/channels 块和所有 apiKey/token 值
+cp ~/.openclaw/openclaw.json "$EXPORT_DIR/openclaw-config-NEEDS-MANUAL-REDACTION.json"
+
+# 5. 打包
+cd ~/Downloads && tar -czf "$EXPORT_DIR.tar.gz" "$(basename $EXPORT_DIR)"
+
+echo "⚠️ 请务必在发送前手动检查并删除配置中的敏感信息!"
+```
+
+## 常见问题排查线索
+
+导出数据后,研发团队通常关注以下方面:
+
+| 排查方向 | 查看文件 | 关键信息 |
+|---------|---------|---------|
+| 插件是否加载 | `logs/` 中搜索 `[memory-tdai]` | 插件注册、配置解析日志(注:日志标签仍为 `[memory-tdai]`,与插件 ID 无关) |
+| 记忆召回是否工作 | `logs/` 中搜索 `[recall]` | 搜索策略、耗时、命中数 |
+| L1 提取是否触发 | `logs/` 中搜索 `[pipeline]` | 调度触发、L1/L2/L3 执行状态 |
+| 向量搜索是否可用 | `openclaw-config-redacted.json` 的 `plugins.entries` | embedding 配置是否正确 |
+| 数据量/磁盘占用 | `env-info.txt` | du 输出、文件数量 |
+| checkpoint 状态 | `memory-tdai/.metadata/recall_checkpoint.json` | 进度、游标、计数器 |
diff --git a/SKILL-MIGRATION.md b/SKILL-MIGRATION.md
new file mode 100644
index 0000000..0498f64
--- /dev/null
+++ b/SKILL-MIGRATION.md
@@ -0,0 +1,239 @@
+---
+name: openclaw-memory-tencentdb-migration
+description: 帮助存量用户将 OpenClaw 记忆插件从旧包 @tdai/memory-tdai 迁移到新包 @tencentdb-agent-memory/memory-tencentdb。当用户提到"插件迁移""更换记忆插件包名""memory-tdai 升级""包名变更"或出现旧包相关安装报错时应触发。
+version: 1.0.0
+---
+
+## 目的
+
+帮助已安装 `@tdai/memory-tdai`(旧包名)的存量用户,平滑迁移到 `@tencentdb-agent-memory/memory-tencentdb`(新包名),确保已有记忆数据不丢失、配置完整还原。
+
+## 背景
+
+- **旧包名**:`@tdai/memory-tdai`(插件 ID:`memory-tdai`)
+- **新包名**:`@tencentdb-agent-memory/memory-tencentdb`(插件 ID:`memory-tencentdb`)
+- 新旧插件共用相同的数据目录(`~/.openclaw/memory-tdai/`),卸载旧插件**不会删除数据目录**,已有记忆数据不受影响
+- 卸载旧插件**会删除** `openclaw.json` 中该插件的配置段,需提前备份
+
+## 适用场景
+
+- 用户已安装 `@tdai/memory-tdai`,需迁移到新包名
+- 用户执行 `openclaw plugins install @tdai/memory-tdai` 报 404 / not found
+- 用户被告知旧包已废弃,需要迁移
+
+## 不适用场景
+
+- 用户从未安装过记忆插件(应使用 `openclaw-memory-tencentdb-setup` skill)
+- 用户使用的是其他记忆插件(如 `openclaw-mem0`)
+
+## 标准工作流
+
+### 1) 确认当前状态
+
+确认旧插件是否已安装:
+
+```bash
+openclaw plugins list | grep -i memory
+```
+
+预期看到 `memory-tdai` 或 `@tdai/memory-tdai` 处于 loaded 状态。
+
+如果未看到旧插件,跳过迁移流程,直接使用 `openclaw-memory-tencentdb-setup` skill 进行全新安装。
+
+### 2) 备份现有配置(关键步骤)
+
+卸载旧插件会删除 `openclaw.json` 中的配置段。**必须先备份**。
+
+执行以下命令提取旧插件配置:
+
+```bash
+cat ~/.openclaw/openclaw.json | python3 -c "
+import sys, json
+cfg = json.load(sys.stdin)
+plugins = cfg.get('plugins', {}).get('entries', {})
+old_cfg = plugins.get('memory-tdai', {})
+if old_cfg:
+ print(json.dumps(old_cfg, indent=2, ensure_ascii=False))
+ with open('/tmp/memory-tdai-config-backup.json', 'w') as f:
+ json.dump(old_cfg, f, indent=2, ensure_ascii=False)
+ print('\n✅ 配置已备份到 /tmp/memory-tdai-config-backup.json')
+else:
+ print('⚠️ 未找到 memory-tdai 配置段(可能使用默认配置)')
+"
+```
+
+**特别关注以下配置是否存在(如有则必须记录)**:
+
+- `embedding` 配置(`provider`、`baseUrl`、`apiKey`、`model`、`dimensions`、`proxyUrl`)
+- `extraction.model`(提取使用的模型)
+- `persona.model`(画像使用的模型)
+- `capture.excludeAgents`(排除的 agent 列表)
+- `capture.l0l1RetentionDays`(数据保留天数)
+
+### 3) 确认数据目录存在
+
+```bash
+ls -la ~/.openclaw/memory-tdai/
+```
+
+预期看到:`conversations/`、`records/`、`scene_blocks/`、`vectors.db`、`persona.md` 等文件。
+
+记录当前数据量作为迁移后验证依据:
+
+```bash
+echo "=== 迁移前数据统计 ==="
+wc -l ~/.openclaw/memory-tdai/conversations/*.jsonl 2>/dev/null || echo "无对话数据"
+wc -l ~/.openclaw/memory-tdai/records/*.jsonl 2>/dev/null || echo "无记录数据"
+ls ~/.openclaw/memory-tdai/scene_blocks/*.md 2>/dev/null | wc -l | xargs -I{} echo "场景块: {} 个"
+wc -c ~/.openclaw/memory-tdai/persona.md 2>/dev/null || echo "无 persona"
+```
+
+### 4) 卸载旧插件
+
+```bash
+openclaw plugins uninstall memory-tdai
+```
+
+执行后确认:
+
+- `openclaw.json` 中 `memory-tdai` 配置段已被删除(预期行为)
+- `~/.openclaw/memory-tdai/` 数据目录**仍然存在**(不会被删除)
+
+```bash
+# 验证数据目录仍在
+ls ~/.openclaw/memory-tdai/ && echo "✅ 数据目录完好" || echo "❌ 数据目录丢失!"
+```
+
+### 5) 安装新插件
+
+```bash
+openclaw plugins install @tencentdb-agent-memory/memory-tencentdb
+```
+
+### 6) 还原配置
+
+将步骤 2 备份的配置写回 `openclaw.json`,注意新插件的配置 key 是 `memory-tencentdb`:
+
+```bash
+python3 -c "
+import json, os
+
+# 读取备份配置
+backup_path = '/tmp/memory-tdai-config-backup.json'
+if os.path.exists(backup_path):
+ with open(backup_path) as f:
+ old_cfg = json.load(f)
+ print('📋 备份配置内容:')
+ print(json.dumps(old_cfg, indent=2, ensure_ascii=False))
+else:
+ old_cfg = {'enabled': True}
+ print('⚠️ 未找到备份,使用最小配置')
+
+# 读取当前 openclaw.json
+config_path = os.path.expanduser('~/.openclaw/openclaw.json')
+with open(config_path) as f:
+ cfg = json.load(f)
+
+# 写入新插件配置
+cfg.setdefault('plugins', {}).setdefault('entries', {})['memory-tencentdb'] = old_cfg
+
+with open(config_path, 'w') as f:
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
+
+print('\n✅ 配置已写入 memory-tencentdb')
+"
+```
+
+如果备份丢失或用户需要手动恢复,至少确保写入最小配置:
+
+```json
+{
+ "memory-tencentdb": {
+ "enabled": true
+ }
+}
+```
+
+### 7) 重启 Gateway 并验证
+
+```bash
+openclaw gateway restart
+```
+
+检查项:
+
+- Gateway 日志中出现 `[memory-tdai]` 前缀(注:日志标签仍为 memory-tdai,这是正常的)
+- 数据目录内容未变化
+
+```bash
+echo "=== 迁移后验证 ==="
+# 确认新插件已加载
+openclaw plugins list | grep -i memory
+
+# 确认数据量与迁移前一致
+wc -l ~/.openclaw/memory-tdai/conversations/*.jsonl 2>/dev/null
+wc -l ~/.openclaw/memory-tdai/records/*.jsonl 2>/dev/null
+```
+
+### 8) 功能冒烟验证
+
+执行一次对话确认记忆链路正常:
+
+1. 发送一条包含个人信息的消息(如偏好、习惯)
+2. 确认日志中有 `[before_prompt_build]` 和 `[agent_end]` 相关输出
+3. 如有 embedding 配置,确认向量检索正常(日志无 embedding 报错)
+
+## 回滚方案
+
+如迁移后出现问题,可快速回滚:
+
+```bash
+# 1. 卸载新插件
+openclaw plugins uninstall memory-tencentdb
+
+# 2. 重新安装旧插件(如 npm 源仍可用)
+openclaw plugins install @tdai/memory-tdai
+
+# 3. 手动还原配置(从备份)
+# 将 /tmp/memory-tdai-config-backup.json 内容写回 openclaw.json 的 memory-tdai 段
+
+# 4. 重启
+openclaw gateway restart
+```
+
+## 故障排查
+
+| 现象 | 可能原因 | 解决方案 |
+|------|----------|----------|
+| 新插件无日志输出 | 配置中 `enabled` 未设为 `true` | 检查 `openclaw.json` 中 `memory-tencentdb.enabled` |
+| 安装新插件报错 | npm 源不可用 | 检查网络 / npm registry 配置 |
+| 迁移后无历史记忆 | 配置还原不完整 | 对比 `/tmp/memory-tdai-config-backup.json` 与当前配置 |
+| embedding 报错 | `apiKey` 等配置丢失 | 从备份中还原 `embedding` 配置段 |
+| 数据目录为空 | 卸载时异常删除(极少见) | 检查 `~/.openclaw/memory-tdai/` 是否存在 |
+
+## 安全与合规约束
+
+- 备份文件 `/tmp/memory-tdai-config-backup.json` 可能包含 `apiKey`,迁移完成后建议删除:`rm /tmp/memory-tdai-config-backup.json`
+- 不在聊天、日志中明文展示 `apiKey`
+- 仅修改 `memory-tencentdb` 配置段,不影响用户其它插件
+
+## 完成定义(Definition of Done)
+
+迁移完成需同时满足:
+
+- [x] 旧插件 `@tdai/memory-tdai` 已卸载
+- [x] 新插件 `@tencentdb-agent-memory/memory-tencentdb` 已安装并加载
+- [x] `openclaw.json` 中存在完整的 `memory-tencentdb` 配置(含用户自定义的 embedding 等配置)
+- [x] Gateway 已重启
+- [x] 日志中出现 `[memory-tdai]` 前缀
+- [x] 数据目录完好,数据量与迁移前一致
+- [x] 至少 1 次对话验证记忆链路正常
+- [x] 已清理备份文件中的敏感信息
+
+## 交付话术模板
+
+> 已完成记忆插件迁移:
+> - 旧插件 `@tdai/memory-tdai` → 新插件 `@tencentdb-agent-memory/memory-tencentdb`
+> - 已有记忆数据完整保留(对话/记录/场景块/向量库均未受影响)
+> - 配置已从旧插件完整还原(含 embedding / extraction / persona 等自定义配置)
+> - Gateway 已重启,记忆链路验证正常
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..74c3d7a
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,201 @@
+---
+name: openclaw-memory-tencentdb-setup
+description: 用于在 OpenClaw 环境中安装、配置并验证 @tencentdb-agent-memory/memory-tencentdb 插件。当用户提到"安装记忆插件""配置 memory-tencentdb""开启长期记忆/召回"或出现相关报错时应触发。
+version: 1.0.0
+---
+
+## 目的
+
+在不依赖外部托管记忆服务的前提下,为 OpenClaw 提供可持续的本地长期记忆能力(L0→L1→L2→L3),并完成从安装、配置到验收的一次性闭环。
+
+## 适用场景
+
+- 用户要求在 OpenClaw 中安装或启用 `memory-tencentdb`
+- 用户需要配置召回、提取、画像、清理等参数
+- 用户反馈"插件已装但无记忆 / 无召回 / 无向量检索"
+
+## 不适用场景
+
+- 用户只需要解释 memory 理念,不要求实际落地
+- 用户要接入非 OpenClaw 宿主(先确认目标框架)
+
+## 标准工作流
+
+### 1) 环境预检
+
+先确认基础版本满足要求:
+
+- OpenClaw: `>= 2026.3.13`
+- Node.js: `>= 22.16.0`
+
+执行:
+
+```bash
+openclaw --version
+node -v
+```
+
+若版本不满足,先升级再继续。
+
+### 2) 安装插件
+
+执行安装命令:
+
+```bash
+openclaw plugins install @tencentdb-agent-memory/memory-tencentdb
+```
+
+如已安装则执行更新:
+
+```bash
+openclaw plugins update memory-tencentdb
+```
+
+### 3) 写入最小配置
+
+编辑 `~/.openclaw/openclaw.json`,确保存在:
+
+```json
+{
+ "memory-tencentdb": {
+ "enabled": true
+ }
+}
+```
+
+说明:该插件支持零配置启动;不补充其它字段也能运行基础能力。
+
+### 4) 按需追加推荐配置(生产常用)
+
+根据用户需求补充如下分组:
+
+- `capture`: 对话捕获与保留策略
+- `extraction`: L1 提取与去重
+- `pipeline`: L1→L2→L3 调度
+- `recall`: 召回数量、阈值、策略
+- `persona`: 场景与画像触发参数
+- `embedding`: 向量检索配置(远端 OpenAI 兼容)
+
+推荐模板:
+
+```json
+{
+ "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": 60,
+ "l2DelayAfterL1Seconds": 90,
+ "l2MinIntervalSeconds": 300,
+ "l2MaxIntervalSeconds": 1800,
+ "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`),必须同时提供:
+ - `apiKey`
+ - `baseUrl`
+ - `model`
+ - `dimensions`
+- 上述任一缺失时,插件会继续运行,但自动降级为非向量模式。
+- `l0l1RetentionDays`:
+ - `0` 表示不清理
+ - 非 `0` 时建议 `>=3`
+ - 若设为 `1~2`,需显式开启 `allowAggressiveCleanup`
+
+### 6) 重启并验证生效
+
+执行:
+
+```bash
+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_search`
+ - `tdai_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` 模型参数。
\ No newline at end of file
diff --git a/index.ts b/index.ts
index 594fb8f..eb7431a 100644
--- a/index.ts
+++ b/index.ts
@@ -10,7 +10,6 @@
* All processing is local, zero external API dependencies.
*/
-import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
@@ -19,54 +18,33 @@ import type { MemoryTdaiConfig } from "./src/config.js";
import { performAutoRecall } from "./src/hooks/auto-recall.js";
import { performAutoCapture } from "./src/hooks/auto-capture.js";
import { MemoryPipelineManager } from "./src/utils/pipeline-manager.js";
-import { SceneExtractor } from "./src/scene/scene-extractor.js";
import { CheckpointManager } from "./src/utils/checkpoint.js";
-import { PersonaTrigger } from "./src/persona/persona-trigger.js";
-import { PersonaGenerator } from "./src/persona/persona-generator.js";
-import { prewarmEmbeddedAgent } from "./src/utils/clean-context-runner.js";
+import {
+ prewarmEmbeddedAgent,
+ setPreferredEmbeddedAgentRuntime,
+} from "./src/utils/clean-context-runner.js";
import { SessionFilter } from "./src/utils/session-filter.js";
-import { extractL1Memories } from "./src/record/l1-extractor.js";
-import { readConversationMessagesGroupedBySessionId } from "./src/conversation/l0-recorder.js";
-import type { ConversationMessage } from "./src/conversation/l0-recorder.js";
-import { VectorStore } from "./src/store/vector-store.js";
-import { createEmbeddingService } from "./src/store/embedding.js";
+import type { IMemoryStore } from "./src/store/types.js";
import type { EmbeddingService } from "./src/store/embedding.js";
import { executeMemorySearch, formatSearchResponse } from "./src/tools/memory-search.js";
import { executeConversationSearch, formatConversationSearchResponse } from "./src/tools/conversation-search.js";
import { LocalMemoryCleaner } from "./src/utils/memory-cleaner.js";
-import { getOrCreateInstanceId, initReporter, report } from "./src/report/reporter.js";
+import { registerMemoryTdaiCli } from "./src/cli/index.js";
+import {
+ initDataDirectories,
+ initStores,
+ resetStores,
+ createPipelineManager,
+ createL1Runner,
+ createPersister,
+ createL2Runner,
+ createL3Runner,
+} from "./src/utils/pipeline-factory.js";
+import { getOrCreateInstanceId, initReporter, report, resetReporter } from "./src/report/reporter.js";
+import { ensureL2L3Local } from "./src/profile/profile-sync.js";
const TAG = "[memory-tdai]";
-/**
- * Initialize all required data directories under the plugin data root.
- *
- * Called once at plugin registration time so downstream modules
- * (L0 recorder, L1 writer, scene extractor, persona generator, etc.)
- * don't need to lazily mkdir on every write — the directories are
- * guaranteed to exist from startup.
- *
- * Directory layout:
- * /
- * ├── conversations/ — L0 daily JSONL shards (one message per line)
- * ├── records/ — L1 daily JSONL shards (extracted memories)
- * ├── scene_blocks/ — L2 scene block .md files (LLM-managed)
- * ├── .metadata/ — checkpoint, scene_index.json
- * └── .backup/ — rotating backups (persona, scene_blocks)
- */
-function initDataDirectories(dataDir: string): void {
- const dirs = [
- "conversations",
- "records",
- "scene_blocks",
- ".metadata",
- ".backup",
- ];
- for (const sub of dirs) {
- fs.mkdirSync(path.join(dataDir, sub), { recursive: true });
- }
-}
-
/**
* Epoch ms when the plugin was registered (cold-start timestamp).
* Used as a fallback cursor in performAutoCapture when no checkpoint
@@ -151,20 +129,20 @@ function sweepStaleCaches(): void {
export default function register(api: OpenClawPluginApi) {
pluginStartTimestamp = Date.now();
+ setPreferredEmbeddedAgentRuntime(api.runtime.agent);
+ // Reset reporter singleton so config changes take effect on hot-reload.
+ resetReporter();
const _require = createRequire(import.meta.url);
const pluginVersion = (() => { try { return (_require("./package.json") as { version?: string }).version ?? "unknown"; } catch { return "unknown"; } })();
- api.logger.info(
+ api.logger.debug?.(
`${TAG} Registering plugin ... ` +
`startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`,
);
- // Persistent instance ID for metric reporting (populated async below)
- let instanceId: string | undefined;
-
let cfg: MemoryTdaiConfig;
try {
cfg = parseConfig(api.pluginConfig as Record | undefined);
- api.logger.info(
+ api.logger.debug?.(
`${TAG} Config parsed: ` +
`capture=${cfg.capture.enabled}, ` +
`recall=${cfg.recall.enabled}(maxResults=${cfg.recall.maxResults}), ` +
@@ -186,12 +164,26 @@ export default function register(api: OpenClawPluginApi) {
// Resolve plugin data directory via runtime API (avoid importing internal paths directly)
const pluginDataDir = path.join(api.runtime.state.resolveStateDir(), "memory-tdai");
initDataDirectories(pluginDataDir);
- api.logger.info(`${TAG} Data dir: ${pluginDataDir} (all subdirectories initialized)`);
+ api.logger.debug?.(`${TAG} Data dir: ${pluginDataDir} (all subdirectories initialized)`);
+
+ // Kick off instanceId resolution immediately after data dir is ready.
+ // getOrCreateInstanceId only reads/writes a small UUID file and caches the
+ // result — starting it here means it will almost certainly be settled before
+ // the first L1 runner fires, avoiding the need to defer metric reporting.
+ let instanceId: string | undefined;
+ getOrCreateInstanceId(pluginDataDir).then((id) => {
+ instanceId = id;
+ // initReporter is guarded by a "already initialised" check, so calling it
+ // here is safe even if the registration-complete call below fires first.
+ initReporter({ enabled: cfg.report.enabled, type: cfg.report.type, logger: api.logger, instanceId: id, pluginVersion });
+ }).catch((err) => {
+ api.logger.warn(`${TAG} Failed to initialize instanceId for metrics: ${err instanceof Error ? err.message : String(err)}`);
+ });
// Unified session/agent filter: combines internal-session detection + user-configured excludeAgents
const sessionFilter = new SessionFilter(cfg.capture.excludeAgents);
if (cfg.capture.excludeAgents.length > 0) {
- api.logger.info(`${TAG} Agent exclude patterns: ${cfg.capture.excludeAgents.join(", ")}`);
+ api.logger.debug?.(`${TAG} Agent exclude patterns: ${cfg.capture.excludeAgents.join(", ")}`);
}
// Daily local JSONL cleaner (L0/L1), enabled only when retentionDays is configured.
@@ -205,13 +197,13 @@ export default function register(api: OpenClawPluginApi) {
logger: api.logger,
});
sharedMemoryCleaner.start();
- api.logger.info(`${TAG} Memory cleaner started (singleton)`);
+ api.logger.debug?.(`${TAG} Memory cleaner started (singleton)`);
} else {
- api.logger.info(`${TAG} Memory cleaner already started in this process, reusing existing instance`);
+ api.logger.debug?.(`${TAG} Memory cleaner already started in this process, reusing existing instance`);
}
memoryCleaner = sharedMemoryCleaner;
} else {
- api.logger.info(`${TAG} Memory cleaner disabled (retentionDays not configured)`);
+ api.logger.debug?.(`${TAG} Memory cleaner disabled (retentionDays not configured)`);
}
// Hardcoded actor ID (legacy, to be removed)
@@ -228,7 +220,7 @@ export default function register(api: OpenClawPluginApi) {
// ============================
// Shared references for tools (populated when extraction scheduler creates them)
- let sharedVectorStore: VectorStore | undefined;
+ let sharedVectorStore: IMemoryStore | undefined;
let sharedEmbeddingService: EmbeddingService | undefined;
/**
@@ -272,12 +264,14 @@ export default function register(api: OpenClawPluginApi) {
};
// tdai_memory_search — Agent-callable L1 memory search tool
+ // TODO: implement hard per-turn call limit via before_tool_call hook + execute early-return (方案 D)
api.registerTool(
{
name: "tdai_memory_search",
label: "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.",
+ "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. " +
+ "Limit: tdai_memory_search and tdai_conversation_search share a combined limit of 3 calls per turn. Stop searching after 3 total attempts.",
parameters: {
type: "object",
properties: {
@@ -367,6 +361,7 @@ export default function register(api: OpenClawPluginApi) {
);
// tdai_conversation_search — Agent-callable L0 conversation search tool
+ // TODO: implement hard per-turn call limit via before_tool_call hook + execute early-return (方案 D)
api.registerTool(
{
name: "tdai_conversation_search",
@@ -375,7 +370,8 @@ export default function register(api: OpenClawPluginApi) {
"Search through past conversation history (raw dialogue records). " +
"Use this when tdai_memory_search (structured memories) doesn't have the information you need, " +
"or when you want to find specific past conversations, dialogue context, or exact words " +
- "the user said before. Returns relevant individual messages ranked by relevance.",
+ "the user said before. Returns relevant individual messages ranked by relevance. " +
+ "Limit: tdai_memory_search and tdai_conversation_search share a combined limit of 3 calls per turn. Stop searching after 3 total attempts.",
parameters: {
type: "object",
properties: {
@@ -464,7 +460,7 @@ export default function register(api: OpenClawPluginApi) {
// (migrated from legacy before_agent_start to before_prompt_build so that
// event.messages is guaranteed to be available — session is already loaded)
if (cfg.recall.enabled) {
- api.logger.info(`${TAG} Registering before_prompt_build hook (auto-recall)`);
+ api.logger.debug?.(`${TAG} Registering before_prompt_build hook (auto-recall)`);
api.on("before_prompt_build", async (event, ctx) => {
const startMs = Date.now();
api.logger.debug?.(`${TAG} [before_prompt_build] Hook triggered`);
@@ -619,343 +615,128 @@ export default function register(api: OpenClawPluginApi) {
scheduler.start({});
}
- // Pre-warm the embedded agent import so the first extraction run doesn't
- // pay the cold-start cost (~35s jiti compile → <50ms with dist/ path).
- prewarmEmbeddedAgent(api.logger);
+ // Pre-warm the embedded agent entrypoint. When runtime already exposes
+ // runEmbeddedPiAgent this becomes a no-op; otherwise it still preloads
+ // the legacy dist bridge to reduce first-run cold start.
+ prewarmEmbeddedAgent(api.logger, api.runtime.agent);
};
if (cfg.extraction.enabled) {
- // === Initialize VectorStore (always) + EmbeddingService (only when embedding enabled) ===
- let vectorStore: VectorStore | undefined;
+ // === Store + scheduler initialization (async, runs eagerly) ===
+ // Wrapped in an async IIFE because register() is synchronous.
+ // initStores() is once-async: the first call creates the store,
+ // subsequent calls (e.g. from seed CLI) reuse the cached result.
+ let vectorStore: IMemoryStore | undefined;
let embeddingService: EmbeddingService | undefined;
- // VectorStore is always created as the metadata store for L0/L1 records.
- // It works as a pure SQLite store even without embedding — keyword search,
- // L0/L1 reads, and pipeline queries all use structured SQL, not vectors.
- try {
- const dims = cfg.embedding.dimensions; // 0 when provider="none" → vec0 tables deferred
- const dbPath = path.join(pluginDataDir, "vectors.db");
- vectorStore = new VectorStore(dbPath, dims, api.logger);
+ const storeReady = (async () => {
+ const stores = await initStores(cfg, pluginDataDir, api.logger);
+ vectorStore = stores.vectorStore;
+ embeddingService = stores.embeddingService;
- // Create EmbeddingService only when embedding is enabled (remote provider configured)
- if (cfg.embedding.enabled) {
+ // Share with tools immediately
+ sharedVectorStore = vectorStore;
+ sharedEmbeddingService = embeddingService;
+
+ // Keep cleaner's SQLite handle updated (singleton cleaner may start earlier).
+ memoryCleaner?.setVectorStore(vectorStore);
+
+ if (vectorStore?.pullProfiles) {
try {
- if (cfg.embedding.provider !== "local" && cfg.embedding.apiKey) {
- // Remote embedding provider (OpenAI-compatible API: OpenAI, Azure, self-hosted, etc.)
- embeddingService = createEmbeddingService({
- provider: cfg.embedding.provider,
- baseUrl: cfg.embedding.baseUrl,
- apiKey: cfg.embedding.apiKey,
- model: cfg.embedding.model,
- dimensions: cfg.embedding.dimensions,
- proxyUrl: cfg.embedding.proxyUrl,
- maxInputChars: cfg.embedding.maxInputChars,
- timeoutMs: cfg.embedding.timeoutMs,
- }, api.logger);
- } else {
- // Local provider (node-llama-cpp) — preserved internally but not reachable from user config
- embeddingService = createEmbeddingService({
- provider: "local",
- modelPath: cfg.embedding.model || undefined,
- modelCacheDir: cfg.embedding.modelCacheDir,
- }, api.logger);
- }
+ await ensureL2L3Local(pluginDataDir, vectorStore, api.logger);
} catch (err) {
- api.logger.warn(
- `${TAG} EmbeddingService init failed, continuing with keyword-only mode: ${err instanceof Error ? err.message : String(err)}`,
- );
- embeddingService = undefined;
+ api.logger.warn(`${TAG} Startup L2/L3 pull failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
}
- } else {
- api.logger.info(`${TAG} Embedding disabled by config, VectorStore will serve as metadata-only store`);
}
- // Init VectorStore with provider info (undefined when no embedding → skips provider change detection)
- const providerInfo = embeddingService?.getProviderInfo();
- const initResult = vectorStore.init(providerInfo);
-
- // If VectorStore entered degraded mode (e.g. sqlite-vec load failed),
- // treat it as unavailable and fall back to keyword-only mode.
- if (vectorStore.isDegraded()) {
- api.logger.warn(
- `${TAG} VectorStore is in degraded mode, falling back to keyword dedup`,
- );
- vectorStore = undefined;
- embeddingService = undefined;
- } else {
+ // If embedding provider/model/dimensions changed, re-embed all existing texts
+ if (stores.needsReindex && embeddingService && vectorStore) {
+ const svc = embeddingService;
+ const vs = vectorStore;
api.logger.info(
- `${TAG} VectorStore initialized: ${dbPath} (${dims}D, provider=${cfg.embedding.provider})`,
+ `${TAG} Embedding config changed (${stores.reindexReason}). ` +
+ `Starting background re-embed of all stored texts...`,
);
-
- // If embedding provider/model/dimensions changed, re-embed all existing texts
- if (initResult.needsReindex && embeddingService) {
- const svc = embeddingService; // capture for async closure
- const vs = vectorStore; // capture for async closure
+ vs.reindexAll(
+ (text) => svc.embed(text),
+ (done, total, layer) => {
+ if (done === total || done % 50 === 0) {
+ api.logger.debug?.(`${TAG} Re-embed progress: ${layer} ${done}/${total}`);
+ }
+ },
+ ).then(({ l1Count, l0Count }) => {
api.logger.info(
- `${TAG} Embedding config changed (${initResult.reason}). ` +
- `Starting background re-embed of all stored texts...`,
+ `${TAG} Re-embed complete: L1=${l1Count} records, L0=${l0Count} messages`,
);
- // Run re-embed asynchronously so it doesn't block plugin startup
- vs.reindexAll(
- (text) => svc.embed(text),
- (done, total, layer) => {
- if (done === total || done % 50 === 0) {
- api.logger.debug?.(`${TAG} Re-embed progress: ${layer} ${done}/${total}`);
- }
- },
- ).then(({ l1Count, l0Count }) => {
- api.logger.info(
- `${TAG} Re-embed complete: L1=${l1Count} records, L0=${l0Count} messages`,
- );
- }).catch((err) => {
- api.logger.error(
- `${TAG} Re-embed failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- }
+ }).catch((err) => {
+ api.logger.error(
+ `${TAG} Re-embed failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
+ );
+ });
}
- } catch (err) {
- api.logger.warn(
- `${TAG} VectorStore init failed; vector/FTS recall and dedup conflict detection will be unavailable: ${err instanceof Error ? err.message : String(err)}`,
- );
- vectorStore = undefined;
- embeddingService = undefined;
- }
+ })();
- // Share vectorStore/embeddingService with tdai_memory_search tool
- sharedVectorStore = vectorStore;
- sharedEmbeddingService = embeddingService;
+ // === Create pipeline manager (sync — does not need store) ===
+ scheduler = createPipelineManager(cfg, api.logger, sessionFilter);
- // Keep cleaner's SQLite handle updated (singleton cleaner may start earlier).
- memoryCleaner?.setVectorStore(vectorStore);
+ // Wire runners after store is ready
+ storeReady.then(() => {
+ // L1 runner via shared factory
+ scheduler!.setL1Runner(createL1Runner({
+ pluginDataDir,
+ cfg,
+ openclawConfig: api.config,
+ vectorStore,
+ embeddingService,
+ logger: api.logger,
+ getInstanceId: () => instanceId,
+ }));
- // === Create pipeline manager ===
- scheduler = new MemoryPipelineManager(
- {
- everyNConversations: cfg.pipeline.everyNConversations,
- enableWarmup: cfg.pipeline.enableWarmup,
- l1: { idleTimeoutSeconds: cfg.pipeline.l1IdleTimeoutSeconds },
- l2: {
- delayAfterL1Seconds: cfg.pipeline.l2DelayAfterL1Seconds,
- minIntervalSeconds: cfg.pipeline.l2MinIntervalSeconds,
- maxIntervalSeconds: cfg.pipeline.l2MaxIntervalSeconds,
- sessionActiveWindowHours: cfg.pipeline.sessionActiveWindowHours,
- },
- },
- api.logger,
- sessionFilter,
- );
+ // Persister via shared factory
+ scheduler!.setPersister(createPersister(pluginDataDir, api.logger));
- // L1 runner: read L0 from DB (primary) or JSONL (fallback) → local LLM extraction → L1 JSONL + VectorStore
- scheduler.setL1Runner(async ({ sessionKey }) => {
- // L1 reads L0 data from VectorStore DB (primary, indexed query).
- // Fallback: read from L0 JSONL files when VectorStore is unavailable.
- if (!api.config) {
- api.logger.debug?.(`${TAG} [pipeline-l1] No OpenClaw config, skipping L1 extraction`);
- return { processedCount: 0 };
- }
-
- const checkpoint = new CheckpointManager(pluginDataDir, api.logger);
- const cp = await checkpoint.read();
- const runnerState = checkpoint.getRunnerState(cp, sessionKey);
-
- api.logger.info(
- `${TAG} [pipeline-l1] Session ${sessionKey}: ` +
- `l1_cursor=${runnerState.last_l1_cursor || "(start)"}`,
- );
-
- try {
- // Read L0 messages since last L1 cursor, grouped by sessionId.
- // Within the same sessionKey, different sessionIds represent different
- // conversation instances (e.g. after /reset). Each group is extracted
- // independently so its sessionId is correctly associated with L1 records.
- //
- // Primary path: read from VectorStore DB (indexed query, fast).
- // Fallback: read from L0 JSONL files (scan + parse, slower).
- let groups: Array<{ sessionId: string; messages: ConversationMessage[] }>;
-
- if (vectorStore && !vectorStore.isDegraded()) {
- // DB path: fast indexed query
- // NOTE: When last_l1_cursor is 0 (first L1 run), we pass undefined
- // to query all messages — but only those captured AFTER plugin start
- // (L0 capture uses pluginStartTimestamp as floor, so DB won't contain
- // pre-existing messages). This is safe because auto-capture already
- // filters out messages older than pluginStartTimestamp.
- const l1Cursor = runnerState.last_l1_cursor > 0
- ? runnerState.last_l1_cursor
- : undefined;
- const dbGroups = vectorStore.queryL0GroupedBySessionId(
- sessionKey,
- l1Cursor,
- );
- // Cast role from string to "user" | "assistant" (DB stores as string)
- groups = dbGroups.map((g) => ({
- sessionId: g.sessionId,
- messages: g.messages.map((m) => ({
- id: m.id,
- role: m.role as "user" | "assistant",
- content: m.content,
- timestamp: m.timestamp,
- })),
- }));
- api.logger.debug?.(
- `${TAG} [pipeline-l1] L0 data source: VectorStore DB`,
- );
- } else {
- // Fallback: JSONL files
- api.logger.debug?.(
- `${TAG} [pipeline-l1] L0 data source: JSONL files (VectorStore unavailable)`,
- );
- const jsonlGroups = await readConversationMessagesGroupedBySessionId(
- sessionKey,
+ // L2 runner: read L1 records (incremental) → SceneExtractor
+ scheduler!.setL2Runner(async (sessionKey: string, cursor?: string) => {
+ try {
+ const l2Runner = createL2Runner({
pluginDataDir,
- runnerState.last_l1_cursor || undefined,
- api.logger,
- 50, // Match DB path limit (queryL0ForL1 default)
- );
- // Convert SessionIdMessageGroup[] to the same shape
- groups = jsonlGroups.map((g) => ({
- sessionId: g.sessionId,
- messages: g.messages,
- }));
- }
-
- if (groups.length === 0) {
- api.logger.debug?.(`${TAG} [pipeline-l1] No new L0 messages for session ${sessionKey}`);
- return { processedCount: 0 };
- }
-
- const totalMessages = groups.reduce((sum, g) => sum + g.messages.length, 0);
- api.logger.info(
- `${TAG} [pipeline-l1] Processing ${totalMessages} L0 messages across ${groups.length} sessionId group(s) for session ${sessionKey}`,
- );
-
- let totalExtracted = 0;
- let totalStored = 0;
- let lastSceneName: string | undefined;
- let maxTimestamp = 0;
-
- for (const group of groups) {
- api.logger.debug?.(
- `${TAG} [pipeline-l1] Group sessionId=${group.sessionId || "(empty)"}: ${group.messages.length} messages`,
- );
-
- const l1Result = await extractL1Memories({
- messages: group.messages,
- sessionKey,
- sessionId: group.sessionId,
- baseDir: pluginDataDir,
- config: api.config,
- options: {
- enableDedup: cfg.extraction.enableDedup,
- maxMemoriesPerSession: cfg.extraction.maxMemoriesPerSession,
- model: cfg.extraction.model,
- previousSceneName: lastSceneName ?? (runnerState.last_scene_name || undefined),
- vectorStore,
- embeddingService,
- conflictRecallTopK: cfg.embedding.conflictRecallTopK,
- },
+ cfg,
+ openclawConfig: api.config,
+ vectorStore,
logger: api.logger,
instanceId,
});
-
- totalExtracted += l1Result.extractedCount;
- totalStored += l1Result.storedCount;
- if (l1Result.lastSceneName) {
- lastSceneName = l1Result.lastSceneName;
- }
-
- const groupMaxTs = Math.max(...group.messages.map((m) => m.timestamp));
- maxTimestamp = Math.max(maxTimestamp, groupMaxTs);
+ return await l2Runner(sessionKey, cursor);
+ } catch (err) {
+ api.logger.error(`${TAG} [pipeline-l2] L2 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
+ throw err;
}
+ });
- // Update checkpoint on disk — cursor is the global max timestamp across all groups
- await checkpoint.markL1ExtractionComplete(
- sessionKey,
- totalStored,
- maxTimestamp,
- lastSceneName,
- );
-
- api.logger.info(
- `${TAG} [pipeline-l1] L1 complete: extracted=${totalExtracted}, stored=${totalStored} (${groups.length} group(s))`,
- );
-
- return { processedCount: totalMessages };
- } catch (err) {
- api.logger.error(`${TAG} [pipeline-l1] L1 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
- // ── error_degradation metric ──
- if (instanceId) {
- report("error_degradation", {
- module: "l1-extraction",
- action: "extractL1Memories",
- errorType: "exception",
- errorMessage: err instanceof Error ? err.message : String(err),
- degradedTo: null,
- impact: "blocking",
+ // L3 runner: persona trigger + generation
+ scheduler!.setL3Runner(async () => {
+ try {
+ const l3Runner = createL3Runner({
+ pluginDataDir,
+ cfg,
+ openclawConfig: api.config,
+ vectorStore,
+ logger: api.logger,
+ instanceId,
});
+ await l3Runner();
+ } catch (err) {
+ api.logger.error(`${TAG} [pipeline-l3] Failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
}
- throw err; // rethrow so pipeline-manager can retry
- }
+ });
+ }).catch((err) => {
+ api.logger.error(
+ `${TAG} Store init failed; vector/FTS recall and dedup will be unavailable: ${err instanceof Error ? err.message : String(err)}`,
+ );
});
- // Persister: saves pipeline session states to checkpoint
- scheduler.setPersister(async (states) => {
- const checkpoint = new CheckpointManager(pluginDataDir, api.logger);
- await checkpoint.mergePipelineStates(states);
- });
-
- // L2 runner: read L1 records (incremental) → SceneExtractor
- scheduler.setL2Runner(async (sessionKey: string, cursor?: string) => {
- try {
- return await runLocalL2Extraction(api, cfg, pluginDataDir, sessionKey, vectorStore, cursor, instanceId);
- } catch (err) {
- api.logger.error(`${TAG} [pipeline-l2] L2 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
- throw err; // rethrow so pipeline-manager can handle retry/fallback (consistent with L1 runner)
- }
- });
-
- // L3 runner: persona trigger + generation
- scheduler.setL3Runner(async () => {
- try {
- const trigger = new PersonaTrigger({
- dataDir: pluginDataDir,
- interval: cfg.persona.triggerEveryN,
- logger: api.logger,
- });
-
- const { should, reason } = await trigger.shouldGenerate();
- if (!should) {
- api.logger.debug?.(`${TAG} [pipeline-l3] Persona generation not needed`);
- return;
- }
-
- if (!api.config) {
- api.logger.warn(`${TAG} [pipeline-l3] No OpenClaw config, skipping persona generation`);
- return;
- }
-
- api.logger.info(`${TAG} [pipeline-l3] Starting persona generation: ${reason}`);
- const generator = new PersonaGenerator({
- dataDir: pluginDataDir,
- config: api.config,
- model: cfg.persona.model,
- backupCount: cfg.persona.backupCount,
- logger: api.logger,
- instanceId,
- });
- const genResult = await generator.generate(reason);
- api.logger.info(`${TAG} [pipeline-l3] Persona generation ${genResult ? "succeeded" : "skipped (no changes)"}`);
- } catch (err) {
- api.logger.error(`${TAG} [pipeline-l3] Failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
- }
- });
-
- // Capture vectorStore reference for cleanup
- const vectorStoreRef = vectorStore;
-
// Register a SINGLE gateway_stop hook for ordered shutdown.
- // Order: memoryCleaner → scheduler → vectorStore → embeddingService
+ // Order: memoryCleaner → scheduler → vectorStore → embeddingService → resetStores
// (memoryCleaner may use VectorStore during cleanup, so it must stop first)
//
// The entire hook is wrapped with a 3 s timeout to guarantee we never
@@ -965,8 +746,11 @@ export default function register(api: OpenClawPluginApi) {
const GATEWAY_STOP_TIMEOUT_MS = 3_000;
const hookStartMs = Date.now();
+ // Ensure store init has completed before tearing down
+ await storeReady.catch(() => {});
+
const doCleanup = async (): Promise => {
- // 1. Stop the memory cleaner
+ // 1. Stop the memory cleaner first (it may be running deleteL1ExpiredByUpdatedTime)
if (memoryCleaner) {
try {
memoryCleaner.destroy();
@@ -983,16 +767,20 @@ export default function register(api: OpenClawPluginApi) {
const t = Date.now();
await scheduler.destroy();
api.logger.info(`${TAG} [gateway_stop] Scheduler destroyed (${Date.now() - t}ms)`);
+ } else {
+ api.logger.info(`${TAG} [gateway_stop] Scheduler was never started, skipping destroy`);
}
- // 3. Close VectorStore
- if (vectorStoreRef) {
- vectorStoreRef.close();
+ // 3. Close VectorStore last (after all consumers are done)
+ if (vectorStore) {
+ api.logger.info(`${TAG} [gateway_stop] Closing VectorStore`);
+ vectorStore.close();
}
- // 4. Release embedding service resources
+ // 4. Release embedding service resources (model memory, GPU, etc.)
if (embeddingService?.close) {
try {
+ api.logger.info(`${TAG} [gateway_stop] Closing EmbeddingService`);
await embeddingService.close();
} catch (err) {
api.logger.warn(`${TAG} [gateway_stop] EmbeddingService close error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1021,11 +809,14 @@ export default function register(api: OpenClawPluginApi) {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
+ // 5. Reset store singleton cache so hot-restart can re-initialize
+ resetStores();
+
api.logger.info(`${TAG} [gateway_stop] Cleanup finished, all resources released (${Date.now() - hookStartMs}ms)`);
});
}
- api.logger.info(`${TAG} Registering agent_end hook (auto-capture)`);
+ api.logger.debug?.(`${TAG} Registering agent_end hook (auto-capture)`);
api.on("agent_end", async (event, ctx) => {
const startMs = Date.now();
api.logger.debug?.(`${TAG} [agent_end] Hook triggered`);
@@ -1139,7 +930,7 @@ export default function register(api: OpenClawPluginApi) {
}
});
} else {
- api.logger.info(`${TAG} Auto-capture disabled`);
+ api.logger.debug?.(`${TAG} Auto-capture disabled`);
}
// memoryCleaner gateway_stop is handled in the unified handler above (inside extraction.enabled block).
@@ -1159,177 +950,30 @@ export default function register(api: OpenClawPluginApi) {
});
}
- api.logger.info(
+ // ============================
+ // CLI registration
+ // ============================
+
+ api.registerCli(
+ ({ program, config, logger: cliLogger }) => {
+ const memoryTdai = program
+ .command("memory-tdai")
+ .description("memory-tdai plugin commands (seed, query, stats)");
+
+ registerMemoryTdaiCli(memoryTdai, {
+ config,
+ pluginConfig: api.pluginConfig,
+ stateDir: api.runtime.state.resolveStateDir(),
+ logger: cliLogger,
+ });
+ },
+ { commands: ["memory-tdai"] },
+ );
+
+ api.logger.debug?.(
`${TAG} Plugin registration complete (v3). ` +
`startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`,
);
-
- // Resolve persistent instance ID for metric reporting (used by agent_turn, error_degradation, etc.)
- getOrCreateInstanceId(pluginDataDir).then((id) => {
- instanceId = id;
- initReporter({ enabled: cfg.report.enabled, type: cfg.report.type, logger: api.logger, instanceId: id, pluginVersion });
- }).catch((err) => {
- api.logger.warn(`${TAG} Failed to initialize instanceId for metrics: ${err instanceof Error ? err.message : String(err)}`);
- });
-}
-
-// ============================
-// L2 extraction implementations
-// ============================
-
-/**
- * Local L2 extraction: read L1 records → SceneExtractor.
- *
- * Uses **incremental** reads when VectorStore is available:
- * 1. Receive the pipeline cursor (`last_extraction_updated_time`) from pipeline-manager
- * 2. Query only L1 records updated AFTER that cursor via `queryMemoryRecords`
- * 3. Return the latest `updatedAt` from the batch so pipeline-manager can advance the cursor
- *
- * Falls back to JSONL read (with client-side time filtering) when VectorStore is unavailable.
- */
-async function runLocalL2Extraction(
- api: OpenClawPluginApi,
- cfg: MemoryTdaiConfig,
- pluginDataDir: string,
- sessionKey: string,
- vectorStore?: VectorStore,
- updatedAfter?: string,
- instanceId?: string,
-): Promise<{ latestCursor?: string } | void> {
- api.logger.debug?.(
- `${TAG} [L2-local] session=${sessionKey}, updatedAfter=${updatedAfter ?? "(full)"}`,
- );
-
- let records: Array<{ content: string; created_at: string; id: string; updatedAt: string }>;
-
- // Prefer incremental SQLite query when VectorStore is available
- if (vectorStore && !vectorStore.isDegraded()) {
- const { queryMemoryRecords } = await import("./src/record/l1-reader.js");
- const memRecords = queryMemoryRecords(vectorStore, {
- sessionKey,
- updatedAfter,
- }, api.logger);
-
- if (memRecords.length === 0) {
- api.logger.debug?.(
- `${TAG} [L2-local] No new L1 records since cursor (session=${sessionKey}, updatedAfter=${updatedAfter ?? "(full)"}), skipping scene extraction`,
- );
- return;
- }
-
- api.logger.debug?.(
- `${TAG} [L2-local] Incremental query returned ${memRecords.length} record(s) (session=${sessionKey})`,
- );
-
- records = memRecords.map((r) => ({
- content: r.content,
- created_at: r.createdAt,
- id: r.id,
- updatedAt: r.updatedAt,
- }));
- } else {
- // Fallback: read JSONL files with client-side time filtering
- api.logger.debug?.(`${TAG} [L2-local] VectorStore unavailable, falling back to JSONL read`);
- const { readAllMemoryRecords } = await import("./src/record/l1-reader.js");
- let allRecords = await readAllMemoryRecords(pluginDataDir, api.logger);
-
- // Apply updatedAfter filter on JSONL records (same semantics as SQLite path)
- if (updatedAfter) {
- const beforeCount = allRecords.length;
- allRecords = allRecords.filter((r) => {
- const t = r.updatedAt || r.createdAt || "";
- return t > updatedAfter;
- });
- api.logger.debug?.(
- `${TAG} [L2-local] JSONL time filter: ${beforeCount} → ${allRecords.length} record(s) (updatedAfter=${updatedAfter})`,
- );
- }
-
- if (allRecords.length === 0) {
- api.logger.debug?.(`${TAG} [L2-local] No new L1 records found (JSONL fallback), skipping scene extraction`);
- return;
- }
-
- records = allRecords.map((r) => ({
- content: r.content,
- created_at: r.createdAt,
- id: r.id,
- updatedAt: r.updatedAt,
- }));
- }
-
- const extractor = new SceneExtractor({
- dataDir: pluginDataDir,
- config: api.config!,
- model: cfg.persona.model,
- maxScenes: cfg.persona.maxScenes,
- sceneBackupCount: cfg.persona.sceneBackupCount,
- logger: api.logger,
- instanceId,
- });
-
- const memories = records.map((r) => ({
- content: r.content,
- created_at: r.created_at,
- id: r.id,
- }));
-
- // ── Checkpoint guard ──────────────────────────────────────────────
- // Snapshot critical counters BEFORE the LLM agent runs.
- // The LLM operates on checkpoint via raw file tools (write_to_file /
- // replace_in_file) which bypass CheckpointManager's file lock.
- // If the LLM accidentally overwrites the entire checkpoint (e.g. via
- // write_to_file), system-managed counters like scenes_processed and
- // memories_since_last_persona can be reset to stale values.
- // After extraction we detect and repair such corruption.
- const preCheckpoint = new CheckpointManager(pluginDataDir, api.logger);
- const preState = await preCheckpoint.read();
- const preScenesProcessed = preState.scenes_processed;
- const preMemoriesSince = preState.memories_since_last_persona;
- const preTotalProcessed = preState.total_processed;
-
- const extractResult = await extractor.extract(memories);
- if (extractResult.success && extractResult.memoriesProcessed > 0) {
- const checkpoint = new CheckpointManager(pluginDataDir, api.logger);
-
- // Detect and repair LLM-caused checkpoint corruption.
- // If the LLM wrote the entire checkpoint file (instead of using
- // replace_in_file on specific fields), system-managed counters may
- // have been overwritten with stale/zero values.
- const postState = await checkpoint.read();
- if (
- postState.scenes_processed < preScenesProcessed ||
- postState.total_processed < preTotalProcessed
- ) {
- api.logger.warn(
- `${TAG} [L2-local] ⚠️ Checkpoint corruption detected! ` +
- `scenes_processed: ${preScenesProcessed} → ${postState.scenes_processed}, ` +
- `total_processed: ${preTotalProcessed} → ${postState.total_processed}, ` +
- `memories_since: ${preMemoriesSince} → ${postState.memories_since_last_persona}. ` +
- `Repairing...`,
- );
- await checkpoint.write({
- ...postState,
- scenes_processed: Math.max(postState.scenes_processed, preScenesProcessed),
- total_processed: Math.max(postState.total_processed, preTotalProcessed),
- memories_since_last_persona: Math.max(postState.memories_since_last_persona, preMemoriesSince),
- });
- api.logger.info(`${TAG} [L2-local] Checkpoint repaired`);
- }
-
- await checkpoint.incrementScenesProcessed();
-
- // Return the max updatedAt from this batch as the new cursor
- const latestCursor = records.reduce((latest, r) => {
- return r.updatedAt > latest ? r.updatedAt : latest;
- }, "");
-
- api.logger.debug?.(
- `${TAG} [L2-local] Extraction complete: processed=${extractResult.memoriesProcessed}, latestCursor=${latestCursor}`,
- );
-
- return { latestCursor: latestCursor || undefined };
- }
}
// ============================
diff --git a/openclaw.plugin.json b/openclaw.plugin.json
index 200f714..e3fcc53 100644
--- a/openclaw.plugin.json
+++ b/openclaw.plugin.json
@@ -6,6 +6,12 @@
"type": "object",
"additionalProperties": true,
"properties": {
+ "storeBackend": {
+ "type": "string",
+ "enum": ["sqlite", "tcvdb"],
+ "default": "sqlite",
+ "description": "存储后端:sqlite(本地 SQLite + sqlite-vec)或 tcvdb(腾讯云向量数据库)"
+ },
"capture": {
"type": "object",
"description": "对话自动捕获设置 (L0)",
@@ -32,7 +38,7 @@
"description": "场景归纳与用户画像设置 (L2/L3)",
"properties": {
"triggerEveryN": { "type": "number", "default": 50, "description": "每 N 条新记忆触发画像生成" },
- "maxScenes": { "type": "number", "default": 20, "description": "最大场景数" },
+ "maxScenes": { "type": "number", "default": 15, "description": "最大场景数" },
"backupCount": { "type": "number", "default": 3, "description": "画像备份保留数量" },
"sceneBackupCount": { "type": "number", "default": 10, "description": "场景块备份保留数量" },
"model": { "type": "string", "description": "画像生成模型 (格式: provider/model),未填写时使用openclaw默认模型" }
@@ -66,7 +72,7 @@
"type": "object",
"description": "向量搜索 (Embedding) 配置",
"properties": {
- "enabled": { "type": "boolean", "default": true, "description": "是否启用向量搜索" },
+ "enabled": { "type": "boolean", "default": true, "description": "是否启用向量搜索(若 provider=none,则实际会被禁用)" },
"provider": { "type": "string", "default": "none", "description": "Embedding 服务提供者:填写兼容 OpenAI API 的远端服务名称(如 openai、deepseek 等);不填或填 none 则禁用向量搜索" },
"proxyUrl": { "type": "string", "description": "本地代理地址(仅 provider=qclaw 时必填)。配置后 embedding 请求将通过该代理转发,原始 baseUrl 作为 Remote-URL 头传递" },
"baseUrl": { "type": "string", "description": "API Base URL(必填):填写对应 provider 的 API 地址" },
@@ -74,7 +80,30 @@
"model": { "type": "string", "description": "模型名称(必填)" },
"dimensions": { "type": "number", "description": "向量维度(必填,需与所选模型匹配)" },
"conflictRecallTopK": { "type": "number", "default": 5, "description": "冲突检测时召回 Top-K 数" },
- "maxInputChars": { "type": "number", "default": 5000, "description": "Embedding 输入文本最大字符数,超出时截断并打印警告日志(默认 5000,适合大多数模型的 token 上限)" }
+ "maxInputChars": { "type": "number", "default": 5000, "description": "Embedding 输入文本最大字符数,超出时截断并打印警告日志(默认 5000,适合大多数模型的 token 上限)" },
+ "timeoutMs": { "type": "number", "default": 10000, "description": "单次 embedding API 调用超时(毫秒),超时后该次请求中止且不重试" }
+ }
+ },
+ "tcvdb": {
+ "type": "object",
+ "description": "腾讯云向量数据库配置(仅 storeBackend=tcvdb 时生效)",
+ "properties": {
+ "url": { "type": "string", "description": "实例 URL(必填,如 http://10.0.1.1:8100)" },
+ "username": { "type": "string", "default": "root", "description": "账户名" },
+ "apiKey": { "type": "string", "description": "API Key(必填)" },
+ "database": { "type": "string", "description": "数据库名(未填写时默认根据实例 ID 自动生成)" },
+ "alias": { "type": "string", "description": "用户友好别名(可选,用于 database.json 中识别)" },
+ "embeddingModel": { "type": "string", "default": "bge-large-zh", "description": "服务端 embedding 模型" },
+ "timeout": { "type": "number", "default": 10000, "description": "请求超时(毫秒)" },
+ "caPemPath": { "type": "string", "description": "CA 证书 PEM 文件路径(HTTPS 连接时使用)" }
+ }
+ },
+ "bm25": {
+ "type": "object",
+ "description": "BM25 稀疏向量编码设置(主要用于 tcvdb 后端)",
+ "properties": {
+ "enabled": { "type": "boolean", "default": true, "description": "是否启用 BM25 稀疏向量编码" },
+ "language": { "type": "string", "enum": ["zh", "en"], "default": "zh", "description": "分词语言:zh(中文)或 en(英文)" }
}
},
"report": {
diff --git a/package.json b/package.json
index ba22a31..e3596de 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,33 @@
{
"name": "@tencentdb-agent-memory/memory-tencentdb",
- "version": "0.1.4",
+ "version": "0.2.2",
"description": "Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)",
"type": "module",
"main": "index.ts",
+ "bin": {
+ "migrate-sqlite-to-tcvdb": "./bin/migrate-sqlite-to-tcvdb.mjs",
+ "export-tencent-vdb": "./bin/export-tencent-vdb.mjs",
+ "read-local-memory": "./bin/read-local-memory.mjs"
+ },
"exports": {
".": "./index.ts"
},
+ "scripts": {
+ "build:scripts": "npm run build:migrate-sqlite-to-vdb && npm run build:export-tencent-vdb && npm run build:read-local-memory",
+ "prepack": "npm run build:scripts",
+ "build:migrate-sqlite-to-vdb": "tsc -p scripts/migrate-sqlite-to-tcvdb/tsconfig.json --noEmitOnError false",
+ "migrate-sqlite-to-tcvdb": "node ./bin/migrate-sqlite-to-tcvdb.mjs",
+ "build:export-tencent-vdb": "tsc --project scripts/export-tencent-vdb/tsconfig.json",
+ "export-tencent-vdb": "node ./bin/export-tencent-vdb.mjs",
+ "build:read-local-memory": "tsc --project scripts/read-local-memory/tsconfig.json",
+ "read-local-memory": "node ./bin/read-local-memory.mjs"
+ },
"files": [
+ "bin/",
"index.ts",
+ "scripts/migrate-sqlite-to-tcvdb/dist/",
+ "scripts/export-tencent-vdb/dist/",
+ "scripts/read-local-memory/dist/",
"src/",
"openclaw.plugin.json",
"README.md",
@@ -39,11 +58,14 @@
},
"dependencies": {
"@node-rs/jieba": "^2.0.1",
- "sqlite-vec": "0.1.7-alpha.2"
+ "@tencentdb-agent-memory/tcvdb-text": "^0.1.1",
+ "json5": "^2.2.3",
+ "sqlite-vec": "0.1.7-alpha.2",
+ "undici": "8.0.2"
},
"peerDependencies": {
- "openclaw": ">=2026.3.7",
- "node-llama-cpp": "^3.16.2"
+ "node-llama-cpp": "^3.16.2",
+ "openclaw": ">=2026.3.7"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -56,6 +78,17 @@
"openclaw": {
"extensions": [
"./index.ts"
- ]
+ ],
+ "compat": {
+ "pluginApi": ">=2026.3.13",
+ "minGatewayVersion": ">=2026.3.13"
+ },
+ "build": {
+ "openclawVersion": "2026.3.13",
+ "pluginSdkVersion": "2026.3.13"
+ },
+ "bundle": {
+ "stageRuntimeDependencies": true
+ }
}
}
diff --git a/scripts/export-diagnostic.sh b/scripts/export-diagnostic.sh
new file mode 100755
index 0000000..9103e00
--- /dev/null
+++ b/scripts/export-diagnostic.sh
@@ -0,0 +1,227 @@
+#!/usr/bin/env bash
+# OpenClaw + memory-tencentdb(原 memory-tdai)诊断数据导出脚本
+# 注:插件已更名为 memory-tencentdb,但数据目录始终为 memory-tdai(代码硬编码)
+# 用法: bash export-diagnostic.sh [输出目录]
+# 默认输出到 ~/Downloads/openclaw-diagnostic-/
+
+set -euo pipefail
+
+# ── 参数 ──
+OUTPUT_BASE="${1:-$HOME/Downloads}"
+TIMESTAMP=$(date +%Y%m%d-%H%M%S)
+EXPORT_DIR="${OUTPUT_BASE}/openclaw-diagnostic-${TIMESTAMP}"
+ARCHIVE_PATH="${EXPORT_DIR}.tar.gz"
+
+# ── OpenClaw 工作目录探测 ──
+if [ -n "${OPENCLAW_STATE_DIR:-}" ]; then
+ STATE_DIR="$OPENCLAW_STATE_DIR"
+elif [ -d "$HOME/.openclaw" ]; then
+ STATE_DIR="$HOME/.openclaw"
+elif [ -d "$HOME/.clawdbot" ]; then
+ STATE_DIR="$HOME/.clawdbot"
+else
+ echo "❌ 未找到 OpenClaw 工作目录 (~/.openclaw 或 ~/.clawdbot)"
+ exit 1
+fi
+
+echo "📂 OpenClaw 工作目录: $STATE_DIR"
+echo "📦 导出目录: $EXPORT_DIR"
+
+mkdir -p "$EXPORT_DIR"
+
+# ── 1. 收集环境信息 ──
+echo "🔍 收集环境信息..."
+{
+ echo "=== 导出时间 ==="
+ date -Iseconds 2>/dev/null || date
+ echo ""
+ echo "=== 系统信息 ==="
+ echo "OS: $(uname -a)"
+ echo "Node: $(node --version 2>/dev/null || echo 'not found')"
+ echo "pnpm: $(pnpm --version 2>/dev/null || echo 'not found')"
+ echo ""
+ echo "=== OpenClaw 版本 ==="
+ openclaw --version 2>/dev/null || pnpm openclaw --version 2>/dev/null || echo "(unknown)"
+ echo ""
+ echo "=== 工作目录 ==="
+ echo "STATE_DIR: $STATE_DIR"
+ echo ""
+ echo "=== 目录结构 ==="
+ ls -la "$STATE_DIR/" 2>/dev/null || echo "(empty)"
+ echo ""
+ echo "=== memory-tdai 目录结构 ==="
+ ls -laR "$STATE_DIR/memory-tdai/" 2>/dev/null || echo "(not found)"
+ echo ""
+ echo "=== 磁盘占用 ==="
+ du -sh "$STATE_DIR/memory-tdai/"* 2>/dev/null || echo "(not found)"
+} > "$EXPORT_DIR/env-info.txt" 2>&1
+
+# ── 2. 收集 OpenClaw 日志 ──
+echo "📋 收集 OpenClaw 日志..."
+mkdir -p "$EXPORT_DIR/logs"
+
+# 网关日志 (~/.openclaw/logs/)
+if [ -d "$STATE_DIR/logs" ]; then
+ cp -r "$STATE_DIR/logs/" "$EXPORT_DIR/logs/gateway-logs/" 2>/dev/null || true
+fi
+
+# 滚动日志 (/tmp/openclaw/)
+TMP_LOG_DIR="/tmp/openclaw"
+if [ -d "$TMP_LOG_DIR" ]; then
+ mkdir -p "$EXPORT_DIR/logs/rolling-logs"
+ # 只取最近 3 个日志文件
+ ls -t "$TMP_LOG_DIR"/openclaw-*.log 2>/dev/null | head -3 | while read -r f; do
+ # 每个文件只取最后 5000 行,避免过大
+ tail -5000 "$f" > "$EXPORT_DIR/logs/rolling-logs/$(basename "$f")" 2>/dev/null || true
+ done
+fi
+
+# ── 3. 收集记忆插件数据 ──
+# 注:数据目录名为 memory-tdai(历史原因,插件更名为 memory-tencentdb 后未改目录名)
+echo "🧠 收集记忆插件数据..."
+MEMORY_DIR="$STATE_DIR/memory-tdai"
+if [ -d "$MEMORY_DIR" ]; then
+ mkdir -p "$EXPORT_DIR/memory-tdai"
+
+ # L0 对话记录 (JSONL)
+ if [ -d "$MEMORY_DIR/conversations" ]; then
+ cp -r "$MEMORY_DIR/conversations/" "$EXPORT_DIR/memory-tdai/conversations/" 2>/dev/null || true
+ fi
+
+ # L1 结构化记忆 (JSONL)
+ if [ -d "$MEMORY_DIR/records" ]; then
+ cp -r "$MEMORY_DIR/records/" "$EXPORT_DIR/memory-tdai/records/" 2>/dev/null || true
+ fi
+
+ # L2 场景文件 (Markdown)
+ if [ -d "$MEMORY_DIR/scene_blocks" ]; then
+ cp -r "$MEMORY_DIR/scene_blocks/" "$EXPORT_DIR/memory-tdai/scene_blocks/" 2>/dev/null || true
+ fi
+
+ # L3 用户画像
+ [ -f "$MEMORY_DIR/persona.md" ] && cp "$MEMORY_DIR/persona.md" "$EXPORT_DIR/memory-tdai/" 2>/dev/null || true
+
+ # checkpoint + scene_index
+ if [ -d "$MEMORY_DIR/.metadata" ]; then
+ cp -r "$MEMORY_DIR/.metadata/" "$EXPORT_DIR/memory-tdai/.metadata/" 2>/dev/null || true
+ fi
+
+ # SQLite 数据库(用于检查向量/FTS索引状态)
+ [ -f "$MEMORY_DIR/vectors.db" ] && cp "$MEMORY_DIR/vectors.db" "$EXPORT_DIR/memory-tdai/" 2>/dev/null || true
+
+ # 备份目录(可选,可能较大)
+ if [ -d "$MEMORY_DIR/.backup" ]; then
+ cp -r "$MEMORY_DIR/.backup/" "$EXPORT_DIR/memory-tdai/.backup/" 2>/dev/null || true
+ fi
+else
+ echo " ⚠️ 未找到 memory-tdai 数据目录(memory-tencentdb 插件的数据也存储在此目录)"
+fi
+
+# ── 4. 收集 OpenClaw 配置(脱敏) ──
+echo "🔧 收集 OpenClaw 配置(已脱敏)..."
+CONFIG_FILE="$STATE_DIR/openclaw.json"
+if [ -f "$CONFIG_FILE" ]; then
+ # 使用 node 脱敏处理配置
+ node -e "
+ const fs = require('fs');
+ const JSON5 = (() => { try { return require('json5'); } catch { return JSON; } })();
+ const raw = fs.readFileSync('$CONFIG_FILE', 'utf-8');
+ let cfg;
+ try { cfg = JSON5.parse(raw); } catch { cfg = JSON.parse(raw); }
+
+ // 递归脱敏函数
+ function redact(obj, path) {
+ if (!obj || typeof obj !== 'object') return obj;
+ if (Array.isArray(obj)) return obj.map((v, i) => redact(v, path + '[' + i + ']'));
+ const result = {};
+ for (const [k, v] of Object.entries(obj)) {
+ const fullPath = path ? path + '.' + k : k;
+ // 脱敏规则:API key、token、password、secret 类字段
+ if (/api_?key|token|password|secret|credential/i.test(k) && typeof v === 'string') {
+ result[k] = v.length > 0 ? '***REDACTED(' + v.length + 'chars)***' : '';
+ }
+ // 脱敏 SecretRef 对象
+ else if (v && typeof v === 'object' && v.source && v.id && v.provider) {
+ result[k] = { source: v.source, provider: v.provider, id: '***REDACTED***' };
+ }
+ // 整体跳过的顶层敏感块
+ else if (['models', 'secrets', 'channels', 'env'].includes(k) && !path) {
+ result[k] = '***REDACTED_SECTION(use openclaw config get ' + k + ' to inspect)***';
+ }
+ // gateway.auth 内的 token/password
+ else if (path === 'gateway.auth' && /token|password/i.test(k)) {
+ result[k] = typeof v === 'string' ? '***REDACTED***' : v;
+ }
+ else {
+ result[k] = redact(v, fullPath);
+ }
+ }
+ return result;
+ }
+
+ const redacted = redact(cfg, '');
+ // plugins 已经过递归 redact(),其中 apiKey/token/password/secret 等字段
+ // 会被自动脱敏,同时保留 provider/model/enabled 等排查所需的非敏感配置
+
+ fs.writeFileSync('$EXPORT_DIR/openclaw-config-redacted.json', JSON.stringify(redacted, null, 2));
+ console.log(' ✅ 配置已脱敏导出');
+ " 2>&1 || {
+ echo " ⚠️ Node 脱敏失败,使用 grep 粗略脱敏"
+ # 粗略脱敏:删除包含敏感关键字的行
+ grep -v -iE '(api.?key|token|password|secret|credential).*:.*"[^"]{8,}"' "$CONFIG_FILE" \
+ | sed -E 's/"(models|secrets|channels|env)"\s*:\s*\{[^}]*\}/"__REDACTED_SECTION__"/g' \
+ > "$EXPORT_DIR/openclaw-config-redacted.json" 2>/dev/null || true
+ }
+else
+ echo " ⚠️ 未找到配置文件"
+fi
+
+# ── 5. 收集插件安装信息 ──
+echo "🔌 收集插件安装信息..."
+if [ -d "$STATE_DIR/extensions" ]; then
+ {
+ echo "=== 已安装插件 ==="
+ ls -la "$STATE_DIR/extensions/" 2>/dev/null
+ echo ""
+ for ext_dir in "$STATE_DIR/extensions"/*/; do
+ [ -d "$ext_dir" ] || continue
+ pkg="$ext_dir/node_modules/openclaw/package.json"
+ plugin_pkg="$ext_dir/package.json"
+ echo "--- $(basename "$ext_dir") ---"
+ if [ -f "$plugin_pkg" ]; then
+ node -e "const p=require('$plugin_pkg'); console.log('name:', p.name, 'version:', p.version)" 2>/dev/null || true
+ fi
+ done
+ } > "$EXPORT_DIR/plugins-info.txt" 2>&1
+fi
+
+# ── 6. 打包 ──
+echo "📦 打包中..."
+cd "$(dirname "$EXPORT_DIR")"
+tar -czf "$ARCHIVE_PATH" "$(basename "$EXPORT_DIR")"
+
+# 计算大小
+ARCHIVE_SIZE=$(du -sh "$ARCHIVE_PATH" | cut -f1)
+
+echo ""
+echo "═══════════════════════════════════════════════════"
+echo " ✅ 诊断数据导出完成"
+echo "═══════════════════════════════════════════════════"
+echo ""
+echo " 📦 压缩包: $ARCHIVE_PATH"
+echo " 📏 大小: $ARCHIVE_SIZE"
+echo ""
+echo " 包含内容:"
+echo " - env-info.txt — 环境信息、目录结构"
+echo " - logs/ — OpenClaw 网关日志 + 滚动日志"
+echo " - memory-tdai/ — 记忆插件全量数据 (L0~L3 + SQLite)"
+echo " - openclaw-config-redacted.json — 脱敏后的配置文件"
+echo " - plugins-info.txt — 插件安装信息"
+echo ""
+echo " ⚠️ 安全提示:"
+echo " - 配置文件已自动脱敏(API Key、Token、Password 等已移除)"
+echo " - models/secrets/channels/env 等敏感配置块已整体替换"
+echo " - 记忆数据中可能包含用户对话内容,请确认后再发送"
+echo ""
+echo " 📤 请手动检查后发送给研发团队"
+echo "═══════════════════════════════════════════════════"
diff --git a/src/config.ts b/src/config.ts
index 532abf2..7d9c473 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -127,6 +127,37 @@ export interface MemoryCleanupConfig {
cleanTime: string;
}
+/** BM25 sparse vector encoding configuration (local @tencentdb-agent-memory/tcvdb-text). */
+export interface BM25Config {
+ /** Whether BM25 sparse encoding is enabled (default: true) */
+ enabled: boolean;
+ /** Language for BM25 pre-trained params: "zh" or "en" (default: "zh") */
+ language: "zh" | "en";
+}
+
+/** Tencent Cloud VectorDB configuration. */
+export interface TcvdbConfig {
+ /** Instance URL (e.g. "http://10.0.1.1:80" or external domain) */
+ url: string;
+ /** Account name (default: "root") */
+ username: string;
+ /** API Key */
+ apiKey: string;
+ /** Database name (auto-generated from instance_id if empty) */
+ database: string;
+ /** User-friendly alias for this database (optional, for identification in database.json) */
+ alias: string;
+ /** Built-in embedding model (default: "bge-large-zh") */
+ embeddingModel: string;
+ /** Request timeout in ms (default: 10000) */
+ timeout: number;
+ /** Path to CA certificate PEM file (for HTTPS connections) */
+ caPemPath?: string;
+}
+
+/** Storage backend type. */
+export type StoreBackend = "sqlite" | "tcvdb";
+
/** Report settings — controls metric/event reporting. */
export interface ReportConfig {
/** Enable reporting (default: true) */
@@ -143,6 +174,13 @@ export interface MemoryTdaiConfig {
pipeline: PipelineTriggerConfig;
recall: RecallConfig;
embedding: EmbeddingConfig;
+ /** Storage backend: "sqlite" (default) or "tcvdb" */
+ storeBackend: StoreBackend;
+ /** Tencent Cloud VectorDB configuration (required when storeBackend = "tcvdb") */
+ tcvdb: TcvdbConfig;
+ /** BM25 sparse vector encoding (local @tencentdb-agent-memory/tcvdb-text) */
+ bm25: BM25Config;
+ /** Local JSONL cleanup settings */
memoryCleanup: MemoryCleanupConfig;
report: ReportConfig;
}
@@ -272,6 +310,16 @@ export function parseConfig(raw: Record | undefined): MemoryTda
const cleanTime = normalizeCleanTime(str(captureGroup, "cleanTime")) ?? "03:00";
+ // --- BM25 (local @tencentdb-agent-memory/tcvdb-text encoder) ---
+ const bm25Group = obj(c, "bm25");
+
+ // --- Store backend ---
+ const storeBackendRaw = str(c, "storeBackend") ?? "sqlite";
+ const storeBackend: StoreBackend = storeBackendRaw === "tcvdb" ? "tcvdb" : "sqlite";
+
+ // --- TCVDB config ---
+ const tcvdbGroup = obj(c, "tcvdb");
+
const memoryCleanup: MemoryCleanupConfig = {
retentionDays,
enabled: retentionDays != null,
@@ -293,7 +341,7 @@ export function parseConfig(raw: Record | undefined): MemoryTda
},
persona: {
triggerEveryN: num(personaGroup, "triggerEveryN") ?? 50,
- maxScenes: num(personaGroup, "maxScenes") ?? 20,
+ maxScenes: num(personaGroup, "maxScenes") ?? 15,
backupCount: num(personaGroup, "backupCount") ?? 3,
sceneBackupCount: num(personaGroup, "sceneBackupCount") ?? 10,
model: optStr(personaGroup, "model"),
@@ -328,6 +376,21 @@ export function parseConfig(raw: Record | undefined): MemoryTda
modelCacheDir: optStr(embeddingGroup, "modelCacheDir"),
configError: embeddingConfigError,
},
+ storeBackend,
+ tcvdb: {
+ url: str(tcvdbGroup, "url") ?? "",
+ username: str(tcvdbGroup, "username") ?? "root",
+ apiKey: str(tcvdbGroup, "apiKey") ?? "",
+ database: str(tcvdbGroup, "database") ?? "",
+ alias: str(tcvdbGroup, "alias") ?? "",
+ embeddingModel: str(tcvdbGroup, "embeddingModel") ?? "bge-large-zh",
+ timeout: num(tcvdbGroup, "timeout") ?? 10000,
+ caPemPath: str(tcvdbGroup, "caPemPath") || undefined,
+ },
+ bm25: {
+ enabled: bool(bm25Group, "enabled") ?? true,
+ language: (str(bm25Group, "language") === "en" ? "en" : "zh") as "zh" | "en",
+ },
memoryCleanup,
report: {
enabled: bool(obj(c, "report"), "enabled") ?? false,
diff --git a/src/conversation/l0-recorder.ts b/src/conversation/l0-recorder.ts
index f0dd7dd..6eb9274 100644
--- a/src/conversation/l0-recorder.ts
+++ b/src/conversation/l0-recorder.ts
@@ -160,7 +160,7 @@ export async function recordConversation(params: {
// - If a message lacks a timestamp field, extractUserAssistantMessages()
// assigns Date.now() at extraction time, which is always > previous cursor.
const cursor = afterTimestamp ?? 0;
- const extracted = cursor > 0
+ const extracted = cursor !== 0
? allExtracted.filter((m) => m.timestamp > cursor)
: allExtracted;
@@ -440,7 +440,7 @@ export async function readConversationMessages(
*/
export interface SessionIdMessageGroup {
sessionId: string;
- messages: ConversationMessage[];
+ messages: Array;
}
/**
@@ -451,29 +451,32 @@ export interface SessionIdMessageGroup {
* so that each group's sessionId is correctly associated with its extracted memories.
*
* When `limit` is provided, only the **newest** `limit` messages (across all groups)
- * are retained — matching the DB path's `ORDER BY timestamp DESC LIMIT ?` behavior.
+ * are retained — matching the DB path's `ORDER BY recorded_at DESC LIMIT ?` behavior.
* Groups that become empty after truncation are dropped.
*
* Groups are returned in chronological order (by earliest message timestamp).
* Messages within each group are also in chronological order.
+ *
+ * @param afterRecordedAtMs - Epoch ms cursor: only messages with recordedAt > this are included.
*/
export async function readConversationMessagesGroupedBySessionId(
sessionKey: string,
baseDir: string,
- afterTimestamp?: number,
+ afterRecordedAtMs?: number,
logger?: Logger,
limit?: number,
): Promise {
const records = await readConversationRecords(sessionKey, baseDir, logger);
- // Collect all messages with their sessionId, respecting afterTimestamp filter
- const allMessages: Array<{ sessionId: string; msg: ConversationMessage }> = [];
+ // Collect all messages with their sessionId, filtering by recorded_at cursor
+ const allMessages: Array<{ sessionId: string; msg: ConversationMessage & { recordedAtMs: number } }> = [];
for (const record of records) {
const sid = record.sessionId || "";
+ const recMs = Date.parse(record.recordedAt) || 0;
+ if (afterRecordedAtMs && recMs <= afterRecordedAtMs) continue;
for (const msg of record.messages) {
- if (afterTimestamp && msg.timestamp <= afterTimestamp) continue;
- allMessages.push({ sessionId: sid, msg });
+ allMessages.push({ sessionId: sid, msg: { ...msg, recordedAtMs: recMs } });
}
}
@@ -491,7 +494,7 @@ export async function readConversationMessagesGroupedBySessionId(
}
// Re-group by sessionId
- const groupMap = new Map();
+ const groupMap = new Map>();
for (const { sessionId, msg } of selected) {
let group = groupMap.get(sessionId);
if (!group) {
diff --git a/src/hooks/auto-capture.ts b/src/hooks/auto-capture.ts
index 906a2ab..6fd50d7 100644
--- a/src/hooks/auto-capture.ts
+++ b/src/hooks/auto-capture.ts
@@ -16,7 +16,7 @@ import { CheckpointManager } from "../utils/checkpoint.js";
import type { MemoryPipelineManager } from "../utils/pipeline-manager.js";
import { recordConversation } from "../conversation/l0-recorder.js";
import type { ConversationMessage } from "../conversation/l0-recorder.js";
-import type { VectorStore, L0VectorRecord } from "../store/vector-store.js";
+import type { IMemoryStore, L0Record } from "../store/types.js";
import type { EmbeddingService } from "../store/embedding.js";
const TAG = "[memory-tdai] [capture]";
@@ -68,7 +68,7 @@ export async function performAutoCapture(params: {
* prevents the first agent_end from dumping all session history into L0. */
pluginStartTimestamp?: number;
/** VectorStore for L0 vector indexing (optional). */
- vectorStore?: VectorStore;
+ vectorStore?: IMemoryStore;
/** EmbeddingService for L0 vector indexing (optional). */
embeddingService?: EmbeddingService;
}): Promise {
@@ -131,32 +131,42 @@ export async function performAutoCapture(params: {
const tL0RecordEnd = performance.now();
// ============================
- // Step 1.5: L0 vector indexing — metadata written synchronously,
- // embedding done in background (non-blocking)
+ // Step 1.5: L0 vector indexing
// ============================
- // PERF FIX: Remote embedding API calls (2-3s each) were blocking
- // the agent_end hook, adding 5-9s latency per conversation round.
- // Now we:
- // 1. Write L0 metadata + FTS immediately (no embedding) — ~10ms
- // 2. Fire off background task to embed + update vectors (non-blocking)
- // This way the user gets their response immediately.
+ // Two paths depending on store capabilities:
+ //
+ // A) Store supports updateL0Embedding (sqlite):
+ // - Write metadata + FTS immediately WITHOUT embedding (~ms)
+ // - Fire-and-forget background task: embedBatch + updateL0Embedding
+ // - PERF: avoids blocking agent_end with 2-3s embedding calls
+ //
+ // B) Store does NOT support updateL0Embedding (VDB / remote):
+ // - Embed synchronously, then upsertL0 with embedding in one call
+ // - VDB backends handle embedding server-side or need it upfront
const tL0VecStart = performance.now();
let l0VectorsWritten = 0;
+ let l0EmbedTotalMs = 0;
+ let l0UpsertTotalMs = 0;
logger?.debug?.(
`${TAG} [L0-vec-index] Check: filteredMessages=${filteredMessages.length}, ` +
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`,
);
- // Pre-generate L0 records and write metadata synchronously (fast path)
- const l0Records: Array<{ record: L0VectorRecord; content: string }> = [];
+ const supportsBgEmbed = vectorStore?.supportsDeferredEmbedding === true;
+
if (filteredMessages.length > 0 && vectorStore) {
const now = new Date().toISOString();
- logger?.debug?.(`${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey}`);
+ const bgRecords: Array<{ recordId: string; content: string }> = [];
+ logger?.debug?.(
+ `${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey} ` +
+ `(mode=${supportsBgEmbed ? "async-bg" : "sync"})`,
+ );
+
for (let i = 0; i < filteredMessages.length; i++) {
const msg = filteredMessages[i];
try {
- const l0Record: L0VectorRecord = {
+ const l0Record: L0Record = {
id: generateL0RecordId(sessionKey, i),
sessionKey,
sessionId: sessionId || "",
@@ -166,11 +176,45 @@ export async function performAutoCapture(params: {
timestamp: msg.timestamp,
};
- // Write metadata + FTS immediately WITHOUT embedding (fast, ~ms)
- const upsertOk = vectorStore.upsertL0(l0Record, undefined);
+ let embedding: Float32Array | undefined;
+
+ if (!supportsBgEmbed && embeddingService) {
+ // Path B (VDB): embed synchronously — needed for upsertL0
+ // Skip local embed when using server-side embedding (NoopEmbeddingService, dims=0)
+ if (embeddingService.getDimensions() === 0) {
+ logger?.debug?.(
+ `${TAG} [L0-vec-index] Server-side embedding (dims=0), skipping local embed for message ${i}`,
+ );
+ } else {
+ const tEmbedStart = performance.now();
+ try {
+ embedding = await embeddingService.embed(msg.content);
+ l0EmbedTotalMs += performance.now() - tEmbedStart;
+ logger?.debug?.(
+ `${TAG} [L0-vec-index] Embedding OK: dims=${embedding.length}, ` +
+ `norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`,
+ );
+ } catch (embedErr) {
+ l0EmbedTotalMs += performance.now() - tEmbedStart;
+ logger?.warn(
+ `${TAG} [L0-vec-index] Embedding FAILED for message ${i}, ` +
+ `will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`,
+ );
+ }
+ }
+ }
+
+ // Path A (sqlite): pass undefined embedding — metadata + FTS only
+ // Path B (VDB): pass embedding (may be undefined on failure)
+ const tUpsertStart = performance.now();
+ const upsertOk = await vectorStore.upsertL0(l0Record, supportsBgEmbed ? undefined : embedding);
+ l0UpsertTotalMs += performance.now() - tUpsertStart;
+
if (upsertOk) {
l0VectorsWritten++;
- l0Records.push({ record: l0Record, content: msg.content });
+ if (supportsBgEmbed) {
+ bgRecords.push({ recordId: l0Record.id, content: msg.content });
+ }
} else {
logger?.warn(`${TAG} [L0-vec-index] upsertL0 returned false for message ${i}`);
}
@@ -178,38 +222,39 @@ export async function performAutoCapture(params: {
logger?.warn?.(`${TAG} [L0-vec-index] FAILED for message ${i} (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
}
}
- logger?.debug?.(`${TAG} [L0-vec-index] DONE: ${l0VectorsWritten}/${filteredMessages.length} metadata records written (sync)`);
- // Fire-and-forget: batch embed + update vectors in background
- if (l0Records.length > 0 && embeddingService) {
- const bgVectorStore = vectorStore; // capture for closure
+ const modeLabel = supportsBgEmbed ? "metadata-only, embed=background" : `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms`;
+ logger?.debug?.(`${TAG} [L0-vec-index] DONE: ${l0VectorsWritten}/${filteredMessages.length} records written (${modeLabel})`);
+
+ // Path A only: fire-and-forget background embedding for sqlite stores
+ if (supportsBgEmbed && bgRecords.length > 0 && embeddingService) {
+ const bgVectorStore = vectorStore;
const bgEmbeddingService = embeddingService;
- const bgRecords = [...l0Records]; // snapshot
+ const bgSnapshot = [...bgRecords];
const bgLogger = logger;
- // Do NOT await — this runs in background after response is sent
+ // Do NOT await — runs in background after response is sent
void (async () => {
const tBgStart = performance.now();
try {
- // Use embedBatch for a single API call instead of N sequential calls
- const texts = bgRecords.map((r) => r.content);
+ const texts = bgSnapshot.map((r) => r.content);
const embeddings = await bgEmbeddingService.embedBatch(texts);
let bgUpdated = 0;
- for (let i = 0; i < bgRecords.length; i++) {
+ for (let i = 0; i < bgSnapshot.length; i++) {
try {
- const ok = bgVectorStore.updateL0Embedding(bgRecords[i].record.id, embeddings[i]);
+ const ok = await bgVectorStore.updateL0Embedding!(bgSnapshot[i].recordId, embeddings[i]);
if (ok) bgUpdated++;
} catch (err) {
bgLogger?.warn?.(
- `${TAG} [L0-vec-index-bg] Failed to update embedding for ${bgRecords[i].record.id}: ` +
+ `${TAG} [L0-vec-index-bg] Failed to update embedding for ${bgSnapshot[i].recordId}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
}
const bgMs = performance.now() - tBgStart;
bgLogger?.debug?.(
- `${TAG} [L0-vec-index-bg] Background embedding complete: ${bgUpdated}/${bgRecords.length} vectors updated (${bgMs.toFixed(0)}ms)`,
+ `${TAG} [L0-vec-index-bg] Background embedding complete: ${bgUpdated}/${bgSnapshot.length} vectors updated (${bgMs.toFixed(0)}ms)`,
);
} catch (err) {
const bgMs = performance.now() - tBgStart;
@@ -235,11 +280,13 @@ export async function performAutoCapture(params: {
logger?.debug?.(`${TAG} Scheduler notified of conversation round (sessionKey=${sessionKey})`);
const totalMs = performance.now() - tCaptureStart;
+ const vecDetail = supportsBgEmbed
+ ? `metadata-only, embed=background, msgs=${filteredMessages.length}`
+ : `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms, msgs=${filteredMessages.length}`;
logger?.info(
`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
- `l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms ` +
- `(metadata-only, embed=background, msgs=${filteredMessages.length}), ` +
+ `l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms (${vecDetail}), ` +
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`,
);
@@ -252,11 +299,13 @@ export async function performAutoCapture(params: {
}
const totalMs = performance.now() - tCaptureStart;
+ const vecDetail = supportsBgEmbed
+ ? `metadata-only, embed=background, msgs=${filteredMessages.length}`
+ : `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms, msgs=${filteredMessages.length}`;
logger?.info(
`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
- `l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms ` +
- `(metadata-only, embed=background, msgs=${filteredMessages.length}), ` +
+ `l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms (${vecDetail}), ` +
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`,
);
diff --git a/src/hooks/auto-recall.ts b/src/hooks/auto-recall.ts
index 045c8d1..d94230a 100644
--- a/src/hooks/auto-recall.ts
+++ b/src/hooks/auto-recall.ts
@@ -16,8 +16,8 @@ import type { MemoryTdaiConfig } from "../config.js";
import { readSceneIndex } from "../scene/scene-index.js";
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
import type { MemoryRecord } from "../record/l1-reader.js";
-import type { VectorStore, VectorSearchResult, FtsSearchResult } from "../store/vector-store.js";
-import { buildFtsQuery } from "../store/vector-store.js";
+import type { IMemoryStore, L1SearchResult, L1FtsResult } from "../store/types.js";
+import { buildFtsQuery } from "../store/sqlite.js";
import type { EmbeddingService } from "../store/embedding.js";
import { sanitizeText } from "../utils/sanitize.js";
@@ -35,6 +35,11 @@ const MEMORY_TOOLS_GUIDE = `
- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件节点、规则等关键信息。
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节;也可用于补充或校验 memory_search 的结果。
- **read_file**(Scene Navigation 中的路径):当已定位到相关情境,且需要该场景的完整画像、事件经过或阶段结论时使用。
+
+### ⚠️ 调用次数限制
+每轮对话中,tdai_memory_search 和 tdai_conversation_search **合计最多调用 3 次**。
+- 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
+- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户,不要继续搜索。
`
/**
@@ -82,7 +87,7 @@ export async function performAutoRecall(params: {
cfg: MemoryTdaiConfig;
pluginDataDir: string;
logger?: Logger;
- vectorStore?: VectorStore;
+ vectorStore?: IMemoryStore;
embeddingService?: EmbeddingService;
}): Promise {
const { cfg, logger } = params;
@@ -112,7 +117,7 @@ async function performAutoRecallInner(params: {
cfg: MemoryTdaiConfig;
pluginDataDir: string;
logger?: Logger;
- vectorStore?: VectorStore;
+ vectorStore?: IMemoryStore;
embeddingService?: EmbeddingService;
}): Promise {
const { userText, cfg, pluginDataDir, logger, vectorStore, embeddingService } = params;
@@ -269,7 +274,7 @@ async function searchMemoriesWithDetails(
cfg: MemoryTdaiConfig,
logger: Logger | undefined,
strategy: "keyword" | "embedding" | "hybrid",
- vectorStore?: VectorStore,
+ vectorStore?: IMemoryStore,
embeddingService?: EmbeddingService,
): Promise<{ lines: string[]; memories: RecalledMemory[]; timing: SearchTiming }> {
const result = await searchMemories(userText, pluginDataDir, cfg, logger, strategy, vectorStore, embeddingService);
@@ -305,7 +310,7 @@ async function searchMemories(
cfg: MemoryTdaiConfig,
logger: Logger | undefined,
strategy: "keyword" | "embedding" | "hybrid",
- vectorStore?: VectorStore,
+ vectorStore?: IMemoryStore,
embeddingService?: EmbeddingService,
): Promise {
const emptyResult: SearchResult = { lines: [], timing: { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 } };
@@ -378,14 +383,14 @@ async function searchByKeyword(
maxResults: number,
threshold: number,
logger?: Logger,
- vectorStore?: VectorStore,
+ vectorStore?: IMemoryStore,
): Promise {
// Prefer FTS5 if available
if (vectorStore?.isFtsAvailable()) {
const ftsQuery = buildFtsQuery(userText);
if (ftsQuery) {
logger?.debug?.(`${TAG} [keyword-fts] Using FTS5 BM25 search: query="${ftsQuery}"`);
- const ftsResults = vectorStore.ftsSearchL1(ftsQuery, maxResults * 2);
+ const ftsResults = await vectorStore.searchL1Fts(ftsQuery, maxResults * 2);
if (ftsResults.length > 0) {
logger?.debug?.(
`${TAG} [keyword-fts] FTS5 raw results (${ftsResults.length}): ` +
@@ -428,7 +433,7 @@ async function searchByEmbedding(
userText: string,
maxResults: number,
threshold: number,
- vectorStore: VectorStore,
+ vectorStore: IMemoryStore,
embeddingService: EmbeddingService,
logger?: Logger,
): Promise {
@@ -442,7 +447,7 @@ async function searchByEmbedding(
`searching top-${maxResults * 2}...`,
);
// Retrieve more candidates for subsequent filtering
- const vecResults: VectorSearchResult[] = vectorStore.search(queryEmbedding, maxResults * 2);
+ const vecResults: L1SearchResult[] = await vectorStore.searchL1Vector(queryEmbedding, maxResults * 2);
if (vecResults.length === 0) {
logger?.debug?.(`${TAG} [embedding-search] Returned 0 results`);
@@ -489,7 +494,7 @@ async function searchHybrid(
_pluginDataDir: string,
maxResults: number,
_threshold: number,
- vectorStore: VectorStore,
+ vectorStore: IMemoryStore,
embeddingService: EmbeddingService,
logger?: Logger,
): Promise {
@@ -505,7 +510,7 @@ async function searchHybrid(
if (vectorStore.isFtsAvailable()) {
const ftsQuery = buildFtsQuery(userText);
if (ftsQuery) {
- const ftsResults = vectorStore.ftsSearchL1(ftsQuery, candidateK);
+ const ftsResults = await vectorStore.searchL1Fts(ftsQuery, candidateK);
if (ftsResults.length > 0) {
logger?.debug?.(`${TAG} [hybrid-keyword-fts] FTS5 found ${ftsResults.length} candidates`);
// Convert FtsSearchResult to ScoredRecord for RRF merge
@@ -547,12 +552,12 @@ async function searchHybrid(
logger?.debug?.(
`${TAG} [hybrid-embedding] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
);
- const results = vectorStore.search(queryEmbedding, candidateK);
+ const results = await vectorStore.searchL1Vector(queryEmbedding, candidateK, userText);
logger?.debug?.(`${TAG} [hybrid-embedding] Got ${results.length} candidates`);
return { results, ms: performance.now() - tStart };
} catch (err) {
logger?.warn?.(`${TAG} Hybrid: embedding part failed: ${err instanceof Error ? err.message : String(err)}`);
- return { results: [] as VectorSearchResult[], ms: performance.now() - tStart };
+ return { results: [] as L1SearchResult[], ms: performance.now() - tStart };
}
})(),
]);
@@ -719,7 +724,7 @@ function recordToFormatable(record: MemoryRecord): FormatableMemory {
* Build a FormatableMemory from a VectorSearchResult (embedding search path).
* Handles empty/invalid metadata_json, empty timestamp_str gracefully.
*/
-function vectorResultToFormatable(r: VectorSearchResult): FormatableMemory {
+function vectorResultToFormatable(r: L1SearchResult): FormatableMemory {
let activityStart: string | undefined;
let activityEnd: string | undefined;
if (r.metadata_json && r.metadata_json !== "{}") {
@@ -743,7 +748,7 @@ function vectorResultToFormatable(r: VectorSearchResult): FormatableMemory {
* Build a FormatableMemory from an FtsSearchResult (FTS5 keyword search path).
* Handles empty/invalid metadata_json, empty timestamp_str gracefully.
*/
-function ftsResultToFormatable(r: FtsSearchResult): FormatableMemory {
+function ftsResultToFormatable(r: L1FtsResult): FormatableMemory {
let activityStart: string | undefined;
let activityEnd: string | undefined;
if (r.metadata_json && r.metadata_json !== "{}") {
diff --git a/src/persona/persona-generator.ts b/src/persona/persona-generator.ts
index 6f6481b..36a753a 100644
--- a/src/persona/persona-generator.ts
+++ b/src/persona/persona-generator.ts
@@ -53,9 +53,9 @@ export class PersonaGenerator {
}
/**
- * Execute persona generation.
+ * Execute local persona generation without advancing checkpoint.
*/
- async generate(triggerReason?: string): Promise {
+ async generateLocalPersona(triggerReason?: string): Promise {
const startMs = Date.now();
this.logger?.debug?.(`${TAG} Starting generation: reason="${triggerReason ?? "none"}"`);
@@ -181,9 +181,6 @@ export class PersonaGenerator {
const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText;
await fs.writeFile(personaPath, finalContent, "utf-8");
- // 12. Update checkpoint
- await cpManager.markPersonaGenerated(cp.total_processed);
-
const elapsedMs = Date.now() - startMs;
this.logger?.info(`${TAG} Persona written (${finalContent.length} chars) in ${elapsedMs}ms`);
@@ -202,4 +199,17 @@ export class PersonaGenerator {
return true;
}
+
+ /**
+ * Backward-compatible wrapper: local generation + checkpoint advance.
+ */
+ async generate(triggerReason?: string): Promise {
+ const updated = await this.generateLocalPersona(triggerReason);
+ if (!updated) return false;
+
+ const cpManager = new CheckpointManager(this.dataDir);
+ const cp = await cpManager.read();
+ await cpManager.markPersonaGenerated(cp.total_processed);
+ return true;
+ }
}
diff --git a/src/profile/profile-sync.ts b/src/profile/profile-sync.ts
new file mode 100644
index 0000000..c32ee34
--- /dev/null
+++ b/src/profile/profile-sync.ts
@@ -0,0 +1,213 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import type { IMemoryStore, ProfileRecord, ProfileSyncRecord } from "../store/types.js";
+import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
+import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
+
+const PROFILE_SCOPE = "global";
+
+interface Logger {
+ debug?: (message: string) => void;
+ info: (message: string) => void;
+ warn: (message: string) => void;
+ error: (message: string) => void;
+}
+
+export interface ProfileBaseline {
+ version: number;
+ contentMd5: string;
+ createdAtMs: number;
+}
+
+export function buildProfileStableId(scope: string, type: "l2" | "l3", filename: string): string {
+ const hash = createHash("sha256")
+ .update(`${scope}\u0000${type}\u0000${filename}`)
+ .digest("hex");
+ return `profile:v1:${hash}`;
+}
+
+function md5(text: string): string {
+ return createHash("md5").update(text).digest("hex");
+}
+
+async function statTimes(filePath: string): Promise<{ createdAtMs: number; updatedAtMs: number }> {
+ try {
+ const stat = await fs.stat(filePath);
+ return {
+ createdAtMs: Math.floor(stat.birthtimeMs || stat.ctimeMs || Date.now()),
+ updatedAtMs: Math.floor(stat.mtimeMs || Date.now()),
+ };
+ } catch {
+ const now = Date.now();
+ return { createdAtMs: now, updatedAtMs: now };
+ }
+}
+
+async function refreshPersonaNavigation(dataDir: string): Promise {
+ const personaPath = path.join(dataDir, "persona.md");
+ let body: string;
+ try {
+ body = stripSceneNavigation(await fs.readFile(personaPath, "utf-8")).trim();
+ } catch {
+ return;
+ }
+
+ if (!body) return;
+
+ const index = await readSceneIndex(dataDir);
+ const nav = generateSceneNavigation(index);
+ const finalContent = nav ? `${body}\n\n${nav}\n` : `${body}\n`;
+ await fs.writeFile(personaPath, finalContent, "utf-8");
+}
+
+export async function listLocalProfiles(dataDir: string): Promise {
+ const profiles: ProfileRecord[] = [];
+ const blocksDir = path.join(dataDir, "scene_blocks");
+
+ try {
+ const files = (await fs.readdir(blocksDir)).filter((file) => file.endsWith(".md")).sort();
+ for (const filename of files) {
+ const filePath = path.join(blocksDir, filename);
+ const content = await fs.readFile(filePath, "utf-8");
+ const { createdAtMs, updatedAtMs } = await statTimes(filePath);
+ profiles.push({
+ id: buildProfileStableId(PROFILE_SCOPE, "l2", filename),
+ type: "l2",
+ filename,
+ content,
+ contentMd5: md5(content),
+ version: 0,
+ createdAtMs,
+ updatedAtMs,
+ });
+ }
+ } catch {
+ // ignore missing scene_blocks directory
+ }
+
+ const personaPath = path.join(dataDir, "persona.md");
+ try {
+ const rawPersona = await fs.readFile(personaPath, "utf-8");
+ const body = stripSceneNavigation(rawPersona).trim();
+ if (body) {
+ const { createdAtMs, updatedAtMs } = await statTimes(personaPath);
+ profiles.push({
+ id: buildProfileStableId(PROFILE_SCOPE, "l3", "persona.md"),
+ type: "l3",
+ filename: "persona.md",
+ content: body,
+ contentMd5: md5(body),
+ version: 0,
+ createdAtMs,
+ updatedAtMs,
+ });
+ }
+ } catch {
+ // ignore missing persona file
+ }
+
+ return profiles;
+}
+
+export async function pullProfilesToLocal(
+ dataDir: string,
+ store: IMemoryStore,
+ logger: Logger,
+): Promise