diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1c6de3..601e0e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,71 @@
---
+## [1.0.0] - 2026-06-11
+
+> **正式版发布**:从 OpenClaw 专属插件演进为**面向所有 Agent 的通用记忆服务**。完整的 Gateway 独立服务 + v2 HTTP API + 官方 TypeScript / Python SDK,任何 Agent 框架均可接入完整的多层记忆与上下文压缩能力。
+
+### ⚠️ Breaking Changes
+
+- **客户端/服务端架构拆分**:记忆引擎从 OpenClaw 嵌入式插件拆分为独立 Gateway 服务进程,部署方式与接入方式发生变化。
+- **配置结构变更**:插件配置结构扁平化重构,原 `gateway` 字段迁移为 `server` 嵌套。
+- **插件入口模式变更**:支持 `local`(进程内本地运行,默认)和 `client`(连接外部 Memory Gateway)两种接入模式。
+
+### ✨ 新功能
+
+#### 独立 Gateway 服务(v2 API)
+
+记忆能力不再绑定 OpenClaw 宿主,以独立服务形式运行,通过 v2 HTTP API 为任意 Agent 提供记忆读写与管线管理:
+
+- **完整 v2 API**:14 条标准路由覆盖记忆 CRUD、原子更新、场景索引、管线状态查询等全部操作。
+- **管线状态查询(`/v2/pipeline/status`)**:实时获取 L1/L2/L3 各阶段运行状态与进度。
+- **实例生命周期管理(`/v2/instance/destroy`)**:支持外部系统主动创建/销毁记忆实例。
+- **可选 Bearer 鉴权 + CORS 白名单**:保护对外暴露的 API 安全。
+- **请求体校验**:强制 1 MiB 上限,防止异常请求。
+
+#### 官方 SDK
+
+- **TypeScript SDK 1.0.0**(`@tencentdb-agent-memory/memory-sdk-ts`):类型安全,覆盖全部 v2 API,npm 安装即用。
+- **Python SDK**(`tencentdb-agent-memory-sdk-python`):pip wheel 安装,同步/异步双模式,覆盖全部 v2 API。
+
+#### 通用 Agent 框架适配
+
+- **OpenClaw 插件适配**:支持 `local`(进程内本地运行,默认)和 `client`(连接外部 Memory Gateway)两种接入模式。`local` 模式保持原有体验。
+- **Hermes Agent 适配**:`memory_tencentdb_v2` adapter,支持 Hermes 框架多租户场景。
+- **通用接入**:任何能发 HTTP 请求的 Agent(LangChain、AutoGPT、自研框架等)均可通过 SDK 或裸 API 接入。
+
+#### 可观测性
+
+- **OpenTelemetry 全链路 Trace**:支持 OTLP 协议上报,可对接 Jaeger / Grafana Tempo 等后端。
+- **Langfuse 集成**:仅转发 LLM 相关 span,适合评估记忆提取质量。
+- **管线评测指标**:L1 提取率、去重决策分布、各阶段 token 消耗、recall 延迟等。
+
+#### 部署
+
+- **Standalone Docker 镜像**:单容器即可运行完整记忆服务,无需外部依赖。
+- **Hermes + Memory All-in-One 镜像**:Agent + 记忆服务一体化部署方案。
+- **`memory-tencentdb-ctl` CLI**:运维命令行工具,支持配置管理、存储引擎切换等操作。
+- **v2 安装器脚本**:一键配置插件适配层。
+
+### 📖 文档
+
+- SDK 接入指南(TypeScript / Python 示例)。
+- Gateway 独立部署文档。
+- OpenClaw / Hermes 插件安装说明。
+- Docker 部署与配置参考。
+
+---
+
+
+1.0.0 预发布版本
+
+## [1.0.0-beta.2] - 2026-06-05
+
+### ✨ 新功能
+
+- 统一插件入口,支持 local(进程内本地运行,默认)和 client(连接外部 Memory Gateway)两种接入模式。
+- 新增 Offload Server V2:L1/L1.5/L2 异步执行器、L3 压缩处理器、MMD 注入;Gateway 集成 /v2/offload/* 路由;
+
## [1.0.0-beta.1] - 2026-05-29
> **架构大版本**:从 OpenClaw 专属插件演进为**面向所有 Agent 的通用记忆服务**。核心能力通过独立 Gateway 服务 + 标准化 v2 HTTP API 对外暴露,配套官方 TypeScript / Python SDK,任何 Agent 框架均可接入完整的多层记忆管线。
@@ -56,6 +121,8 @@
- OpenClaw / Hermes 插件安装说明。
- Docker 部署与配置参考。
+
+
---
## [0.3.6] - 2026-05-27
diff --git a/README.md b/README.md
index 2bb052d..8409af9 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,53 @@ docker pull agentmemory/hermes-memory:1.0.0-beta
docker pull agentmemory/openclaw-memory:1.0.0-beta
```
+### 1.2 Zero-config to enable
+
+Defaults to a local `SQLite + sqlite-vec` backend.
+
+```jsonc
+// ~/.openclaw/openclaw.json
+{
+ "memory-tencentdb": {
+ "enabled": true
+ }
+}
+```
+
+Once enabled, TencentDB Agent Memory automatically handles conversation capture, memory extraction, scene aggregation, persona generation, and recall before the next turn.
+
+### 1.3 Enable short-term compression (optional, requires version ≥ 0.3.4)
+
+```jsonc
+{
+ "memory-tencentdb": {
+ "config": {
+ "offload": {
+ "enabled": true
+ }
+ }
+ }
+}
+```
+
+#### Step 1 — Register the slot in your plugin config
+
+Add the `slots` field so OpenClaw routes context-offload requests to this plugin:
+
+```jsonc
+{
+ "plugins": {
+ "slots": {
+ "contextEngine": "memory-tencentdb"
+ }
+ }
+}
+```
+
+#### Step 2 — Apply the runtime patch
+
+For the best results, run the patch script below. It hooks `after-tool-call` messages so they can be offloaded and recovered correctly:
+
#### Hermes + Memory (standalone)
```bash
diff --git a/README_CN.md b/README_CN.md
index 82fffed..392f870 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -1,3 +1,4 @@
+

@@ -126,6 +127,54 @@ docker pull agentmemory/hermes-memory:1.0.0-beta
docker pull agentmemory/openclaw-memory:1.0.0-beta
```
+### 1.2 零配置启用
+
+默认使用本地 `SQLite + sqlite-vec` 后端。
+
+```jsonc
+// ~/.openclaw/openclaw.json
+{
+ "memory-tencentdb": {
+ "enabled": true
+ }
+}
+```
+
+启用后,TencentDB Agent Memory 会自动完成对话录制、记忆提取、场景归纳、用户画像生成和下一轮对话前召回。
+
+
+### 1.3 启用短期记忆压缩(可选,要求版本 ≥ 0.3.4)
+
+```jsonc
+{
+ "memory-tencentdb": {
+ "config": {
+ "offload": {
+ "enabled": true
+ }
+ }
+ }
+}
+```
+
+#### 步骤 1 —— 在插件配置中注册 slot
+
+在 `slots` 字段中声明 `contextEngine`,让 OpenClaw 把上下文卸载请求路由到本插件:
+
+```jsonc
+{
+ "plugins": {
+ "slots": {
+ "contextEngine": "memory-tencentdb"
+ }
+ }
+}
+```
+
+#### 步骤 2 —— 执行 patch 脚本
+
+为保证最佳效果,请执行以下 patch 脚本。该脚本会注入 `after-tool-call` 消息钩子,让工具调用结果能被正确卸载与回溯:
+
#### Hermes + Memory(standalone)
```bash
diff --git a/hermes-plugin/memory/memory_tencentdb/__init__.py b/hermes-plugin/memory/memory_tencentdb/__init__.py
index 86350fa..caab56b 100644
--- a/hermes-plugin/memory/memory_tencentdb/__init__.py
+++ b/hermes-plugin/memory/memory_tencentdb/__init__.py
@@ -971,11 +971,19 @@ class MemoryTencentdbProvider(MemoryProvider):
except Exception as e:
logger.debug("memory-tencentdb session end failed: %s", e)
- # Note: do NOT shut down the supervisor/Gateway here — it may serve
- # other sessions. The Gateway manages its own lifecycle.
- # We *do* drop our reference to the supervisor so any in-flight
- # _try_recover_gateway() call sees self._supervisor is None and
- # bails out instead of resurrecting a released provider.
+ # Stop only the Gateway process this supervisor spawned. If the
+ # provider merely attached to an already-running external Gateway,
+ # GatewaySupervisor.shutdown() is a no-op because _process is None.
+ supervisor = self._supervisor
+ if supervisor is not None:
+ try:
+ supervisor.shutdown()
+ except Exception as e:
+ logger.debug("memory-tencentdb supervisor shutdown failed: %s", e)
+
+ # Drop our reference so any in-flight _try_recover_gateway() call sees
+ # self._supervisor is None and bails out instead of resurrecting a
+ # released provider.
self._client = None
self._gateway_available = False
self._initialized = False
diff --git a/hermes-plugin/memory/memory_tencentdb/supervisor.py b/hermes-plugin/memory/memory_tencentdb/supervisor.py
index 1b025cc..aea9e75 100644
--- a/hermes-plugin/memory/memory_tencentdb/supervisor.py
+++ b/hermes-plugin/memory/memory_tencentdb/supervisor.py
@@ -8,12 +8,22 @@ On shutdown(), sends a flush signal and waits for clean exit.
from __future__ import annotations
+import contextlib
import logging
import os
+import re
import shlex
+import signal
import subprocess
+import tempfile
+import threading
import time
-from typing import IO, Optional
+from typing import Dict, IO, Iterator, Optional
+
+try:
+ import fcntl
+except ImportError: # pragma: no cover - Windows fallback
+ fcntl = None # type: ignore[assignment]
from .client import MemoryTencentdbSdkClient
@@ -31,6 +41,39 @@ HEALTH_CHECK_RETRIES = 3 # retries for is_running check
# Log file rotation parameters
LOG_TAIL_BYTES_ON_CRASH = 2048 # bytes of stderr log to surface on startup crash
+# Startup single-flight state. The thread lock prevents multiple supervisor
+# instances in the same Python process from spawning the same Gateway
+# concurrently; the optional fcntl lock extends the guard across processes.
+_START_LOCKS: Dict[str, threading.Lock] = {}
+_START_LOCKS_GUARD = threading.Lock()
+
+
+def _lock_key(host: str, port: int) -> str:
+ safe_host = re.sub(r"[^A-Za-z0-9_.-]", "_", host)
+ return f"{safe_host}-{port}"
+
+
+@contextlib.contextmanager
+def _startup_singleflight(host: str, port: int) -> Iterator[None]:
+ key = _lock_key(host, port)
+ with _START_LOCKS_GUARD:
+ thread_lock = _START_LOCKS.setdefault(key, threading.Lock())
+
+ lock_path = os.path.join(tempfile.gettempdir(), f"memory-tencentdb-gateway-{key}.lock")
+ with thread_lock:
+ lock_file = None
+ try:
+ if fcntl is not None:
+ lock_file = open(lock_path, "a", encoding="utf-8")
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
+ yield
+ finally:
+ if lock_file is not None:
+ try:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
+ finally:
+ lock_file.close()
+
class GatewaySupervisor:
"""Manages the memory-tencentdb Gateway sidecar lifecycle."""
@@ -76,6 +119,7 @@ class GatewaySupervisor:
self._stdout_log: Optional[IO[bytes]] = None
self._stderr_log: Optional[IO[bytes]] = None
self._stderr_log_path: Optional[str] = None
+ self._shutdown_requested = False
# Resolve Gateway command
# Priority: explicit arg > MEMORY_TENCENTDB_GATEWAY_CMD env
@@ -146,77 +190,96 @@ class GatewaySupervisor:
logger.info("memory-tencentdb Gateway already running at %s", self._base_url)
return True
- # If we previously spawned a child and it has since died, drop the
- # stale Popen handle so the new spawn below isn't shadowed by a
- # zombie reference. Without this, a crashed-then-respawned Gateway
- # would keep ``self._process`` pointing at the dead PID forever and
- # ``is_process_alive()`` would mislead the watchdog.
- self._reap_dead_process()
-
- # Try to start the Gateway
- if not self._gateway_cmd:
- logger.warning(
- "memory-tencentdb Gateway is not running and no gateway command configured. "
- "Set MEMORY_TENCENTDB_GATEWAY_CMD environment variable or pass gateway_cmd to supervisor. "
- "memory-tencentdb memory will be unavailable."
- )
- return False
-
- logger.info("Starting memory-tencentdb Gateway: %s", self._gateway_cmd)
-
- try:
- env = os.environ.copy()
- env["MEMORY_TENCENTDB_GATEWAY_PORT"] = str(self._port)
- env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host
- # Note: we deliberately do NOT inject TDAI_GATEWAY_API_KEY into
- # the child's env from here. Whether the Gateway enforces auth is
- # the operator's call — they configure it on the Gateway side
- # (env, yaml, docker run, systemd unit) just like any other
- # Gateway setting. The supervisor's ``api_key`` is purely the
- # client-side Bearer token used for outbound requests.
-
- # Redirect child stdout/stderr to log files instead of PIPE.
- # Using PIPE without an active reader will deadlock the child once
- # the pipe buffer (~64 KB) fills up. A log directory next to the
- # data dir keeps logs inspectable on crash while eliminating the
- # blocking risk entirely.
- log_dir = self._resolve_log_dir()
- try:
- os.makedirs(log_dir, exist_ok=True)
- except OSError as e:
- logger.warning(
- "memory-tencentdb Gateway: failed to create log dir %s (%s); "
- "falling back to DEVNULL", log_dir, e,
+ with _startup_singleflight(self._host, self._port):
+ # Another supervisor may have started the Gateway while we were
+ # waiting on the single-flight lock. Re-probe before spawning.
+ if self.is_running():
+ logger.info(
+ "memory-tencentdb Gateway became available at %s while waiting for startup lock",
+ self._base_url,
)
- log_dir = None
+ return True
- if log_dir is not None:
- stdout_path = os.path.join(log_dir, "gateway.stdout.log")
- stderr_path = os.path.join(log_dir, "gateway.stderr.log")
- # Append mode: preserve previous runs for postmortem.
- self._stdout_log = open(stdout_path, "ab", buffering=0)
- self._stderr_log = open(stderr_path, "ab", buffering=0)
- self._stderr_log_path = stderr_path
- stdout_target: object = self._stdout_log
- stderr_target: object = self._stderr_log
- else:
- stdout_target = subprocess.DEVNULL
- stderr_target = subprocess.DEVNULL
+ # If we previously spawned a child and it has since died, drop the
+ # stale Popen handle so the new spawn below isn't shadowed by a
+ # zombie reference. Without this, a crashed-then-respawned Gateway
+ # would keep ``self._process`` pointing at the dead PID forever and
+ # ``is_process_alive()`` would mislead the watchdog.
+ self._reap_dead_process()
- self._process = subprocess.Popen(
- shlex.split(self._gateway_cmd),
- env=env,
- stdout=stdout_target,
- stderr=stderr_target,
- start_new_session=True, # Detach from parent process group
- )
- except Exception as e:
- logger.error("Failed to start memory-tencentdb Gateway: %s", e)
- self._close_log_handles()
- return False
+ # Try to start the Gateway
+ if not self._gateway_cmd:
+ logger.warning(
+ "memory-tencentdb Gateway is not running and no gateway command configured. "
+ "Set MEMORY_TENCENTDB_GATEWAY_CMD environment variable or pass gateway_cmd to supervisor. "
+ "memory-tencentdb memory will be unavailable."
+ )
+ return False
- # Wait for health check
- return self._wait_for_health()
+ logger.info("Starting memory-tencentdb Gateway: %s", self._gateway_cmd)
+ self._shutdown_requested = False
+
+ try:
+ env = os.environ.copy()
+ # The Python provider historically used MEMORY_TENCENTDB_* while
+ # src/gateway/config.ts reads TDAI_GATEWAY_*. Export both so a
+ # non-default supervisor port cannot accidentally spawn a child
+ # that still binds the default 8420.
+ env["MEMORY_TENCENTDB_GATEWAY_PORT"] = str(self._port)
+ env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host
+ env["TDAI_GATEWAY_PORT"] = str(self._port)
+ env["TDAI_GATEWAY_HOST"] = self._host
+ # Note: we deliberately do NOT inject TDAI_GATEWAY_API_KEY into
+ # the child's env from here. Whether the Gateway enforces auth is
+ # the operator's call — they configure it on the Gateway side
+ # (env, yaml, docker run, systemd unit) just like any other
+ # Gateway setting. The supervisor's ``api_key`` is purely the
+ # client-side Bearer token used for outbound requests.
+
+ # Redirect child stdout/stderr to log files instead of PIPE.
+ # Using PIPE without an active reader will deadlock the child once
+ # the pipe buffer (~64 KB) fills up. A log directory next to the
+ # data dir keeps logs inspectable on crash while eliminating the
+ # blocking risk entirely.
+ log_dir = self._resolve_log_dir()
+ try:
+ os.makedirs(log_dir, exist_ok=True)
+ except OSError as e:
+ logger.warning(
+ "memory-tencentdb Gateway: failed to create log dir %s (%s); "
+ "falling back to DEVNULL", log_dir, e,
+ )
+ log_dir = None
+
+ if log_dir is not None:
+ stdout_path = os.path.join(log_dir, "gateway.stdout.log")
+ stderr_path = os.path.join(log_dir, "gateway.stderr.log")
+ # Append mode: preserve previous runs for postmortem.
+ self._stdout_log = open(stdout_path, "ab", buffering=0)
+ self._stderr_log = open(stderr_path, "ab", buffering=0)
+ self._stderr_log_path = stderr_path
+ stdout_target: object = self._stdout_log
+ stderr_target: object = self._stderr_log
+ else:
+ stdout_target = subprocess.DEVNULL
+ stderr_target = subprocess.DEVNULL
+
+ self._process = subprocess.Popen(
+ shlex.split(self._gateway_cmd),
+ env=env,
+ stdout=stdout_target,
+ stderr=stderr_target,
+ start_new_session=True,
+ )
+ except Exception as e:
+ logger.error("Failed to start memory-tencentdb Gateway: %s", e)
+ self._close_log_handles()
+ return False
+
+ # Keep the lock until the spawned process becomes healthy; otherwise
+ # a second waiter can observe the port as down during cold start and
+ # launch a duplicate process that immediately hits EADDRINUSE.
+ return self._wait_for_health()
def _resolve_log_dir(self) -> str:
"""Pick a directory to store Gateway stdout/stderr logs.
@@ -269,6 +332,10 @@ class GatewaySupervisor:
"""Wait for the Gateway to become healthy."""
start = time.monotonic()
while time.monotonic() - start < HEALTH_CHECK_MAX_WAIT:
+ if self._shutdown_requested:
+ logger.info("memory-tencentdb Gateway startup wait cancelled by shutdown")
+ return False
+
# Check if process died
if self._process and self._process.poll() is not None:
rc = self._process.returncode
@@ -303,20 +370,32 @@ class GatewaySupervisor:
def shutdown(self) -> None:
"""Shut down the managed Gateway process (if we started it)."""
+ self._shutdown_requested = True
if self._process is None:
return
logger.info("Shutting down memory-tencentdb Gateway...")
try:
- # Send SIGTERM for graceful shutdown
- self._process.terminate()
+ proc = self._process
+ if proc.poll() is None:
+ # The Gateway is started with start_new_session=True. Terminate
+ # the whole process group so `pnpm -> tsx -> node server.ts`
+ # does not leave the real listener orphaned after the top-level
+ # wrapper exits.
+ try:
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+ except Exception:
+ proc.terminate()
try:
- self._process.wait(timeout=10)
+ proc.wait(timeout=10)
except subprocess.TimeoutExpired:
logger.warning("memory-tencentdb Gateway did not exit in 10s, sending SIGKILL")
- self._process.kill()
- self._process.wait(timeout=5)
+ try:
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+ except Exception:
+ proc.kill()
+ proc.wait(timeout=5)
except Exception as e:
logger.warning("Error shutting down memory-tencentdb Gateway: %s", e)
finally:
diff --git a/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py b/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py
index 81934af..adbd7c3 100644
--- a/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py
+++ b/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py
@@ -231,6 +231,69 @@ def test_reap_dead_process_keeps_alive_handle():
assert sup._process is alive
+def test_ensure_running_singleflight_prevents_duplicate_spawn(monkeypatch):
+ """Concurrent supervisors for one port must spawn at most one Gateway."""
+ port = 28421
+ running = False
+ spawn_envs = []
+ spawn_lock = threading.Lock()
+
+ class FakePopen:
+ pid = 12345
+ returncode = None
+
+ def poll(self):
+ return None
+
+ def fake_is_running(self):
+ return running
+
+ def fake_wait_for_health(self):
+ nonlocal running
+ time.sleep(0.1)
+ running = True
+ return True
+
+ def fake_popen(_argv, *, env, stdout, stderr, start_new_session):
+ with spawn_lock:
+ spawn_envs.append(dict(env))
+ assert start_new_session is True
+ return FakePopen()
+
+ monkeypatch.setattr(supervisor_module.GatewaySupervisor, "is_running", fake_is_running)
+ monkeypatch.setattr(supervisor_module.GatewaySupervisor, "_wait_for_health", fake_wait_for_health)
+ monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen)
+
+ supervisors = [
+ supervisor_module.GatewaySupervisor(
+ host="127.0.0.1",
+ port=port,
+ gateway_cmd="fake gateway",
+ )
+ for _ in range(6)
+ ]
+ barrier = threading.Barrier(len(supervisors))
+ results = []
+
+ def worker(sup):
+ barrier.wait()
+ results.append(sup.ensure_running())
+
+ threads = [threading.Thread(target=worker, args=(sup,)) for sup in supervisors]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout=3)
+
+ assert results == [True] * len(supervisors)
+ assert len(spawn_envs) == 1
+ env = spawn_envs[0]
+ assert env["MEMORY_TENCENTDB_GATEWAY_PORT"] == str(port)
+ assert env["MEMORY_TENCENTDB_GATEWAY_HOST"] == "127.0.0.1"
+ assert env["TDAI_GATEWAY_PORT"] == str(port)
+ assert env["TDAI_GATEWAY_HOST"] == "127.0.0.1"
+
+
# ---------------------------------------------------------------------------
# Watchdog: detects death, resurrects, and reattaches
# ---------------------------------------------------------------------------
diff --git a/index.ts b/index.ts
index 18df0c7..f1efcd0 100644
--- a/index.ts
+++ b/index.ts
@@ -22,6 +22,7 @@ import path from "node:path";
import fs from "node:fs";
import { createRequire } from "node:module";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
+import registerClientOpenClawPlugin from "./openclaw-plugin/index.js";
import { parseConfig } from "./src/config.js";
import type { MemoryTdaiConfig } from "./src/config.js";
import { executeReadCos, READ_COS_TOOL_SCHEMA, READ_COS_TOOL_NAME, READ_COS_TOOL_DESCRIPTION } from "./src/core/tools/read-cos.js";
@@ -29,6 +30,7 @@ import { LocalStorageBackend } from "./src/core/storage/local-backend.js";
import { StaticCredentialProvider } from "./src/core/storage/credential-provider.js";
import type { IStorageBackend } from "./src/core/storage/types.js";
import { registerOffload } from "./src/offload/index.js";
+import { registerOffloadClient } from "./src/offload-client/index.js";
import {
setPreferredEmbeddedAgentRuntime,
prewarmEmbeddedAgent,
@@ -52,6 +54,22 @@ import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.js";
const TAG = "[memory-tdai]";
+type OpenClawAdapterMode = "local" | "client";
+
+function resolveOpenClawAdapterMode(rawPluginConfig: Record
| undefined): OpenClawAdapterMode {
+ const rawMode = typeof rawPluginConfig?.mode === "string"
+ ? rawPluginConfig.mode.trim().toLowerCase()
+ : "";
+
+ if (rawMode === "client" || rawMode === "gateway" || rawMode === "remote") {
+ return "client";
+ }
+
+ // Default to local/function mode: OpenClaw calls this plugin in-process and
+ // memory processing uses the host LLM runner (no standalone Gateway needed).
+ return "local";
+}
+
/**
* Epoch ms when the plugin was registered (cold-start timestamp).
* Used as a fallback cursor in performAutoCapture when no checkpoint
@@ -158,6 +176,13 @@ export default function register(api: OpenClawPluginApi) {
}
// ─── Full / discovery mode: complete runtime initialization ───
+ const rawPluginConfigForMode = api.pluginConfig as Record | undefined;
+ const adapterMode = resolveOpenClawAdapterMode(rawPluginConfigForMode);
+ if (adapterMode === "client") {
+ api.logger.info?.(`${TAG} mode=client: delegating to memory-tencentdb-client adapter`);
+ return registerClientOpenClawPlugin(api as any);
+ }
+
pluginStartTimestamp = Date.now();
setPreferredEmbeddedAgentRuntime(api.runtime.agent);
// Reset reporter singleton so config changes take effect on hot-reload.
@@ -189,7 +214,7 @@ export default function register(api: OpenClawPluginApi) {
`pipeline=(everyN=${cfg.pipeline.everyNConversations}, warmup=${cfg.pipeline.enableWarmup}, l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, l2DelayAfterL1=${cfg.pipeline.l2DelayAfterL1Seconds}s, l2Min=${cfg.pipeline.l2MinIntervalSeconds}s, l2Max=${cfg.pipeline.l2MaxIntervalSeconds}s, activeWindow=${cfg.pipeline.sessionActiveWindowHours}h), ` +
`persona(triggerEvery=${cfg.persona.triggerEveryN}, backupCount=${cfg.persona.backupCount}, sceneBackupCount=${cfg.persona.sceneBackupCount}), ` +
`memoryCleanup(enabled=${cfg.memoryCleanup.enabled}, retentionDays=${cfg.memoryCleanup.retentionDays ?? "(disabled)"}, cleanTime=${cfg.memoryCleanup.cleanTime}), ` +
- `offload(enabled=${cfg.offload.enabled}, backendUrl=${cfg.offload.backendUrl ?? "(none)"}, mildRatio=${cfg.offload.mildOffloadRatio}, aggressiveRatio=${cfg.offload.aggressiveCompressRatio}, retentionDays=${cfg.offload.offloadRetentionDays})`,
+ `offload(enabled=${cfg.offload.enabled}, mode=${cfg.offload.mode}, ${cfg.offload.mode === "client" ? `serverUrl=${cfg.offload.serverUrl ?? "(none)"}` : `backendUrl=${cfg.offload.backendUrl ?? "(none)"}, mildRatio=${cfg.offload.mildOffloadRatio}, aggressiveRatio=${cfg.offload.aggressiveCompressRatio}`}, retentionDays=${cfg.offload.offloadRetentionDays})`,
);
} catch (err) {
api.logger.error(`${TAG} Config parsing failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -232,7 +257,7 @@ export default function register(api: OpenClawPluginApi) {
try {
ensurePluginHookPolicy({
rootConfig: api.config,
- runtimeConfig: api.runtime?.config,
+ runtimeConfig: (api.runtime as any)?.config,
logger: api.logger,
});
} catch (err) {
@@ -556,7 +581,10 @@ export default function register(api: OpenClawPluginApi) {
storagePromise = (async (): Promise => {
if (cosSecretId && cosSecretKey && cosBucket) {
try {
- const { CosStorageBackend } = await import("./src/integrations/cos/cos-backend.js");
+ const cosBackendModulePath = ["./src/integrations/cos", "cos-backend.js"].join("/");
+ const { CosStorageBackend } = await import(cosBackendModulePath) as {
+ CosStorageBackend: new (options: any) => IStorageBackend;
+ };
const backend = new CosStorageBackend({
credentialProvider: new StaticCredentialProvider({
secretId: cosSecretId,
@@ -954,8 +982,24 @@ export default function register(api: OpenClawPluginApi) {
if (cfg.offload.enabled) {
api.logger.debug?.(`${TAG} Offload enabled, registering offload module...`);
try {
- registerOffload(api, cfg.offload);
- api.logger.debug?.(`${TAG} Offload module registered successfully`);
+ if (cfg.offload.mode === "client") {
+ // New: stateless client mode — all compression delegated to server
+ registerOffloadClient(api as any, {
+ enabled: cfg.offload.enabled,
+ serverUrl: cfg.offload.serverUrl ?? "",
+ apiKey: cfg.offload.apiKey ?? "",
+ serviceId: cfg.offload.serviceId ?? "",
+ agentName: cfg.offload.agentName ?? "default",
+ compactionRatio: cfg.offload.compactionRatio ?? 0.5,
+ ingestTimeoutMs: cfg.offload.ingestTimeoutMs ?? 5000,
+ compactionTimeoutMs: cfg.offload.compactionTimeoutMs ?? 30000,
+ });
+ api.logger.debug?.(`${TAG} Offload client module registered (mode=client)`);
+ } else {
+ // Legacy: full local L3 compression
+ registerOffload(api, cfg.offload);
+ api.logger.debug?.(`${TAG} Offload module registered successfully (mode=${cfg.offload.mode})`);
+ }
} catch (err) {
api.logger.error(`${TAG} Offload module registration failed: ${err instanceof Error ? err.message : String(err)}`);
}
diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts
index 325893b..cbe7e97 100644
--- a/openclaw-plugin/index.ts
+++ b/openclaw-plugin/index.ts
@@ -2,7 +2,8 @@
* memory-tencentdb-client — OpenClaw 记忆插件(客户端接入版)
*
* 通过 @tencentdb-agent-memory/memory-sdk-ts 连接远端 memory server,
- * 提供四层记忆的自动捕获、召回和工具调用能力。
+ * 提供四层记忆的自动捕获、召回和工具调用能力,
+ * 并集成 offload 上下文压缩/摘要服务。
*
* 本插件不包含任何数据处理逻辑(无 VDB/Embedding/Pipeline),
* 所有操作委托给远端 server。
@@ -14,6 +15,8 @@ import { performCapture } from "./src/hooks/capture.js";
import { handleMemorySearch } from "./src/tools/memory-search.js";
import { handleConversationSearch } from "./src/tools/conversation-search.js";
import { handleReadCos } from "./src/tools/read-cos.js";
+// @ts-ignore — rootDir mismatch in openclaw-plugin tsconfig; resolved at bundle time by tsdown
+import { registerOffloadClient } from "../src/offload-client/index.js";
const TAG = "[memory-client]";
@@ -22,6 +25,7 @@ interface ServerConfig {
url?: string;
apiKey?: string;
instanceId?: string;
+ rejectUnauthorized?: boolean;
}
interface RecallConfig {
maxResults?: number;
@@ -31,10 +35,18 @@ interface RecallConfig {
interface CaptureConfig {
enabled?: boolean;
}
+interface OffloadConfig {
+ enabled?: boolean;
+ serverUrl?: string;
+ compactionRatio?: number;
+ ingestTimeoutMs?: number;
+ compactionTimeoutMs?: number;
+}
interface PluginConfig {
server?: ServerConfig;
recall?: RecallConfig;
capture?: CaptureConfig;
+ offload?: OffloadConfig;
}
// Matches OpenClaw plugin register() signature: export default function register(api)
@@ -44,6 +56,7 @@ export default function register(api: any) {
const server = cfg.server ?? {};
const recall = cfg.recall ?? {};
const capture = cfg.capture ?? {};
+ const offload = cfg.offload ?? {};
const serverUrl = server.url || "http://127.0.0.1:8420";
const apiKey = server.apiKey || "sk-xxxx";
@@ -52,6 +65,13 @@ export default function register(api: any) {
const includePersona = recall.includePersona !== false;
const includeSceneNav = recall.includeSceneNav !== false;
const captureEnabled = capture.enabled !== false;
+ const rejectUnauthorized = server.rejectUnauthorized !== false;
+
+ // Offload config (auto-inherits from server config)
+ const offloadEnabled = offload.enabled ?? false;
+ const offloadServerUrl = offload.serverUrl || serverUrl;
+ const offloadCompactionRatio = offload.compactionRatio ?? 0.5;
+ const offloadCompactionTimeoutMs = offload.compactionTimeoutMs ?? 30000;
// ── Initialize SDK ──
// NOTE: pass config (not a raw Transport) so client.readFile can lazily
@@ -60,12 +80,15 @@ export default function register(api: any) {
endpoint: serverUrl,
apiKey,
serviceId: instanceId,
+ rejectUnauthorized,
});
+
+
api.logger.info?.(
`${TAG} Initialized: server=${serverUrl}, instance=${instanceId}, ` +
`recall(persona=${includePersona},sceneNav=${includeSceneNav},max=${recallMaxResults}), ` +
- `capture=${captureEnabled}`,
+ `capture=${captureEnabled}, offload=${offloadEnabled}, rejectUnauthorized=${rejectUnauthorized}`,
);
// ── Register Tools (same pattern as extensions/memory-tencentdb/index.ts) ──
@@ -249,4 +272,17 @@ export default function register(api: any) {
} else {
api.logger.info?.(`${TAG} capture disabled by config`);
}
+
+ // ── Offload: unified registration via registerOffloadClient ────────────────────
+ if (offloadEnabled) {
+ registerOffloadClient(api, {
+ enabled: true,
+ serverUrl: offloadServerUrl,
+ apiKey,
+ serviceId: instanceId,
+ agentName: "default",
+ compactionRatio: offloadCompactionRatio,
+ compactionTimeoutMs: offloadCompactionTimeoutMs,
+ });
+ }
}
diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json
index ecc2f3e..9717707 100644
--- a/openclaw-plugin/openclaw.plugin.json
+++ b/openclaw-plugin/openclaw.plugin.json
@@ -35,6 +35,11 @@
"type": "string",
"default": "default",
"description": "Memory instance id(HTTP header x-tdai-service-id)"
+ },
+ "rejectUnauthorized": {
+ "type": "boolean",
+ "default": true,
+ "description": "是否校验 HTTPS 证书。默认 true;仅自签证书测试环境需要显式设为 false"
}
}
},
@@ -69,6 +74,36 @@
"description": "是否启用自动对话捕获 (L0)"
}
}
+ },
+ "offload": {
+ "type": "object",
+ "description": "Offload 压缩/摘要服务配置(可选,不配则复用 server 配置)",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "是否启用 Offload 上下文压缩"
+ },
+ "serverUrl": {
+ "type": "string",
+ "description": "Offload server URL。不填则自动从 server.url 推导(替换端口为 9100)"
+ },
+ "compactionRatio": {
+ "type": "number",
+ "default": 0.5,
+ "description": "上下文占比超过此阈值时触发压缩(0-1)"
+ },
+ "ingestTimeoutMs": {
+ "type": "number",
+ "default": 5000,
+ "description": "Ingest 请求超时(毫秒)"
+ },
+ "compactionTimeoutMs": {
+ "type": "number",
+ "default": 30000,
+ "description": "Compaction 请求超时(毫秒)"
+ }
+ }
}
}
}
diff --git a/openclaw.plugin.json b/openclaw.plugin.json
index 085effe..5fde0e5 100644
--- a/openclaw.plugin.json
+++ b/openclaw.plugin.json
@@ -7,17 +7,32 @@
"onStartup": true
},
"contracts": {
- "tools": ["tdai_memory_search", "tdai_conversation_search"]
+ "tools": ["tdai_memory_search", "tdai_conversation_search", "tdai_read_cos"]
},
"configSchema": {
"type": "object",
"additionalProperties": true,
"properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["local", "function", "client", "gateway", "remote"],
+ "default": "local",
+ "description": "OpenClaw 接入模式:local/function(默认,本地进程内运行,使用 OpenClaw function/host LLM runner)或 client/gateway/remote(轻量客户端模式,连接外部 Memory Gateway)"
+ },
"storeBackend": {
"type": "string",
"enum": ["sqlite", "tcvdb"],
"default": "sqlite",
- "description": "存储后端:sqlite(本地 SQLite + sqlite-vec)或 tcvdb(腾讯云向量数据库)"
+ "description": "存储后端:sqlite(本地 SQLite + sqlite-vec)或 tcvdb(腾讯云向量数据库)。仅 local/function 模式生效"
+ },
+ "server": {
+ "type": "object",
+ "description": "client/gateway/remote 模式下的外部 Memory Gateway 连接配置",
+ "properties": {
+ "url": { "type": "string", "default": "http://127.0.0.1:8420", "description": "Memory Gateway URL" },
+ "apiKey": { "type": "string", "default": "local", "description": "API Key for Gateway authentication" },
+ "instanceId": { "type": "string", "default": "default", "description": "Memory instance id(HTTP header x-tdai-service-id)" }
+ }
},
"capture": {
"type": "object",
@@ -140,23 +155,27 @@
},
"offload": {
"type": "object",
- "description": "Context Offload 设置 — 多层上下文压缩系统(独立开关,默认关闭)",
+ "description": "Context Offload 设置 — 多层上下文压缩系统(独立开关,默认关闭)。支持 local(本地 LLM 处理)和 client(复用 server.url 远端服务)两种模式",
"properties": {
"enabled": { "type": "boolean", "default": false, "description": "是否启用 Context Offload(默认关闭,不影响 Memory 功能)" },
- "model": { "type": "string", "description": "Offload 使用的 LLM 模型(格式: provider/model),未填写时使用 openclaw 默认模型" },
- "temperature": { "type": "number", "default": 0.2, "description": "LLM 温度参数" },
- "forceTriggerThreshold": { "type": "number", "default": 4, "description": "累积多少个 tool pair 后强制触发 L1" },
- "dataDir": { "type": "string", "description": "自定义数据目录(绝对路径),默认 ~/.openclaw/context-offload" },
+ "mode": { "type": "string", "enum": ["local", "backend", "client", "collect"], "description": "Offload 运行模式:local(本地 LLM 处理)、backend(走 backendUrl 远端 v1 服务)、client(复用 server.url 远端 offload v2 服务)、collect(仅数据采集,不压缩)。未设置时自动推导:有 backendUrl → backend,有 server.url → client,否则 → local" },
+ "model": { "type": "string", "description": "【local 模式】Offload 使用的 LLM 模型(格式: provider/model),未填写时使用 openclaw 默认模型" },
+ "temperature": { "type": "number", "default": 0.2, "description": "【local 模式】LLM 温度参数" },
+ "forceTriggerThreshold": { "type": "number", "default": 4, "description": "【local 模式】累积多少个 tool pair 后强制触发 L1" },
+ "dataDir": { "type": "string", "description": "【local 模式】自定义数据目录(绝对路径),默认 ~/.openclaw/context-offload" },
"defaultContextWindow": { "type": "number", "default": 200000, "description": "默认上下文窗口大小" },
- "maxPairsPerBatch": { "type": "number", "default": 20, "description": "L1 每批最大 tool pair 数" },
- "l2NullThreshold": { "type": "number", "default": 4, "description": "offload.jsonl 中 node_id=null 数量达到此阈值时触发 L2" },
- "l2TimeoutSeconds": { "type": "number", "default": 300, "description": "距上次 L2 超过此秒数时触发 L2" },
+ "maxPairsPerBatch": { "type": "number", "default": 20, "description": "【local 模式】L1 每批最大 tool pair 数" },
+ "l2NullThreshold": { "type": "number", "default": 4, "description": "【local 模式】offload.jsonl 中 node_id=null 数量达到此阈值时触发 L2" },
+ "l2TimeoutSeconds": { "type": "number", "default": 300, "description": "【local 模式】距上次 L2 超过此秒数时触发 L2" },
"mildOffloadRatio": { "type": "number", "default": 0.5, "description": "温和压缩触发比例(占 context window)" },
"aggressiveCompressRatio": { "type": "number", "default": 0.85, "description": "激进压缩触发比例" },
- "mmdMaxTokenRatio": { "type": "number", "default": 0.2, "description": "MMD 注入 token 预算比例" },
- "backendUrl": { "type": "string", "description": "后端服务 URL(如 https://offload-api.example.com),配置后 L1/L1.5/L2/L4 走后端" },
- "backendApiKey": { "type": "string", "description": "后端 API 认证 token" },
- "backendTimeoutMs": { "type": "number", "default": 10000, "description": "后端调用超时(毫秒)" }
+ "mmdMaxTokenRatio": { "type": "number", "default": 0.2, "description": "【local 模式】MMD 注入 token 预算比例" },
+ "compactionRatio": { "type": "number", "default": 0.5, "description": "【client 模式】上下文占比超过此阈值时触发压缩(0-1),复用 server.url 作为后端地址" },
+ "ingestTimeoutMs": { "type": "number", "default": 5000, "description": "【client 模式】Ingest 请求超时(毫秒)" },
+ "compactionTimeoutMs": { "type": "number", "default": 30000, "description": "【client 模式】Compaction 请求超时(毫秒)" },
+ "backendUrl": { "type": "string", "description": "【已废弃,请使用 mode=client】后端服务 URL,配置后 L1/L1.5/L2/L4 走后端" },
+ "backendApiKey": { "type": "string", "description": "【已废弃,请使用 mode=client】后端 API 认证 token" },
+ "backendTimeoutMs": { "type": "number", "default": 10000, "description": "【已废弃,请使用 mode=client】后端调用超时(毫秒)" }
}
}
}
diff --git a/package.json b/package.json
index 9dab67c..0918afe 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@tencentdb-agent-memory/memory-tencentdb",
- "version": "1.0.0-beta.1",
+ "version": "1.0.0",
"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": "./dist/index.mjs",
@@ -45,11 +45,14 @@
"scripts/read-local-memory/dist/",
"scripts/memory-tencentdb-ctl.sh",
"scripts/install_hermes_memory_tencentdb.sh",
+ "scripts/install-hermes-plugin-v2.sh",
+ "scripts/install-openclaw-plugin-v2.sh",
"scripts/README.memory-tencentdb-ctl.md",
"src",
"scripts/openclaw-after-tool-call-messages.patch.sh",
"scripts/setup-offload.sh",
"hermes-plugin/",
+ "openclaw-plugin/",
"openclaw.plugin.json",
"README.md",
"CHANGELOG.md",
@@ -90,6 +93,7 @@
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.41.1",
+ "@tencentdb-agent-memory/memory-sdk-ts": "^1.0.0",
"@tencentdb-agent-memory/tcvdb-text": "^0.1.1",
"ai": "^6.0.164",
"crc-32": "^1.2.2",
@@ -110,7 +114,7 @@
"cos-nodejs-sdk-v5": "^2.15.4",
"ioredis": "^5.10.1",
"kafkajs": "^2.2.4",
- "opik": "^1.0.0"
+ "opik": "^1.11.14"
},
"peerDependencies": {
"node-llama-cpp": "^3.16.2",
diff --git a/scripts/install-openclaw-plugin-v2.sh b/scripts/install-openclaw-plugin-v2.sh
index 42c7c46..cf2b034 100755
--- a/scripts/install-openclaw-plugin-v2.sh
+++ b/scripts/install-openclaw-plugin-v2.sh
@@ -121,9 +121,7 @@ if (fs.existsSync(file)) {
}
config.plugins ??= {};
-config.plugins.slots ??= {};
config.plugins.entries ??= {};
-config.plugins.slots.memory = pluginId;
const entry = config.plugins.entries[pluginId] ?? {};
entry.enabled = true;
@@ -173,7 +171,6 @@ cat >&2 </dev/null; then
+ kill "${pid}"
+ echo "[OK] Stopped (pid=${pid})"
+ else
+ echo "[WARN] Process ${pid} not running"
+ fi
+ rm -f "${PID_FILE}"
+ else
+ echo "[WARN] No PID file found"
+ fi
+}
+
+do_status() {
+ if [ -f "${PID_FILE}" ] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null; then
+ echo "[OK] Running (pid=$(cat "${PID_FILE}"), port=${PORT})"
+ else
+ echo "[OFF] Not running"
+ fi
+}
+
+do_start() {
+ # Kill existing if running
+ if [ -f "${PID_FILE}" ] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null; then
+ kill "$(cat "${PID_FILE}")" 2>/dev/null
+ sleep 1
+ fi
+
+ mkdir -p "${DATA_DIR}" "${LOG_DIR}"
+
+ local LOG_FILE="${LOG_DIR}/offload-server-$(date +%Y%m%d).log"
+
+ echo "═══════════════════════════════════════════════════════════"
+ echo " Offload Server — Local Mode (zero dependencies)"
+ echo "═══════════════════════════════════════════════════════════"
+ echo " Port: ${PORT}"
+ echo " Data: ${DATA_DIR}"
+ echo " Log: ${LOG_FILE}"
+ echo " API Key: ${API_KEY:0:8}..."
+ echo " LLM URL: ${LLM_BASE_URL}"
+ echo " LLM Model: ${LLM_MODEL}"
+ echo " Opik: ${OPIK_ENABLED} (${OPIK_URL_OVERRIDE:-disabled})"
+ echo " L2 threshold: ${L2_NULL_THRESHOLD}"
+ echo " Aggressive ratio: ${AGGRESSIVE_COMPRESS_RATIO}"
+ echo " Emergency ratio: ${EMERGENCY_COMPRESS_RATIO}"
+ echo "═══════════════════════════════════════════════════════════"
+
+ cd "${PROJECT_ROOT}"
+
+ # 动态生成临时配置文件,将 standalone yaml 与 offload 覆盖项合并
+ local OVERRIDE_CONFIG="/tmp/openclaw/tdai-gateway.override.yaml"
+ mkdir -p /tmp/openclaw
+ cat "${PROJECT_ROOT}/tdai-gateway.standalone.yaml" > "${OVERRIDE_CONFIG}"
+ cat >> "${OVERRIDE_CONFIG}" << YAML_EOF
+
+# ── 动态注入的 offload 覆盖配置(由 start-offload-local.sh 生成)──
+offload:
+ l2NullThreshold: ${L2_NULL_THRESHOLD}
+ aggressiveCompressRatio: ${AGGRESSIVE_COMPRESS_RATIO}
+ emergencyCompressRatio: ${EMERGENCY_COMPRESS_RATIO}
+YAML_EOF
+
+ TDAI_GATEWAY_CONFIG="${OVERRIDE_CONFIG}" \
+ TDAI_GATEWAY_PORT="${PORT}" \
+ TDAI_GATEWAY_HOST="0.0.0.0" \
+ TDAI_DATA_DIR="${DATA_DIR}" \
+ STATE_BACKEND="local" \
+ TDAI_V2_API_KEY="${API_KEY}" \
+ TDAI_LLM_API_KEY="${LLM_API_KEY}" \
+ TDAI_LLM_BASE_URL="${LLM_BASE_URL}" \
+ TDAI_LLM_MODEL="${LLM_MODEL}" \
+ OPIK_ENABLED="${OPIK_ENABLED}" \
+ OPIK_URL_OVERRIDE="${OPIK_URL_OVERRIDE}" \
+ OPIK_API_KEY="${OPIK_API_KEY}" \
+ OPIK_WORKSPACE="${OPIK_WORKSPACE}" \
+ OPIK_PROJECT_NAME="${OPIK_PROJECT_NAME}" \
+ MEMORY_MAX_BODY_BYTES="10485760" \
+ nohup npx tsx src/gateway/server.ts >> "${LOG_FILE}" 2>&1 &
+
+ echo $! > "${PID_FILE}"
+ sleep 2
+
+ if kill -0 "$(cat "${PID_FILE}")" 2>/dev/null; then
+ echo "[OK] Started (pid=$(cat "${PID_FILE}"), log=${LOG_FILE})"
+ echo ""
+ echo " 验证: curl -s http://127.0.0.1:${PORT}/health"
+ echo " 停止: bash scripts/start-offload-local.sh stop"
+ echo " 日志: tail -f ${LOG_FILE}"
+ else
+ echo "[FAIL] Process exited, check log: ${LOG_FILE}"
+ tail -10 "${LOG_FILE}"
+ exit 1
+ fi
+}
+
+case "${CMD}" in
+ start) do_start ;;
+ stop) do_stop ;;
+ restart) do_stop; sleep 1; do_start ;;
+ status) do_status ;;
+ *)
+ echo "用法: bash scripts/start-offload-local.sh [start|stop|restart|status]"
+ exit 1
+ ;;
+esac
diff --git a/sdk/python/README.md b/sdk/python/README.md
index 5f1d21b..4f54149 100644
--- a/sdk/python/README.md
+++ b/sdk/python/README.md
@@ -63,6 +63,23 @@ print(core["content"])
# L3: write core memory
client.write_core("# User Profile\n...")
+# Offload v2: send tool pairs for server-side L1 async processing (fire-and-forget)
+client.offload_ingest(
+ session_id="agent_sess_123",
+ tool_pairs=[
+ {"tool_name": "search", "tool_call_id": "call_1", "params": {"q": "..."}, "result": "...", "timestamp": "..."},
+ ],
+)
+
+# Offload v2: server-side context compaction (sync wait for result)
+compacted = client.offload_compact(
+ session_id="agent_sess_123",
+ messages=[...],
+ ratio=0.7,
+ context_window=128000,
+)
+print(compacted["messages"], compacted["report"])
+
# Read memory pipeline artifacts (e.g. persona.md, scene_blocks/*.md)
raw = client.read_file("scene_blocks/工作.md")
```
@@ -103,6 +120,9 @@ asyncio.run(main())
| L2 | `rm_scenario()` | `POST /v2/scenario/rm` |
| L3 | `read_core()` | `POST /v2/core/read` |
| L3 | `write_core()` | `POST /v2/core/write` |
+| Offload | `offload_ingest()` | `POST /v2/offload/ingest` |
+| Offload | `offload_compact()` | `POST /v2/offload/compact` |
+| Offload | `offload_query_mmd()` | `POST /v2/offload/query-mmd` |
## Error Handling
diff --git a/sdk/python/README_CN.md b/sdk/python/README_CN.md
index 7ccedbc..7dd0011 100644
--- a/sdk/python/README_CN.md
+++ b/sdk/python/README_CN.md
@@ -63,6 +63,23 @@ print(core["content"])
# L3: 写入核心记忆
client.write_core("# User Profile\n...")
+# Offload v2: 上报工具调用对,触发服务端 L1 异步处理(可 fire-and-forget)
+client.offload_ingest(
+ session_id="agent_sess_123",
+ tool_pairs=[
+ {"tool_name": "search", "tool_call_id": "call_1", "params": {"q": "..."}, "result": "...", "timestamp": "..."},
+ ],
+)
+
+# Offload v2: 服务端上下文压缩(同步等待结果)
+compacted = client.offload_compact(
+ session_id="agent_sess_123",
+ messages=[...],
+ ratio=0.7,
+ context_window=128000,
+)
+print(compacted["messages"], compacted["report"])
+
# 读取记忆 pipeline 产物(如 persona.md、scene_blocks/*.md)
raw = client.read_file("scene_blocks/工作.md")
```
@@ -103,6 +120,9 @@ asyncio.run(main())
| L2 | `rm_scenario()` | `POST /v2/scenario/rm` |
| L3 | `read_core()` | `POST /v2/core/read` |
| L3 | `write_core()` | `POST /v2/core/write` |
+| Offload | `offload_ingest()` | `POST /v2/offload/ingest` |
+| Offload | `offload_compact()` | `POST /v2/offload/compact` |
+| Offload | `offload_query_mmd()` | `POST /v2/offload/query-mmd` |
## 错误处理
diff --git a/sdk/python/tencentdb_agent_memory/client.py b/sdk/python/tencentdb_agent_memory/client.py
index 2884520..c4347d2 100644
--- a/sdk/python/tencentdb_agent_memory/client.py
+++ b/sdk/python/tencentdb_agent_memory/client.py
@@ -255,6 +255,116 @@ class MemoryClient:
"""``POST /core/write``"""
return self._stub.post(f"{_V2}/core/write", {"content": content})
+ # -- Offload (Ingest + Compact + Query-MMD) ----------------------------
+
+ def offload_ingest(
+ self,
+ session_id: str,
+ tool_pairs: List[Dict[str, Any]],
+ *,
+ prompt: Optional[str] = None,
+ recent_messages: Optional[List[Dict[str, Any]]] = None,
+ ) -> Dict[str, Any]:
+ """``POST /v2/offload/ingest`` — 上报工具调用对,触发 L1 异步处理。
+
+ 可 fire-and-forget 使用(忽略返回值)。
+
+ Parameters
+ ----------
+ session_id : str
+ 会话 ID。
+ tool_pairs : list[dict]
+ 工具调用对列表,每个元素包含 ``tool_name``、``tool_call_id``、
+ ``params``、``result``、``timestamp``,可选 ``duration_ms``。
+ prompt : str, optional
+ 最新 user message,用于 L1.5 任务判断。
+ recent_messages : list[dict], optional
+ 近期历史消息列表(``role`` + ``content``),辅助 L1 提取上下文。
+ """
+ return self._stub.post(
+ f"{_V2}/offload/ingest",
+ _strip_none({
+ "session_id": session_id,
+ "tool_pairs": tool_pairs,
+ "prompt": prompt,
+ "recent_messages": recent_messages,
+ }),
+ )
+
+ def offload_compact(
+ self,
+ session_id: str,
+ messages: List[Dict[str, Any]],
+ ratio: float,
+ total_tokens: int,
+ *,
+ context_window: Optional[int] = None,
+ message_tokens: Optional[List[int]] = None,
+ ) -> Dict[str, Any]:
+ """``POST /v2/offload/compact`` — 对 messages 执行服务端上下文压缩。
+
+ Parameters
+ ----------
+ session_id : str
+ 会话 ID。
+ messages : list[dict]
+ 当前完整对话消息列表。
+ ratio : float
+ 当前 token 使用比例(已用 / context_window),触发压缩策略判断。
+ total_tokens : int
+ 当前完整上下文的总 token 数(包含 system prompt、tool schemas 等不在
+ messages 中的隐性开销)。服务端用于计算 fixed overhead 和校准 token 估算。
+ context_window : int, optional
+ 模型 context window 大小(token 数)。
+ message_tokens : list[int], optional
+ 每条消息对应的 token 数,提供时可跳过服务端估算,提升性能。
+
+ Returns
+ -------
+ dict
+ ``messages``(压缩后消息列表)+ ``report``(压缩报告)。
+ """
+ return self._stub.post(
+ f"{_V2}/offload/compact",
+ _strip_none({
+ "session_id": session_id,
+ "messages": messages,
+ "ratio": ratio,
+ "total_tokens": total_tokens,
+ "context_window": context_window,
+ "message_tokens": message_tokens,
+ }),
+ )
+
+ def offload_query_mmd(
+ self,
+ session_id: str,
+ *,
+ limit: Optional[int] = None,
+ ) -> Dict[str, Any]:
+ """``POST /v2/offload/query-mmd`` — 查询 session 的任务流程图(MMD 文件)。
+
+ Parameters
+ ----------
+ session_id : str
+ 会话 ID。
+ limit : int, optional
+ 最多返回几个 MMD 文件。``limit=1`` 时走快速路径只返回当前活跃 MMD。
+
+ Returns
+ -------
+ dict
+ ``mmds``(列表,每项含 ``filename``、``content``、``version``)+
+ ``current_mmd``(当前活跃 MMD 文件名,无则为 ``None``)。
+ """
+ return self._stub.post(
+ f"{_V2}/offload/query-mmd",
+ _strip_none({
+ "session_id": session_id,
+ "limit": limit,
+ }),
+ )
+
# -- File read (memory pipeline artifacts) -----------------------------
def read_file(self, path: str) -> str:
@@ -432,6 +542,65 @@ class AsyncMemoryClient:
async def write_core(self, content: str) -> Dict[str, Any]:
return await self._stub.post(f"{_V2}/core/write", {"content": content})
+ # -- Offload (Ingest + Compact + Query-MMD) ----------------------------
+
+ async def offload_ingest(
+ self,
+ session_id: str,
+ tool_pairs: List[Dict[str, Any]],
+ *,
+ prompt: Optional[str] = None,
+ recent_messages: Optional[List[Dict[str, Any]]] = None,
+ ) -> Dict[str, Any]:
+ """``POST /v2/offload/ingest``(异步)"""
+ return await self._stub.post(
+ f"{_V2}/offload/ingest",
+ _strip_none({
+ "session_id": session_id,
+ "tool_pairs": tool_pairs,
+ "prompt": prompt,
+ "recent_messages": recent_messages,
+ }),
+ )
+
+ async def offload_compact(
+ self,
+ session_id: str,
+ messages: List[Dict[str, Any]],
+ ratio: float,
+ total_tokens: int,
+ *,
+ context_window: Optional[int] = None,
+ message_tokens: Optional[List[int]] = None,
+ ) -> Dict[str, Any]:
+ """``POST /v2/offload/compact``(异步)"""
+ return await self._stub.post(
+ f"{_V2}/offload/compact",
+ _strip_none({
+ "session_id": session_id,
+ "messages": messages,
+ "ratio": ratio,
+ "total_tokens": total_tokens,
+ "context_window": context_window,
+ "message_tokens": message_tokens,
+ }),
+ )
+
+ async def offload_query_mmd(
+ self,
+ session_id: str,
+ *,
+ limit: Optional[int] = None,
+ ) -> Dict[str, Any]:
+ """``POST /v2/offload/query-mmd``(异步)"""
+ return await self._stub.post(
+ f"{_V2}/offload/query-mmd",
+ _strip_none({
+ "session_id": session_id,
+ "limit": limit,
+ }),
+ )
+
# -- lifecycle ---------------------------------------------------------
# -- File read (memory pipeline artifacts) -----------------------------
diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md
index 5215c7d..73ce541 100644
--- a/sdk/typescript/README.md
+++ b/sdk/typescript/README.md
@@ -58,6 +58,28 @@ console.log(core.content);
// L3: write core memory
await client.writeCore({ content: "# User Profile\n..." });
+// Offload v2: send tool pairs for server-side L1 async processing (fire-and-forget)
+await client.offloadIngest({
+ session_id: "agent_sess_123",
+ tool_pairs: [
+ { tool_name: "search", tool_call_id: "call_1", params: { q: "..." }, result: "...", timestamp: "..." },
+ ],
+});
+
+// Offload v2: server-side context compaction (sync wait for result)
+const compacted = await client.offloadCompact({
+ session_id: "agent_sess_123",
+ messages: [...],
+ ratio: 0.7,
+ context_window: 128000,
+ total_tokens: 160000,
+});
+console.log(compacted.messages, compacted.report);
+
+// Offload v2: query MMD task graphs
+const mmd = await client.offloadQueryMmd({ session_id: "agent_sess_123", limit: 1 });
+console.log(mmd.current_mmd, mmd.mmds);
+
// Read memory pipeline artifacts (e.g. persona.md, scene_blocks/*.md)
const raw = await client.readFile("scene_blocks/工作.md");
```
@@ -80,6 +102,9 @@ const raw = await client.readFile("scene_blocks/工作.md");
| L2 | `rmScenario()` | `POST /v2/scenario/rm` |
| L3 | `readCore()` | `POST /v2/core/read` |
| L3 | `writeCore()` | `POST /v2/core/write` |
+| Offload | `offloadIngest()` | `POST /v2/offload/ingest` |
+| Offload | `offloadCompact()` | `POST /v2/offload/compact` |
+| Offload | `offloadQueryMmd()` | `POST /v2/offload/query-mmd` |
## Error Handling
diff --git a/sdk/typescript/README_CN.md b/sdk/typescript/README_CN.md
index 3957465..78a19a1 100644
--- a/sdk/typescript/README_CN.md
+++ b/sdk/typescript/README_CN.md
@@ -58,6 +58,28 @@ console.log(core.content);
// L3: 写入核心记忆
await client.writeCore({ content: "# User Profile\n..." });
+// Offload v2: 上报工具调用对,触发服务端 L1 异步处理(fire-and-forget)
+await client.offloadIngest({
+ session_id: "agent_sess_123",
+ tool_pairs: [
+ { tool_name: "search", tool_call_id: "call_1", params: { q: "..." }, result: "...", timestamp: "..." },
+ ],
+});
+
+// Offload v2: 服务端上下文压缩(同步等待结果)
+const compacted = await client.offloadCompact({
+ session_id: "agent_sess_123",
+ messages: [...],
+ ratio: 0.7,
+ context_window: 128000,
+ total_tokens: 160000,
+});
+console.log(compacted.messages, compacted.report);
+
+// Offload v2: 查询任务流程图(MMD)
+const mmd = await client.offloadQueryMmd({ session_id: "agent_sess_123", limit: 1 });
+console.log(mmd.current_mmd, mmd.mmds);
+
// 读取记忆 pipeline 产物(如 persona.md、scene_blocks/*.md)
const raw = await client.readFile("scene_blocks/工作.md");
```
@@ -80,6 +102,9 @@ const raw = await client.readFile("scene_blocks/工作.md");
| L2 | `rmScenario()` | `POST /v2/scenario/rm` |
| L3 | `readCore()` | `POST /v2/core/read` |
| L3 | `writeCore()` | `POST /v2/core/write` |
+| Offload | `offloadIngest()` | `POST /v2/offload/ingest` |
+| Offload | `offloadCompact()` | `POST /v2/offload/compact` |
+| Offload | `offloadQueryMmd()` | `POST /v2/offload/query-mmd` |
## 错误处理
diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts
index 05df0d4..16ed571 100644
--- a/sdk/typescript/src/client.ts
+++ b/sdk/typescript/src/client.ts
@@ -25,6 +25,12 @@ import type {
CoreFile,
CoreWriteData,
CoreWriteRequest,
+ OffloadCompactData,
+ OffloadCompactRequest,
+ OffloadIngestData,
+ OffloadIngestRequest,
+ OffloadQueryMmdData,
+ OffloadQueryMmdRequest,
ScenarioFile,
ScenarioListData,
ScenarioListRequest,
@@ -148,6 +154,32 @@ export class MemoryClient {
return this.http.post(`${V2}/core/write`, params as unknown as Record);
}
+ // -- Offload (Compaction + Ingest) ------------------------------------
+
+ /**
+ * Send tool pairs (+ optional context) to offload server for L1 processing.
+ * Fire-and-forget usage: caller can `.catch()` without blocking.
+ */
+ offloadIngest(params: OffloadIngestRequest): Promise {
+ return this.http.post(`${V2}/offload/ingest`, stripUndefined(params as unknown as Record));
+ }
+
+ /**
+ * Request server-side context compaction.
+ * Returns compacted messages + report, or throws on failure.
+ */
+ offloadCompact(params: OffloadCompactRequest): Promise {
+ return this.http.post(`${V2}/offload/compact`, stripUndefined(params as unknown as Record));
+ }
+
+ /**
+ * Query MMD task graphs for a session.
+ * limit=1 returns only the current active MMD (fast path).
+ */
+ offloadQueryMmd(params: OffloadQueryMmdRequest): Promise {
+ return this.http.post(`${V2}/offload/query-mmd`, stripUndefined(params as unknown as Record));
+ }
+
// -- File read (memory pipeline artifacts) ----------------------------
/**
diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts
index c73075e..d1b319a 100644
--- a/sdk/typescript/src/index.ts
+++ b/sdk/typescript/src/index.ts
@@ -6,4 +6,28 @@ export { MemoryClient, type MemoryClientConfig, type Transport } from "./client.
export { TDAMError } from "./errors.js";
export { HttpTransport, type HttpTransportOptions } from "./http.js";
export { MemoryFileReader, StsCredentialManager, StsCredential, createMemoryFileReader, cosV5Sign, type MemoryFileReaderConfig } from "./cos.js";
-export type * from "./types.js";
+export type {
+ // L0
+ ConversationItem, ConversationAddRequest, ConversationAddData,
+ ConversationQueryRequest, ConversationQueryData,
+ ConversationSearchRequest, ConversationSearchData,
+ ConversationDeleteRequest, ConversationDeleteData,
+ // L1
+ AtomicDetail, AtomicUpdateRequest, AtomicUpdateData,
+ AtomicQueryRequest, AtomicQueryData,
+ AtomicSearchRequest, AtomicSearchData,
+ AtomicDeleteRequest, AtomicDeleteData,
+ // L2
+ ScenarioEntry, ScenarioListRequest, ScenarioListData,
+ ScenarioReadRequest, ScenarioFile,
+ ScenarioWriteRequest, ScenarioWriteData,
+ ScenarioRmRequest,
+ // L3
+ CoreFile, CoreWriteRequest, CoreWriteData,
+ // Offload
+ OffloadToolPair, OffloadRecentMessage,
+ OffloadIngestRequest, OffloadIngestData,
+ OffloadCompactRequest, OffloadCompactData, OffloadCompactReport,
+ // Common
+ ApiResponseEnvelope,
+} from "./types.js";
diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts
index 8b49f72..be0f435 100644
--- a/sdk/typescript/src/types.ts
+++ b/sdk/typescript/src/types.ts
@@ -188,3 +188,75 @@ export interface CoreWriteRequest {
export interface CoreWriteData {
updated_at: string;
}
+
+// ---------------------------------------------------------------------------
+// Offload (Compaction + Ingest)
+// ---------------------------------------------------------------------------
+
+export interface OffloadToolPair {
+ tool_name: string;
+ tool_call_id: string;
+ params: unknown;
+ result: unknown;
+ error?: string;
+ timestamp: string;
+ duration_ms?: number;
+}
+
+export interface OffloadRecentMessage {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export interface OffloadIngestRequest {
+ session_id: string;
+ tool_pairs: OffloadToolPair[];
+ prompt?: string;
+ recent_messages?: OffloadRecentMessage[];
+}
+
+export interface OffloadIngestData {
+ accepted: boolean;
+}
+
+export interface OffloadCompactRequest {
+ session_id: string;
+ messages: unknown[];
+ ratio: number;
+ context_window: number;
+ total_tokens: number;
+ message_tokens?: number[];
+}
+
+export interface OffloadCompactReport {
+ resolvedLevel: string;
+ originalCount: number;
+ compactedCount: number;
+ fastPathReplaced: number;
+ fastPathDeleted: number;
+ mildReplacements: number;
+ aggressiveDeleted: number;
+ emergencyDeleted: number;
+ mmdInjected: number;
+}
+
+export interface OffloadCompactData {
+ messages: unknown[];
+ report: OffloadCompactReport;
+}
+
+export interface OffloadQueryMmdRequest {
+ session_id: string;
+ limit?: number;
+}
+
+export interface OffloadMmdFile {
+ filename: string;
+ content: string;
+ version: number;
+}
+
+export interface OffloadQueryMmdData {
+ mmds: OffloadMmdFile[];
+ current_mmd: string | null;
+}
diff --git a/src/config.ts b/src/config.ts
index 2487387..36f724e 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -213,11 +213,12 @@ export interface OffloadConfig {
* LLM execution mode for L1/L1.5/L2 tasks.
* - "local": call LLM directly via AI SDK (uses offload.model or main agent model)
* - "backend": route through remote backend service (requires backendUrl)
+ * - "client": stateless client mode — all compression delegated to offload server v2
* - "collect": data collection only — runs L1/L1.5/L2 asynchronously but disables
* L3 compression and does NOT occupy the contextEngine slot (uses legacy compaction)
* Default: "local" (auto-detects based on backendUrl presence for backward compat)
*/
- mode: "local" | "backend" | "collect";
+ mode: "local" | "backend" | "client" | "collect";
/** LLM model for offload tasks, format: "provider/model-id". Falls back to agents.defaults.model when omitted. */
model?: string;
/** LLM temperature (default: 0.2) */
@@ -265,6 +266,22 @@ export interface OffloadConfig {
* primary non-loopback IPv4 address.
*/
userId?: string;
+
+ // ── Client mode fields (used when mode === "client") ──────────────
+ /** Offload server v2 base URL (e.g. "http://localhost:9100"). */
+ serverUrl?: string;
+ /** Bearer token for offload server v2 Authorization header. */
+ apiKey?: string;
+ /** X-TDAI-Service-Id header value. */
+ serviceId?: string;
+ /** Agent name used in storage path (default: "default"). */
+ agentName?: string;
+ /** Client-side threshold: skip compaction when ratio < this value (default: 0.5). */
+ compactionRatio?: number;
+ /** Ingest request timeout in ms (default: 5000). */
+ ingestTimeoutMs?: number;
+ /** Compaction request timeout in ms (default: 30000). */
+ compactionTimeoutMs?: number;
}
/** Fully resolved plugin configuration (v3). */
@@ -439,10 +456,20 @@ export function parseConfig(raw: Record | undefined): MemoryTda
// --- Offload ---
const offloadGroup = obj(c, "offload");
- const offloadMode: "local" | "backend" | "collect" = (() => {
+ // Auto-derive offload serverUrl/apiKey/serviceId from top-level server config
+ // when offload client fields are not explicitly set.
+ const serverGroup = obj(c, "server");
+ const serverUrl = optStr(serverGroup, "url");
+ const serverApiKey = optStr(serverGroup, "apiKey");
+ const serverInstanceId = optStr(serverGroup, "instanceId");
+
+ const offloadMode: "local" | "backend" | "client" | "collect" = (() => {
const raw = optStr(offloadGroup, "mode");
- if (raw === "local" || raw === "backend" || raw === "collect") return raw;
- return optStr(offloadGroup, "backendUrl") ? "backend" : "local";
+ if (raw === "local" || raw === "backend" || raw === "client" || raw === "collect") return raw;
+ // Auto-derive: if backendUrl is set → "backend"; if server.url is set → "client"; else "local"
+ if (optStr(offloadGroup, "backendUrl")) return "backend";
+ if (serverUrl) return "client";
+ return "local";
})();
const offload: OffloadConfig = {
@@ -465,6 +492,13 @@ export function parseConfig(raw: Record | undefined): MemoryTda
offloadRetentionDays: normalizeOffloadRetentionDays(num(offloadGroup, "offloadRetentionDays") ?? 0),
logMaxSizeMb: num(offloadGroup, "logMaxSizeMb") ?? 50,
userId: optStr(offloadGroup, "userId"),
+ // Client mode fields — fall back to top-level server config when not explicitly set
+ serverUrl: optStr(offloadGroup, "serverUrl") ?? serverUrl,
+ apiKey: optStr(offloadGroup, "apiKey") ?? serverApiKey,
+ serviceId: optStr(offloadGroup, "serviceId") ?? serverInstanceId,
+ compactionRatio: num(offloadGroup, "compactionRatio") ?? 0.5,
+ ingestTimeoutMs: num(offloadGroup, "ingestTimeoutMs") ?? 5000,
+ compactionTimeoutMs: num(offloadGroup, "compactionTimeoutMs") ?? 30000,
};
return {
diff --git a/src/core/conversation/l0-recorder.ts b/src/core/conversation/l0-recorder.ts
index f050bb6..7d281b4 100644
--- a/src/core/conversation/l0-recorder.ts
+++ b/src/core/conversation/l0-recorder.ts
@@ -18,6 +18,7 @@ import crypto from "node:crypto";
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
+import type { Logger } from "../types.js";
// ============================
// Types
@@ -63,13 +64,6 @@ export interface L0ConversationRecord {
messages: ConversationMessage[];
}
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
const TAG = "[memory-tdai][l0]";
// ============================
diff --git a/src/core/hooks/auto-capture.ts b/src/core/hooks/auto-capture.ts
index 0249acc..c47060d 100644
--- a/src/core/hooks/auto-capture.ts
+++ b/src/core/hooks/auto-capture.ts
@@ -20,14 +20,9 @@ import type { IMemoryStore, L0Record } from "../store/types.js";
import type { EmbeddingService } from "../store/embedding.js";
import type { StorageAdapter } from "../storage/adapter.js";
-const TAG = "[memory-tdai] [capture]";
+import type { Logger } from "../types.js";
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
+const TAG = "[memory-tdai] [capture]";
export interface AutoCaptureResult {
/** Whether the scheduler was notified (conversation count incremented) */
diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts
index 61dac99..36987c2 100644
--- a/src/core/hooks/auto-recall.ts
+++ b/src/core/hooks/auto-recall.ts
@@ -21,6 +21,7 @@ import type { EmbeddingService, EmbeddingCallOptions } from "../store/embedding.
import { sanitizeText } from "../../utils/sanitize.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
+import type { Logger } from "../types.js";
const TAG = "[memory-tdai] [recall]";
const RECALL_TRUNCATION_SUFFIX = "…(已截断;可用 tdai_memory_search 或 tdai_conversation_search 查看详情)";
@@ -46,13 +47,6 @@ const MEMORY_TOOLS_GUIDE = `
- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户,不要继续搜索。
`
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
/** A single recalled L1 memory with its search score and type. */
export interface RecalledMemory {
content: string;
@@ -890,11 +884,15 @@ function normalizeBudgetLimit(value: number | undefined): number | undefined {
}
function truncateRecallLine(line: string, maxChars: number): string {
- if (line.length <= maxChars) return line;
+ // Count and slice by code point, not UTF-16 code unit, so a cut never lands
+ // between the halves of a surrogate pair (which would corrupt a non-BMP
+ // character to U+FFFD when the line is UTF-8 encoded for the request).
+ const cps = Array.from(line);
+ if (cps.length <= maxChars) return line;
if (maxChars <= RECALL_TRUNCATION_SUFFIX.length) {
- return line.slice(0, maxChars);
+ return cps.slice(0, maxChars).join("");
}
- return `${line.slice(0, maxChars - RECALL_TRUNCATION_SUFFIX.length).trimEnd()}${RECALL_TRUNCATION_SUFFIX}`;
+ return `${cps.slice(0, maxChars - RECALL_TRUNCATION_SUFFIX.length).join("").trimEnd()}${RECALL_TRUNCATION_SUFFIX}`;
}
/**
diff --git a/src/core/persona/persona-generator.ts b/src/core/persona/persona-generator.ts
index ecde98a..4e75d35 100644
--- a/src/core/persona/persona-generator.ts
+++ b/src/core/persona/persona-generator.ts
@@ -12,19 +12,12 @@ import { BackupManager } from "../../utils/backup.js";
import { escapeXmlTags } from "../../utils/sanitize.js";
import { report } from "../report/reporter.js";
import { reportL3LatencyMetrics } from "../report/metric-tracking-l3-latency.js";
-import type { LLMRunner } from "../types.js";
+import type { LLMRunner, Logger } from "../types.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
const TAG = "[memory-tdai] [persona]";
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
export class PersonaGenerator {
private dataDir: string;
private runner: LLMRunner;
diff --git a/src/core/persona/persona-trigger.ts b/src/core/persona/persona-trigger.ts
index 741e68c..09b86fe 100644
--- a/src/core/persona/persona-trigger.ts
+++ b/src/core/persona/persona-trigger.ts
@@ -8,14 +8,11 @@ import { stripSceneNavigation } from "../scene/scene-navigation.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
+import type { Logger } from "../types.js";
+
const TAG = "[memory-tdai] [trigger]";
-interface TriggerLogger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
+type TriggerLogger = Logger;
export interface TriggerResult {
should: boolean;
diff --git a/src/core/profile/profile-sync.ts b/src/core/profile/profile-sync.ts
index 5b85879..afb1235 100644
--- a/src/core/profile/profile-sync.ts
+++ b/src/core/profile/profile-sync.ts
@@ -6,6 +6,7 @@ import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
+import type { Logger } from "../types.js";
const PROFILE_SCOPE = "global";
@@ -15,13 +16,6 @@ function isRenameRaceError(err: unknown): boolean {
return code === "ENOTEMPTY" || code === "EEXIST";
}
-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;
diff --git a/src/core/record/l1-dedup.ts b/src/core/record/l1-dedup.ts
index b27b19b..d6c6497 100644
--- a/src/core/record/l1-dedup.ts
+++ b/src/core/record/l1-dedup.ts
@@ -19,14 +19,7 @@ import { sanitizeJsonForParse } from "../../utils/sanitize.js";
import type { IMemoryStore } from "../store/types.js";
import { buildFtsQuery } from "../store/sqlite.js";
import type { EmbeddingService } from "../store/embedding.js";
-import type { LLMRunner } from "../types.js";
-
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
+import type { LLMRunner, Logger } from "../types.js";
const TAG = "[memory-tdai][l1-dedup]";
diff --git a/src/core/record/l1-extractor.ts b/src/core/record/l1-extractor.ts
index ef41ed5..8b20e95 100644
--- a/src/core/record/l1-extractor.ts
+++ b/src/core/record/l1-extractor.ts
@@ -24,16 +24,9 @@ import type { EmbeddingService } from "../store/embedding.js";
import { report } from "../report/reporter.js";
import { metricProducer } from "../report/kafka-metric-producer.js";
import { reportL1LatencyMetrics } from "../report/metric-tracking-l1-latency.js";
-import type { LLMRunner } from "../types.js";
+import type { LLMRunner, Logger } from "../types.js";
import type { StorageAdapter } from "../storage/adapter.js";
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
const TAG = "[memory-tdai][l1-extractor]";
// ============================
diff --git a/src/core/record/l1-reader.ts b/src/core/record/l1-reader.ts
index 401874c..9675a6e 100644
--- a/src/core/record/l1-reader.ts
+++ b/src/core/record/l1-reader.ts
@@ -19,13 +19,7 @@ import { StoragePaths } from "../storage/types.js";
// Re-export types that readers need
export type { MemoryRecord, MemoryType, EpisodicMetadata } from "./l1-writer.js";
export type { L1QueryFilter } from "../store/types.js";
-
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
+import type { Logger } from "../types.js";
const TAG = "[memory-tdai] [l1-reader]";
diff --git a/src/core/record/l1-writer.ts b/src/core/record/l1-writer.ts
index 8756bc4..d4ea992 100644
--- a/src/core/record/l1-writer.ts
+++ b/src/core/record/l1-writer.ts
@@ -21,6 +21,7 @@ import type { IMemoryStore } from "../store/types.js";
import type { EmbeddingService } from "../store/embedding.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
+import type { Logger } from "../types.js";
// ============================
// Types
@@ -110,13 +111,6 @@ export interface DedupDecision {
merged_timestamps?: string[];
}
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
const TAG = "[memory-tdai][l1-writer]";
// ============================
diff --git a/src/core/report/metric-tracking-runner.ts b/src/core/report/metric-tracking-runner.ts
index c728236..12a2b04 100644
--- a/src/core/report/metric-tracking-runner.ts
+++ b/src/core/report/metric-tracking-runner.ts
@@ -198,14 +198,19 @@ export class MetricTrackingRunner implements LLMRunner {
}
async run(params: LLMRunParams): Promise {
+ // 0. 注入 instanceId(如果调用方没传,从 getInstanceId 回调获取)
+ const enrichedParams = params.instanceId
+ ? params
+ : { ...params, instanceId: this.getInstanceId() };
+
// 1. 先执行原方法,拿到结果(异常直接 re-throw)
- const text = await this.inner.run(params);
+ const text = await this.inner.run(enrichedParams);
// 2. 原方法成功后,try-catch 做上报(静默失败)
try {
const metricName = taskIdToMetricName(params.taskId);
if (metricName) {
- const instanceId = params.instanceId ?? this.getInstanceId();
+ const instanceId = enrichedParams.instanceId ?? this.getInstanceId();
if (instanceId) {
// 优先从 inner runner 的 lastUsage side-channel 读取精确 token 数
const innerWithUsage = this.inner as LLMRunnerWithUsage;
diff --git a/src/core/report/traced-task-executor.ts b/src/core/report/traced-task-executor.ts
index 20f4d08..6fbfdfe 100644
--- a/src/core/report/traced-task-executor.ts
+++ b/src/core/report/traced-task-executor.ts
@@ -62,6 +62,24 @@ export class TracedTaskExecutor implements TaskExecutor {
return this.executeL1(task);
}
+ async executeOffloadL1?(task: TaskPayload, signal?: AbortSignal): Promise {
+ if (this.inner.executeOffloadL1) {
+ return this.executeWithTrace("offload-l1", task, () => this.inner.executeOffloadL1!(task, signal));
+ }
+ }
+
+ async executeOffloadL15?(task: TaskPayload, signal?: AbortSignal): Promise {
+ if (this.inner.executeOffloadL15) {
+ return this.executeWithTrace("offload-l15", task, () => this.inner.executeOffloadL15!(task, signal));
+ }
+ }
+
+ async executeOffloadL2?(task: TaskPayload, signal?: AbortSignal): Promise {
+ if (this.inner.executeOffloadL2) {
+ return this.executeWithTrace("offload-l2", task, () => this.inner.executeOffloadL2!(task, signal));
+ }
+ }
+
/**
* 核心方法:在 Trace Context 中执行任务。
*
diff --git a/src/core/scene/scene-extractor.ts b/src/core/scene/scene-extractor.ts
index 6c533b1..6a215b9 100644
--- a/src/core/scene/scene-extractor.ts
+++ b/src/core/scene/scene-extractor.ts
@@ -27,18 +27,13 @@ import { normalizeSceneFilenames } from "./filename-normalizer.js";
import { buildSceneExtractionPrompt } from "../prompts/scene-extraction.js";
import { report } from "../report/reporter.js";
import { reportL2LatencyMetrics } from "../report/metric-tracking-l2-latency.js";
-import type { LLMRunner } from "../types.js";
+import type { LLMRunner, Logger } from "../types.js";
import type { StorageAdapter } from "../storage/adapter.js";
import { StoragePaths } from "../storage/types.js";
const TAG = "[memory-tdai] [extractor]";
-interface ExtractorLogger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
+type ExtractorLogger = Logger;
export interface ExtractionResult {
memoriesProcessed: number;
@@ -470,24 +465,24 @@ export class SceneExtractor {
success: true,
error: null,
});
- }
- // ── 评测指标:L2 延迟 + 场景变化 ──
- try {
- const postSceneCount = (await readSceneIndex(this.dataDir, this.storage)).length;
- reportL2LatencyMetrics({
- instanceId: this.instanceId ?? "",
- extractionLatencyMs: totalMs,
- llmDurationMs: llmDurationMs > 0 ? llmDurationMs : null,
- sceneCountBefore: preExtractIndex.size,
- sceneCountAfter: postSceneCount,
- scenesCreated,
- scenesUpdated,
- scenesDeleted,
- hasError: false,
- });
- } catch {
- // 静默忽略
+ // ── 评测指标:L2 延迟 + 场景变化 ──
+ try {
+ const postSceneCount = (await readSceneIndex(this.dataDir, this.storage)).length;
+ reportL2LatencyMetrics({
+ instanceId: this.instanceId ?? "",
+ extractionLatencyMs: totalMs,
+ llmDurationMs: llmDurationMs > 0 ? llmDurationMs : null,
+ sceneCountBefore: preExtractIndex.size,
+ sceneCountAfter: postSceneCount,
+ scenesCreated,
+ scenesUpdated,
+ scenesDeleted,
+ hasError: false,
+ });
+ } catch {
+ // 静默忽略
+ }
}
// Detect empty extraction: pre and post scene_index both empty means LLM didn't write anything
diff --git a/src/core/state/types.ts b/src/core/state/types.ts
index feae69a..ae8f41f 100644
--- a/src/core/state/types.ts
+++ b/src/core/state/types.ts
@@ -56,7 +56,7 @@ export interface TimerEntry {
export interface TaskPayload {
id: string;
- type: "L1" | "L2" | "L3" | "flush";
+ type: "L1" | "L2" | "L3" | "flush" | "offload-l1" | "offload-l15" | "offload-l2";
instanceId: string;
sessionId: string;
priority: number; // 0=high, 1=normal, 2=low
diff --git a/src/core/store/bm25-client.ts b/src/core/store/bm25-client.ts
index 031c2d2..df39ad8 100644
--- a/src/core/store/bm25-client.ts
+++ b/src/core/store/bm25-client.ts
@@ -13,6 +13,8 @@
* check health to dynamically downgrade to pure semantic search.
*/
+import type { Logger } from "../types.js";
+
// ============================
// Types
// ============================
@@ -20,13 +22,6 @@
/** Sparse vector: array of [token_hash, weight] pairs. */
export type SparseVector = Array<[number, number]>;
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
export interface BM25ClientConfig {
/** Sidecar service URL (default: "http://127.0.0.1:8084") */
serviceUrl: string;
diff --git a/src/core/store/bm25-local.ts b/src/core/store/bm25-local.ts
index 548db4f..b24acaa 100644
--- a/src/core/store/bm25-local.ts
+++ b/src/core/store/bm25-local.ts
@@ -11,16 +11,10 @@
import { BM25Encoder } from "@tencentdb-agent-memory/tcvdb-text";
import type { SparseVector } from "@tencentdb-agent-memory/tcvdb-text";
+import type { Logger } from "../types.js";
export type { SparseVector };
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
export interface BM25LocalConfig {
/** Whether BM25 sparse encoding is enabled (default: true) */
enabled: boolean;
diff --git a/src/core/store/embedding.ts b/src/core/store/embedding.ts
index 635cb90..f07c344 100644
--- a/src/core/store/embedding.ts
+++ b/src/core/store/embedding.ts
@@ -13,6 +13,8 @@
* - Throws on failure; callers decide fallback strategy.
*/
+import type { Logger } from "../types.js";
+
// ============================
// Types
// ============================
@@ -104,17 +106,6 @@ export class EmbeddingNotReadyError extends Error {
}
}
-// ============================
-// Logger interface
-// ============================
-
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
const TAG = "[memory-tdai][embedding]";
// ============================
diff --git a/src/core/store/sqlite.ts b/src/core/store/sqlite.ts
index 75f11c6..0a9233a 100644
--- a/src/core/store/sqlite.ts
+++ b/src/core/store/sqlite.ts
@@ -21,6 +21,8 @@
*/
import { createRequire } from "node:module";
+import { mkdirSync, existsSync } from "node:fs";
+import path from "node:path";
import type { DatabaseSync, StatementSync, SQLInputValue } from "node:sqlite";
import type { MemoryRecord } from "../record/l1-writer.js";
import type { EmbeddingProviderInfo } from "./embedding.js";
@@ -40,6 +42,9 @@ import type {
L1PaginatedFilter,
L1PaginatedResult,
} from "./types.js";
+import type { Logger } from "../types.js";
+
+export type { L1RecordRow } from "./types.js";
// ============================
// Types
@@ -86,13 +91,6 @@ export interface L0RecordRow {
timestamp: number;
}
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
const TAG = "[memory-tdai][sqlite]";
/** Persisted metadata about the embedding provider used to generate stored vectors. */
@@ -394,6 +392,12 @@ export class VectorStore implements IMemoryStore {
this.dimensions = dimensions;
this.logger = logger;
+ // Ensure parent directory exists (for non-default instance paths)
+ const dbDir = path.dirname(dbPath);
+ if (!existsSync(dbDir)) {
+ mkdirSync(dbDir, { recursive: true });
+ }
+
// Open database with extension support enabled
const { DatabaseSync: DbSync } = requireNodeSqlite();
this.db = new DbSync(dbPath, { allowExtension: true });
diff --git a/src/core/store/store-pool.ts b/src/core/store/store-pool.ts
index 468808d..bec4a31 100644
--- a/src/core/store/store-pool.ts
+++ b/src/core/store/store-pool.ts
@@ -16,6 +16,7 @@
*/
import path from "node:path";
+import { existsSync, mkdirSync } from "node:fs";
import type { MemoryTdaiConfig } from "../../config.js";
import type { IMemoryStore, StoreLogger } from "./types.js";
import type { EmbeddingService } from "./embedding.js";
@@ -303,6 +304,11 @@ export class StorePool {
const dims = embCfg.dimensions ?? 0;
const dbPath = this.getSqlitePath(instanceId);
+ // 确保数据库目录存在(对于非 default instance)
+ const dbDir = path.dirname(dbPath);
+ if (!existsSync(dbDir)) {
+ mkdirSync(dbDir, { recursive: true });
+ }
const store = new VectorStore(dbPath, dims, this.logger as StoreLogger);
return {
diff --git a/src/core/store/types.ts b/src/core/store/types.ts
index 4dd0f72..c419b10 100644
--- a/src/core/store/types.ts
+++ b/src/core/store/types.ts
@@ -17,6 +17,7 @@
import type { MemoryRecord } from "../record/l1-writer.js";
import type { EmbeddingProviderInfo } from "./embedding.js";
+import type { Logger } from "../types.js";
// Re-export so consumers can import everything from types.ts
export type { MemoryRecord, EmbeddingProviderInfo };
@@ -26,12 +27,7 @@ export type { MemoryRecord, EmbeddingProviderInfo };
// ============================
/** Minimal logger interface accepted by store implementations. */
-export interface StoreLogger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
+export type StoreLogger = Logger;
// ============================
// L1 Types (Structured Memories)
diff --git a/src/core/tools/conversation-search.ts b/src/core/tools/conversation-search.ts
index b3db689..eb63f76 100644
--- a/src/core/tools/conversation-search.ts
+++ b/src/core/tools/conversation-search.ts
@@ -13,18 +13,12 @@
import type { IMemoryStore, L0SearchResult } from "../store/types.js";
import { buildFtsQuery } from "../store/sqlite.js";
import type { EmbeddingService } from "../store/embedding.js";
+import type { Logger } from "../types.js";
// ============================
// Types
// ============================
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
export interface ConversationSearchResultItem {
id: string;
session_key: string;
diff --git a/src/core/tools/memory-search.ts b/src/core/tools/memory-search.ts
index d040ffa..76e6391 100644
--- a/src/core/tools/memory-search.ts
+++ b/src/core/tools/memory-search.ts
@@ -13,18 +13,12 @@
import type { IMemoryStore, L1SearchResult } from "../store/types.js";
import { buildFtsQuery } from "../store/sqlite.js";
import type { EmbeddingService } from "../store/embedding.js";
+import type { Logger } from "../types.js";
// ============================
// Types
// ============================
-interface Logger {
- debug?: (message: string) => void;
- info: (message: string) => void;
- warn: (message: string) => void;
- error: (message: string) => void;
-}
-
export interface MemorySearchResultItem {
id: string;
content: string;
diff --git a/src/core/types.ts b/src/core/types.ts
index b56ee23..f26facf 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -15,10 +15,10 @@
// ============================
/**
- * Minimal logger interface used throughout TDAI Core.
+ * Canonical logger interface used across all TDAI modules.
*
- * Matches the existing `StoreLogger` and `RunnerLogger` interfaces
- * already used in the codebase — no migration needed for existing callers.
+ * Named variants (StoreLogger, PluginLogger, etc.) are type aliases
+ * of this interface, kept for backward compatibility.
*/
export interface Logger {
debug?: (message: string) => void;
diff --git a/src/gateway/config.ts b/src/gateway/config.ts
index 2ffd6ae..e51e190 100644
--- a/src/gateway/config.ts
+++ b/src/gateway/config.ts
@@ -292,6 +292,25 @@ export interface GatewayConfig {
cos: CosExtraConfig;
/** 可观测性配置 (yaml: observability, env: KAFKA_METRIC_*) */
observability: ObservabilityConfig;
+ /** Offload server executor 配置 (yaml: offload) */
+ offload: {
+ forceTriggerThreshold: number;
+ pendingMaxAgeSeconds: number;
+ l1Temperature: number;
+ l1MaxTokens: number;
+ l1TimeoutMs: number;
+ l15Temperature: number;
+ l15MaxTokens: number;
+ l15TimeoutMs: number;
+ l2Temperature: number;
+ l2MaxTokens: number;
+ l2TimeoutMs: number;
+ l2NullThreshold: number;
+ mildOffloadRatio: number;
+ aggressiveCompressRatio: number;
+ emergencyCompressRatio: number;
+ maxRetries: number;
+ };
}
// ============================
@@ -491,6 +510,27 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo
const observability: ObservabilityConfig = { otel, clickhouse, kafka, langfuse };
+ // Offload executor config (yaml: offload)
+ const offloadConfig = obj(fileConfig, "offload");
+ const offload = {
+ forceTriggerThreshold: num(offloadConfig, "forceTriggerThreshold") ?? 4,
+ pendingMaxAgeSeconds: num(offloadConfig, "pendingMaxAgeSeconds") ?? 30,
+ l1Temperature: num(offloadConfig, "l1Temperature") ?? 0.3,
+ l1MaxTokens: num(offloadConfig, "l1MaxTokens") ?? 8000,
+ l1TimeoutMs: num(offloadConfig, "l1TimeoutMs") ?? 120_000,
+ l15Temperature: num(offloadConfig, "l15Temperature") ?? 0.2,
+ l15MaxTokens: num(offloadConfig, "l15MaxTokens") ?? 3000,
+ l15TimeoutMs: num(offloadConfig, "l15TimeoutMs") ?? 120_000,
+ l2Temperature: num(offloadConfig, "l2Temperature") ?? 0.4,
+ l2MaxTokens: num(offloadConfig, "l2MaxTokens") ?? 16000,
+ l2TimeoutMs: num(offloadConfig, "l2TimeoutMs") ?? 120_000,
+ l2NullThreshold: num(offloadConfig, "l2NullThreshold") ?? 6,
+ mildOffloadRatio: num(offloadConfig, "mildOffloadRatio") ?? 0.5,
+ aggressiveCompressRatio: num(offloadConfig, "aggressiveCompressRatio") ?? 0.85,
+ emergencyCompressRatio: num(offloadConfig, "emergencyCompressRatio") ?? 0.95,
+ maxRetries: num(offloadConfig, "maxRetries") ?? 3,
+ };
+
const base: GatewayConfig = {
deployMode,
stateBackend,
@@ -505,6 +545,7 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo
worker,
cos,
observability,
+ offload,
};
// Merge overrides one level deep so partial `server`/`data`/`llm` patches
diff --git a/src/gateway/error-handler.ts b/src/gateway/error-handler.ts
index b1062ed..d100d1a 100644
--- a/src/gateway/error-handler.ts
+++ b/src/gateway/error-handler.ts
@@ -46,6 +46,7 @@ export interface ClassifiedError {
*
* Recognized:
* - PayloadTooLargeError (CR-7) — detected via duck-typing on statusCode === 413
+ * - Invalid JSON body — parseJsonBody throws Error("Invalid JSON body") on malformed input
* - RecallFailure (H-15) — code from RecallError taxonomy
* - SeedValidationError — 400 with generic message
* - Anything else — 500 Internal server error
@@ -64,6 +65,37 @@ export function classifyError(err: unknown): ClassifiedError {
};
}
+ // 1b. Invalid JSON body / decompression error — parseJsonBody throws this fixed message on
+ // JSON.parse failure or gzip/deflate decompression failure. Client error, not server fault.
+ if (err instanceof Error && err.message === "Invalid JSON body") {
+ return {
+ status: 400,
+ client: { code: 400, message: "Invalid JSON body", trace_id, retryable: false },
+ logLine: `[${trace_id}] InvalidJsonBody: request body could not be parsed as JSON`,
+ };
+ }
+
+ // 1c. COS AppendPositionErr — concurrent append conflict. The client should retry.
+ // This is a transient conflict, not a permanent error.
+ if (err instanceof Error && /AppendPositionErr|Position not equal object length/i.test(err.message)) {
+ return {
+ status: 409,
+ client: { code: 409, message: "Concurrent write conflict, please retry", trace_id, retryable: true },
+ logLine: `[${trace_id}] CosAppendConflict: ${err.message}`,
+ };
+ }
+
+ // 1d. Unsupported Content-Encoding — parseJsonBody rejects with this message when the
+ // client sends an encoding the server does not support (not gzip/deflate/identity).
+ if (err instanceof Error && err.message.startsWith("Unsupported Content-Encoding:")) {
+ const safeMsg = err.message.replace(/[^\w\s:/-]/g, "");
+ return {
+ status: 415,
+ client: { code: 415, message: safeMsg, trace_id, retryable: false },
+ logLine: `[${trace_id}] UnsupportedEncoding: ${err.message}`,
+ };
+ }
+
// 2. RecallFailure (H-15) — the recall layer has already produced a safe message.
// (Gateway handlers normally translate this into RecallResult.error before reaching
// the global catch, but if a code path forgets, this fallback ensures no raw leak.)
diff --git a/src/gateway/server.ts b/src/gateway/server.ts
index 4a5f3de..02882d9 100644
--- a/src/gateway/server.ts
+++ b/src/gateway/server.ts
@@ -17,6 +17,7 @@
import http from "node:http";
import { URL } from "node:url";
import { timingSafeEqual } from "node:crypto";
+import zlib from "node:zlib";
import dayjs from "dayjs";
import { TdaiCore } from "../core/tdai-core.js";
import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js";
@@ -53,6 +54,9 @@ import { executeSeed } from "../core/seed/seed-runtime.js";
import type { SeedProgress } from "../core/seed/types.js";
import { handleV2Route, errorEnvelope, makeRequestId } from "./v2-router.js";
import type { V2RouterDeps } from "./v2-router.js";
+import { handleOffloadV2Route } from "../offload_server/router.js";
+import type { OffloadV2Deps } from "../offload_server/router.js";
+import { initServerOpikTracer } from "../offload_server/opik-tracer.js";
import { classifyError } from "./error-handler.js";
import { LocalStorageBackend } from "../core/storage/local-backend.js";
import { StorageAdapter } from "../core/storage/adapter.js";
@@ -142,11 +146,25 @@ export async function parseJsonBody(req: http.IncomingMessage): Promise {
return;
}
+ // Determine if the body is compressed (Content-Encoding header).
+ // Support gzip and deflate; reject unsupported encodings with 400.
+ const encoding = (req.headers["content-encoding"] ?? "").toLowerCase().trim();
+ let source: NodeJS.ReadableStream = req;
+ if (encoding === "gzip" || encoding === "x-gzip") {
+ source = req.pipe(zlib.createGunzip());
+ } else if (encoding === "deflate") {
+ source = req.pipe(zlib.createInflate());
+ } else if (encoding !== "" && encoding !== "identity") {
+ req.resume(); // drain
+ reject(new Error(`Unsupported Content-Encoding: ${encoding}`));
+ return;
+ }
+
const chunks: Buffer[] = [];
let received = 0;
let aborted = false;
- req.on("data", (chunk: Buffer) => {
+ source.on("data", (chunk: Buffer) => {
if (aborted) return;
received += chunk.length;
if (received > MAX_BODY_BYTES) {
@@ -157,18 +175,19 @@ export async function parseJsonBody(req: http.IncomingMessage): Promise {
}
chunks.push(chunk);
});
- req.on("end", () => {
+ source.on("end", () => {
if (aborted) return;
try {
const body = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(body) as T);
- } catch (err) {
+ } catch {
reject(new Error("Invalid JSON body"));
}
});
- req.on("error", (err) => {
+ source.on("error", (_err) => {
if (aborted) return; // already rejected with PayloadTooLargeError
- reject(err);
+ // Decompression errors (e.g. truncated gzip) are client-side faults
+ reject(new Error("Invalid JSON body"));
});
});
}
@@ -333,6 +352,9 @@ export class TdaiGateway {
// Initialize core
await this.core.initialize();
+ // ── Initialize Opik tracer for offload server ──
+ await initServerOpikTracer(this.logger);
+
// ── Initialize StorageAdapter for v2 API ──
// In standalone mode, use LocalStorageBackend pointing to dataDir.
// In service mode, CosStorageBackend is injected externally.
@@ -578,6 +600,17 @@ export class TdaiGateway {
}
}
+ // ── Offload V2 routes (async ingest + mmd query) ──
+ const offloadDeps: OffloadV2Deps = {
+ resolveStorage: v2Deps.resolveStorage,
+ getStorage: v2Deps.getStorage ?? (() => undefined),
+ logger: this.logger,
+ stateBackend: this.stateBackend,
+ config: { ...this.config.offload, l1Model: "", l15Model: "", l2Model: "" },
+ };
+ const offloadHandled = await handleOffloadV2Route(req, res, pathname, method, parseJsonBody, sendJson, offloadDeps);
+ if (offloadHandled) return;
+
const handled = await handleV2Route(req, res, pathname, method, parseJsonBody, sendJson, v2Deps);
if (handled) return;
@@ -1101,26 +1134,65 @@ export class TdaiGateway {
type: backendType,
local: backendType === "local" ? {
onTimerExpired: (entry) => {
- // Parse timer member: "sessionId:L2_schedule" or "sessionId:L1_idle"
- const parts = entry.member.split(":");
- const sessionId = parts.slice(0, -1).join(":");
- const timerType = parts[parts.length - 1];
- const taskType = timerType === "L2_schedule" ? "L2" : timerType === "L1_idle" ? "L1" : "L3";
- const instanceId = this.config.instanceId ?? "default";
+ // Parse timer member by prefix: "offload-{type}:{instanceId}:{sessionId}[:{extra}]"
+ // or legacy "session:L2_schedule"
+ const member = entry.member;
+ let taskType: string;
+ let instanceId: string;
+ let sessionId: string;
+
+ const firstColon = member.indexOf(":");
+ const prefix = firstColon > 0 ? member.slice(0, firstColon) : member;
+
+ if (prefix === "offload-l1" || prefix === "offload-l15" || prefix === "offload-l2") {
+ taskType = prefix;
+ // Format: "offload-{type}:{instanceId}:{sessionId}[:{mmdFile}]"
+ // instanceId is the segment right after the prefix
+ const rest = member.slice(firstColon + 1);
+ const instanceEnd = rest.indexOf(":");
+ if (instanceEnd > 0) {
+ instanceId = rest.slice(0, instanceEnd);
+ sessionId = rest.slice(instanceEnd + 1);
+ } else {
+ instanceId = this.config.instanceId ?? "default";
+ sessionId = rest;
+ }
+ // For offload-l2: strip trailing ":{mmdFile}" from sessionId
+ // (mmdFile is extracted separately from timerMember in the executor)
+ if (prefix === "offload-l2" && sessionId.endsWith(".mmd")) {
+ const lastColon = sessionId.lastIndexOf(":");
+ if (lastColon > 0) {
+ sessionId = sessionId.slice(0, lastColon);
+ }
+ }
+ } else {
+ // Legacy format: "sessionId:L2_schedule" or "sessionId:L1_idle"
+ const lastColon = member.lastIndexOf(":");
+ const suffix = lastColon >= 0 ? member.slice(lastColon + 1) : "";
+ sessionId = lastColon >= 0 ? member.slice(0, lastColon) : member;
+ taskType = suffix === "L2_schedule" ? "offload-l2" : suffix === "L1_idle" ? "offload-l1" : "L3";
+ instanceId = this.config.instanceId ?? "default";
+ }
const now = Date.now();
+ // Extract targetMmdFile from member for offload-l2 (needed by pipeline-worker lockKey)
+ let targetMmdFile: string | undefined;
+ if (taskType === "offload-l2") {
+ const mmdMatch = member.match(/(\d+-[^:]+\.mmd)$/);
+ if (mmdMatch) targetMmdFile = mmdMatch[1];
+ }
const task = {
id: `${taskType}-${sessionId}-${now}`,
- type: taskType,
+ type: taskType as any,
instanceId,
sessionId,
priority: 0,
createdAt: now,
- data: { triggeredBy: "timer_scanner", timerMember: entry.member, instanceId },
+ data: { triggeredBy: "timer_scanner", timerMember: member, instanceId, targetMmdFile },
};
this.stateBackend!.enqueueTask(task).then(() => {
- this.logger.info(`[local-timer] Timer fired: ${entry.member} → enqueued ${taskType} task`);
+ this.logger.info(`[local-timer] Timer fired: ${member} → enqueued ${taskType} task`);
}).catch((err) => {
- this.logger.error(`[local-timer] Failed to enqueue task for ${entry.member}: ${err instanceof Error ? err.message : String(err)}`);
+ this.logger.error(`[local-timer] Failed to enqueue task for ${member}: ${err instanceof Error ? err.message : String(err)}`);
});
},
} : undefined,
@@ -1571,6 +1643,120 @@ export class TdaiGateway {
async executeFlush(task: TaskPayload) {
await core.handleSessionEnd(task.sessionId);
},
+
+ // ── Offload executors (L1 summary, L1.5 task judgment, L2 MMD update) ──
+ async executeOffloadL1(task: TaskPayload, signal?: AbortSignal) {
+ if (signal?.aborted) return;
+ const { OffloadTaskExecutor } = await import("../offload_server/offload-task-executor.js");
+ const storage = await resolveStorage(task);
+ if (!storage) return;
+ const llmClient = gateway.buildOffloadLlmClient();
+ if (!llmClient) {
+ gateway.logger.warn(`[executor] offload-l1 skipped: no LLM client available`);
+ return;
+ }
+ const executor = new OffloadTaskExecutor({
+ resolveStorage: async () => storage,
+ llmClient,
+ stateBackend: gateway.stateBackend!,
+ config: { ...gateway.config.offload, l1Model: "", l15Model: "", l2Model: "" },
+ logger: gateway.logger,
+ });
+ await executor.executeOffloadL1(task, signal);
+ },
+ async executeOffloadL15(task: TaskPayload, signal?: AbortSignal) {
+ if (signal?.aborted) return;
+ const { OffloadTaskExecutor } = await import("../offload_server/offload-task-executor.js");
+ const storage = await resolveStorage(task);
+ if (!storage) return;
+ const llmClient = gateway.buildOffloadLlmClient();
+ if (!llmClient) {
+ gateway.logger.warn(`[executor] offload-l15 skipped: no LLM client available`);
+ return;
+ }
+ const executor = new OffloadTaskExecutor({
+ resolveStorage: async () => storage,
+ llmClient,
+ stateBackend: gateway.stateBackend!,
+ config: { ...gateway.config.offload, l1Model: "", l15Model: "", l2Model: "" },
+ logger: gateway.logger,
+ });
+ await executor.executeOffloadL15(task, signal);
+ },
+ async executeOffloadL2(task: TaskPayload, signal?: AbortSignal) {
+ if (signal?.aborted) return;
+ const { OffloadTaskExecutor } = await import("../offload_server/offload-task-executor.js");
+ const storage = await resolveStorage(task);
+ if (!storage) return;
+ const llmClient = gateway.buildOffloadLlmClient();
+ if (!llmClient) {
+ gateway.logger.warn(`[executor] offload-l2 skipped: no LLM client available`);
+ return;
+ }
+ const executor = new OffloadTaskExecutor({
+ resolveStorage: async () => storage,
+ llmClient,
+ stateBackend: gateway.stateBackend!,
+ config: { ...gateway.config.offload, l1Model: "", l15Model: "", l2Model: "" },
+ logger: gateway.logger,
+ });
+ await executor.executeOffloadL2(task, signal);
+ },
+ };
+ }
+
+ /**
+ * Build a simple LLM client for offload executors using gateway's LLM config.
+ */
+ private buildOffloadLlmClient() {
+ const llmCfg = this.config.llm;
+ if (!llmCfg.baseUrl || !llmCfg.apiKey || !llmCfg.model) return null;
+ const logger = this.logger;
+
+ return {
+ async chat(params: {
+ model: string;
+ messages: Array<{ role: "system" | "user"; content: string }>;
+ temperature: number;
+ max_tokens: number;
+ timeoutMs?: number;
+ }): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 30000);
+ try {
+ const response = await fetch(`${llmCfg.baseUrl}/chat/completions`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${llmCfg.apiKey}`,
+ },
+ body: JSON.stringify({
+ model: llmCfg.model || params.model,
+ messages: params.messages,
+ temperature: params.temperature,
+ max_tokens: params.max_tokens,
+ }),
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+ if (!response.ok) {
+ throw new Error(`LLM API returned ${response.status}: ${await response.text()}`);
+ }
+ const json = (await response.json()) as any;
+ const finishReason = json.choices?.[0]?.finish_reason;
+ if (finishReason === "length") {
+ const content = json.choices?.[0]?.message?.content ?? "";
+ logger.warn(
+ `[offload-llm] Response truncated (finish_reason=length, max_tokens=${params.max_tokens}), ` +
+ `content=${content.length} chars`,
+ );
+ }
+ return json.choices?.[0]?.message?.content ?? "";
+ } catch (err) {
+ clearTimeout(timer);
+ throw err;
+ }
+ },
};
}
}
diff --git a/src/offload-client/context-engine.ts b/src/offload-client/context-engine.ts
new file mode 100644
index 0000000..01a9e01
--- /dev/null
+++ b/src/offload-client/context-engine.ts
@@ -0,0 +1,526 @@
+/**
+ * offload-client — OffloadContextEngine.
+ * Occupies the Context Engine slot and delegates compression to the server.
+ */
+import type { OffloadClientConfig, RecentMessage, Logger } from "./types.js";
+import type { OffloadApiClient } from "./offload-api-client.js";
+import { estimateAllTokens, estimateMessageTokens } from "./token-estimator.js";
+
+const DEFAULT_CONTEXT_WINDOW = 128000;
+/** compact target: keep messages until total <= contextWindow * TARGET_RATIO */
+const COMPACT_TARGET_RATIO = 0.5;
+/** When truncating a single large tool_result, keep at most this many chars */
+const TOOL_RESULT_TRUNCATE_CHARS = 2000;
+
+// ─── Message role helpers (handle multiple formats) ─────────────────────────
+
+function getMsgRole(msg: any): string {
+ return msg?.role ?? msg?.message?.role ?? msg?.type ?? "";
+}
+
+function isToolResult(msg: any): boolean {
+ const role = getMsgRole(msg);
+ if (role === "tool" || role === "toolResult" || role === "tool_result") return true;
+ // Anthropic: user message with tool_result content blocks
+ if (role === "user" && Array.isArray(msg?.content)) {
+ return msg.content.some((b: any) => b?.type === "tool_result");
+ }
+ return false;
+}
+
+function isAssistantWithToolUse(msg: any): boolean {
+ const role = getMsgRole(msg);
+ if (role !== "assistant") return false;
+ const content = msg?.type === "message" ? msg?.message?.content : msg?.content;
+ if (!Array.isArray(content)) return false;
+ return content.some((b: any) => b?.type === "tool_use" || b?.type === "toolCall");
+}
+
+/**
+ * Truncate tool_result content in-place, returning a shallow-cloned message.
+ */
+function truncateToolResult(msg: any, maxChars: number): any {
+ const clone = JSON.parse(JSON.stringify(msg));
+ const content = clone.type === "message" ? clone.message?.content : clone.content;
+ if (typeof content === "string" && content.length > maxChars) {
+ const truncated = content.slice(0, maxChars) + "\n...[truncated]";
+ if (clone.type === "message") clone.message.content = truncated;
+ else clone.content = truncated;
+ return clone;
+ }
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (typeof block?.text === "string" && block.text.length > maxChars) {
+ block.text = block.text.slice(0, maxChars) + "\n...[truncated]";
+ }
+ if (typeof block?.content === "string" && block.content.length > maxChars) {
+ block.content = block.content.slice(0, maxChars) + "\n...[truncated]";
+ }
+ }
+ return clone;
+ }
+ return clone;
+}
+
+export class OffloadContextEngine {
+ /** Per-session state cache. */
+ private sessions = new Map();
+
+ /** Get or create per-session state. */
+ private getSession(sessionKey: string) {
+ let s = this.sessions.get(sessionKey);
+ if (!s) {
+ s = {
+ lastAccessMs: Date.now(), // ← NEW
+ lastKnownTotalTokens: 0,
+ lastKnownMsgCount: 0,
+ lastL15PromptHash: "",
+ cachedRecentMessages: [],
+ };
+ this.sessions.set(sessionKey, s);
+ } else {
+ s.lastAccessMs = Date.now(); // ← NEW: update on access
+ }
+ return s;
+ }
+
+ /**
+ * Reset session state (call when session is /new'd or destroyed).
+ */
+ resetSession(sessionKey: string): void {
+ this.sessions.delete(sessionKey);
+ }
+
+ /**
+ * Clear all session states (emergency shutdown).
+ */
+ clearAllSessions(): void {
+ const n = this.sessions.size;
+ this.sessions.clear();
+ if (n > 0) {
+ this.logger.info(`[offload-client] cleared ${n} session states`);
+ }
+ }
+
+ /**
+ * Get the cached context for a session (for after_tool_call hook to send with ingest).
+ */
+ getContext(sessionKey?: string): { prompt?: string; recentMessages?: RecentMessage[] } | undefined {
+ if (!sessionKey) return undefined;
+ const s = this.sessions.get(sessionKey);
+ if (!s || (!s.cachedPrompt && s.cachedRecentMessages.length === 0)) return undefined;
+ return { prompt: s.cachedPrompt, recentMessages: s.cachedRecentMessages };
+ }
+
+ /**
+ * Get the cached formatted context string for a session (for legacy compatibility).
+ */
+ getRecentContext(sessionKey?: string): string | undefined {
+ if (!sessionKey) return undefined;
+ return this.sessions.get(sessionKey)?.cachedRecentContext;
+ }
+
+ constructor(
+ private client: OffloadApiClient,
+ private config: OffloadClientConfig,
+ private logger: Logger,
+ ) {}
+
+ get info() {
+ return {
+ id: "memory-tencentdb",
+ name: "Offload Client Context Engine",
+ version: "2.0.0",
+ ownsCompaction: true,
+ };
+ }
+
+ /**
+ * bootstrap — called when a new session starts (e.g. /new command).
+ * Resets per-session cached state.
+ */
+ async bootstrap(params: { sessionKey?: string; sessionId?: string }) {
+ const sk = params.sessionKey ?? params.sessionId;
+ if (sk) this.resetSession(sk);
+ return { bootstrapped: true };
+ }
+
+ /**
+ * ingest — no-op for client mode (ingest is handled by after_tool_call hook).
+ * Required by the framework ContextEngine interface.
+ */
+ async ingest(_params: {
+ sessionId: string;
+ sessionKey?: string;
+ message: any;
+ isHeartbeat?: boolean;
+ }) {
+ return { ingested: true };
+ }
+
+ /**
+ * compact — record the framework's authoritative token count for calibration,
+ * then defer actual compaction to assemble().
+ */
+ async compact(params: {
+ sessionId: string;
+ sessionKey?: string;
+ sessionFile: string;
+ tokenBudget?: number;
+ force?: boolean;
+ currentTokenCount?: number;
+ compactionTarget?: "budget" | "threshold";
+ customInstructions?: string;
+ runtimeContext?: any;
+ }) {
+ // Record framework token count for calibration
+ if (params.currentTokenCount && params.currentTokenCount > 0) {
+ const sk = params.sessionKey ?? params.sessionId;
+ const s = this.getSession(sk);
+ s.lastKnownTotalTokens = params.currentTokenCount;
+ this.logger.debug?.(
+ `[offload-client] compact: calibration updated, knownTokens=${s.lastKnownTotalTokens}`,
+ );
+ }
+
+ const contextWindow = params.tokenBudget ?? DEFAULT_CONTEXT_WINDOW;
+ const targetTokens = Math.floor(contextWindow * COMPACT_TARGET_RATIO);
+
+ this.logger.info(
+ `[offload-client] compact: sessionKey=${params.sessionKey ?? params.sessionId}, ` +
+ `budget=${contextWindow}, target=${targetTokens}, ` +
+ `currentTokens=${params.currentTokenCount}, force=${params.force}`,
+ );
+
+ return {
+ ok: true,
+ compacted: false,
+ reason: "offload-client: compaction deferred to assemble()",
+ };
+ }
+
+ /**
+ * Resolve the best known token total for calibration.
+ * Uses lastKnownTotalTokens only if message count hasn't changed significantly.
+ */
+ private resolveCalibrationTokens(sessionKey: string, msgCount: number): number | undefined {
+ const s = this.sessions.get(sessionKey);
+ if (!s || s.lastKnownTotalTokens <= 0) return undefined;
+ // If message count changed by >20%, the cached total is stale — skip calibration
+ if (s.lastKnownMsgCount > 0 && Math.abs(msgCount - s.lastKnownMsgCount) / s.lastKnownMsgCount > 0.2) {
+ return undefined;
+ }
+ return s.lastKnownTotalTokens;
+ }
+
+ /**
+ * Local brute-force compaction: keep tail messages up to target budget,
+ * respecting tool pairs and truncating oversized tool_results.
+ * Used as fallback when server compaction is unavailable.
+ */
+ private localCompact(messages: any[], contextWindow: number, sessionKey: string): any[] {
+ const targetTokens = Math.floor(contextWindow * COMPACT_TARGET_RATIO);
+ const knownTokens = this.resolveCalibrationTokens(sessionKey, messages.length);
+ const { perMessage } = estimateAllTokens(messages, knownTokens);
+ const n = messages.length;
+
+ // Step 1: scan from tail, find the cut index
+ let cumTokens = 0;
+ let cutIdx = n; // everything before cutIdx gets deleted
+ for (let i = n - 1; i >= 0; i--) {
+ cumTokens += perMessage[i];
+ if (cumTokens > targetTokens) {
+ cutIdx = i + 1;
+ break;
+ }
+ cutIdx = i;
+ }
+
+ // Never delete the very first user message (index 0)
+ if (cutIdx <= 0) cutIdx = 0;
+
+ // Step 2: expand cut boundary to respect tool pairs
+ // If cutIdx lands inside a tool pair, move it to include the full pair.
+
+ // 2a: If msg at cutIdx is a tool_result, its paired assistant+tool_use is
+ // before cutIdx (would be deleted). Move cutIdx back to include the pair.
+ while (cutIdx < n && isToolResult(messages[cutIdx])) {
+ cutIdx++;
+ }
+
+ // 2b: If msg at cutIdx-1 (last deleted) is assistant+tool_use, its tool_result
+ // at cutIdx would be orphaned. Pull cutIdx back to keep the pair.
+ while (cutIdx > 0 && cutIdx < n && isAssistantWithToolUse(messages[cutIdx - 1])) {
+ cutIdx--;
+ }
+
+ // Step 3: build retained array
+ const retained = messages.slice(cutIdx);
+
+ if (retained.length === 0) {
+ return [...messages]; // safety: don't delete everything
+ }
+
+ const deletedCount = cutIdx;
+ let retainedTokens = 0;
+ for (let i = cutIdx; i < n; i++) retainedTokens += perMessage[i];
+
+ this.logger.info(
+ `[offload-client] localCompact: deleted ${deletedCount}/${n} msgs, ` +
+ `retained ${retained.length} msgs, tokens=${retainedTokens}/${targetTokens} target`,
+ );
+
+ // Step 4: if still over target and there's a large tool_result, truncate it
+ if (retainedTokens > targetTokens) {
+ let maxTrIdx = -1;
+ let maxTrTokens = 0;
+ for (let i = 0; i < retained.length; i++) {
+ if (isToolResult(retained[i])) {
+ const t = estimateMessageTokens(retained[i]);
+ if (t > maxTrTokens) {
+ maxTrTokens = t;
+ maxTrIdx = i;
+ }
+ }
+ }
+ if (maxTrIdx >= 0 && maxTrTokens > TOOL_RESULT_TRUNCATE_CHARS / 4) {
+ retained[maxTrIdx] = truncateToolResult(retained[maxTrIdx], TOOL_RESULT_TRUNCATE_CHARS);
+ const newTokens = estimateMessageTokens(retained[maxTrIdx]);
+ retainedTokens = retainedTokens - maxTrTokens + newTokens;
+ this.logger.info(
+ `[offload-client] localCompact: truncated tool_result[${maxTrIdx}] ` +
+ `${maxTrTokens}→${newTokens} tokens, total now=${retainedTokens}`,
+ );
+ }
+ }
+
+ return retained;
+ }
+
+ // ─── L1.5 Trigger ───────────────────────────────────────────────────────────
+
+ /**
+ * Trigger L1.5 task judgment via ingest API when a new user prompt is detected.
+ * Fire-and-forget — does not block the assemble flow.
+ */
+ private triggerL15IfNeeded(prompt: string | undefined, messages: any[], sessionKey: string): void {
+ if (!prompt || typeof prompt !== "string" || prompt.length === 0) return;
+
+ // Skip system/internal prompts that are not user-initiated
+ if (this.isInternalPrompt(prompt)) {
+ this.logger.debug?.(`[offload-client] L1.5 skipped: internal prompt (${prompt.slice(0, 60)})`);
+ return;
+ }
+
+ // Always update cached context for after_tool_call hook (L1 needs it)
+ const recentMsgs = this.buildRecentMessages(prompt, messages);
+ const s = this.getSession(sessionKey);
+ s.cachedPrompt = prompt.slice(0, 500);
+ s.cachedRecentMessages = recentMsgs;
+ s.cachedRecentContext = this.formatContextForL1(prompt, recentMsgs);
+
+ // Dedup: skip L1.5 if same prompt as last trigger for this session
+ const hash = this.simpleHash(prompt);
+ if (s.lastL15PromptHash === hash) {
+ this.logger.debug?.(`[offload-client] L1.5 skipped: same prompt hash (${hash})`);
+ return;
+ }
+ s.lastL15PromptHash = hash;
+
+ this.logger.info(
+ `[offload-client] L1.5 triggered: promptHash=${hash}, recentMsgs=${recentMsgs.length}`,
+ );
+
+ // Fire-and-forget L1.5
+ this.client.ingestL15(sessionKey, prompt.slice(0, 500), recentMsgs).catch((err) => {
+ this.logger.warn(`[offload-client] L1.5 ingestL15 failed: ${err}`);
+ });
+ }
+
+ /**
+ * Detect internal/system prompts that should not trigger L1.5.
+ * These are framework-generated messages, not user-initiated conversations.
+ */
+ private isInternalPrompt(prompt: string): boolean {
+ // Compaction flush prompts
+ if (prompt.startsWith("Pre-compaction")) return true;
+ // Inter-session routing messages
+ if (prompt.startsWith("[Inter-session message]")) return true;
+ // Heartbeat/keepalive
+ if (prompt.includes("HEARTBEAT") || prompt.includes("heartbeat")) return true;
+ return false;
+ }
+
+ /**
+ * Build structured RecentMessage[] for ingest API.
+ * Filters: user/assistant text only, no tool calls, no heartbeats.
+ * Max 5 recent turns, 400 chars per message.
+ */
+ private buildRecentMessages(prompt: string, messages: any[]): RecentMessage[] {
+ const normalizedPrompt = prompt.trim().slice(0, 200).toLowerCase();
+
+ // Scan messages, collect user/assistant pairs
+ const pairs: RecentMessage[] = [];
+ for (const msg of messages) {
+ const role = getMsgRole(msg);
+
+ // Skip tool messages entirely
+ if (isToolResult(msg) || isAssistantWithToolUse(msg)) continue;
+ if (role === "tool" || role === "toolResult" || role === "tool_result") continue;
+
+ if (role === "user") {
+ const text = this.extractMsgText(msg);
+ if (!text || text.length <= 5) continue;
+ if (text.includes("HEARTBEAT") || text.includes("heartbeat")) continue;
+ const trimmed = text.slice(0, 400);
+ // Skip if it matches current prompt
+ const normalizedText = trimmed.slice(0, 200).toLowerCase();
+ if (normalizedPrompt && (normalizedText === normalizedPrompt || normalizedText.startsWith(normalizedPrompt) || normalizedPrompt.startsWith(normalizedText))) continue;
+ pairs.push({ role: "user", content: trimmed });
+ } else if (role === "assistant") {
+ const text = this.extractMsgText(msg);
+ if (!text || text.length <= 10) continue;
+ if (text.includes("HEARTBEAT") || text.includes("heartbeat")) continue;
+ pairs.push({ role: "assistant", content: text.slice(0, 400) });
+ }
+ }
+
+ // Keep last N messages (max 10 messages ≈ 5 turns)
+ const recent = pairs.slice(-10);
+ return recent;
+ }
+
+ /**
+ * Format context string for L1 executor (recent-context.txt).
+ */
+ private formatContextForL1(prompt: string, recentMsgs: RecentMessage[]): string {
+ const parts: string[] = [];
+ if (recentMsgs.length > 0) {
+ parts.push("历史消息,可作为参考:");
+ for (const m of recentMsgs) {
+ parts.push(`[${m.role === "user" ? "User" : "Assistant"}]: ${m.content}`);
+ }
+ }
+ parts.push(`\n最新user message:\n[User]: ${prompt.slice(0, 500)}`);
+ return parts.join("\n");
+ }
+
+ /**
+ * Extract text content from a message.
+ */
+ private extractMsgText(msg: any): string {
+ const content = msg?.content ?? msg?.message?.content ?? "";
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ return content
+ .map((b: any) => (typeof b === "string" ? b : b?.text ?? ""))
+ .join("");
+ }
+ return "";
+ }
+
+ /**
+ * Simple string hash for prompt deduplication.
+ */
+ private simpleHash(str: string): string {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
+ }
+ return hash.toString(36);
+ }
+
+ /**
+ * assemble — estimate ratio → call server compaction → fallback to localCompact.
+ * Framework calls this to build the model context for each turn.
+ */
+ async assemble(params: {
+ sessionId: string;
+ sessionKey?: string;
+ messages?: any[];
+ tokenBudget?: number;
+ prompt?: string;
+ availableTools?: Set;
+ citationsMode?: string;
+ model?: string;
+ }) {
+ const { messages, sessionKey, sessionId } = params;
+ if (!messages || messages.length === 0) {
+ return { messages: messages ? [...messages] : [], estimatedTokens: 0 };
+ }
+
+ const sk = sessionKey ?? sessionId ?? "unknown";
+
+ // ── L1.5 trigger: fire-and-forget on new user prompt ──
+ this.triggerL15IfNeeded(params.prompt, messages, sk);
+
+ const contextWindow = params.tokenBudget ?? DEFAULT_CONTEXT_WINDOW;
+ // Don't use framework's knownTokens for calibration — our tiktoken is already precise.
+ // Framework's currentTokenCount may use a different calculation method (e.g. chars/4).
+ const { total, perMessage } = estimateAllTokens(messages);
+ const ratio = total / contextWindow;
+
+ // Update calibration state for future calls
+ const s = this.getSession(sk);
+ s.lastKnownTotalTokens = total;
+ s.lastKnownMsgCount = messages.length;
+
+ // Below client threshold — skip compaction
+ if (ratio < this.config.compactionRatio) {
+ this.logger.debug?.(
+ `[offload-client] assemble: ratio=${(ratio * 100).toFixed(1)}% < ${(this.config.compactionRatio * 100).toFixed(0)}%, skip`,
+ );
+ return { messages: [...messages], estimatedTokens: total };
+ }
+
+ this.logger.info(
+ `[offload-client] assemble: ratio=${(ratio * 100).toFixed(1)}%, msgs=${messages.length}, calling compaction...`,
+ );
+
+ // Try server-side compaction first
+ const result = await this.client.compaction({
+ sessionId: sessionKey ?? sessionId ?? "unknown",
+ messages,
+ ratio,
+ contextWindow,
+ totalTokens: total,
+ messageTokens: perMessage,
+ });
+
+ if (result) {
+ const compactedTokens = result.messages.reduce(
+ (sum: number, msg: any) => sum + estimateMessageTokens(msg),
+ 0,
+ );
+ this.logger.info(
+ `[offload-client] server compaction done: level=${result.report.resolvedLevel}, ` +
+ `${result.report.originalCount}→${result.report.compactedCount} msgs, ` +
+ `mild=${result.report.mildReplacements}, agg=${result.report.aggressiveDeleted}, ` +
+ `em=${result.report.emergencyDeleted}, mmd=${result.report.mmdInjected}`,
+ );
+ return { messages: result.messages, estimatedTokens: compactedTokens };
+ }
+
+ // Fallback: local brute-force compaction
+ this.logger.warn("[offload-client] server compaction failed, falling back to local compact");
+ const compacted = this.localCompact(messages, contextWindow, sk);
+ const compactedTokens = compacted.reduce(
+ (sum: number, msg: any) => sum + estimateMessageTokens(msg),
+ 0,
+ );
+ return { messages: compacted, estimatedTokens: compactedTokens };
+ }
+
+ /**
+ * afterTurn — no-op (ingest is handled by after_tool_call hook).
+ */
+ async afterTurn() {}
+}
diff --git a/src/offload-client/hooks/after-tool-call.ts b/src/offload-client/hooks/after-tool-call.ts
new file mode 100644
index 0000000..873b757
--- /dev/null
+++ b/src/offload-client/hooks/after-tool-call.ts
@@ -0,0 +1,53 @@
+/**
+ * offload-client — after_tool_call hook handler.
+ * Fire-and-forget: sends tool pair + context to ingest API for L1 processing.
+ */
+import type { OffloadApiClient } from "../offload-api-client.js";
+import type { OffloadClientConfig, ToolPairPayload, RecentMessage, Logger } from "../types.js";
+
+export interface AfterToolCallEvent {
+ toolName: string;
+ toolCallId: string;
+ params?: unknown;
+ result?: unknown;
+ error?: string;
+ durationMs?: number;
+}
+
+/**
+ * Create the after_tool_call hook handler.
+ * Sends each tool call result to the server for L1 processing.
+ *
+ * @param getContext Optional getter for { prompt, recentMessages } context per session.
+ */
+export function createAfterToolCallHandler(
+ client: OffloadApiClient,
+ config: OffloadClientConfig,
+ logger: Logger,
+ getContext?: (sessionKey: string) => { prompt?: string; recentMessages?: RecentMessage[] } | undefined,
+) {
+ return (event: AfterToolCallEvent, ctx: { sessionKey?: string; sessionId?: string }) => {
+ const sessionId = ctx.sessionKey ?? ctx.sessionId;
+ logger.debug?.(
+ `[offload-client] after_tool_call: tool=${event.toolName}, session=${sessionId ?? "(none)"}, callId=${event.toolCallId ?? "(none)"}`,
+ );
+ if (!sessionId) return;
+
+ const toolPair: ToolPairPayload = {
+ toolName: event.toolName,
+ toolCallId: event.toolCallId,
+ params: event.params ?? {},
+ result: event.result,
+ error: event.error,
+ timestamp: new Date().toISOString(),
+ durationMs: event.durationMs,
+ };
+
+ const context = getContext?.(sessionId);
+
+ // Fire-and-forget — do not block the LLM flow
+ client.ingestWithContext(sessionId, [toolPair], context?.prompt, context?.recentMessages).catch((err) => {
+ logger.warn(`[offload-client] ingest fire-and-forget error: ${err}`);
+ });
+ };
+}
diff --git a/src/offload-client/index.ts b/src/offload-client/index.ts
new file mode 100644
index 0000000..1c45c7d
--- /dev/null
+++ b/src/offload-client/index.ts
@@ -0,0 +1,101 @@
+/**
+ * offload-client — Plugin registration entry point.
+ * Stateless, server-delegated offload client.
+ * 1 hook (after_tool_call) + 1 Context Engine (assemble → compaction API).
+ */
+import type { OffloadClientConfig, Logger } from "./types.js";
+import { defaultOffloadClientConfig } from "./types.js";
+import { OffloadApiClient } from "./offload-api-client.js";
+import { OffloadContextEngine } from "./context-engine.js";
+import { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
+
+export interface OpenClawPluginApi {
+ on: (hookName: string, handler: (...args: any[]) => any) => void;
+ registerContextEngine: (id: string, factoryOrInstance: any) => any;
+ logger?: Logger;
+}
+
+/**
+ * Register the offload-client plugin.
+ * Call this from the main plugin's register() when offload-client config is enabled.
+ */
+export function registerOffloadClient(api: OpenClawPluginApi, userConfig: Partial): void {
+ const config: OffloadClientConfig = { ...defaultOffloadClientConfig(), ...userConfig };
+ const logger: Logger = api.logger ?? { info: console.log, warn: console.warn, error: console.error, debug: console.debug };
+
+ if (!config.enabled) {
+ logger.info("[offload-client] disabled by config");
+ return;
+ }
+
+ if (!config.serverUrl || !config.apiKey || !config.serviceId) {
+ logger.error("[offload-client] missing required config: serverUrl, apiKey, or serviceId");
+ return;
+ }
+
+ const client = new OffloadApiClient(config, logger);
+
+ // Context Engine: occupies slot, assemble() calls compaction API
+ const engine = new OffloadContextEngine(client, config, logger);
+
+ // Hook: fire-and-forget ingest on every tool call (with context from engine per session)
+ const afterToolCallHandler = createAfterToolCallHandler(
+ client, config, logger,
+ (sessionKey) => engine.getContext(sessionKey),
+ config.agentName, // ← NEW: pass agentName for sessionId construction
+ );
+ api.on("after_tool_call", afterToolCallHandler);
+
+ // ── Memory management hooks ──
+
+ // agent_end: clear token cache after each agent turn
+ api.on("agent_end", (_event: any, ctx: { sessionKey?: string; sessionId?: string }) => {
+ const sk = ctx.sessionKey ?? ctx.sessionId;
+ if (sk) {
+ engine.resetSession(sk);
+ logger.debug?.(`[offload-client] reset session state: ${sk}`);
+ }
+ });
+
+ // gateway_stop: emergency cleanup on shutdown
+ api.on("gateway_stop", async () => {
+ engine.clearAllSessions();
+ logger.info("[offload-client] all session states cleared on gateway_stop");
+ });
+
+ try {
+ const result = api.registerContextEngine("memory-tencentdb", () => engine) as any;
+ if (result?.ok === false) {
+ logger.error(
+ `[offload-client] Context Engine slot occupied by "${result.existingOwner ?? "unknown"}". ` +
+ `Compaction disabled — only ingest will work.`,
+ );
+ } else {
+ logger.info("[offload-client] Context Engine registered");
+ }
+ } catch (err) {
+ logger.error(`[offload-client] registerContextEngine failed: ${err}. Compaction disabled.`);
+ }
+
+ // ── Health check (async, non-blocking) ──
+ client.checkHealth().then((ok) => {
+ if (!ok) {
+ logger.warn(
+ `[offload-client] ⚠️ Server ${config.serverUrl} unreachable! ` +
+ `Ingest calls will fail silently until server becomes available.`
+ );
+ } else {
+ logger.info(`[offload-client] Server health OK: ${config.serverUrl}`);
+ }
+ }).catch((_err) => {
+ // ignore
+ });
+
+ logger.info(`[offload-client] registered (server=${config.serverUrl})`);
+}
+
+export { OffloadApiClient } from "./offload-api-client.js";
+export { OffloadContextEngine } from "./context-engine.js";
+export { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
+export { estimateTokens, estimateMessageTokens, estimateAllTokens } from "./token-estimator.js";
+export type { OffloadClientConfig, ToolPairPayload, CompactionResult, CompactionReport, Logger } from "./types.js";
diff --git a/src/offload-client/offload-api-client.ts b/src/offload-client/offload-api-client.ts
new file mode 100644
index 0000000..fb9766f
--- /dev/null
+++ b/src/offload-client/offload-api-client.ts
@@ -0,0 +1,177 @@
+/**
+ * offload-client — HTTP client for Offload Server v2 API.
+ */
+import type { OffloadClientConfig, ToolPairPayload, RecentMessage, CompactionResult, Logger } from "./types.js";
+
+export class OffloadApiClient {
+ constructor(
+ private config: OffloadClientConfig,
+ private logger: Logger,
+ ) {}
+
+ /** Health check: GET /v2/offload/health. Returns true if server is reachable (any HTTP response = reachable). */
+ async checkHealth(): Promise {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+ const res = await fetch(`${this.config.serverUrl}/v2/offload/health`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${this.config.apiKey}` },
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ // Any HTTP response (including 401/403) means server is reachable
+ return res.status < 500;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Fire-and-forget: send tool pairs to ingest endpoint.
+ * Does not throw — failures are logged as warnings.
+ */
+ async ingest(sessionId: string, toolPairs: ToolPairPayload[]): Promise {
+ return this.ingestWithContext(sessionId, toolPairs, undefined, undefined);
+ }
+
+ /**
+ * Fire-and-forget: send tool pairs + optional context to ingest endpoint.
+ * When prompt/recentMessages are provided, server triggers L1 with context (skip L1.5).
+ */
+ async ingestWithContext(
+ sessionId: string,
+ toolPairs: ToolPairPayload[],
+ prompt?: string,
+ recentMessages?: RecentMessage[],
+ ): Promise {
+ const url = `${this.config.serverUrl}/v2/offload/ingest`;
+ const payload: Record = {
+ session_id: sessionId,
+ tool_pairs: toolPairs.map((tp) => ({
+ tool_name: tp.toolName,
+ tool_call_id: tp.toolCallId,
+ params: tp.params,
+ result: tp.result,
+ error: tp.error,
+ timestamp: tp.timestamp,
+ duration_ms: tp.durationMs,
+ })),
+ };
+ if (prompt) payload.prompt = prompt;
+ if (recentMessages && recentMessages.length > 0) payload.recent_messages = recentMessages;
+ const body = JSON.stringify(payload);
+
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.config.ingestTimeoutMs);
+
+ await fetch(url, {
+ method: "POST",
+ headers: this.buildHeaders(),
+ body,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timer);
+ } catch (err) {
+ this.logger.warn(`[offload-client] ingest failed: ${err}`);
+ }
+ }
+
+ /**
+ * Fire-and-forget: trigger L1.5 task judgment via ingest endpoint.
+ * Sends prompt + recentMessages (empty toolPairs) to activate the L1.5 path on the server.
+ */
+ async ingestL15(sessionId: string, prompt: string, recentMessages?: RecentMessage[]): Promise {
+ const url = `${this.config.serverUrl}/v2/offload/ingest`;
+ const payload: Record = {
+ session_id: sessionId,
+ tool_pairs: [],
+ prompt,
+ };
+ if (recentMessages && recentMessages.length > 0) payload.recent_messages = recentMessages;
+ const body = JSON.stringify(payload);
+
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.config.ingestTimeoutMs);
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: this.buildHeaders(),
+ body,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timer);
+
+ if (!response.ok) {
+ this.logger.warn(`[offload-client] ingestL15 returned ${response.status}`);
+ }
+ } catch (err) {
+ this.logger.warn(`[offload-client] ingestL15 failed: ${err}`);
+ }
+ }
+
+ /**
+ * Synchronous compaction call. Returns compressed messages + report.
+ * Returns null on timeout/failure (caller should keep original messages).
+ */
+ async compaction(req: {
+ sessionId: string;
+ messages: any[];
+ ratio: number;
+ contextWindow: number;
+ totalTokens: number;
+ messageTokens?: number[];
+ }): Promise {
+ const url = `${this.config.serverUrl}/v2/offload/compact`;
+ const body = JSON.stringify({
+ session_id: req.sessionId,
+ messages: req.messages,
+ ratio: req.ratio,
+ context_window: req.contextWindow,
+ total_tokens: req.totalTokens,
+ message_tokens: req.messageTokens,
+ });
+
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.config.compactionTimeoutMs);
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: this.buildHeaders(),
+ body,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timer);
+
+ if (!response.ok) {
+ this.logger.warn(`[offload-client] compaction returned ${response.status}`);
+ return null;
+ }
+
+ const json = (await response.json()) as any;
+ if (json.code !== 0 || !json.data) {
+ this.logger.warn(`[offload-client] compaction error: ${json.message ?? "unknown"}`);
+ return null;
+ }
+
+ return { messages: json.data.messages, report: json.data.report };
+ } catch (err) {
+ this.logger.warn(`[offload-client] compaction failed: ${err}`);
+ return null;
+ }
+ }
+
+ private buildHeaders(): Record {
+ return {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.config.apiKey}`,
+ "X-TDAI-Service-Id": this.config.serviceId,
+ };
+ }
+}
diff --git a/src/offload-client/token-estimator.ts b/src/offload-client/token-estimator.ts
new file mode 100644
index 0000000..c5d6c7d
--- /dev/null
+++ b/src/offload-client/token-estimator.ts
@@ -0,0 +1,227 @@
+/**
+ * offload-client — Token estimation using tiktoken (precise).
+ * Aligned with server-side preciseMessageTokens: only counts LLM-visible content.
+ *
+ * Strategy:
+ * - Primary: tiktoken BPE encoding (o200k_base) on role + content
+ * - Fallback: CJK-aware heuristic if tiktoken fails
+ *
+ * `estimateAllTokens` still supports optional calibration from framework-reported
+ * totalTokens, but with tiktoken the drift should be minimal.
+ */
+import { getEncoding, type Tiktoken } from "js-tiktoken";
+
+// ─── Tiktoken Encoder (lazy singleton) ──────────────────────────────────────
+
+let _encoder: Tiktoken | null = null;
+function getEncoder(): Tiktoken {
+ if (!_encoder) _encoder = getEncoding("o200k_base");
+ return _encoder;
+}
+
+// ─── Calibration constants ──────────────────────────────────────────────────
+
+/** If heuristic drifts > 15% from known total, apply linear scaling. */
+const CALIBRATION_THRESHOLD = 0.15;
+/** Clamp calibration factor to prevent extreme scaling from noisy estimates. */
+const CALIBRATION_FACTOR_MIN = 0.5;
+const CALIBRATION_FACTOR_MAX = 3.0;
+
+// ─── LLM-visible text extraction (must match server-side extractLlmVisibleText) ──
+
+/**
+ * Extract the LLM-visible portion of a message (role + content only).
+ * Matches server-side preciseMessageTokens logic exactly.
+ */
+function extractLlmVisibleText(msg: any): string {
+ const role: string = msg?.role ?? msg?.message?.role ?? "";
+ const rawContent = msg?.content ?? msg?.message?.content ?? "";
+
+ let contentStr: string;
+ if (typeof rawContent === "string") {
+ contentStr = rawContent;
+ } else if (Array.isArray(rawContent)) {
+ const parts: string[] = [];
+ for (const block of rawContent) {
+ if (typeof block === "string") {
+ parts.push(block);
+ } else if (block?.type === "text" && typeof block.text === "string") {
+ parts.push(block.text);
+ } else if (block?.type === "tool_use" || block?.type === "toolCall") {
+ parts.push(block.name ?? block.toolName ?? "");
+ if (block.arguments) {
+ parts.push(typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments));
+ }
+ if (block.input) {
+ parts.push(typeof block.input === "string" ? block.input : JSON.stringify(block.input));
+ }
+ } else if (block?.type === "tool_result") {
+ if (typeof block.content === "string") parts.push(block.content);
+ else if (block.content) parts.push(JSON.stringify(block.content));
+ } else {
+ parts.push(JSON.stringify(block));
+ }
+ }
+ contentStr = parts.join("\n");
+ } else {
+ contentStr = JSON.stringify(rawContent);
+ }
+
+ return `${role}\n${contentStr}`;
+}
+
+// ─── Per-message token cache (WeakMap — auto GC when msg object is released) ──
+
+const _tokenCache = new WeakMap