Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/core/hooks/__tests__/stable-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseConfig } from "../../../config.js";
import { performAutoRecall } from "../auto-recall.js";
import type { IMemoryStore, L1FtsResult, StoreCapabilities } from "../../store/types.js";
import type { Logger } from "../../types.js";

const tempDirs: string[] = [];

const logger: Logger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};

const capabilities: StoreCapabilities = {
vectorSearch: false,
ftsSearch: true,
nativeHybridSearch: false,
sparseVectors: false,
};

function makeStore(results: L1FtsResult[]): IMemoryStore {
return {
getCapabilities: () => capabilities,
isFtsAvailable: () => true,
searchL1Fts: () => results,
searchL1Vector: () => [],
} as unknown as IMemoryStore;
}

async function makeDataDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-stable-wrapper-"));
tempDirs.push(dir);
return dir;
}

async function recall(results: L1FtsResult[], userText = "How is my prompt cache?") {
return performAutoRecall({
userText,
actorId: "agent",
sessionKey: "session",
cfg: parseConfig({
recall: {
strategy: "keyword",
scoreThreshold: 0,
},
}),
pluginDataDir: await makeDataDir(),
logger,
vectorStore: makeStore(results),
});
}

function ftsResult(content: string): L1FtsResult {
return {
record_id: `record-${content}`,
content,
type: "instruction",
priority: 1,
scene_name: "",
score: 1,
timestamp_str: "",
timestamp_start: "",
timestamp_end: "",
session_key: "session",
session_id: "session-id",
metadata_json: "{}",
};
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});

describe("performAutoRecall stable wrapper", () => {
it("keeps a relevant-memories wrapper when L1 search runs with zero hits", async () => {
const result = await recall([]);

expect(result?.prependContext).toContain("<relevant-memories>");
expect(result?.prependContext).toContain("未召回相关记忆");
expect(result?.prependContext).toContain("</relevant-memories>");
expect(result?.appendSystemContext).toContain("<memory-tools-guide>");
expect(result?.recalledL1Memories).toEqual([]);
});

it("keeps real recalled memories instead of the empty placeholder", async () => {
const result = await recall([ftsResult("User prefers concise answers")]);

expect(result?.prependContext).toContain("<relevant-memories>");
expect(result?.prependContext).toContain("User prefers concise answers");
expect(result?.prependContext).not.toContain("未召回相关记忆");
});

it("does not inject the empty wrapper when recall search is skipped", async () => {
const result = await recall([], "");

expect(result).toBeUndefined();
});
});
8 changes: 7 additions & 1 deletion src/core/hooks/auto-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const TAG = "[memory-tdai] [recall]";
const RECALL_TRUNCATION_SUFFIX = "…(已截断;可用 tdai_memory_search 或 tdai_conversation_search 查看详情)";
const MIN_TRUNCATED_RECALL_LINE_CHARS = 40;
const RECALL_LINE_SEPARATOR = "\n";
const NO_RELEVANT_MEMORIES_PLACEHOLDER = "(本次对话未召回相关记忆)";

/**
* Memory tools usage guide — injected at the end of memory context so the
Expand Down Expand Up @@ -115,12 +116,14 @@ async function performAutoRecallInner(params: {
// Search relevant memories (L1 layer) — skip only when userText is empty/undefined
const tSearchStart = performance.now();
let memoryLines: string[] = [];
let memorySearchAttempted = false;
let effectiveStrategy = "skipped";
let recalledL1Memories: RecalledMemory[] = [];
let searchTiming: SearchTiming = { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 };
if (!userText || userText.length === 0) {
logger?.debug?.(`${TAG} User text empty/undefined, skipping memory search (persona/scene still injected)`);
} else {
memorySearchAttempted = true;
effectiveStrategy = cfg.recall.strategy ?? "hybrid";
const searchResult = await searchMemories(userText, pluginDataDir, cfg, logger, effectiveStrategy as "keyword" | "embedding" | "hybrid", vectorStore, embeddingService);
memoryLines = searchResult.lines;
Expand Down Expand Up @@ -169,7 +172,7 @@ async function performAutoRecallInner(params: {
}
const tSceneEnd = performance.now();

if (memoryLines.length === 0 && !personaContent && !sceneNavigation) {
if (!memorySearchAttempted && memoryLines.length === 0 && !personaContent && !sceneNavigation) {
const totalMs = performance.now() - tRecallStart;
logger?.info(
`${TAG} ⏱ Recall timing: total=${totalMs.toFixed(0)}ms, ` +
Expand Down Expand Up @@ -202,10 +205,13 @@ async function performAutoRecallInner(params: {
}

// Dynamic part: L1 relevant memories (changes every turn) → prependContext (user prompt)
// Keep the wrapper even for zero-hit searches so prompt shape stays stable.
let prependContext: string | undefined;
if (memoryLines.length > 0) {
prependContext =
`<relevant-memories>\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join(RECALL_LINE_SEPARATOR)}\n</relevant-memories>`;
} else if (memorySearchAttempted) {
prependContext = `<relevant-memories>\n${NO_RELEVANT_MEMORIES_PLACEHOLDER}\n</relevant-memories>`;
}

// Append memory tools usage guide to the stable part so the agent knows
Expand Down