Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,3 @@ node_modules/
*.tsbuildinfo
.env
.DS_Store
src/
tsconfig.json
135 changes: 116 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { mkdirSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";

// Module-level flag: emit hardcoded-key warning only once per process lifetime
let _apiKeyWarningEmitted = false;

// Dynamic require for node:sqlite (available in Node 22+, avoids TS import issues)
let NodeDatabaseSync: any;
try {
Expand Down Expand Up @@ -87,6 +90,10 @@ function resolveEnv(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] ?? "");
}

function isEnvInterpolation(value: unknown): value is string {
return typeof value === "string" && /^\$\{[^}]+\}$/.test(value.trim());
}

function parseConfig(raw: unknown): EvaMemoryConfig {
const defaults: EvaMemoryConfig = {
cortexUrl: "http://localhost:8000",
Expand All @@ -98,7 +105,7 @@ function parseConfig(raw: unknown): EvaMemoryConfig {
retrievalBudget: 2000,
maxInjectionChars: 8000,
maxInjectedMemories: 8,
minRelevanceScore: 0.25,
minRelevanceScore: 0.30,
retrievalMode: "fast",
recencyFilterMinutes: 15,
injectCornerstones: false,
Expand Down Expand Up @@ -780,10 +787,29 @@ async function syncMemoryCache(
}
}

// --- Inbound metadata stripper ---

function stripInboundMetadata(prompt: string): string {
let text = prompt;
// Strip Sender/Conversation info metadata blocks
text = text.replace(/(?:Sender|Conversation info) \(untrusted metadata\):\n```json\n[\s\S]*?```\n?/g, "");
// Strip only OpenClaw-decorated system lines (not bare "System:" which could be user content)
text = text.replace(/^System \(untrusted\):.*$/gm, "");
// Strip [Thread history ...] block: header line + indented/prefixed lines until blank line
text = text.replace(/^\[Thread history[^\n]*\n(?:(?: |\w+:)[^\n]*\n)*\n?/gm, "");
// Strip timestamp prefixes at start of text like [Mon 2026-04-02 14:51 GMT+7]
text = text.replace(/^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}[^\]]*\]\s*/gm, "");
// Strip <relevant-memories>...</relevant-memories> blocks
text = text.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "");
// Collapse multiple newlines to single
text = text.replace(/\n{2,}/g, "\n");
return text.trim();
}

// --- Memory-relevance heuristic ---

const MEMORY_KEYWORDS = /\b(remember|forgot|recall|last time|previously|before|earlier|you said|you told|we discussed|we decided|my preference|my name|who am i|what do i|do you know|history|past|memory|memorize|don'?t forget)\b/i;
const TRIVIAL_PATTERNS = /^(hi|hello|hey|thanks|ok|yes|no|sure|bye|good morning|good night|👍|😊)[\s!?.]*$/i;
const TRIVIAL_PATTERNS = /^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|sure|bye|good morning|good night|go ahead|sounds good|do it|got it|lets do it|let's do it|lets go|let's go|cool|nice|great|agreed|done|will do|on it|yep|nope|nah|alright|perfect|roger|absolutely|definitely|for sure|ship it|merge it|deploy it|👍|😊|👌|🙏|✅)[\s!?.]*$/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Expanded TRIVIAL_PATTERNS includes actionable commands like 'deploy it' and 'ship it'

The TRIVIAL_PATTERNS regex at src/index.ts:812 was expanded to include actionable phrases like "do it", "ship it", "merge it", "deploy it". These patterns are used in both isMemoryRelevant (skips retrieval) and the new isTrivial branch (attempts augmentation with last assistant turn). In the old code, these short prompts without memory keywords would also skip retrieval (via the prompt.length < 40 check in isMemoryRelevant). The new code is marginally better: it at least attempts to augment with the last assistant turn for context. The key observation is that these actionable commands are typically confirmations of something the assistant proposed, so the assistant turn context is the most relevant signal for retrieval.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Sub-agent completion events and runtime system events should not trigger memory retrieval
const SYSTEM_EVENT_PATTERNS = /\[Internal task completion event\]|<<<BEGIN_UNTRUSTED_CHILD_RESULT>>>|source: subagent|type: subagent task|^\[.*\] Exec (?:completed|failed)|^\[.*\] OpenClaw runtime context/m;
Expand Down Expand Up @@ -897,7 +923,24 @@ function filterEchoMemories(
});
}

function formatMemoryContext(items: RetrievedItem[], maxChars: number, maxCount = 8, minScore = 0.25): string {
const MEMORY_PREAMBLE = `These are long-term memories retrieved from your memory system based on this conversation.
Each has an [item_id], [date], and [salience/category] label.

How to use these memories:
- identity/preferences/decisions: Trust as stable context. Reference when relevant.
- episodic: Specific past events. Check the date — may be outdated.
- goals: Strategic direction. Verify if context has changed since the date.
- behavioral/relational: Patterns and dynamics. Descriptive, not prescriptive.
- high salience: Identity-defining or emotionally significant. Handle with care.

Guidelines:
- Surface relevant preferences or prior decisions naturally in your response.
- If a memory might change your approach, ask a clarifying question before proceeding.
- If memories contradict the current request, flag the contradiction.
- Never expose raw item_ids, scores, or metadata to the user.
- To look up more context on any memory, use cortex_search with the item_id.`;

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preamble says to “use cortex_search with the item_id”, but formatMemoryContext() only includes the first 8 characters of item.item_id (e.g. [abcd1234]). That truncated ID is unlikely to be sufficient to search/retrieve the specific memory, so this guidance is not actionable. Either include the full item_id in the injected context (still within <relevant-memories>), or adjust the preamble to recommend searching by content/date/category instead of item_id.

Suggested change
- To look up more context on any memory, use cortex_search with the item_id.`;
- To look up more context on any memory, use cortex_search with a semantic query based on its content, date, salience, or category; do not rely on item_id.`;

Copilot uses AI. Check for mistakes.

function formatMemoryContext(items: RetrievedItem[], maxChars: number, maxCount = 8, minScore = 0.30): string {
if (!items.length) return "";

// Filter by relevance score and cap count
Expand All @@ -907,7 +950,8 @@ function formatMemoryContext(items: RetrievedItem[], maxChars: number, maxCount
if (!relevant.length) return "";

const lines: string[] = ["<relevant-memories>"];
let charCount = 0;
lines.push(MEMORY_PREAMBLE);
let charCount = MEMORY_PREAMBLE.length;
Comment on lines 961 to +963

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: MEMORY_PREAMBLE consumes ~1211 of 8000 char budget

The MEMORY_PREAMBLE is 1,211 characters long, consuming roughly 15% of the default maxInjectionChars budget (8000). The charCount in formatMemoryContext at src/index.ts:958 is initialized to MEMORY_PREAMBLE.length, and the overhead of the <relevant-memories> tag (~20 chars), newlines between elements, the optional footer line, and the closing tag (~21 chars) are not included in charCount. This means the actual injected text slightly exceeds maxInjectionChars by roughly 60-100 characters. With the default 8000 budget this is negligible (<2%), but users who set a tight maxInjectionChars value close to the preamble length would get no memories injected without understanding why.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


Comment on lines 961 to 965

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The maxChars budget accounting starts at MEMORY_PREAMBLE.length and then adds line.length, but it does not include the <relevant-memories> header line or newline separators between lines. With the new ~1KB preamble this can overshoot the intended maxInjectionChars budget by a non-trivial amount. Consider including the header + newline characters in charCount (or compute the final string length before returning).

Copilot uses AI. Check for mistakes.
for (const item of relevant) {
const tag = item.source === "cornerstone" ? " [cornerstone]" : "";
Expand All @@ -930,7 +974,7 @@ function formatMemoryContext(items: RetrievedItem[], maxChars: number, maxCount
charCount += line.length;
}

if (lines.length === 1) return ""; // Only header, no items fit
if (lines.length <= 2) return ""; // Only header + preamble, no actual items fit
lines.push("</relevant-memories>");
return lines.join("\n");
}
Expand All @@ -940,10 +984,16 @@ function formatMemoryContext(items: RetrievedItem[], maxChars: number, maxCount
function extractMessages(rawMessages: unknown[]): Array<{ role: string; content: string }> {
const result: Array<{ role: string; content: string }> = [];

for (const msg of rawMessages.slice(-10)) {
// Walk backwards from end to collect last 5 user/assistant messages
// Avoids filtering the entire session history (can be 30K+ messages / 70MB+)
const tail: Record<string, unknown>[] = [];
for (let i = rawMessages.length - 1; i >= 0 && tail.length < 5; i--) {
const msg = rawMessages[i];
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
if (m.role !== "user" && m.role !== "assistant") continue;
if (m.role === "user" || m.role === "assistant") tail.unshift(m);
}
Comment on lines +1007 to +1015

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: extractMessages reduced from 10 to 5 messages affects capture context

The extractMessages function was changed from rawMessages.slice(-10) (last 10 messages of any type, filtered for user/assistant) to walking backwards for the last 5 user/assistant messages specifically. This is an intentional optimization for large sessions (comment references 30K+ messages / 70MB+), but it reduces the conversation context sent to the Cortex remember API. In conversations with many interleaved tool calls, the new code skips further back to find user/assistant messages (good), but the reduced count means less context for memory extraction. For most conversations this is fine, but for complex multi-step workflows the 5-message window might miss important earlier context that the 10-message window would have captured.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

for (const m of tail) {

let text = "";
if (typeof m.content === "string") {
Expand Down Expand Up @@ -1003,7 +1053,7 @@ const cortexPlugin = {
retrievalBudget: { type: "number" },
maxInjectionChars: { type: "number" },
maxInjectedMemories: { type: "number", description: "Max memories to inject per turn (default: 8)" },
minRelevanceScore: { type: "number", description: "Min score to inject a memory (default: 0.25)" },
minRelevanceScore: { type: "number", description: "Min score to inject a memory (default: 0.30)" },
retrievalMode: { type: "string", enum: ["auto", "fast", "thorough"], description: "Retrieval mode for memory search (default: auto)" },
recencyFilterMinutes: { type: "number", description: "Filter out memories created within this many minutes to suppress echo (default: 15, 0 to disable)" },
},
Expand All @@ -1012,9 +1062,13 @@ const cortexPlugin = {
},

register(api: OpenClawPluginApi) {
const rawConfig = api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
? api.pluginConfig as Record<string, unknown>
: null;
const cfg = parseConfig(api.pluginConfig);

if (cfg.apiKey && !cfg.apiKey.startsWith("${")) {

if (cfg.apiKey && !isEnvInterpolation(rawConfig?.apiKey) && !_apiKeyWarningEmitted) {
_apiKeyWarningEmitted = true;
api.logger.warn("cortex: API key appears to be hardcoded in config. Consider using environment variable: apiKey: '${CORTEX_API_KEY}'");
Comment on lines +1090 to 1092

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: API key warning fix correctly addresses pre-existing bug

The old code at lines 1017-1018 (old) checked !cfg.apiKey.startsWith("${") on the resolved value. Since resolveEnv replaces ${CORTEX_API_KEY} with the actual env var value (e.g., sk-abc123), the old check would incorrectly warn even when the API key was properly configured via environment variable. The new code (src/index.ts:1070) correctly checks the raw config value using isEnvInterpolation(rawConfig?.apiKey), which tests whether the original config string was a ${...} interpolation before resolution. This is a correct fix. The old code also had a duplicate warning (lines 1017 and 1044 in old code); the new code consolidates into a single check with _apiKeyWarningEmitted deduplication.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

Expand All @@ -1040,11 +1094,6 @@ const cortexPlugin = {
api.logger.info("cortex: node:sqlite not available — local cache disabled");
}

// Security: warn if API key is hardcoded in config instead of env var
if (cfg.apiKey && !cfg.apiKey.startsWith("${")) {
api.logger.warn("cortex: API key appears to be hardcoded in config. Consider using environment variable: apiKey: '${CORTEX_API_KEY}'");
}

api.logger.info(
`cortex: registered (cortex=${cfg.cortexUrl}, owner=${cfg.ownerId}, recall=${cfg.autoRecall}, capture=${cfg.autoCapture}, shadow=${cfg.shadowMode})`,
);
Expand Down Expand Up @@ -1406,6 +1455,9 @@ const cortexPlugin = {
// Hooks
// -------------------------------------------------------------------------

// Cache last assistant turn per session for trivial-query augmentation
const lastAssistantTurnCache = new Map<string, string>();

// Auto-recall: inject cornerstones (always) + relevant memories (when relevant)
// Lane guards prevent injection on heartbeat, boot, subagent, cron, isolated lanes.
// Server-first: always call Cortex API (semantic embeddings). Local cache is fallback only.
Expand All @@ -1421,7 +1473,40 @@ const cortexPlugin = {

// --- Fetch contextual memories (+ optional cornerstones) ---
if (cfg.autoRecall) {
const doRetrieve = event.prompt && isMemoryRelevant(event.prompt);
// Strip metadata from prompt for retrieval decisions
const cleanedPrompt = event.prompt ? stripInboundMetadata(event.prompt) : "";

// Determine retrieval strategy
const hasQuestion = cleanedPrompt.includes("?");
const isTrivial = TRIVIAL_PATTERNS.test(cleanedPrompt.trim());
const isRelevant = cleanedPrompt && isMemoryRelevant(cleanedPrompt);

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRelevant is assigned using cleanedPrompt && isMemoryRelevant(cleanedPrompt), which yields "" | boolean rather than a boolean. While it works in the current else if (isRelevant) check, it’s easy to trip over later (e.g., if passed somewhere that expects boolean). Consider making this explicitly boolean (e.g., const isRelevant = cleanedPrompt.length > 0 && isMemoryRelevant(cleanedPrompt)).

Suggested change
const isRelevant = cleanedPrompt && isMemoryRelevant(cleanedPrompt);
const isRelevant = cleanedPrompt.length > 0 && isMemoryRelevant(cleanedPrompt);

Copilot uses AI. Check for mistakes.

let retrievalQuery = cleanedPrompt;
let maxMemories = cfg.maxInjectedMemories; // default (8)
let doRetrieve = false;

if (hasQuestion) {
// Questions always retrieve
doRetrieve = true;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
Comment on lines +1500 to +1523

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Question mark gate is very broad — URLs and code in prompts trigger full retrieval

The hasQuestion gate at src/index.ts:1495 uses cleanedPrompt.includes('?') which matches any ? character, including those inside URLs (e.g. https://example.com?key=val), code snippets, or escaped characters. Since hasQuestion takes priority over the trivial and relevance gates, prompts containing URLs with query strings would always trigger full memory retrieval with the maximum memory count. This is unlikely to cause harm (extra retrieval is just a small latency cost), but it's worth noting as a heuristic gap.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

} else if (isTrivial) {
// Trivial patterns: augment with last assistant turn if available
const sessionKey = ctx.sessionKey ?? "";
const lastAssistant = sessionKey ? lastAssistantTurnCache.get(sessionKey) : undefined;
if (lastAssistant) {
// Take last ~200 chars, trim to nearest word boundary on left edge
let tail = lastAssistant.slice(-200).trim();
const spaceIdx = tail.indexOf(" ");
if (spaceIdx > 0 && spaceIdx < 40) tail = tail.slice(spaceIdx + 1);
const lastTwo = tail;
retrievalQuery = lastTwo + " " + cleanedPrompt;
doRetrieve = true;
maxMemories = 3; // Cap injection for trivial queries
}
// If no cached assistant turn, skip retrieval (no signal)
Comment on lines +1524 to +1538

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: lastAssistantTurnCache only populated when autoCapture=true

The lastAssistantTurnCache (line 1459) is read in the before_agent_start handler (line 1494, inside the autoRecall block) but only populated in the agent_end handler (line 1618-1628), which is registered only when cfg.autoCapture is true (src/index.ts:1606). If a user configures autoRecall=true but autoCapture=false, the trivial-query augmentation feature silently degrades — trivial prompts will never find a cached assistant turn, so retrieval is skipped. This isn't a bug (the system degrades gracefully), but the coupling between these two independent config flags is implicit and could surprise operators.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

} else if (isRelevant) {
doRetrieve = true;
}

const doCornerstones = cfg.injectCornerstones; // default false — cornerstones in SOUL.md

// --- Memory retrieval (server-first, local fallback) ---
Expand All @@ -1439,7 +1524,7 @@ const cortexPlugin = {
// Fire cornerstones + API retrieve in parallel
const [cornerstonesResult, retrieveResult] = await Promise.allSettled([
doCornerstones ? client.getCornerstones() : Promise.resolve(null),
client.retrieve(event.prompt!, cfg.retrievalBudget, cfg.retrievalMode),
client.retrieve(retrievalQuery, cfg.retrievalBudget, cfg.retrievalMode),
]);

// Process cornerstones
Expand Down Expand Up @@ -1467,7 +1552,7 @@ const cortexPlugin = {
// Server down — fall back to local cache if available
if (memoryCache) {
try {
const fallbackResults = memoryCache.search(event.prompt!, 20);
const fallbackResults = memoryCache.search(retrievalQuery, 20);
if (fallbackResults.length) {
memoryItems = fallbackResults;
api.logger.info(`cortex: using local cache fallback (${fallbackResults.length} results)`);
Comment on lines 1585 to 1591

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Server HTTP error (non-200) silently skips local cache fallback

When client.retrieve() gets a non-200 HTTP response (e.g. 500), the post() method at src/index.ts:178 returns null — the Promise resolves to null rather than rejecting. In the Promise.allSettled result at src/index.ts:1542, this means retrieveResult.status === "fulfilled" with value === null, so retrieveResult.value?.items?.length is falsy and the items branch is skipped. The else if (retrieveResult.status === "rejected") branch at src/index.ts:1550 also doesn't trigger because the status is "fulfilled". The net effect: a server 500 error silently produces zero memories with no fallback to local cache and no warning logged. Only true network errors (fetch rejection, abort signal) trigger the fallback path. This is a pre-existing issue not introduced by this PR, but the new three-gate retrieval logic routes more queries through this path (e.g. trivial-augmented queries), making it slightly more impactful.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down Expand Up @@ -1495,7 +1580,7 @@ const cortexPlugin = {
`cortex: echo filter removed ${memoryItems.length - filtered.length} same-session/recent memories`,
);
}
const context = formatMemoryContext(filtered, cfg.maxInjectionChars, cfg.maxInjectedMemories, cfg.minRelevanceScore);
const context = formatMemoryContext(filtered, cfg.maxInjectionChars, maxMemories, cfg.minRelevanceScore);
if (context) {
const elapsed = Date.now() - startMs;
if (elapsed <= 3000) {
Expand Down Expand Up @@ -1530,6 +1615,18 @@ const cortexPlugin = {

const messages = extractMessages(event.messages);

// Cache last assistant message for next-turn augmentation
const lastAssistantMsg = messages.filter(m => m.role === "assistant").pop();
const cacheKey = ctx.sessionKey ?? "";
if (lastAssistantMsg?.content && cacheKey) {
lastAssistantTurnCache.set(cacheKey, lastAssistantMsg.content.slice(0, 2000));
// Limit cache entries to prevent memory leak
if (lastAssistantTurnCache.size > 100) {
const firstKey = lastAssistantTurnCache.keys().next().value;
if (firstKey) lastAssistantTurnCache.delete(firstKey);
}
Comment on lines +1655 to +1660

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: lastAssistantTurnCache eviction is FIFO not LRU — oldest inserted session evicted even if recently active

The eviction logic at src/index.ts:1624-1627 deletes the first key from the Map when size exceeds 100. JavaScript Maps iterate in insertion order, and Map.set() on an existing key updates the value but does NOT change its insertion order. So if session A was inserted first and is still very active (being .set() on every turn), it remains in its original insertion position and would be evicted first once the cache reaches 101 entries, despite being the most recently updated. For true LRU behavior, the code would need to delete then set on each update to move the key to the end. With 100 slots this is unlikely to cause visible problems, but it's worth noting if the cache size is ever reduced.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

// Require at least 2 real messages — single-message captures are noise
if (messages.length < 2) return;

Expand Down
Loading