Skip to content
Merged
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
31 changes: 28 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ interface PluginConfig {
autoRecallMaxChars?: number;
autoRecallPerItemMaxChars?: number;
captureAssistant?: boolean;
/** Default depth for auto-recall injection.
* - "full": inject complete text (default, backward compatible)
* - "l1": inject L1 overview from metadata (up to ~500 tokens)
* - "l0": inject L0 abstract from metadata (one line, ~100 tokens)
* Agent can use memory_drill_down tool to get deeper content on demand. */
recallDepthDefault?: "l0" | "l1" | "full";
retrieval?: {
mode?: "hybrid" | "vector";
vectorWeight?: number;
Expand Down Expand Up @@ -2260,16 +2266,33 @@ const memoryLanceDBProPlugin = {
return;
}

// Determine recall depth: l0 = compact abstract, l1 = overview, full = complete text
const depthDefault = config.recallDepthDefault || "full";

const preBudgetCandidates = governanceEligible.map((r) => {
const metaObj = parseSmartMetadata(r.entry.metadata, r.entry);
const displayCategory = metaObj.memory_category || r.entry.category;
const displayTier = metaObj.tier || "";
const tierPrefix = displayTier ? `[${displayTier.charAt(0).toUpperCase()}]` : "";
const abstract = metaObj.l0_abstract || r.entry.text;
const summary = sanitizeForContext(abstract).slice(0, autoRecallPerItemMaxChars);

// Select content based on recallDepthDefault
let displayText: string;
if (depthDefault === "l0") {
displayText = metaObj.l0_abstract || r.entry.text;
} else if (depthDefault === "l1") {
displayText = metaObj.l1_overview || metaObj.l0_abstract || r.entry.text;
} else {
// "full": use l2_content from metadata (true full text), fall back to entry.text
const l2 = typeof metaObj.l2_content === "string" ? metaObj.l2_content : null;
displayText = l2 || r.entry.text;
}

const summary = sanitizeForContext(displayText).slice(0, autoRecallPerItemMaxChars);
// Include short ID so agent can call memory_drill_down(id) for deeper content
const shortId = r.entry.id.slice(0, 8);
return {
id: r.entry.id,
prefix: `${tierPrefix}[${displayCategory}:${r.entry.scope}]`,
prefix: `${tierPrefix}[${displayCategory}:${r.entry.scope}|${shortId}]`,
summary,
chars: summary.length,
meta: metaObj,
Expand Down Expand Up @@ -3620,6 +3643,8 @@ export function parsePluginConfig(value: unknown): PluginConfig {
autoRecallMaxChars: parsePositiveInt(cfg.autoRecallMaxChars) ?? 600,
autoRecallPerItemMaxChars: parsePositiveInt(cfg.autoRecallPerItemMaxChars) ?? 180,
captureAssistant: cfg.captureAssistant === true,
recallDepthDefault: (["l0", "l1", "full"].includes(cfg.recallDepthDefault) ? cfg.recallDepthDefault : undefined) as
| "l0" | "l1" | "full" | undefined,
retrieval: typeof cfg.retrieval === "object" && cfg.retrieval !== null ? cfg.retrieval as any : undefined,
decay: typeof cfg.decay === "object" && cfg.decay !== null ? cfg.decay as any : undefined,
tier: typeof cfg.tier === "object" && cfg.tier !== null ? cfg.tier as any : undefined,
Expand Down
114 changes: 114 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,119 @@ export function registerMemoryExplainRankTool(
// Tool Registration Helper
// ============================================================================

/**
* memory_drill_down: Get deeper content for a memory.
* Use after seeing L0 summaries in auto-recall to get L1 overview or L2 full content.
*/
export function registerMemoryDrillDownTool(
api: OpenClawPluginApi,
context: ToolContext,
) {
api.registerTool(
(toolCtx) => {
const runtimeContext = resolveToolContext(context, toolCtx);
return {
name: "memory_drill_down",
label: "Memory Drill Down",
description:
"Get deeper content for a memory entry. Use after seeing compact summaries to get the full text or structured overview.",
parameters: Type.Object({
id: Type.String({
description: "Memory ID or prefix (at least 8 hex chars)",
}),
level: Type.Optional(
Type.Union([Type.Literal("overview"), Type.Literal("full")], {
description: "Content depth: 'overview' (L1) or 'full' (L2, default)",
default: "full",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, runtimeCtx) {
const { id, level = "full" } = params as {
id: string;
level?: "overview" | "full";
};

try {
const entry = await runtimeContext.store.getById(id);
if (!entry) {
return {
content: [
{
type: "text",
text: `No memory found with ID: ${id}`,
},
],
};
}

// Scope check: verify the requesting agent has access to this memory's scope
const agentId = resolveRuntimeAgentId(runtimeContext.agentId, runtimeCtx);
if (!runtimeContext.scopeManager.isAccessible(entry.scope, agentId)) {
return {
content: [
{
type: "text",
text: `Access denied: memory ${id.slice(0, 8)} belongs to scope "${entry.scope}" which is not accessible to agent "${agentId}"`,
},
],
};
}

// Parse metadata to extract L0/L1/L2
let meta: Record<string, unknown> = {};
if (entry.metadata) {
try {
meta = JSON.parse(entry.metadata);
} catch { /* malformed metadata, use raw text */ }
}

const l0 = typeof meta.l0_abstract === "string" ? meta.l0_abstract : null;
const l1 = typeof meta.l1_overview === "string" ? meta.l1_overview : null;
// entry.text stores L0 abstract; L2 full content is in metadata.l2_content
const l2 = typeof meta.l2_content === "string" ? meta.l2_content : entry.text;

let content: string;
if (level === "overview" && l1) {
content = `## ${entry.category} (L1 Overview)\n\n${l1}`;
} else {
content = `## ${entry.category} (Full Content)\n\n${l2}`;
}

const header = [
`**ID**: ${entry.id}`,
`**Category**: ${entry.category}`,
`**Scope**: ${entry.scope}`,
`**Importance**: ${entry.importance}`,
`**Created**: ${new Date(entry.timestamp).toISOString()}`,
l0 ? `**Abstract**: ${l0}` : null,
].filter(Boolean).join("\n");

return {
content: [
{
type: "text",
text: `${header}\n\n${content}`,
},
],
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Error drilling down: ${String(err)}`,
},
],
};
}
},
};
},
{ name: "memory_drill_down" },
);
}

export function registerAllMemoryTools(
api: OpenClawPluginApi,
context: ToolContext,
Expand All @@ -1917,6 +2030,7 @@ export function registerAllMemoryTools(
registerMemoryStoreTool(api, context);
registerMemoryForgetTool(api, context);
registerMemoryUpdateTool(api, context);
registerMemoryDrillDownTool(api, context);

// Management tools (optional)
if (options.enableManagementTools) {
Expand Down
109 changes: 109 additions & 0 deletions test/tiered-storage.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";

describe("Tiered storage: L0/L1/L2 metadata parsing", () => {
it("parses L0/L1/L2 from metadata JSON", () => {
const metadata = JSON.stringify({
l0_abstract: "User prefers TypeScript",
l1_overview: "User consistently chooses TypeScript over JavaScript for new projects, citing type safety.",
l2_content: "Full detailed content here...",
});
const parsed = JSON.parse(metadata);
assert.equal(parsed.l0_abstract, "User prefers TypeScript");
assert.ok(parsed.l1_overview.length > parsed.l0_abstract.length);
});

it("handles missing L0/L1 fields gracefully", () => {
const metadata = JSON.stringify({ accessCount: 5 });
const parsed = JSON.parse(metadata);
assert.equal(parsed.l0_abstract, undefined);
assert.equal(parsed.l1_overview, undefined);
});

it("handles malformed metadata JSON", () => {
const metadata = "not-json";
let parsed = null;
try {
parsed = JSON.parse(metadata);
} catch {
// Expected
}
assert.equal(parsed, null);
});
});

describe("Tiered storage: drill-down level selection", () => {
it("overview level returns L1 when available", () => {
const meta = {
l0_abstract: "Short summary",
l1_overview: "Detailed overview with context and reasoning",
};
const fullText = "Very long full text content...";
const level = "overview";

const result = level === "overview" && meta.l1_overview
? meta.l1_overview
: fullText;

assert.equal(result, meta.l1_overview);
});

it("overview level falls back to full text when L1 missing", () => {
const meta = { l0_abstract: "Short summary" };
const fullText = "Very long full text content...";
const level = "overview";

const result = level === "overview" && meta.l1_overview
? meta.l1_overview
: fullText;

assert.equal(result, fullText);
});

it("full level always returns full text", () => {
const meta = {
l0_abstract: "Short summary",
l1_overview: "Detailed overview",
};
const fullText = "Very long full text content...";
const level = "full";

const result = level === "full" ? fullText : meta.l1_overview || fullText;
assert.equal(result, fullText);
});
});

describe("Tiered storage: recallDepthDefault config", () => {
it("accepts valid depth values", () => {
for (const depth of ["l0", "l1", "full"]) {
assert.ok(["l0", "l1", "full"].includes(depth));
}
});

it("rejects invalid depth values", () => {
for (const depth of ["l3", "summary", "compact", undefined, null, 42]) {
assert.ok(!["l0", "l1", "full"].includes(depth));
}
});

it("defaults to full when not set (backward compat)", () => {
const configValue = undefined;
const resolved = configValue || "full";
assert.equal(resolved, "full");
});
});

describe("Tiered storage: L0 injection format", () => {
it("L0 abstract is compact enough for context injection", () => {
const l0 = "User prefers TypeScript for all new projects";
// L0 should be under 100 tokens (~400 chars)
assert.ok(l0.length < 400, "L0 should be compact");
});

it("L1 overview provides medium detail", () => {
const l1 = "## Preference\nUser consistently chooses TypeScript over JavaScript. Cited reasons: type safety, better IDE support, easier refactoring.";
// L1 should be under 500 tokens (~2000 chars)
assert.ok(l1.length < 2000, "L1 should be medium detail");
assert.ok(l1.length > 50, "L1 should be more than L0");
});
});
Loading