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
28 changes: 24 additions & 4 deletions examples/openclaw-plugin/auto-recall.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { FindResult, FindResultItem, OpenVikingClient } from "./client.js";
import type { MemoryOpenVikingConfig } from "./config.js";
import {
clampScore,
isVagueRecallQuery,
passesRecallScoreThreshold,
pickMemoriesForInjection,
postProcessMemories,
summarizeInjectionMemories,
Expand All @@ -11,6 +14,7 @@ import { sanitizeUserTextForCapture } from "./text-utils.js";

const AUTO_RECALL_TIMEOUT_MS = 5_000;
const RECALL_QUERY_MAX_CHARS = 4_000;
const VAGUE_RECALL_SCORE_FLOOR = 0.6;
export const AUTO_RECALL_SOURCE_MARKER = "Source: openviking-auto-recall";

type Logger = {
Expand Down Expand Up @@ -95,7 +99,8 @@ export async function buildMemoryLines(
const lines: string[] = [];
for (const item of memories) {
const content = await resolveMemoryContent(item, readFn, options);
lines.push(`- [${item.category ?? "memory"}] ${content}`);
const category = item.category?.trim() || "memory";
lines.push(`- [${category}] ${content}`);
}
return lines;
}
Expand Down Expand Up @@ -128,7 +133,8 @@ export async function buildMemoryLinesWithBudget(
}

const content = await resolveMemoryContent(item, readFn, options);
const line = `- [${item.category ?? "memory"}] ${content}`;
const category = item.category?.trim() || "memory";
const line = `- [${category}] ${content}`;
const separatorChars = lines.length > 0 ? 1 : 0;
const projectedChars = totalChars + separatorChars + line.length;

Expand Down Expand Up @@ -215,15 +221,29 @@ export async function buildAutoRecallContext(params: {
index === self.findIndex((m) => m.uri === memory.uri)
);
const leafOnly = uniqueMemories.filter((m) => !m.level || m.level === 2);
const processed = postProcessMemories(leafOnly, {
const thresholded = leafOnly.filter((memory) =>
passesRecallScoreThreshold(memory, cfg.recallScoreThreshold)
);
const processed = postProcessMemories(thresholded, {
limit: candidateLimit,
scoreThreshold: cfg.recallScoreThreshold,
scoreThreshold: 0,
});
const memories = pickMemoriesForInjection(processed, cfg.recallLimit, queryText);

if (memories.length === 0) {
return { memoryCount: 0, estimatedTokens: 0 };
}
if (
isVagueRecallQuery(queryText) &&
memories.every((memory) => clampScore(memory.score) < VAGUE_RECALL_SCORE_FLOOR)
) {
verbose?.(
`openviking: skipping auto-recall injection for vague query; bestScore=${Math.max(
...memories.map((memory) => clampScore(memory.score)),
).toFixed(3)} threshold=${VAGUE_RECALL_SCORE_FLOOR}`,
);
return { memoryCount: 0, estimatedTokens: 0 };
}

const { lines: memoryLines, estimatedTokens } = await buildMemoryLinesWithBudget(
memories,
Expand Down
7 changes: 5 additions & 2 deletions examples/openclaw-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "./text-utils.js";
import {
clampScore,
passesRecallScoreThreshold,
postProcessMemories,
pickMemoriesForInjection,
} from "./memory-ranking.js";
Expand Down Expand Up @@ -1090,10 +1091,12 @@ const contextEnginePlugin = {
};
}

const leafOnly = (result.memories ?? []).filter((m) => !m.level || m.level === 2);
const leafOnly = (result.memories ?? [])
.filter((m) => !m.level || m.level === 2)
.filter((m) => passesRecallScoreThreshold(m, scoreThreshold));
const processed = postProcessMemories(leafOnly, {
limit: requestLimit,
scoreThreshold,
scoreThreshold: 0,
});
const memories = pickMemoriesForInjection(processed, limit, query);
if (memories.length === 0) {
Expand Down
72 changes: 69 additions & 3 deletions examples/openclaw-plugin/memory-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,25 @@ function isLeafLikeMemory(item: FindResultItem): boolean {
return item.level === 2;
}

const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
export function isResourceMemory(item: FindResultItem): boolean {
return item.uri.startsWith("viking://resources/");
}

export const RESOURCE_RECALL_SCORE_FLOOR = 0.56;

export function recallScoreThresholdForItem(item: FindResultItem, baseThreshold: number): number {
return isResourceMemory(item) ? Math.max(baseThreshold, RESOURCE_RECALL_SCORE_FLOOR) : baseThreshold;
}

export function passesRecallScoreThreshold(item: FindResultItem, baseThreshold: number): boolean {
return clampScore(item.score) >= recallScoreThresholdForItem(item, baseThreshold);
}

const PREFERENCE_QUERY_RE =
/prefer|preference|favorite|favourite|like|решил|решила|предпочита|люблю|нравит|хочу|выбрал|выбрала|偏好|喜欢|爱好|更倾向/i;
const TEMPORAL_QUERY_RE =
/when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i;
const QUERY_TOKEN_RE = /[a-z0-9]{2,}/gi;
/when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|когда|дата|день|месяц|год|вчера|сегодня|завтра|прошл|следующ|недел|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i;
const QUERY_TOKEN_RE = /[\p{L}\p{N}][\p{L}\p{N}_-]+/giu;
const QUERY_TOKEN_STOPWORDS = new Set([
"what",
"when",
Expand All @@ -174,7 +189,48 @@ const QUERY_TOKEN_STOPWORDS = new Set([
"this",
"your",
"you",
"что",
"это",
"как",
"где",
"когда",
"там",
"тут",
"мне",
"меня",
"мой",
"моя",
"мои",
"про",
"для",
"или",
"если",
"уже",
"еще",
"ещё",
"надо",
"нужно",
"давай",
"посмотри",
"проверь",
"подскажи",
]);
const VAGUE_QUERY_TOKEN_STOPWORDS = new Set([
...QUERY_TOKEN_STOPWORDS,
"было",
"будет",
"дальше",
"сделай",
"разберись",
"разобраться",
"проверим",
"проверить",
"сейчас",
"раньше",
"после",
]);
const VAGUE_QUERY_RE =
/^(посмотри|проверь|подскажи|разберись|давай|что дальше|what now|check|look|tell me)\b/i;

type RecallQueryProfile = {
tokens: string[];
Expand All @@ -193,6 +249,16 @@ function buildRecallQueryProfile(query: string): RecallQueryProfile {
};
}

export function isVagueRecallQuery(queryText: string): boolean {
const text = queryText.trim();
if (!text) {
return true;
}
const tokens = text.toLowerCase().match(QUERY_TOKEN_RE) ?? [];
const meaningfulTokens = tokens.filter((token) => !VAGUE_QUERY_TOKEN_STOPWORDS.has(token));
return meaningfulTokens.length <= 1 || (VAGUE_QUERY_RE.test(text) && meaningfulTokens.length <= 2);
}

function lexicalOverlapBoost(tokens: string[], text: string): number {
if (tokens.length === 0 || !text) {
return 0;
Expand Down
27 changes: 27 additions & 0 deletions examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ describe("buildMemoryLines", () => {

expect(lines[0]).toContain("[memory]");
});

it("defaults blank category to 'memory'", async () => {
const memories = [makeMemory({ category: "" })];
const readFn = vi.fn();

const lines = await buildMemoryLines(memories, readFn, {
recallPreferAbstract: true,
});

expect(lines[0]).toBe("- [memory] Test memory abstract");
});
});

describe("buildMemoryLinesWithBudget", () => {
Expand Down Expand Up @@ -225,4 +236,20 @@ describe("buildMemoryLinesWithBudget", () => {
expect(lines).toHaveLength(0);
expect(estimatedTokens).toBe(0);
});

it("defaults blank category while applying the budget", async () => {
const memories = [makeMemory({ category: "", abstract: "short" })];
const readFn = vi.fn();

const { lines } = await buildMemoryLinesWithBudget(
memories,
readFn,
{
recallPreferAbstract: true,
recallMaxInjectedChars: 100,
},
);

expect(lines[0]).toBe("- [memory] short");
});
});
119 changes: 119 additions & 0 deletions examples/openclaw-plugin/tests/ut/context-engine-assemble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,125 @@ describe("context-engine assemble()", () => {
}
});

it("skips low-score resources during auto-recall", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "ok" }),
}),
);
try {
const { engine, client } = makeEngine(
{
latest_archive_overview: "unused",
pre_archive_abstracts: [],
messages: [],
estimatedTokens: 0,
stats: makeStats(),
},
{
cfgOverrides: {
autoRecall: true,
recallResources: true,
recallScoreThreshold: 0.5,
recallPreferAbstract: true,
},
},
);
client.find
.mockResolvedValueOnce({ memories: [], total: 0 })
.mockResolvedValueOnce({ memories: [], total: 0 })
.mockResolvedValueOnce({
resources: [
{
uri: "viking://resources/second-brain/noisy-transcript.md",
level: 2,
category: "",
abstract: "A loose raw transcript with unrelated meeting chatter.",
score: 0.55,
},
],
total: 1,
});

const sourceMessages = [
{ role: "assistant", content: "Previous answer." },
{ role: "user", content: "Посмотри что там было и подскажи" },
];

const result = await engine.assemble({
sessionId: "session-low-score-resource",
messages: sourceMessages,
});

expect(result.messages).toBe(sourceMessages);
expect(client.find).toHaveBeenCalledTimes(3);
} finally {
vi.unstubAllGlobals();
}
});

it("still injects strong user memories for Russian OpenViking queries", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "ok" }),
}),
);
try {
const { engine, client } = makeEngine(
{
latest_archive_overview: "unused",
pre_archive_abstracts: [],
messages: [],
estimatedTokens: 0,
stats: makeStats(),
},
{
cfgOverrides: {
autoRecall: true,
recallResources: true,
recallScoreThreshold: 0.5,
recallPreferAbstract: true,
},
},
);
client.find
.mockResolvedValueOnce({
memories: [
{
uri: "viking://user/default/memories/entities/openviking/docker_regression_test.md",
level: 2,
category: "",
abstract: "OV Docker regression marker survived the migration.",
score: 0.68,
},
],
total: 1,
})
.mockResolvedValueOnce({ memories: [], total: 0 })
.mockResolvedValueOnce({ resources: [], total: 0 });

const sourceMessages = [
{ role: "assistant", content: "Previous answer." },
{ role: "user", content: "Напомни контрольную метку OV Docker regression test после миграции" },
];

const result = await engine.assemble({
sessionId: "session-russian-ov-query",
messages: sourceMessages,
});

expect(result.messages[1]?.content).toMatch(/^<relevant-memories>/);
expect(result.messages[1]?.content).toContain("[memory] OV Docker regression marker");
expect(result.messages[1]?.content).toContain(sourceMessages[1]!.content);
} finally {
vi.unstubAllGlobals();
}
});

it("passes through transformContext messages when the latest message is not user", async () => {
const { engine, getClient } = makeEngine(
{
Expand Down
Loading