diff --git a/examples/openclaw-plugin/auto-recall.ts b/examples/openclaw-plugin/auto-recall.ts
index 1538945cc3..73131c0ab6 100644
--- a/examples/openclaw-plugin/auto-recall.ts
+++ b/examples/openclaw-plugin/auto-recall.ts
@@ -12,6 +12,7 @@ import {
resolveRecallSearchPlan,
type RecallResourceType,
} from "./registries/recall-resource-types.js";
+import { isPreviewableImageResourceUri, withPreviewUrls } from "./preview-url.js";
import {
type RecallTraceEntry,
type RecallTraceResult,
@@ -99,6 +100,12 @@ export type BuildMemoryLinesOptions = {
};
function memoryCategory(item: FindResultItem): string {
+ if (item.preview_url && isPreviewableImageResourceUri(item.uri)) {
+ return item.category?.trim() || "resource:image";
+ }
+ if (item.uri.startsWith("viking://resources/")) {
+ return item.category?.trim() || "resource";
+ }
return item.category?.trim() || "memory";
}
@@ -116,14 +123,18 @@ function formatMemoryLine(
): string {
const category = memoryCategory(item);
if (!options.includeUri) {
- return `- [${category}] ${content}`;
+ return [
+ `- [${category}] ${content}`,
+ item.preview_url ? ` ${item.preview_url}` : "",
+ ].filter(Boolean).join("\n");
}
return [
`- [${category}]`,
` ${item.uri}`,
+ item.preview_url ? ` ${item.preview_url}` : "",
indentContent(content),
- ].join("\n");
+ ].filter(Boolean).join("\n");
}
async function resolveMemoryContent(
@@ -212,13 +223,17 @@ export async function buildMemoryLinesWithBudget(
}
export function buildRecallContextBlock(memoryLines: string[]): string {
+ const previewInstruction = memoryLines.some((line) => line.includes(""))
+ ? "If you reference an image result, copy its exact into the answer; do not invent or rewrite preview URLs."
+ : "";
return [
"",
AUTO_RECALL_SOURCE_MARKER,
"The following OpenViking memories may be relevant:",
+ previewInstruction,
...memoryLines,
"",
- ].join("\n");
+ ].filter(Boolean).join("\n");
}
function newTraceId(): string {
@@ -482,8 +497,11 @@ export async function buildAutoRecallContext(params: {
return { memoryCount: 0, estimatedTokens: 0 };
}
+ const memoriesWithPreview = cfg.enableResourcePreviewUrls
+ ? await withPreviewUrls(memories, client, agentId)
+ : memories;
const { lines: memoryLines, estimatedTokens } = await buildMemoryLinesWithBudget(
- memories,
+ memoriesWithPreview,
(uri) => client.read(uri, agentId),
{
recallPreferAbstract,
@@ -505,10 +523,10 @@ export async function buildAutoRecallContext(params: {
`openviking: injecting ${memoryLines.length} memories (${block.length} chars, ~${estimatedTokens} tokens, maxInjectedChars=${maxInjectedChars})`,
);
verbose?.(
- `openviking: inject-detail ${toJsonLog({ count: memories.length, memories: summarizeInjectionMemories(memories) })}`,
+ `openviking: inject-detail ${toJsonLog({ count: memoriesWithPreview.length, memories: summarizeInjectionMemories(memoriesWithPreview) })}`,
);
- await recordTrace(memories.slice(0, memoryLines.length), memoryLines.length, estimatedTokens);
+ await recordTrace(memoriesWithPreview.slice(0, memoryLines.length), memoryLines.length, estimatedTokens);
return { block, memoryCount: memoryLines.length, estimatedTokens };
})(),
cfg.autoRecallTimeoutMs,
diff --git a/examples/openclaw-plugin/client.ts b/examples/openclaw-plugin/client.ts
index d6d2f932b2..322f8325b7 100644
--- a/examples/openclaw-plugin/client.ts
+++ b/examples/openclaw-plugin/client.ts
@@ -21,6 +21,7 @@ export type FindResultItem = {
category?: string;
score?: number;
match_reason?: string;
+ preview_url?: string;
};
export type FindResult = {
@@ -30,6 +31,10 @@ export type FindResult = {
total?: number;
};
+export type PreviewUrlResult = {
+ preview_url: string;
+};
+
export type FsListEntry = string | Record;
export type FsListResult = FsListEntry[];
@@ -485,6 +490,16 @@ export class OpenVikingClient {
);
}
+ async getPreviewUrl(uri: string, actorPeerId?: string): Promise {
+ const result = await this.request(
+ `/api/v1/content/preview_url?uri=${encodeURIComponent(uri)}`,
+ {},
+ undefined,
+ actorPeerId,
+ );
+ return result.preview_url;
+ }
+
async list(
uri: string,
options?: {
diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts
index dbbc03182d..b702b9f220 100644
--- a/examples/openclaw-plugin/config.ts
+++ b/examples/openclaw-plugin/config.ts
@@ -20,6 +20,8 @@ export type MemoryOpenVikingConfig = {
autoRecall?: boolean;
/** Outer time budget for the whole auto-recall flow, including search, ranking, and reads. */
autoRecallTimeoutMs?: number;
+ /** Hidden switch for temporary preview URLs on recalled image resources. Default false. */
+ enableResourcePreviewUrls?: boolean;
/** Include resources in auto-recall and default memory_recall search. Default false. */
recallResources?: boolean;
recallLimit?: number;
@@ -394,6 +396,7 @@ export const memoryOpenVikingConfigSchema = {
"captureMaxLength",
"autoRecall",
"autoRecallTimeoutMs",
+ "enableResourcePreviewUrls",
"recallResources",
"recallLimit",
"recallScoreThreshold",
@@ -503,6 +506,7 @@ export const memoryOpenVikingConfigSchema = {
1000,
Math.min(300_000, Math.floor(toNumber(cfg.autoRecallTimeoutMs, DEFAULT_AUTO_RECALL_TIMEOUT_MS))),
),
+ enableResourcePreviewUrls: cfg.enableResourcePreviewUrls === true,
recallResources,
recallLimit: Math.max(1, Math.floor(toNumber(cfg.recallLimit, DEFAULT_RECALL_LIMIT))),
recallScoreThreshold: Math.min(
diff --git a/examples/openclaw-plugin/install-manifest.json b/examples/openclaw-plugin/install-manifest.json
index 23a8c69691..227e55c593 100644
--- a/examples/openclaw-plugin/install-manifest.json
+++ b/examples/openclaw-plugin/install-manifest.json
@@ -13,6 +13,7 @@
"context-engine.ts",
"auto-recall.ts",
"client.ts",
+ "preview-url.ts",
"process-manager.ts",
"memory-ranking.ts",
"token-estimator.ts",
diff --git a/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts b/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts
index 98b8d02e90..b30b94b60c 100644
--- a/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts
+++ b/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts
@@ -5,6 +5,7 @@ import type { BuildMemoryLinesWithBudgetOptions } from "../auto-recall.js";
import type { RankingOptions } from "../memory-ranking.js";
import type { EffectiveQueryConfig, QueryConfigContext, RuntimeQueryParams } from "../query-config.js";
import type { RecallResourceType } from "../registries/recall-resource-types.js";
+import { withPreviewUrls } from "../preview-url.js";
import type {
RecallTraceEntry,
RecallTraceResult,
@@ -36,6 +37,7 @@ export type OpenVikingMemoryRecallClient = {
},
) => Promise;
read: (uri: string, agentId?: string) => Promise;
+ getPreviewUrl?: (uri: string, actorPeerId?: string) => Promise;
getDefaultAgentId: () => string;
};
@@ -89,6 +91,7 @@ export type OpenVikingMemoryRecallToolsDeps = {
traceRecallPreviewChars: number;
traceRecallQueryMaxChars: number;
logFindRequests: boolean;
+ enableResourcePreviewUrls?: boolean;
};
logger: {
info?: (message: string) => void;
@@ -254,16 +257,22 @@ export function registerOpenVikingMemoryRecallTools(
};
}
- const leafOnly = (result.memories ?? []).filter((m) => !m.level || m.level === 2);
+ const leafOnly = [
+ ...(result.memories ?? []),
+ ...(result.resources ?? []),
+ ].filter((m) => !m.level || m.level === 2);
const processed = deps.postProcessMemories(leafOnly, {
limit: requestLimit,
scoreThreshold,
});
- const memories = deps.pickMemoriesForInjection(processed, limit, query, scoreThreshold, {
+ const pickedMemories = deps.pickMemoriesForInjection(processed, limit, query, scoreThreshold, {
weights: queryConfig.rankingWeights,
categoryWeights: queryConfig.categoryWeights,
resourceTypeWeights: queryConfig.resourceTypeWeights,
});
+ const memories = deps.cfg.enableResourcePreviewUrls
+ ? await withPreviewUrls(pickedMemories, recallClient, session.agentId)
+ : pickedMemories;
const candidateTraceResults = leafOnly
.map((item) => toTraceResult(item, deps.inferRecallResourceType(item.uri) === "resource" ? "resource" : "memory", deps))
.slice(0, deps.cfg.traceRecallMaxResultsPerSearch);
diff --git a/examples/openclaw-plugin/plugin/openviking-query-formatters.ts b/examples/openclaw-plugin/plugin/openviking-query-formatters.ts
index 42dcbc96aa..f4a207b460 100644
--- a/examples/openclaw-plugin/plugin/openviking-query-formatters.ts
+++ b/examples/openclaw-plugin/plugin/openviking-query-formatters.ts
@@ -22,6 +22,7 @@ export function formatOVSearchRows(result: FindResult): string[] {
if (items.length === 0) {
return [];
}
+ const includePreviewUrls = items.some(({ item }) => Boolean(item.preview_url));
const numberHeader = "no";
const numberWidth = Math.max(numberHeader.length, String(items.length).length);
const typeWidth = Math.max("type".length, ...items.map(({ contextType }) => contextType.length));
@@ -31,12 +32,17 @@ export function formatOVSearchRows(result: FindResult): string[] {
"score".length,
...items.map(({ item }) => (typeof item.score === "number" ? item.score.toFixed(2).length : 0)),
);
+ const previewWidth = includePreviewUrls
+ ? Math.max("preview_url".length, ...items.map(({ item }) => item.preview_url?.length ?? 0))
+ : 0;
+ const previewHeader = includePreviewUrls ? ` ${"preview_url".padEnd(previewWidth)}` : "";
return [
- `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)} abstract`,
+ `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)}${previewHeader} abstract`,
...items.map(({ contextType, item }, index) => {
const score = typeof item.score === "number" ? item.score.toFixed(2) : "";
+ const previewColumn = includePreviewUrls ? ` ${(item.preview_url ?? "").padEnd(previewWidth)}` : "";
const summary = truncateSummary(item.abstract || item.overview || "(no summary)");
- return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)} ${summary}`;
+ return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)}${previewColumn} ${summary}`;
}),
];
}
diff --git a/examples/openclaw-plugin/plugin/openviking-query-runtime.ts b/examples/openclaw-plugin/plugin/openviking-query-runtime.ts
index c57c504949..f5a029efb0 100644
--- a/examples/openclaw-plugin/plugin/openviking-query-runtime.ts
+++ b/examples/openclaw-plugin/plugin/openviking-query-runtime.ts
@@ -1,4 +1,5 @@
import type { FindResult, FindResultItem, FsListResult } from "../client.js";
+import { withFindResultPreviewUrls } from "../preview-url.js";
import type { RecallResourceType } from "../registries/recall-resource-types.js";
import type { RecallTraceEntry, RecallTraceResult } from "../recall-trace.js";
@@ -52,6 +53,7 @@ type OpenVikingQueryClient = {
agentId?: string,
) => Promise;
read: (uri: string, agentId?: string) => Promise;
+ getPreviewUrl?: (uri: string, actorPeerId?: string) => Promise;
list: (
uri: string,
options?: { recursive?: boolean; simple?: boolean; nodeLimit?: number },
@@ -77,6 +79,7 @@ export type OpenVikingQueryRuntimeDeps = {
traceRecallMaxResultsPerSearch: number;
traceRecallPreviewChars: number;
traceRecallQueryMaxChars: number;
+ enableResourcePreviewUrls?: boolean;
};
};
@@ -117,6 +120,7 @@ function formatOVSearchRows(result: FindResult): string[] {
if (items.length === 0) {
return [];
}
+ const includePreviewUrls = items.some(({ item }) => Boolean(item.preview_url));
const numberHeader = "no";
const numberWidth = Math.max(numberHeader.length, String(items.length).length);
const typeWidth = Math.max("type".length, ...items.map(({ contextType }) => contextType.length));
@@ -126,12 +130,17 @@ function formatOVSearchRows(result: FindResult): string[] {
"score".length,
...items.map(({ item }) => (typeof item.score === "number" ? item.score.toFixed(2).length : 0)),
);
+ const previewWidth = includePreviewUrls
+ ? Math.max("preview_url".length, ...items.map(({ item }) => item.preview_url?.length ?? 0))
+ : 0;
+ const previewHeader = includePreviewUrls ? ` ${"preview_url".padEnd(previewWidth)}` : "";
return [
- `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)} abstract`,
+ `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)}${previewHeader} abstract`,
...items.map(({ contextType, item }, index) => {
const score = typeof item.score === "number" ? item.score.toFixed(2) : "";
+ const previewColumn = includePreviewUrls ? ` ${(item.preview_url ?? "").padEnd(previewWidth)}` : "";
const summary = truncateSummary(item.abstract || item.overview || "(no summary)");
- return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)} ${summary}`;
+ return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)}${previewColumn} ${summary}`;
}),
];
}
@@ -412,6 +421,9 @@ export function createOpenVikingQueryRuntime(deps: OpenViki
}
result = mergeFindResults(successful);
}
+ if (deps.cfg.enableResourcePreviewUrls) {
+ result = await withFindResultPreviewUrls(result, client, agentId);
+ }
const selected = [
...(result.memories ?? []).map((item) => ({
uri: item.uri,
diff --git a/examples/openclaw-plugin/preview-url.ts b/examples/openclaw-plugin/preview-url.ts
new file mode 100644
index 0000000000..f8309f129d
--- /dev/null
+++ b/examples/openclaw-plugin/preview-url.ts
@@ -0,0 +1,68 @@
+import type { FindResult, FindResultItem } from "./client.js";
+
+const IMAGE_EXTENSIONS = new Set([
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".webp",
+ ".gif",
+ ".bmp",
+ ".svg",
+ ".avif",
+]);
+
+export type PreviewUrlClient = {
+ getPreviewUrl?: (uri: string, actorPeerId?: string) => Promise;
+};
+
+export function stripUriFragment(uri: string): string {
+ const hashIndex = uri.indexOf("#");
+ return hashIndex >= 0 ? uri.slice(0, hashIndex) : uri;
+}
+
+export function isPreviewableImageResourceUri(uri: string): boolean {
+ const normalized = stripUriFragment(uri).toLowerCase();
+ if (!normalized.startsWith("viking://resources/")) {
+ return false;
+ }
+ return [...IMAGE_EXTENSIONS].some((ext) => normalized.endsWith(ext));
+}
+
+export async function withPreviewUrls(
+ items: FindResultItem[],
+ client: PreviewUrlClient,
+ actorPeerId?: string,
+): Promise {
+ const getPreviewUrl = client.getPreviewUrl;
+ if (typeof getPreviewUrl !== "function") {
+ return items;
+ }
+
+ const enriched = await Promise.all(items.map(async (item) => {
+ if (item.preview_url || !isPreviewableImageResourceUri(item.uri)) {
+ return item;
+ }
+ try {
+ const previewUrl = await getPreviewUrl(stripUriFragment(item.uri), actorPeerId);
+ return previewUrl ? { ...item, preview_url: previewUrl } : item;
+ } catch {
+ return item;
+ }
+ }));
+
+ return enriched;
+}
+
+export async function withFindResultPreviewUrls(
+ result: FindResult,
+ client: PreviewUrlClient,
+ actorPeerId?: string,
+): Promise {
+ const resources = result.resources
+ ? await withPreviewUrls(result.resources, client, actorPeerId)
+ : result.resources;
+ return {
+ ...result,
+ resources,
+ };
+}
diff --git a/examples/openclaw-plugin/setup-helper/install.js b/examples/openclaw-plugin/setup-helper/install.js
index 652e7aca18..733a935f4e 100755
--- a/examples/openclaw-plugin/setup-helper/install.js
+++ b/examples/openclaw-plugin/setup-helper/install.js
@@ -71,6 +71,7 @@ const FALLBACK_CURRENT = {
"context-engine.ts",
"auto-recall.ts",
"client.ts",
+ "preview-url.ts",
"process-manager.ts",
"memory-ranking.ts",
"text-utils.ts",
diff --git a/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts b/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts
index 9c19c86b77..26bca47acf 100644
--- a/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts
+++ b/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts
@@ -84,6 +84,52 @@ describe("buildMemoryLines", () => {
]);
});
+ it("includes preview_url metadata when present", async () => {
+ const memories = [
+ makeMemory({
+ uri: "viking://resources/gallery/photo.png",
+ category: "",
+ abstract: "A reference photo.",
+ preview_url: "https://tos.example.com/photo.png?sig=1",
+ }),
+ ];
+ const readFn = vi.fn();
+
+ const lines = await buildMemoryLines(memories, readFn, {
+ recallPreferAbstract: true,
+ includeUri: true,
+ });
+
+ expect(lines).toEqual([
+ [
+ "- [resource:image]",
+ " viking://resources/gallery/photo.png",
+ " https://tos.example.com/photo.png?sig=1",
+ " A reference photo.",
+ ].join("\n"),
+ ]);
+ });
+
+ it("includes preview_url without uri metadata for explicit recall output", async () => {
+ const memories = [
+ makeMemory({
+ uri: "viking://resources/gallery/photo.jpg",
+ category: "",
+ abstract: "A second reference photo.",
+ preview_url: "https://tos.example.com/photo.jpg?sig=1",
+ }),
+ ];
+ const readFn = vi.fn();
+
+ const lines = await buildMemoryLines(memories, readFn, {
+ recallPreferAbstract: true,
+ });
+
+ expect(lines).toEqual([
+ "- [resource:image] A second reference photo.\n https://tos.example.com/photo.jpg?sig=1",
+ ]);
+ });
+
it("uses abstract when recallPreferAbstract=true", async () => {
const memories = [makeMemory({ abstract: "The abstract text" })];
const readFn = vi.fn();
@@ -224,6 +270,30 @@ describe("buildMemoryLinesWithBudget", () => {
expect(estimatedTokens).toBe(0);
});
+ it("counts preview_url text against the injected character budget", async () => {
+ const memories = [
+ makeMemory({
+ uri: "viking://resources/gallery/too-large.png",
+ abstract: "short",
+ preview_url: "https://tos.example.com/" + "x".repeat(100),
+ }),
+ ];
+ const readFn = vi.fn();
+
+ const { lines, estimatedTokens } = await buildMemoryLinesWithBudget(
+ memories,
+ readFn,
+ {
+ recallPreferAbstract: true,
+ recallMaxInjectedChars: 30,
+ includeUri: true,
+ },
+ );
+
+ expect(lines).toHaveLength(0);
+ expect(estimatedTokens).toBe(0);
+ });
+
it("returns correct estimatedTokens sum", async () => {
const memories = [
makeMemory({ abstract: "short" }),
@@ -453,4 +523,95 @@ describe("buildAutoRecallContext trace", () => {
expect(recorded.selected[0]).toMatchObject({ injected: true });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("auto-recall search failed"));
});
+
+ it("injects preview_url for auto-recalled image resources", async () => {
+ const cfg = makeCfg({
+ recallTargetTypes: ["resource"],
+ recallPreferAbstract: true,
+ enableResourcePreviewUrls: true,
+ });
+ const image = makeMemory({
+ uri: "viking://resources/gallery/photo.png#chunk-1",
+ category: "",
+ abstract: "Reference product image.",
+ score: 0.9,
+ });
+ const client = {
+ healthCheck: vi.fn().mockResolvedValue(undefined),
+ find: vi.fn().mockResolvedValue({ resources: [image], total: 1 }),
+ read: vi.fn().mockResolvedValue("unused"),
+ getPreviewUrl: vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"),
+ };
+
+ const result = await buildAutoRecallContext({
+ cfg,
+ client: client as any,
+ agentId: "agent-1",
+ queryText: "show me the product image",
+ logger: { info: vi.fn(), warn: vi.fn() },
+ });
+
+ expect(client.getPreviewUrl).toHaveBeenCalledWith("viking://resources/gallery/photo.png", "agent-1");
+ expect(result.block).toContain("https://tos.example.com/photo.png?sig=1");
+ expect(result.block).toContain("If you reference an image result");
+ });
+
+ it("does not call preview_url lookup when resource preview urls are disabled", async () => {
+ const cfg = makeCfg({ recallTargetTypes: ["resource"], recallPreferAbstract: true });
+ const image = makeMemory({
+ uri: "viking://resources/gallery/photo.png#chunk-1",
+ category: "",
+ abstract: "Reference product image.",
+ score: 0.9,
+ });
+ const client = {
+ healthCheck: vi.fn().mockResolvedValue(undefined),
+ find: vi.fn().mockResolvedValue({ resources: [image], total: 1 }),
+ read: vi.fn().mockResolvedValue("unused"),
+ getPreviewUrl: vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"),
+ };
+
+ const result = await buildAutoRecallContext({
+ cfg,
+ client: client as any,
+ agentId: "agent-1",
+ queryText: "show me the product image",
+ logger: { info: vi.fn(), warn: vi.fn() },
+ });
+
+ expect(client.getPreviewUrl).not.toHaveBeenCalled();
+ expect(result.block).toContain("Reference product image.");
+ expect(result.block).not.toContain("");
+ expect(result.block).not.toContain("If you reference an image result");
+ });
+
+ it("keeps auto-recall injection when preview_url lookup fails", async () => {
+ const cfg = makeCfg({
+ recallTargetTypes: ["resource"],
+ recallPreferAbstract: true,
+ enableResourcePreviewUrls: true,
+ });
+ const image = makeMemory({
+ uri: "viking://resources/gallery/photo.webp",
+ abstract: "Fallback image abstract.",
+ score: 0.9,
+ });
+ const client = {
+ healthCheck: vi.fn().mockResolvedValue(undefined),
+ find: vi.fn().mockResolvedValue({ resources: [image], total: 1 }),
+ read: vi.fn().mockResolvedValue("unused"),
+ getPreviewUrl: vi.fn().mockRejectedValue(new Error("preview unavailable")),
+ };
+
+ const result = await buildAutoRecallContext({
+ cfg,
+ client: client as any,
+ agentId: "agent-1",
+ queryText: "show me the fallback image",
+ logger: { info: vi.fn(), warn: vi.fn() },
+ });
+
+ expect(result.block).toContain("Fallback image abstract.");
+ expect(result.block).not.toContain("https://");
+ });
});
diff --git a/examples/openclaw-plugin/tests/ut/client.test.ts b/examples/openclaw-plugin/tests/ut/client.test.ts
index 3b485b4295..517711ac6f 100644
--- a/examples/openclaw-plugin/tests/ut/client.test.ts
+++ b/examples/openclaw-plugin/tests/ut/client.test.ts
@@ -82,6 +82,35 @@ describe("isMemoryUri", () => {
});
describe("OpenVikingClient resource and skill import", () => {
+ it("getPreviewUrl requests the content preview URL with plugin auth headers", async () => {
+ const transport = vi.fn().mockResolvedValue(
+ okResponse({ preview_url: "https://tos.example.com/image.png?sig=1" }),
+ );
+
+ const client = new OpenVikingClient("http://127.0.0.1:1933", "sk-user", "agent", 5000, "", "", undefined, false, true, { transport });
+ const result = await client.getPreviewUrl("viking://resources/gallery/image.png#chunk-1", "agent-main");
+
+ expect(result).toBe("https://tos.example.com/image.png?sig=1");
+ expect(transport).toHaveBeenCalledTimes(1);
+ const [url, init] = transport.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe("http://127.0.0.1:1933/api/v1/content/preview_url?uri=viking%3A%2F%2Fresources%2Fgallery%2Fimage.png%23chunk-1");
+ const headers = new Headers(init.headers);
+ expect(headers.get("X-API-Key")).toBe("sk-user");
+ expect(headers.get("X-OpenViking-Actor-Peer")).toBe("agent-main");
+ });
+
+ it("getPreviewUrl surfaces OpenViking error payloads through the shared request path", async () => {
+ const transport = vi.fn().mockResolvedValue(
+ errorResponse("cannot preview directory", "InvalidParameter"),
+ );
+
+ const client = new OpenVikingClient("http://127.0.0.1:1933", "sk-user", "agent", 5000, "", "", undefined, false, true, { transport });
+
+ await expect(client.getPreviewUrl("viking://resources/gallery/")).rejects.toThrow(
+ "OpenViking request failed [InvalidParameter]: cannot preview directory",
+ );
+ });
+
it("addResource posts remote URL as path", async () => {
const transport = vi.fn().mockResolvedValue(
okResponse({ root_uri: "viking://resources/site", status: "success" }),
diff --git a/examples/openclaw-plugin/tests/ut/config.test.ts b/examples/openclaw-plugin/tests/ut/config.test.ts
index b67613351f..cc9527c94d 100644
--- a/examples/openclaw-plugin/tests/ut/config.test.ts
+++ b/examples/openclaw-plugin/tests/ut/config.test.ts
@@ -23,6 +23,7 @@ describe("memoryOpenVikingConfigSchema.parse()", () => {
expect(cfg.captureMode).toBe("semantic");
expect(cfg.captureMaxLength).toBe(24000);
expect(cfg.autoRecallTimeoutMs).toBe(5000);
+ expect(cfg.enableResourcePreviewUrls).toBe(false);
expect(cfg.recallMaxContentChars).toBe(5000);
expect(cfg.peer_role).toBe("assistant");
expect(cfg.peer_prefix).toBe("");
@@ -64,6 +65,14 @@ describe("memoryOpenVikingConfigSchema.parse()", () => {
expect(enabled.disabledTools).not.toContain("add_resource");
});
+ it("enables resource preview urls only when explicitly allowed", () => {
+ const disabled = memoryOpenVikingConfigSchema.parse({});
+ expect(disabled.enableResourcePreviewUrls).toBe(false);
+
+ const enabled = memoryOpenVikingConfigSchema.parse({ enableResourcePreviewUrls: true });
+ expect(enabled.enableResourcePreviewUrls).toBe(true);
+ });
+
it("expands enabled and disabled tool groups", () => {
const cfg = memoryOpenVikingConfigSchema.parse({
enabledTools: ["resource_query", "memory"],
diff --git a/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts b/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts
index aae9ea48ca..92ec3c8db9 100644
--- a/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts
+++ b/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts
@@ -292,6 +292,99 @@ describe("plugin module seams", () => {
expect(readResult.content[0].text).toContain("--- START OF viking://resources/spec.md ---");
});
+ it("adds preview_url to ov_search image resource output", async () => {
+ const find = vi.fn().mockResolvedValue({
+ memories: [],
+ resources: [{
+ uri: "viking://resources/gallery/photo.png#chunk-1",
+ abstract: "Reference image",
+ score: 0.91,
+ category: "image",
+ }],
+ skills: [],
+ total: 1,
+ });
+ const getPreviewUrl = vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1");
+ const runtime = createOpenVikingQueryRuntime({
+ getClient: async () => ({
+ find,
+ read: vi.fn(),
+ list: vi.fn().mockResolvedValue([]),
+ getPreviewUrl,
+ }),
+ queryConfigStore: { getEffective: vi.fn().mockResolvedValue({ ovSearchLimit: 2 }) },
+ toQueryConfigContext: (session) => session,
+ traceRecorder: { recordAndFlush: vi.fn() },
+ inferRecallResourceType: () => "resource",
+ createTraceId: () => "trace-query-preview",
+ boundTraceQuery: (query) => ({ query }),
+ previewText: (value) => typeof value === "string" ? value : undefined,
+ logger: { warn: vi.fn() },
+ cfg: {
+ traceRecallMaxResultsPerSearch: 5,
+ traceRecallPreviewChars: 80,
+ traceRecallQueryMaxChars: 120,
+ enableResourcePreviewUrls: true,
+ },
+ });
+
+ const search = await runtime.searchOpenViking(
+ { query: "photo", uri: "viking://resources/gallery" },
+ "agent-main",
+ { agentId: "agent-main", sessionId: "session-1" },
+ ) as any;
+
+ expect(getPreviewUrl).toHaveBeenCalledWith("viking://resources/gallery/photo.png", "agent-main");
+ expect(search.content[0].text).toContain("https://tos.example.com/photo.png?sig=1");
+ expect(search.details.resources[0].preview_url).toBe("https://tos.example.com/photo.png?sig=1");
+ });
+
+ it("does not fetch preview_url for ov_search when resource preview urls are disabled", async () => {
+ const find = vi.fn().mockResolvedValue({
+ memories: [],
+ resources: [{
+ uri: "viking://resources/gallery/photo.png#chunk-1",
+ abstract: "Reference image",
+ score: 0.91,
+ category: "image",
+ }],
+ skills: [],
+ total: 1,
+ });
+ const getPreviewUrl = vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1");
+ const runtime = createOpenVikingQueryRuntime({
+ getClient: async () => ({
+ find,
+ read: vi.fn(),
+ list: vi.fn().mockResolvedValue([]),
+ getPreviewUrl,
+ }),
+ queryConfigStore: { getEffective: vi.fn().mockResolvedValue({ ovSearchLimit: 2 }) },
+ toQueryConfigContext: (session) => session,
+ traceRecorder: { recordAndFlush: vi.fn() },
+ inferRecallResourceType: () => "resource",
+ createTraceId: () => "trace-query-preview-disabled",
+ boundTraceQuery: (query) => ({ query }),
+ previewText: (value) => typeof value === "string" ? value : undefined,
+ logger: { warn: vi.fn() },
+ cfg: {
+ traceRecallMaxResultsPerSearch: 5,
+ traceRecallPreviewChars: 80,
+ traceRecallQueryMaxChars: 120,
+ },
+ });
+
+ const search = await runtime.searchOpenViking(
+ { query: "photo", uri: "viking://resources/gallery" },
+ "agent-main",
+ { agentId: "agent-main", sessionId: "session-1" },
+ ) as any;
+
+ expect(getPreviewUrl).not.toHaveBeenCalled();
+ expect(search.content[0].text).not.toContain("preview_url");
+ expect(search.details.resources[0].preview_url).toBeUndefined();
+ });
+
it("handles OpenViking query config commands through a dedicated module", async () => {
const session = { agentId: "agent-main", sessionId: "session-1", sessionKey: "key-1", ovSessionId: "ov-session-1" };
const queryCtx = { agentId: "agent-main", sessionId: "session-1", sessionKey: "key-1", ovSessionId: "ov-session-1" };
@@ -1005,4 +1098,82 @@ describe("plugin module seams", () => {
recallMaxInjectedChars: 500,
});
});
+
+ it("adds preview_url to memory_recall image resource lines before formatting", async () => {
+ const registerTool = vi.fn();
+ const image = {
+ uri: "viking://resources/gallery/photo.png#chunk-2",
+ category: "",
+ abstract: "Reference product image.",
+ score: 0.93,
+ level: 2,
+ };
+ const find = vi.fn().mockResolvedValue({ memories: [], resources: [image], total: 1 });
+ const read = vi.fn().mockResolvedValue("Reference product image.");
+ const getPreviewUrl = vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1");
+ const getDefaultAgentId = vi.fn().mockReturnValue("default-agent");
+ const buildMemoryLinesWithBudget = vi.fn(async (items, readFn) => {
+ await readFn(items[0].uri);
+ return {
+ lines: [`- [resource:image] ${items[0].abstract}\n ${items[0].preview_url}`],
+ estimatedTokens: 12,
+ };
+ });
+
+ registerOpenVikingMemoryRecallTools({
+ registerTool,
+ getClient: async () => ({ find, read, getPreviewUrl, getDefaultAgentId }),
+ queryConfigStore: {
+ getEffective: vi.fn().mockResolvedValue({
+ recallLimit: 1,
+ scoreThreshold: 0.5,
+ targetUri: "viking://resources/gallery",
+ resourceTypes: ["resource"],
+ candidateLimit: 4,
+ maxInjectedChars: 500,
+ rankingWeights: {},
+ categoryWeights: {},
+ resourceTypeWeights: {},
+ }),
+ },
+ toQueryConfigContext: (session) => ({ agentId: session.agentId, sessionId: session.sessionId, sessionKey: session.sessionKey, ovSessionId: session.ovSessionId }),
+ resolvePluginSessionRouting: () => ({
+ agentId: "agent-main",
+ sessionId: "session-1",
+ sessionKey: "agent:agent-main:session-1",
+ ovSessionId: "ov-session-1",
+ }),
+ isBypassedSession: () => false,
+ makeBypassedToolResult: (toolName: string) => ({ content: [{ type: "text" as const, text: `bypassed ${toolName}` }], details: { toolName } }),
+ resolveRecallSearchPlan: vi.fn(),
+ postProcessMemories: (items) => items,
+ pickMemoriesForInjection: (items) => items.slice(0, 1),
+ buildMemoryLinesWithBudget,
+ inferRecallResourceType: (uri) => uri.startsWith("viking://resources/") ? "resource" : "user",
+ createTraceId: () => "memory_recall-preview-trace",
+ boundTraceQuery: (query) => ({ query }),
+ previewText: (value) => typeof value === "string" ? value : undefined,
+ traceRecorder: { recordAndFlush: vi.fn() },
+ cfg: {
+ recallTargetTypes: ["resource"],
+ traceRecallMaxResultsPerSearch: 5,
+ traceRecallPreviewChars: 120,
+ traceRecallQueryMaxChars: 200,
+ logFindRequests: false,
+ enableResourcePreviewUrls: true,
+ },
+ logger: { info: vi.fn() },
+ });
+
+ const recallFactory = registerTool.mock.calls[0]?.[0];
+ const recallTool = recallFactory({ sessionId: "session-1" });
+ const result = await recallTool.execute("call-1", { query: "product image" });
+
+ expect(getPreviewUrl).toHaveBeenCalledWith("viking://resources/gallery/photo.png", "agent-main");
+ expect(buildMemoryLinesWithBudget.mock.calls[0]![0][0]).toMatchObject({
+ uri: "viking://resources/gallery/photo.png#chunk-2",
+ preview_url: "https://tos.example.com/photo.png?sig=1",
+ });
+ expect(result.content[0].text).toContain("https://tos.example.com/photo.png?sig=1");
+ });
});
diff --git a/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts b/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts
index 238f241e97..ee9e6e07a4 100644
--- a/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts
+++ b/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts
@@ -16,7 +16,12 @@ describe("openviking query formatters", () => {
{ uri: "viking://user/memory/1", level: 2, score: 0.987, abstract: "remember this" },
],
resources: [
- { uri: "viking://resources/doc", score: 0.7, overview: "resource overview" },
+ {
+ uri: "viking://resources/doc",
+ score: 0.7,
+ overview: "resource overview",
+ preview_url: "https://tos.example.com/doc?sig=1",
+ },
],
skills: [
{ uri: "skill://openviking-context-database", abstract: "skill overview" },
@@ -25,14 +30,26 @@ describe("openviking query formatters", () => {
});
expect(rows[0]).toContain("no type");
+ expect(rows[0]).toContain("preview_url");
expect(rows[1]).toContain("memory");
expect(rows[1]).toContain("viking://user/memory/1");
expect(rows[1]).toContain("0.99");
expect(rows[1]).toContain("remember this");
expect(rows.join("\n")).toContain("resource overview");
+ expect(rows.join("\n")).toContain("https://tos.example.com/doc?sig=1");
expect(rows.join("\n")).toContain("skill overview");
});
+ it("omits preview_url column when no search row has a preview url", () => {
+ const rows = formatOVSearchRows({
+ resources: [{ uri: "viking://resources/doc", score: 0.7, overview: "resource overview" }],
+ total: 1,
+ });
+
+ expect(rows[0]).not.toContain("preview_url");
+ expect(rows.join("\n")).toContain("resource overview");
+ });
+
it("formats search text and empty search text", () => {
expect(formatOVSearchText("query", "viking://resources", { total: 0 })).toBe(
'No OpenViking resource or skill results found for "query" under viking://resources.',