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
38 changes: 27 additions & 11 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ interface PluginConfig {
recallMode?: "full" | "summary" | "adaptive" | "off";
/** Agent IDs excluded from auto-recall injection. Useful for background agents (e.g. memory-distiller, cron workers) whose output should not be contaminated by injected memory context. */
autoRecallExcludeAgents?: string[];
/** Agent IDs included in auto-recall injection (whitelist mode). When set, ONLY these agents receive auto-recall. Cannot be used together with autoRecallExcludeAgents. */
autoRecallIncludeAgents?: string[];
captureAssistant?: boolean;
retrieval?: {
mode?: "hybrid" | "vector";
Expand Down Expand Up @@ -2291,18 +2293,29 @@ const memoryLanceDBProPlugin = {
const sessionKey = typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
if (sessionKey.includes(":subagent:")) return;

// Per-agent exclusion: skip auto-recall for agents in the exclusion list.
// Per-agent inclusion/exclusion: autoRecallIncludeAgents takes precedence over autoRecallExcludeAgents.
// - If autoRecallIncludeAgents is set: ONLY these agents receive auto-recall
// - Else if autoRecallExcludeAgents is set: all agents EXCEPT these receive auto-recall

const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey);
if (
Array.isArray(config.autoRecallExcludeAgents) &&
config.autoRecallExcludeAgents.length > 0 &&
agentId !== undefined &&
config.autoRecallExcludeAgents.includes(agentId)
) {
api.logger.debug?.(
`memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`,
);
return;
if (agentId !== undefined) {
if (Array.isArray(config.autoRecallIncludeAgents) && config.autoRecallIncludeAgents.length > 0) {
if (!config.autoRecallIncludeAgents.includes(agentId)) {
api.logger.debug?.(
`memory-lancedb-pro: auto-recall skipped for agent '${agentId}' not in autoRecallIncludeAgents`,
);
return;
}
} else if (
Array.isArray(config.autoRecallExcludeAgents) &&
config.autoRecallExcludeAgents.length > 0 &&
config.autoRecallExcludeAgents.includes(agentId)
) {
api.logger.debug?.(
`memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`,
);
return;
}
}

// Manually increment turn counter for this session
Expand Down Expand Up @@ -3997,6 +4010,9 @@ export function parsePluginConfig(value: unknown): PluginConfig {
autoRecallExcludeAgents: Array.isArray(cfg.autoRecallExcludeAgents)
? cfg.autoRecallExcludeAgents.filter((id: unknown): id is string => typeof id === "string" && id.trim() !== "")
: undefined,
autoRecallIncludeAgents: Array.isArray(cfg.autoRecallIncludeAgents)
? cfg.autoRecallIncludeAgents.filter((id: unknown): id is string => typeof id === "string" && id.trim() !== "")
: undefined,
captureAssistant: cfg.captureAssistant === true,
retrieval:
typeof cfg.retrieval === "object" && cfg.retrieval !== null
Expand Down
19 changes: 17 additions & 2 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, long-context chunking, and management CLI",
"version": "1.1.0-beta.10",
"kind": "memory",
"skills": ["./skills"],
"skills": [
"./skills"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -854,7 +856,10 @@
}
}
}
}
},
"required": [
"embedding"
]
},
"uiHints": {
"embedding.apiKey": {
Expand Down Expand Up @@ -1376,6 +1381,16 @@
"label": "Max Extractions Per Hour",
"help": "Rate limit for auto-capture extractions. Prevents excessive LLM calls during rapid-fire sessions.",
"advanced": true
},
"autoRecallExcludeAgents": {
"label": "Auto-Recall Excluded Agents",
"help": "Agent IDs excluded from auto-recall injection. Cannot be used with autoRecallIncludeAgents.",
"advanced": true
},
"autoRecallIncludeAgents": {
"label": "Auto-Recall Included Agents",
"help": "Whitelist mode: only these agents receive auto-recall. Overrides autoRecallExcludeAgents.",
"advanced": true
}
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"author": "win4r",
"license": "MIT",
"scripts": {
"test": "node scripts/verify-ci-test-manifest.mjs && npm run test:cli-smoke && npm run test:core-regression && npm run test:storage-and-schema && npm run test:llm-clients-and-auth && npm run test:packaging-and-workflow",
"test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs",
"test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke",
"test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression",
"test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema",
Expand Down Expand Up @@ -62,4 +62,4 @@
"jiti": "^2.6.0",
"typescript": "^5.9.3"
}
}
}
193 changes: 193 additions & 0 deletions test/per-agent-auto-recall.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import jitiFactory from "jiti";

const testDir = path.dirname(fileURLToPath(import.meta.url));
const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs");
const jiti = jitiFactory(import.meta.url, {
interopDefault: true,
alias: {
"openclaw/plugin-sdk": pluginSdkStubPath,
},
});
const { parsePluginConfig } = jiti("../index.ts");

function baseConfig() {
return {
embedding: {
apiKey: "test-api-key",
},
};
}

describe("autoRecallExcludeAgents", () => {
it("defaults to undefined when not specified", () => {
const parsed = parsePluginConfig(baseConfig());
assert.equal(parsed.autoRecallExcludeAgents, undefined);
});

it("parses a valid array of agent IDs", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallExcludeAgents: ["saffron", "maple", "matcha"],
});
assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple", "matcha"]);
});

it("filters out non-string entries", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallExcludeAgents: ["saffron", null, 123, "maple", undefined, ""],
});
assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple"]);
});

it("filters out whitespace-only strings", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallExcludeAgents: ["saffron", " ", "\t", "maple"],
});
assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple"]);
});

it("returns empty array for empty array input (not undefined)", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallExcludeAgents: [],
});
// Empty array stays as [] — falsy check via length is the right way to handle
assert.ok(Array.isArray(parsed.autoRecallExcludeAgents));
assert.equal(parsed.autoRecallExcludeAgents.length, 0);
});

it("handles single agent ID", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallExcludeAgents: ["cron-worker"],
});
assert.deepEqual(parsed.autoRecallExcludeAgents, ["cron-worker"]);
});
});

describe("autoRecallIncludeAgents", () => {
it("defaults to undefined when not specified", () => {
const parsed = parsePluginConfig(baseConfig());
assert.equal(parsed.autoRecallIncludeAgents, undefined);
});

it("parses a valid array of agent IDs", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallIncludeAgents: ["saffron", "maple"],
});
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]);
});

it("filters out non-string entries", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallIncludeAgents: ["saffron", null, 123, "maple"],
});
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]);
});

it("filters out whitespace-only strings", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallIncludeAgents: ["saffron", " ", "maple"],
});
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]);
});

it("returns empty array for empty array input (not undefined)", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallIncludeAgents: [],
});
assert.ok(Array.isArray(parsed.autoRecallIncludeAgents));
assert.equal(parsed.autoRecallIncludeAgents.length, 0);
});

it("handles single agent ID (whitelist mode)", () => {
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallIncludeAgents: ["sage"],
});
assert.deepEqual(parsed.autoRecallIncludeAgents, ["sage"]);
});

it("include takes precedence over exclude in parsing (both specified)", () => {
// Note: logic precedence is handled at runtime in before_prompt_build,
// not in the config parser. Parser accepts both.
const parsed = parsePluginConfig({
...baseConfig(),
autoRecallIncludeAgents: ["saffron"],
autoRecallExcludeAgents: ["maple"],
});
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron"]);
assert.deepEqual(parsed.autoRecallExcludeAgents, ["maple"]);
});
});

describe("mixed-agent scenarios", () => {
// Simulate the runtime logic for agent inclusion/exclusion
function shouldInjectMemory({ agentId, autoRecallIncludeAgents, autoRecallExcludeAgents }) {
if (agentId === undefined) return true; // no agent context, allow

// autoRecallIncludeAgents takes precedence (whitelist mode)
if (Array.isArray(autoRecallIncludeAgents) && autoRecallIncludeAgents.length > 0) {
return autoRecallIncludeAgents.includes(agentId);
}

// Fall back to exclude list (blacklist mode)
if (Array.isArray(autoRecallExcludeAgents) && autoRecallExcludeAgents.length > 0) {
return !autoRecallExcludeAgents.includes(agentId);
}

return true; // no include/exclude configured, allow all
}

it("whitelist mode: only included agents receive auto-recall", () => {
const cfg = { autoRecallIncludeAgents: ["saffron", "maple"] };
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true);
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true);
assert.equal(shouldInjectMemory({ agentId: "matcha", ...cfg }), false);
assert.equal(shouldInjectMemory({ agentId: "cron-worker", ...cfg }), false);
});

it("blacklist mode: all agents except excluded receive auto-recall", () => {
const cfg = { autoRecallExcludeAgents: ["cron-worker", "matcha"] };
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true);
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true);
assert.equal(shouldInjectMemory({ agentId: "cron-worker", ...cfg }), false);
assert.equal(shouldInjectMemory({ agentId: "matcha", ...cfg }), false);
});

it("whitelist takes precedence over blacklist when both set", () => {
const cfg = { autoRecallIncludeAgents: ["saffron"], autoRecallExcludeAgents: ["saffron", "maple"] };
// Include wins — saffron is in include list
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true);
// Exclude is ignored because include is set
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), false);
});

it("no include/exclude: all agents receive auto-recall", () => {
assert.equal(shouldInjectMemory({ agentId: "saffron" }), true);
assert.equal(shouldInjectMemory({ agentId: "maple" }), true);
assert.equal(shouldInjectMemory({ agentId: "matcha" }), true);
});

it("undefined agentId: allow auto-recall (no agent context)", () => {
const cfg = { autoRecallIncludeAgents: ["saffron"] };
assert.equal(shouldInjectMemory({ agentId: undefined, ...cfg }), true);
});

it("empty include list treated as no include configured", () => {
const cfg = { autoRecallIncludeAgents: [], autoRecallExcludeAgents: ["saffron"] };
// Empty include array = not configured, fall through to exclude
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), false);
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true);
});
});