feat: release v1.0.0-beta.1

This commit is contained in:
chrishuan
2026-05-29 17:33:12 +08:00
commit 36b0537676
290 changed files with 72303 additions and 0 deletions
+109
View File
@@ -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),
};
}
+235
View File
@@ -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;
}
+55
View File
@@ -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);
}
+88
View File
@@ -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}` }] };
}
}
+39
View File
@@ -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}` }] };
}
}