mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
/**
|
||
* memory-tencentdb-client — OpenClaw 记忆插件(客户端接入版)
|
||
*
|
||
* 通过 @tencentdb-agent-memory/memory-sdk-ts 连接远端 memory server,
|
||
* 提供四层记忆的自动捕获、召回和工具调用能力,
|
||
* 并集成 offload 上下文压缩/摘要服务。
|
||
*
|
||
* 本插件不包含任何数据处理逻辑(无 VDB/Embedding/Pipeline),
|
||
* 所有操作委托给远端 server。
|
||
*/
|
||
|
||
import { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
|
||
import { performRecall } from "./src/hooks/recall.js";
|
||
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]";
|
||
|
||
// ── Config types (matches openclaw.plugin.json configSchema) ──────────
|
||
interface ServerConfig {
|
||
url?: string;
|
||
apiKey?: string;
|
||
instanceId?: string;
|
||
rejectUnauthorized?: boolean;
|
||
}
|
||
interface RecallConfig {
|
||
maxResults?: number;
|
||
includePersona?: boolean;
|
||
includeSceneNav?: boolean;
|
||
}
|
||
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)
|
||
export default function register(api: any) {
|
||
// ── Read config (nested objects per configSchema) ──────────────────
|
||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||
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";
|
||
const instanceId = server.instanceId || "default";
|
||
const recallMaxResults = recall.maxResults ?? 5;
|
||
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
|
||
// build its internal MemoryFileReader for STS-signed reads.
|
||
const client = new MemoryClient({
|
||
endpoint: serverUrl,
|
||
apiKey,
|
||
serviceId: instanceId,
|
||
rejectUnauthorized,
|
||
});
|
||
|
||
|
||
|
||
api.logger.info?.(
|
||
`${TAG} Initialized: server=${serverUrl}, instance=${instanceId}, ` +
|
||
`recall(persona=${includePersona},sceneNav=${includeSceneNav},max=${recallMaxResults}), ` +
|
||
`capture=${captureEnabled}, offload=${offloadEnabled}, rejectUnauthorized=${rejectUnauthorized}`,
|
||
);
|
||
|
||
// ── Register Tools (same pattern as extensions/memory-tencentdb/index.ts) ──
|
||
|
||
api.registerTool(
|
||
{
|
||
name: "tdai_memory_search",
|
||
label: "Memory Search",
|
||
description:
|
||
"Search structured memories (L1). Returns relevant memory fragments about " +
|
||
"user preferences, past events, rules, and facts.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search query text (natural language)." },
|
||
limit: { type: "number", description: "Max results to return (default: 5)." },
|
||
type: { type: "string", description: "Filter by memory type." },
|
||
},
|
||
required: ["query"],
|
||
},
|
||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||
return handleMemorySearch(client, params as any, api.logger);
|
||
},
|
||
},
|
||
{ name: "tdai_memory_search" },
|
||
);
|
||
|
||
api.registerTool(
|
||
{
|
||
name: "tdai_conversation_search",
|
||
label: "Conversation Search",
|
||
description:
|
||
"Search raw conversation history (L0). Returns original messages with timestamps.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search query text." },
|
||
limit: { type: "number", description: "Max results (default: 5)." },
|
||
session_key: { type: "string", description: "Filter by session ID." },
|
||
},
|
||
required: ["query"],
|
||
},
|
||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||
return handleConversationSearch(client, params as any, api.logger);
|
||
},
|
||
},
|
||
{ name: "tdai_conversation_search" },
|
||
);
|
||
|
||
api.registerTool(
|
||
{
|
||
name: "tdai_read_cos",
|
||
label: "Read COS File",
|
||
description:
|
||
"Read a file from cloud storage. Use paths from Scene Navigation " +
|
||
"(e.g. 'scene_blocks/xxx.md') or 'persona.md'.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
path: { type: "string", description: "File path (relative key)." },
|
||
},
|
||
required: ["path"],
|
||
},
|
||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||
return handleReadCos(client, params as any, api.logger);
|
||
},
|
||
},
|
||
{ name: "tdai_read_cos" },
|
||
);
|
||
|
||
// ── Register Hooks (api.on pattern, same as memory-tencentdb) ──
|
||
|
||
// Per-session caches:
|
||
// - pendingOriginalPrompts: clean user prompt + messageCount captured at
|
||
// before_prompt_build, used at agent_end to (a) replace polluted user
|
||
// message and (b) position-slice this turn's new messages.
|
||
// - sessionCursors: max timestamp of last captured batch — used as a
|
||
// fallback when the position slice cannot be determined.
|
||
const pendingOriginalPrompts = new Map<string, { text: string; messageCount: number }>();
|
||
const sessionCursors = new Map<string, number>();
|
||
|
||
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
||
const sessionKey = ctx?.sessionKey;
|
||
if (!sessionKey) return;
|
||
|
||
const userText = event?.prompt;
|
||
if (!userText) return;
|
||
|
||
// Cache original prompt for agent_end (only if capture is enabled — it is
|
||
// the only consumer; recall doesn't need this data).
|
||
if (captureEnabled) {
|
||
const messageCount = Array.isArray(event?.messages) ? event.messages.length : 0;
|
||
pendingOriginalPrompts.set(sessionKey, { text: userText, messageCount });
|
||
}
|
||
|
||
try {
|
||
const result = await performRecall(client, {
|
||
query: userText,
|
||
maxResults: recallMaxResults,
|
||
includePersona,
|
||
includeSceneNav,
|
||
}, api.logger);
|
||
|
||
// OpenClaw consumes the *return value* of before_prompt_build,
|
||
// not mutations on the event object. Map our RecallResult to the
|
||
// PluginHookBeforePromptBuildResult shape.
|
||
const out: { prependContext?: string; appendSystemContext?: string } = {};
|
||
if (result.prependContext) out.prependContext = result.prependContext;
|
||
if (result.appendSystemContext) out.appendSystemContext = result.appendSystemContext;
|
||
return out;
|
||
} catch (err) {
|
||
api.logger.warn(`${TAG} [recall] Failed: ${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
});
|
||
|
||
if (captureEnabled) {
|
||
api.logger.info?.(`${TAG} Registering agent_end hook for auto-capture`);
|
||
api.on("agent_end", async (event: any, ctx: any) => {
|
||
const startMs = Date.now();
|
||
const sessionKey = ctx?.sessionKey;
|
||
const messages = (event?.messages ?? []) as unknown[];
|
||
|
||
api.logger.debug?.(
|
||
`${TAG} [agent_end] hook triggered: success=${event?.success}, ` +
|
||
`messages=${messages.length}, sessionKey=${sessionKey ?? "(none)"}`,
|
||
);
|
||
|
||
// Skip on agent failure — partial / errored turns shouldn't pollute L0.
|
||
if (event?.success === false) {
|
||
api.logger.info(`${TAG} [agent_end] agent did not succeed, skip capture`);
|
||
return;
|
||
}
|
||
|
||
if (!sessionKey) {
|
||
api.logger.warn(`${TAG} [agent_end] no sessionKey in ctx, skip capture`);
|
||
return;
|
||
}
|
||
if (messages.length === 0) {
|
||
api.logger.debug?.(`${TAG} [agent_end] event.messages is empty, skip capture`);
|
||
return;
|
||
}
|
||
|
||
const cached = pendingOriginalPrompts.get(sessionKey);
|
||
// Don't delete on read — keep until we successfully send (in case of retry),
|
||
// or let it be overwritten on next before_prompt_build.
|
||
|
||
try {
|
||
const result = await performCapture(
|
||
client,
|
||
{
|
||
sessionKey,
|
||
sessionId: ctx?.sessionId,
|
||
rawMessages: messages,
|
||
originalUserText: cached?.text,
|
||
originalUserMessageCount: cached?.messageCount,
|
||
afterTimestamp: sessionCursors.get(sessionKey),
|
||
},
|
||
api.logger,
|
||
);
|
||
|
||
if (result.maxTimestamp) {
|
||
sessionCursors.set(sessionKey, result.maxTimestamp);
|
||
}
|
||
// Cached prompt has been used — clear it so a stale value doesn't
|
||
// bleed into the next turn (e.g. after agent restart).
|
||
pendingOriginalPrompts.delete(sessionKey);
|
||
|
||
const elapsed = Date.now() - startMs;
|
||
api.logger.info(
|
||
`${TAG} [agent_end] capture done in ${elapsed}ms ` +
|
||
`(captured=${result.capturedCount}, serverTotal=${result.serverTotalCount ?? "?"})`,
|
||
);
|
||
} catch (err) {
|
||
const elapsed = Date.now() - startMs;
|
||
api.logger.warn(
|
||
`${TAG} [capture] Failed after ${elapsed}ms: ` +
|
||
(err instanceof Error ? err.message : String(err)),
|
||
);
|
||
}
|
||
});
|
||
} 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,
|
||
});
|
||
}
|
||
}
|