mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-27 20:54:32 +00:00
feat: release v1.0.0-beta.1
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Format recall results into prompt context.
|
||||
*
|
||||
* Output structure (mirrors original memory-tencentdb plugin):
|
||||
* - prependContext: dynamic L1 memories (changes per turn, injected before user message)
|
||||
* - appendSystemContext: stable content (Persona + Scene Nav + tools guide, appended to system prompt)
|
||||
*/
|
||||
|
||||
import type { RecallResult } from "./hooks/recall.js";
|
||||
|
||||
interface L1Item {
|
||||
id: string;
|
||||
content: string;
|
||||
type: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface SceneEntry {
|
||||
path: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── Memory Tools Guide ──
|
||||
const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
|
||||
## 记忆工具调用指南
|
||||
|
||||
当上方注入的记忆片段不足以回答用户问题时,可主动调用以下工具获取更多信息:
|
||||
|
||||
- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件、规则等。
|
||||
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节。
|
||||
- **tdai_read_cos**:读取场景文件详情(使用下方 Scene Navigation 中的路径,如 \`scene_blocks/xxx.md\`)。
|
||||
|
||||
### ⚠️ 调用次数限制
|
||||
每轮对话中,tdai_memory_search 和 tdai_conversation_search **合计最多调用 3 次**。
|
||||
- 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
|
||||
- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户。
|
||||
</memory-tools-guide>`;
|
||||
|
||||
/**
|
||||
* Format L1 memories as prependContext.
|
||||
*/
|
||||
function formatL1Memories(items: L1Item[]): string | undefined {
|
||||
if (items.length === 0) return undefined;
|
||||
|
||||
const lines: string[] = [
|
||||
"<relevant-memories>",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
const typeTag = item.type ? `[${item.type}]` : "";
|
||||
lines.push(`- ${typeTag} ${item.content}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("</relevant-memories>");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format stable system context: Persona + Scene Navigation + Tools Guide.
|
||||
*/
|
||||
function formatSystemContext(
|
||||
persona: string | null,
|
||||
scenes: SceneEntry[],
|
||||
): string | undefined {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Persona (L3)
|
||||
if (persona) {
|
||||
parts.push("<user-persona>");
|
||||
parts.push(persona);
|
||||
parts.push("</user-persona>");
|
||||
}
|
||||
|
||||
// Scene Navigation (L2 index) — only if not already in persona
|
||||
if (scenes.length > 0 && (!persona || !persona.includes("Scene Navigation"))) {
|
||||
parts.push("");
|
||||
parts.push("## 🗺️ Scene Navigation");
|
||||
parts.push("*以下是当前场景记忆索引,可使用 tdai_read_cos 读取详细内容。*");
|
||||
parts.push("");
|
||||
for (const scene of scenes) {
|
||||
parts.push(`- \`${scene.path}\``);
|
||||
}
|
||||
}
|
||||
|
||||
// Tools guide (always append)
|
||||
parts.push("");
|
||||
parts.push(MEMORY_TOOLS_GUIDE);
|
||||
|
||||
const result = parts.join("\n").trim();
|
||||
return result || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main format function: produce RecallResult for prompt injection.
|
||||
*/
|
||||
export function formatRecallResult(
|
||||
l1Items: L1Item[],
|
||||
persona: string | null,
|
||||
scenes: SceneEntry[],
|
||||
): RecallResult {
|
||||
return {
|
||||
prependContext: formatL1Memories(l1Items),
|
||||
appendSystemContext: formatSystemContext(persona, scenes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Capture hook (client mode):
|
||||
* 1. Extract user/assistant messages from the agent_end raw message array
|
||||
* 2. Apply position slice + (optional) timestamp cursor to keep only this turn
|
||||
* 3. Replace the polluted user message with the cached original prompt
|
||||
* 4. Sanitize text + strip code blocks (assistant) + filter noise
|
||||
* 5. POST the cleaned messages to the gateway via SDK addConversation
|
||||
*
|
||||
* Mirrors the structural cleanup in extensions/memory-tencentdb/src/core/conversation/l0-recorder.ts
|
||||
* (recordConversation), but does not write any local JSONL — the server is
|
||||
* authoritative for L0 storage.
|
||||
*/
|
||||
|
||||
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
|
||||
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../sanitize.js";
|
||||
|
||||
const TAG = "[memory-client][capture]";
|
||||
|
||||
interface Logger {
|
||||
debug?: (msg: string) => void;
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
}
|
||||
|
||||
interface ExtractedMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CaptureContext {
|
||||
/** sessionKey from agent_end ctx — used as conversation key on the server. */
|
||||
sessionKey: string;
|
||||
/** sessionId from agent_end ctx — passed to addConversation when present. */
|
||||
sessionId?: string;
|
||||
/** Raw event.messages array (full session history at agent_end time). */
|
||||
rawMessages: unknown[];
|
||||
/** Clean original user prompt (cached at before_prompt_build, pre-pollution). */
|
||||
originalUserText?: string;
|
||||
/**
|
||||
* Number of messages in the session at before_prompt_build time.
|
||||
* Used to position-slice rawMessages so we only re-send messages added in this turn.
|
||||
*/
|
||||
originalUserMessageCount?: number;
|
||||
/**
|
||||
* Epoch ms cursor: only messages with timestamp > this are sent.
|
||||
* Used as a fallback when position slice is unavailable.
|
||||
*/
|
||||
afterTimestamp?: number;
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
/** Number of messages actually sent to the gateway (post-filter). */
|
||||
capturedCount: number;
|
||||
/** Server-reported total count after this batch. */
|
||||
serverTotalCount?: number;
|
||||
/** Max timestamp among captured messages — caller should advance its cursor to this. */
|
||||
maxTimestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single agent_end capture.
|
||||
*
|
||||
* Returns immediately with `capturedCount: 0` if there is nothing to send.
|
||||
* Errors from the gateway are thrown — caller should wrap in try/catch.
|
||||
*/
|
||||
export async function performCapture(
|
||||
client: MemoryClient,
|
||||
ctx: CaptureContext,
|
||||
logger?: Logger,
|
||||
): Promise<CaptureResult> {
|
||||
const { sessionKey, sessionId, rawMessages, originalUserText, originalUserMessageCount, afterTimestamp } = ctx;
|
||||
|
||||
// ── Step 1. Position slice ──
|
||||
// Only consider messages added AFTER before_prompt_build, i.e. this turn's input.
|
||||
const usePositionSlice =
|
||||
originalUserMessageCount != null &&
|
||||
originalUserMessageCount > 0 &&
|
||||
originalUserMessageCount <= rawMessages.length;
|
||||
const slicedMessages = usePositionSlice
|
||||
? rawMessages.slice(originalUserMessageCount)
|
||||
: rawMessages;
|
||||
|
||||
if (usePositionSlice) {
|
||||
logger?.debug?.(
|
||||
`${TAG} Position slice: ${rawMessages.length} raw → ${slicedMessages.length} new ` +
|
||||
`(sliceStart=${originalUserMessageCount})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2. Extract user/assistant messages ──
|
||||
const allExtracted = extractUserAssistantMessages(slicedMessages);
|
||||
logger?.debug?.(
|
||||
`${TAG} Extracted ${allExtracted.length} user/assistant messages from ${slicedMessages.length} raw`,
|
||||
);
|
||||
|
||||
// ── Step 3. Timestamp cursor (fallback when position slice unavailable) ──
|
||||
const cursor = afterTimestamp ?? 0;
|
||||
const filteredByTime = cursor !== 0
|
||||
? allExtracted.filter((m) => m.timestamp > cursor)
|
||||
: allExtracted;
|
||||
|
||||
if (cursor > 0) {
|
||||
logger?.debug?.(
|
||||
`${TAG} Timestamp filter: ${allExtracted.length} → ${filteredByTime.length} (cursor=${cursor})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredByTime.length === 0) {
|
||||
logger?.debug?.(`${TAG} No new messages to capture`);
|
||||
return { capturedCount: 0 };
|
||||
}
|
||||
|
||||
// ── Step 4. Replace polluted user message with cached original ──
|
||||
// The framework appends the user's message AFTER before_prompt_build and
|
||||
// injects prependContext into it. Without this swap, the captured user
|
||||
// text would contain the recall blob, causing a feedback loop.
|
||||
if (originalUserText) {
|
||||
const targetRaw = usePositionSlice
|
||||
? (slicedMessages[0] as Record<string, unknown> | undefined)
|
||||
: (originalUserMessageCount != null && originalUserMessageCount >= 0 && originalUserMessageCount < rawMessages.length)
|
||||
? (rawMessages[originalUserMessageCount] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
const targetTs = typeof targetRaw?.timestamp === "number" ? targetRaw.timestamp : undefined;
|
||||
|
||||
if (targetTs != null) {
|
||||
let replaced = false;
|
||||
for (let i = 0; i < filteredByTime.length; i++) {
|
||||
if (filteredByTime[i].role === "user" && filteredByTime[i].timestamp === targetTs) {
|
||||
logger?.debug?.(
|
||||
`${TAG} Replacing polluted user message (ts=${targetTs}, ` +
|
||||
`${filteredByTime[i].content.length}→${originalUserText.length} chars)`,
|
||||
);
|
||||
filteredByTime[i] = { ...filteredByTime[i], content: originalUserText };
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!replaced) {
|
||||
logger?.warn?.(`${TAG} Could not match cached prompt to any extracted user message — relying on sanitizeText()`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5. Sanitize + strip code + filter ──
|
||||
const cleaned = filteredByTime
|
||||
.map((m) => {
|
||||
let content = sanitizeText(m.content);
|
||||
if (m.role === "assistant") content = stripCodeBlocks(content);
|
||||
return { role: m.role, content, timestamp: m.timestamp };
|
||||
})
|
||||
.filter((m) => shouldCaptureL0(m.content));
|
||||
|
||||
logger?.debug?.(
|
||||
`${TAG} After sanitize+filter: ${cleaned.length} messages (from ${filteredByTime.length})`,
|
||||
);
|
||||
|
||||
if (cleaned.length === 0) {
|
||||
logger?.info(`${TAG} All messages filtered out, skipping POST`);
|
||||
return { capturedCount: 0 };
|
||||
}
|
||||
|
||||
// ── Step 6. POST to gateway ──
|
||||
const result = await client.addConversation({
|
||||
session_id: sessionId ?? sessionKey,
|
||||
messages: cleaned.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: new Date(m.timestamp).toISOString(),
|
||||
})),
|
||||
});
|
||||
|
||||
const maxTimestamp = Math.max(...cleaned.map((m) => m.timestamp));
|
||||
logger?.info(
|
||||
`${TAG} Captured ${cleaned.length} message(s) (server total=${result.total_count}, ` +
|
||||
`sessionKey=${sessionKey.slice(0, 32)}${sessionKey.length > 32 ? "…" : ""})`,
|
||||
);
|
||||
|
||||
return {
|
||||
capturedCount: cleaned.length,
|
||||
serverTotalCount: result.total_count,
|
||||
maxTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user/assistant entries from the framework's raw message array.
|
||||
*
|
||||
* Handles both content shapes the framework may produce:
|
||||
* - `content: string`
|
||||
* - `content: Array<{ type: "text", text: string } | ...>`
|
||||
*
|
||||
* Strips inline base64 image data URIs (replaces with `[image]`) so they do
|
||||
* not bloat the request payload or pollute downstream FTS / embeddings.
|
||||
*/
|
||||
function extractUserAssistantMessages(messages: unknown[]): ExtractedMessage[] {
|
||||
const result: ExtractedMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
const m = msg as Record<string, unknown>;
|
||||
const role = m.role as string | undefined;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
let content: string | undefined;
|
||||
if (typeof m.content === "string") {
|
||||
content = m.content;
|
||||
} else if (Array.isArray(m.content)) {
|
||||
const parts: string[] = [];
|
||||
for (const part of m.content) {
|
||||
if (part && typeof part === "object" && (part as Record<string, unknown>).type === "text") {
|
||||
const text = (part as Record<string, unknown>).text;
|
||||
if (typeof text === "string") parts.push(text);
|
||||
}
|
||||
}
|
||||
content = parts.join("\n");
|
||||
}
|
||||
|
||||
if (content && /data:image\/[a-z+]+;base64,/i.test(content)) {
|
||||
content = content.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]");
|
||||
}
|
||||
|
||||
if (content && content.trim()) {
|
||||
const ts = typeof m.timestamp === "number" ? m.timestamp : Date.now();
|
||||
result.push({
|
||||
role: role as "user" | "assistant",
|
||||
content: content.trim(),
|
||||
timestamp: ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Recall hook: search memories from Gateway + format prompt injection.
|
||||
*/
|
||||
|
||||
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
|
||||
import { formatRecallResult } from "../format.js";
|
||||
|
||||
const TAG = "[memory-client][recall]";
|
||||
|
||||
interface Logger {
|
||||
debug?: (msg: string) => void;
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface RecallOptions {
|
||||
query: string;
|
||||
maxResults: number;
|
||||
includePersona: boolean;
|
||||
includeSceneNav: boolean;
|
||||
}
|
||||
|
||||
export interface RecallResult {
|
||||
prependContext?: string;
|
||||
appendSystemContext?: string;
|
||||
}
|
||||
|
||||
export async function performRecall(
|
||||
client: MemoryClient,
|
||||
opts: RecallOptions,
|
||||
logger?: Logger,
|
||||
): Promise<RecallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Parallel requests: L1 search + L3 persona + L2 scenario list
|
||||
const [searchResult, persona, scenarios] = await Promise.allSettled([
|
||||
client.searchAtomic({ query: opts.query, limit: opts.maxResults }),
|
||||
opts.includePersona ? client.readCore() : Promise.resolve(null),
|
||||
opts.includeSceneNav ? client.listScenarios({}) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Extract results (graceful on failures)
|
||||
const l1Items = searchResult.status === "fulfilled" ? (searchResult.value?.items ?? []) : [];
|
||||
const personaContent = persona.status === "fulfilled" && persona.value ? persona.value.content : null;
|
||||
const sceneEntries = scenarios.status === "fulfilled" && scenarios.value ? (scenarios.value.entries ?? []) : [];
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
logger?.info(
|
||||
`${TAG} Recall complete (${elapsedMs}ms): L1=${l1Items.length}, ` +
|
||||
`persona=${personaContent ? "yes" : "no"}, scenes=${sceneEntries.length}`,
|
||||
);
|
||||
|
||||
return formatRecallResult(l1Items, personaContent, sceneEntries);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Text sanitization for L0 capture (client-side).
|
||||
* Mirrors the cleaning logic in extensions/memory-tencentdb/src/utils/sanitize.ts
|
||||
* — kept in sync to ensure the client and server filter the same noise.
|
||||
*/
|
||||
|
||||
/** Strip injected memory tags + framework metadata blocks + media markers. */
|
||||
export function sanitizeText(text: string): string {
|
||||
let cleaned = text;
|
||||
|
||||
// Remove injected memory context tags (prevent feedback loops on re-capture)
|
||||
cleaned = cleaned.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "");
|
||||
cleaned = cleaned.replace(/<user-persona>[\s\S]*?<\/user-persona>/g, "");
|
||||
cleaned = cleaned.replace(/<relevant-scenes>[\s\S]*?<\/relevant-scenes>/g, "");
|
||||
cleaned = cleaned.replace(/<scene-navigation>[\s\S]*?<\/scene-navigation>/g, "");
|
||||
cleaned = cleaned.replace(/<memory-tools-guide>[\s\S]*?<\/memory-tools-guide>/g, "");
|
||||
|
||||
// Offload-injected task context blocks
|
||||
cleaned = cleaned.replace(/<current_task_context>[\s\S]*?<\/current_task_context>/g, "");
|
||||
cleaned = cleaned.replace(/<history_task_context[\s\S]*?<\/history_task_context>/g, "");
|
||||
|
||||
// Framework-injected inbound metadata blocks (label + ```json ... ```)
|
||||
cleaned = cleaned.replace(
|
||||
/(?:Conversation info|Sender|Thread starter|Replied message|Forwarded message context|Chat history since last reply)\s*\(untrusted[\s\S]*?\):\s*```json\s*[\s\S]*?```/g,
|
||||
"",
|
||||
);
|
||||
|
||||
// Legacy conversation metadata JSON blocks
|
||||
cleaned = cleaned.replace(/```json\s*\{[\s\S]*?"session[\s\S]*?\}\s*```/g, "");
|
||||
|
||||
// Reply directive tags: [[reply_to_current]]
|
||||
cleaned = cleaned.replace(/\[\[reply_to[^\]]*\]\]\s*/g, "");
|
||||
|
||||
// Skill-selection wrappers: ¥¥[ ... ]¥¥
|
||||
cleaned = cleaned.replace(/¥¥\[[\s\S]*?\]¥¥/g, "");
|
||||
|
||||
// Line-leading timestamps: [Tue 2026-03-24 03:48 UTC] / GMT+8 / GMT+5:30
|
||||
cleaned = cleaned.replace(/^\[[\w\d\-:+ ]+\]\s*/gm, "");
|
||||
|
||||
// Gateway media-attachment markers
|
||||
cleaned = cleaned.replace(/\[media attached:[^\]]*\]\s*/g, "");
|
||||
|
||||
// Gateway image-reply instructions
|
||||
cleaned = cleaned.replace(
|
||||
/To send an image back,[\s\S]*?(?:Keep caption in the text body\.)\s*/g,
|
||||
"",
|
||||
);
|
||||
|
||||
// System exec blocks: "System: [timestamp] Exec completed ..."
|
||||
cleaned = cleaned.replace(/^System:\s*\[[\s\S]*?$/gm, "");
|
||||
|
||||
// Inline base64 image data URIs
|
||||
cleaned = cleaned.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "");
|
||||
|
||||
// Null chars + collapse whitespace
|
||||
cleaned = cleaned.replace(/\0/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip fenced code blocks from assistant replies before L0 capture.
|
||||
* Only applied to role=assistant — keeps explanatory text but drops noisy code.
|
||||
*/
|
||||
export function stripCodeBlocks(text: string): string {
|
||||
return text.replace(/```[^\n]*\n[\s\S]*?```/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* L0 capture filter — permissive. Only drops messages that are structurally
|
||||
* useless (empty, framework bootstrap noise, slash commands).
|
||||
*/
|
||||
export function shouldCaptureL0(text: string): boolean {
|
||||
if (!text || !text.trim()) return false;
|
||||
if (isFrameworkNoise(text)) return false;
|
||||
if (text.startsWith("/")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isFrameworkNoise(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (t === "(session bootstrap)") return true;
|
||||
if (t.startsWith("A new session was started via")) return true;
|
||||
if (/^✅\s*New session started/.test(t)) return true;
|
||||
if (t.startsWith("Pre-compaction memory flush")) return true;
|
||||
if (/^NO_REPLY\s*$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* tdai_conversation_search tool — delegates to SDK searchConversation.
|
||||
*/
|
||||
|
||||
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
|
||||
|
||||
interface Logger {
|
||||
debug?: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
}
|
||||
|
||||
export async function handleConversationSearch(
|
||||
client: MemoryClient,
|
||||
params: { query: string; limit?: number; session_key?: string },
|
||||
logger?: Logger,
|
||||
) {
|
||||
const { query, limit = 5, session_key } = params;
|
||||
|
||||
if (!query?.trim()) {
|
||||
return { content: [{ type: "text" as const, text: "Query cannot be empty." }] };
|
||||
}
|
||||
|
||||
try {
|
||||
logger?.debug?.(`[conversation-search] query="${query}", limit=${limit}, session=${session_key ?? "(all)"}`);
|
||||
const result = await client.searchConversation({
|
||||
query,
|
||||
limit,
|
||||
session_id: session_key,
|
||||
});
|
||||
const messages = result.messages ?? [];
|
||||
logger?.debug?.(`[conversation-search] ✅ ${messages.length} results`);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { content: [{ type: "text" as const, text: "No matching conversation messages found." }] };
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${messages.length} matching message(s):`, ""];
|
||||
for (const msg of messages) {
|
||||
const scoreStr = msg.score != null ? ` (score: ${msg.score.toFixed(3)})` : "";
|
||||
const dateStr = msg.timestamp ? ` [${msg.timestamp}]` : "";
|
||||
lines.push(`---`);
|
||||
lines.push(`**[${msg.role}]**${dateStr}${scoreStr}`);
|
||||
lines.push("");
|
||||
lines.push(msg.content);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
details: { count: messages.length },
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger?.warn(`[conversation-search] Failed: ${msg}`);
|
||||
return { content: [{ type: "text" as const, text: `Conversation search failed: ${msg}` }] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* tdai_memory_search tool — delegates to SDK searchAtomic.
|
||||
*/
|
||||
|
||||
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
|
||||
|
||||
interface Logger {
|
||||
debug?: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
}
|
||||
|
||||
export async function handleMemorySearch(
|
||||
client: MemoryClient,
|
||||
params: { query: string; limit?: number; type?: string },
|
||||
logger?: Logger,
|
||||
) {
|
||||
const { query, limit = 5, type } = params;
|
||||
|
||||
if (!query?.trim()) {
|
||||
return { content: [{ type: "text" as const, text: "Query cannot be empty." }] };
|
||||
}
|
||||
|
||||
try {
|
||||
logger?.debug?.(`[memory-search] query="${query}", limit=${limit}, type=${type ?? "(all)"}`);
|
||||
const result = await client.searchAtomic({ query, limit, type });
|
||||
const items = result.items ?? [];
|
||||
logger?.debug?.(`[memory-search] ✅ ${items.length} results`);
|
||||
|
||||
if (items.length === 0) {
|
||||
return { content: [{ type: "text" as const, text: "No matching memories found." }] };
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${items.length} matching memories:`, ""];
|
||||
for (const item of items) {
|
||||
const scoreStr = item.score != null ? ` (score: ${item.score.toFixed(3)})` : "";
|
||||
lines.push(`- **[${item.type}]**${scoreStr}`);
|
||||
lines.push(` ${item.content}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
details: { count: items.length },
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger?.warn(`[memory-search] Failed: ${msg}`);
|
||||
return { content: [{ type: "text" as const, text: `Memory search failed: ${msg}` }] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* tdai_read_cos tool — reads memory pipeline artifacts (persona.md,
|
||||
* scene_blocks/*.md, ...) by relative path via the SDK's `client.readFile`.
|
||||
*/
|
||||
|
||||
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
|
||||
|
||||
interface Logger {
|
||||
debug?: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
}
|
||||
|
||||
export async function handleReadCos(
|
||||
client: MemoryClient,
|
||||
params: { path: string },
|
||||
logger?: Logger,
|
||||
) {
|
||||
const { path } = params;
|
||||
|
||||
if (!path?.trim()) {
|
||||
return { content: [{ type: "text" as const, text: "Path cannot be empty." }] };
|
||||
}
|
||||
|
||||
// Security: reject path traversal
|
||||
if (path.includes("..") || path.startsWith("/")) {
|
||||
return { content: [{ type: "text" as const, text: `Invalid path: "${path}"` }] };
|
||||
}
|
||||
|
||||
try {
|
||||
logger?.debug?.(`[read-cos] read: "${path}"`);
|
||||
const content = await client.readFile(path);
|
||||
logger?.debug?.(`[read-cos] ✅ "${path}" (${content.length} chars)`);
|
||||
return { content: [{ type: "text" as const, text: content }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger?.warn(`[read-cos] Failed to read "${path}": ${msg}`);
|
||||
return { content: [{ type: "text" as const, text: `Failed to read file: ${msg}` }] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user