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
141 changes: 132 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ interface PluginConfig {
thinkLevel?: ReflectionThinkLevel;
errorReminderMaxEntries?: number;
dedupeErrorSignals?: boolean;
/** Cooldown in ms between reflection triggers for the same session. Default: 120000 (2 min). Set to 0 to disable. */
serialCooldownMs?: number;
/** Agent/session patterns excluded from reflection injection. Supports exact match, wildcard prefix (e.g. "pi-"), and "temp:*". */
excludeAgents?: string[];
};
mdMirror?: { enabled?: boolean; dir?: string };
workspaceBoundary?: WorkspaceBoundaryConfig;
Expand Down Expand Up @@ -310,6 +314,33 @@ function resolveHookAgentId(
: parseAgentIdFromSessionKey(sessionKey)) || "main";
}

// Detect when agentId came from a chat_id / user: source (e.g. "657229412030480397").
// These are numeric Discord/Telegram IDs mistakenly used as agent IDs and cause
// auto-recall to timeout. We skip them rather than block all pure-numeric IDs
// to avoid false positives for intentionally numeric agent names.
function isChatIdBasedAgentId(agentId: string): boolean {
return /^\d+$/.test(agentId); // pure digits = almost certainly a chat_id, not a real agent
}

/**
* Returns true when agentId is invalid — either empty/undefined, detected as a
* numeric chat_id, or not present in the openclaw.json declared agents list.
* Pass `declaredAgents` (from config.declaredAgents) for authoritative validation.
*/
function isInvalidAgentIdFormat(
agentId: string | undefined,
declaredAgents?: Set<string>,
): boolean {
if (!agentId) return true;
// Pure numeric IDs are almost always chat_id extractions, not real agent IDs.
if (isChatIdBasedAgentId(agentId)) return true;
// If we have a declared agents list, treat unknown IDs as invalid.
if (declaredAgents && declaredAgents.size > 0 && !declaredAgents.has(agentId)) {
return true;
}
return false;
}

function resolveSourceFromSessionKey(sessionKey: string | undefined): string {
const trimmed = sessionKey?.trim() ?? "";
const match = /^agent:[^:]+:([^:]+)/.exec(trimmed);
Expand Down Expand Up @@ -1620,6 +1651,37 @@ const pluginVersion = getPluginVersion();
// hook/tool registration for the new API instance" regression that rwmjhb identified.
const _registeredApis = new WeakSet<OpenClawPluginApi>();

function isAgentOrSessionExcluded(
agentId: string,
sessionKey: string | undefined,
patterns: string[],
): boolean {
if (!Array.isArray(patterns) || patterns.length === 0) return false;

const cleanAgentId = agentId.trim();
const isInternal = typeof sessionKey === "string" &&
sessionKey.trim().startsWith("temp:memory-reflection");

for (const pattern of patterns) {
const p = typeof pattern === "string" ? pattern.trim() : "";
if (!p) continue;

if (p === "temp:*") {
if (isInternal) return true;
continue;
}

if (p.endsWith("-")) {
// Wildcard prefix match: "pi-" matches "pi-agent" but NOT "pilot" or "ping"
if (cleanAgentId.startsWith(p)) return true;
} else if (p === cleanAgentId) {
return true;
}
}

return false;
}

const memoryLanceDBProPlugin = {
id: "memory-lancedb-pro",
name: "Memory (LanceDB Pro)",
Expand Down Expand Up @@ -2222,15 +2284,22 @@ const memoryLanceDBProPlugin = {
const AUTO_RECALL_TIMEOUT_MS = parsePositiveInt(config.autoRecallTimeoutMs) ?? 5_000; // configurable; default raised from 3s to 5s for remote embedding APIs behind proxies
api.on("before_prompt_build", async (event: any, ctx: any) => {
// Per-agent exclusion: skip auto-recall for agents in the exclusion list.
const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey);
const sessionKey = (event as any).sessionKey as string | undefined;
const agentId = resolveHookAgentId(ctx?.agentId, sessionKey);
if (isInvalidAgentIdFormat(agentId, config.declaredAgents)) {
api.logger.debug?.(
`memory-lancedb-pro: auto-recall skipped \u2014 invalid agentId format '${agentId}'`,
);
return;
}
if (
agentId !== undefined &&
Array.isArray(config.autoRecallExcludeAgents) &&
config.autoRecallExcludeAgents.length > 0 &&
agentId !== undefined &&
config.autoRecallExcludeAgents.includes(agentId)
isAgentOrSessionExcluded(agentId, sessionKey, config.autoRecallExcludeAgents)
) {
api.logger.debug?.(
`memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`,
`memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}' (sessionKey=${sessionKey ?? "(none)"})`,
);
return;
}
Expand Down Expand Up @@ -2261,6 +2330,10 @@ const memoryLanceDBProPlugin = {
const recallWork = async (): Promise<{ prependContext: string } | undefined> => {
// Determine agent ID and accessible scopes
const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey);
if (isInvalidAgentIdFormat(agentId, config.declaredAgents)) {
api.logger.debug?.(`memory-lancedb-pro: auto-recall skip \u2014 invalid agentId '${agentId}'`);
return undefined;
}
const accessibleScopes = resolveScopeFilter(scopeManager, agentId);

// Use cached raw user message for the recall query to avoid channel
Expand Down Expand Up @@ -2577,6 +2650,10 @@ const memoryLanceDBProPlugin = {

// Determine agent ID and default scope
const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey);
if (isInvalidAgentIdFormat(agentId, config.declaredAgents)) {
api.logger.debug(`memory-lancedb-pro: auto-capture skip \u2014 invalid agentId '${agentId}'`);
return;
}
const accessibleScopes = resolveScopeFilter(scopeManager, agentId);
const defaultScope = isSystemBypassId(agentId)
? config.scopes?.default ?? "global"
Expand Down Expand Up @@ -3092,6 +3169,10 @@ const memoryLanceDBProPlugin = {
typeof ctx.agentId === "string" ? ctx.agentId : undefined,
sessionKey,
);
if (isInvalidAgentIdFormat(agentId, config.declaredAgents)) {
api.logger.debug?.(`memory-lancedb-pro: reflection inheritance skip \u2014 invalid agentId '${agentId}'`);
return;
}
const scopes = resolveScopeFilter(scopeManager, agentId);
const slices = await loadAgentReflectionSlices(agentId, scopes);
if (slices.invariants.length === 0) return;
Expand All @@ -3116,6 +3197,10 @@ const memoryLanceDBProPlugin = {
typeof ctx.agentId === "string" ? ctx.agentId : undefined,
sessionKey,
);
if (isInvalidAgentIdFormat(agentId, config.declaredAgents)) {
api.logger.debug?.(`memory-lancedb-pro: reflection derived+error skip \u2014 invalid agentId '${agentId}'`);
return;
}
pruneReflectionSessionState();

const blocks: string[] = [];
Expand Down Expand Up @@ -3199,22 +3284,28 @@ const memoryLanceDBProPlugin = {
api.logger.info(`memory-reflection: skipping re-entrant call for sessionKey=${sessionKey}; already running (global guard)`);
return;
}
// Parse context before guards so cfg is available for serialCooldownMs
const context = (event.context || {}) as Record<string, unknown>;
const cfg = context.cfg;
// Serial loop guard: skip if a reflection for this sessionKey completed recently
if (sessionKey) {
const serialGuard = getSerialGuardMap();
const lastRun = serialGuard.get(sessionKey);
if (lastRun && (Date.now() - lastRun) < SERIAL_GUARD_COOLDOWN_MS) {
api.logger.info(`memory-reflection: skipping serial re-trigger for sessionKey=${sessionKey}; last run ${(Date.now() - lastRun) / 1000}s ago (cooldown=${SERIAL_GUARD_COOLDOWN_MS / 1000}s)`);
return;
if (lastRun) {
const cooldownMs = typeof (cfg?.memoryReflection as Record<string, unknown> | undefined)?.serialCooldownMs === "number"
? (cfg!.memoryReflection as Record<string, unknown>).serialCooldownMs as number
: 120_000;
if ((Date.now() - lastRun) < cooldownMs) {
api.logger.info(`memory-reflection: command hook skipped (cooldown ${((Date.now() - lastRun) / 1000).toFixed(0)}s/${(cooldownMs / 1000).toFixed(0)}s, sessionKey=${sessionKey})`);
return;
}
}
}
if (sessionKey) globalLock.set(sessionKey, true);
let reflectionRan = false;
try {
pruneReflectionSessionState();
const action = String(event?.action || "unknown");
const context = (event.context || {}) as Record<string, unknown>;
const cfg = context.cfg;
const workspaceDir = resolveWorkspaceDirFromContext(context);
if (!cfg) {
api.logger.warn(`memory-reflection: command:${action} missing cfg in hook context; skip reflection`);
Expand All @@ -3225,6 +3316,17 @@ const memoryLanceDBProPlugin = {
const currentSessionId = typeof sessionEntry.sessionId === "string" ? sessionEntry.sessionId : "unknown";
let currentSessionFile = typeof sessionEntry.sessionFile === "string" ? sessionEntry.sessionFile : undefined;
const sourceAgentId = parseAgentIdFromSessionKey(sessionKey) || "main";
// Exclude agents/sessions listed in memoryReflection.excludeAgents (supports wildcards)
const excludePatterns = (cfg as Record<string, unknown> | undefined)?.memoryReflection
? ((cfg as Record<string, unknown>).memoryReflection as Record<string, unknown>)?.excludeAgents as string[] | undefined
: undefined;
if (excludePatterns && isAgentOrSessionExcluded(sourceAgentId, sessionKey, excludePatterns)) {
api.logger.debug?.(
`memory-reflection: command hook skipped (excluded agent=${sourceAgentId}, sessionKey=${sessionKey ?? "(none)"})`,
);
return;
}

const commandSource = typeof context.commandSource === "string" ? context.commandSource : "";
api.logger.info(
`memory-reflection: command:${action} hook start; sessionKey=${sessionKey || "(none)"}; source=${commandSource || "(unknown)"}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`
Expand Down Expand Up @@ -3568,6 +3670,10 @@ const memoryLanceDBProPlugin = {
typeof ctx.agentId === "string" ? ctx.agentId : undefined,
sessionKey,
);
if (isInvalidAgentIdFormat(agentId, config.declaredAgents)) {
api.logger.debug?.(`session-memory [before_reset]: skip \u2014 invalid agentId '${agentId}'`);
return;
}
const defaultScope = isSystemBypassId(agentId)
? config.scopes?.default ?? "global"
: scopeManager.getDefaultScope(agentId);
Expand Down Expand Up @@ -3887,6 +3993,23 @@ 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,
// Build declaredAgents Set from openclaw.json agents.list for fast validation.
declaredAgents: (() => {
const s = new Set<string>();
const agentsList = (cfg as Record<string, unknown>).agents as Record<string, unknown> | undefined;
if (agentsList) {
const list = agentsList.list as unknown;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object") {
const id = (entry as Record<string, unknown>).id;
if (typeof id === "string" && id.trim().length > 0) s.add(id.trim());
}
}
}
}
return s;
})(),
captureAssistant: cfg.captureAssistant === true,
retrieval:
typeof cfg.retrieval === "object" && cfg.retrieval !== null
Expand Down
107 changes: 94 additions & 13 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 @@ -161,7 +163,12 @@
},
"recallMode": {
"type": "string",
"enum": ["full", "summary", "adaptive", "off"],
"enum": [
"full",
"summary",
"adaptive",
"off"
],
"default": "full",
"description": "Auto-recall depth mode. 'full': inject with configured per-item budget. 'summary': L0 abstracts only (compact). 'adaptive': analyze query intent to auto-select category and depth. 'off': disable auto-recall injection."
},
Expand Down Expand Up @@ -260,23 +267,78 @@
"type": "object",
"additionalProperties": false,
"properties": {
"utility": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 },
"confidence": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 },
"novelty": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 },
"recency": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 },
"typePrior": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.6 }
"utility": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.1
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.1
},
"novelty": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.1
},
"recency": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.1
},
"typePrior": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.6
}
}
},
"typePriors": {
"type": "object",
"additionalProperties": false,
"properties": {
"profile": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.95 },
"preferences": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.9 },
"entities": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.75 },
"events": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.45 },
"cases": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.8 },
"patterns": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.85 }
"profile": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.95
},
"preferences": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.9
},
"entities": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.75
},
"events": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.45
},
"cases": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.8
},
"patterns": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.85
}
}
}
}
Expand Down Expand Up @@ -650,6 +712,18 @@
"dedupeErrorSignals": {
"type": "boolean",
"default": true
},
"serialCooldownMs": {
"type": "integer",
"minimum": 0,
"description": "Cooldown in ms between reflection triggers for the same session. Default: 120000 (2 min). Set to 0 to disable."
},
"excludeAgents": {
"type": "array",
"items": {
"type": "string"
},
"description": "Agent/session patterns excluded from reflection injection. Supports exact match, wildcard prefix (e.g. pi-), and temp:*."
}
}
},
Expand Down Expand Up @@ -853,6 +927,13 @@
"description": "Maximum number of auto-capture extractions allowed per hour"
}
}
},
"autoRecallExcludeAgents": {
"type": "array",
"items": {
"type": "string"
},
"description": "Agent/session patterns excluded from auto-recall and reflection injection. Supports exact match, wildcard prefix (e.g. pi-), and temp:*."
}
}
},
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const CI_TEST_MANIFEST = [
{ group: "core-regression", runner: "node", file: "test/smart-metadata-v2.mjs" },
{ group: "storage-and-schema", runner: "node", file: "test/vector-search-cosine.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/context-support-e2e.mjs" },
{ group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/temporal-facts.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/memory-update-supersede.test.mjs" },
{ group: "llm-clients-and-auth", runner: "node", file: "test/memory-upgrader-diagnostics.test.mjs" },
Expand Down
Loading
Loading