diff --git a/hindsight-integrations/openclaw/README.md b/hindsight-integrations/openclaw/README.md index 4ce28410..108c8f44 100644 --- a/hindsight-integrations/openclaw/README.md +++ b/hindsight-integrations/openclaw/README.md @@ -67,6 +67,40 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh | `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `` system-context block. | | `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) | | `hindsightApiToken` | — | Auth token for external API | +| `ignoreSessionPatterns` | `[]` | Session key glob patterns to skip entirely — no recall, no retain (e.g. `["agent:*:cron:**"]`) | +| `statelessSessionPatterns` | `[]` | Session key glob patterns for read-only sessions — retain is always skipped; recall is skipped when `skipStatelessSessions` is `true` (e.g. `["agent:*:subagent:**", "agent:*:heartbeat:**"]`) | +| `skipStatelessSessions` | `true` | When `true`, sessions matching `statelessSessionPatterns` also skip recall. Set to `false` to allow recall but still skip retain. | + +### Session pattern filtering + +`ignoreSessionPatterns` and `statelessSessionPatterns` accept glob patterns matched against the session key (format: `agent:::`). + +Glob syntax: +- `*` — matches any characters except `:` (single segment) +- `**` — matches anything including `:` (multiple segments) + +| Pattern | Matches | +|---|---| +| `agent:*:cron:**` | All cron sessions for any agent | +| `agent:*:subagent:**` | All subagent sessions for any agent | +| `agent:main:**` | All sessions under the `main` agent | + +**Difference between the two options:** + +| | `ignoreSessionPatterns` | `statelessSessionPatterns` | +|---|---|---| +| Retain | Skipped | Always skipped | +| Recall | Skipped | Skipped only when `skipStatelessSessions: true` | + +**Example config** — exclude cron jobs from memory entirely, allow subagents to read but not write memories: + +```json +{ + "ignoreSessionPatterns": ["agent:*:cron:**"], + "statelessSessionPatterns": ["agent:*:subagent:**"], + "skipStatelessSessions": false +} +``` ## Retention details diff --git a/hindsight-integrations/openclaw/openclaw.plugin.json b/hindsight-integrations/openclaw/openclaw.plugin.json index d1bd6003..33878bb1 100644 --- a/hindsight-integrations/openclaw/openclaw.plugin.json +++ b/hindsight-integrations/openclaw/openclaw.plugin.json @@ -246,6 +246,21 @@ "type": "number", "description": "How often to attempt flushing queued retains in ms. Default: 60000 (1 min).", "default": 60000 + }, + "ignoreSessionPatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Session key glob patterns to skip entirely (no recall, no retain). E.g. [\"agent:main:**\", \"agent:*:cron:**\"]. * matches non-colon chars, ** matches anything." + }, + "statelessSessionPatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Session key glob patterns for read-only sessions: retain is always skipped, recall is skipped when skipStatelessSessions is true. E.g. [\"agent:*:subagent:**\", \"agent:*:heartbeat:**\"]." + }, + "skipStatelessSessions": { + "type": "boolean", + "description": "When true (default), sessions matching statelessSessionPatterns also skip recall. When false, they can recall but never retain.", + "default": true } }, "additionalProperties": false @@ -404,6 +419,18 @@ "retainQueueFlushIntervalMs": { "label": "Retain Queue Flush Interval (ms)", "placeholder": "60000" + }, + "ignoreSessionPatterns": { + "label": "Ignore Session Patterns", + "placeholder": "e.g. [\"agent:main:**\", \"agent:*:cron:**\"]" + }, + "statelessSessionPatterns": { + "label": "Stateless Session Patterns", + "placeholder": "e.g. [\"agent:*:subagent:**\", \"agent:*:heartbeat:**\"]" + }, + "skipStatelessSessions": { + "label": "Skip Stateless Sessions", + "placeholder": "true (default)" } } } diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 66f57f39..9cb651ca 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -2,6 +2,7 @@ import type { MoltbotPluginAPI, PluginConfig, PluginHookAgentContext, MemoryResu import { HindsightEmbedManager } from './embed-manager.js'; import { HindsightClient, type HindsightClientOptions } from './client.js'; import { RetainQueue } from './retain-queue.js'; +import { compileSessionPatterns, matchesSessionPattern } from './session-patterns.js'; import { createHash } from 'crypto'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; @@ -841,6 +842,9 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig { : DEFAULT_RECALL_PROMPT_PREAMBLE, recallInjectionPosition: typeof config.recallInjectionPosition === 'string' && ['prepend', 'append', 'user'].includes(config.recallInjectionPosition) ? config.recallInjectionPosition as PluginConfig['recallInjectionPosition'] : undefined, recallTimeoutMs: typeof config.recallTimeoutMs === 'number' && config.recallTimeoutMs >= 1000 ? config.recallTimeoutMs : undefined, + ignoreSessionPatterns: Array.isArray(config.ignoreSessionPatterns) ? config.ignoreSessionPatterns : [], + statelessSessionPatterns: Array.isArray(config.statelessSessionPatterns) ? config.statelessSessionPatterns : [], + skipStatelessSessions: config.skipStatelessSessions !== false, debug: config.debug ?? false, }; } @@ -1209,6 +1213,24 @@ export default function (api: MoltbotPluginAPI) { return; } + // Session pattern filtering + const sessionKey = ctx?.sessionKey; + if (sessionKey) { + const ignorePatterns = compileSessionPatterns(pluginConfig.ignoreSessionPatterns ?? []); + if (ignorePatterns.length > 0 && matchesSessionPattern(sessionKey, ignorePatterns)) { + debug(`[Hindsight] Skipping recall: session '${sessionKey}' matches ignoreSessionPatterns`); + return; + } + const skipStateless = pluginConfig.skipStatelessSessions !== false; + if (skipStateless) { + const statelessPatterns = compileSessionPatterns(pluginConfig.statelessSessionPatterns ?? []); + if (statelessPatterns.length > 0 && matchesSessionPattern(sessionKey, statelessPatterns)) { + debug(`[Hindsight] Skipping recall: session '${sessionKey}' matches statelessSessionPatterns (skipStatelessSessions=true)`); + return; + } + } + } + // Skip auto-recall when disabled (agent has its own recall tool) if (!pluginConfig.autoRecall) { debug('[Hindsight] Auto-recall disabled via config, skipping'); @@ -1361,6 +1383,21 @@ ${memoriesFormatted} return; } + // Session pattern filtering + const agentEndSessionKey = effectiveCtx?.sessionKey; + if (agentEndSessionKey) { + const ignorePatterns = compileSessionPatterns(pluginConfig.ignoreSessionPatterns ?? []); + if (ignorePatterns.length > 0 && matchesSessionPattern(agentEndSessionKey, ignorePatterns)) { + debug(`[Hindsight] Skipping retain: session '${agentEndSessionKey}' matches ignoreSessionPatterns`); + return; + } + const statelessPatterns = compileSessionPatterns(pluginConfig.statelessSessionPatterns ?? []); + if (statelessPatterns.length > 0 && matchesSessionPattern(agentEndSessionKey, statelessPatterns)) { + debug(`[Hindsight] Skipping retain: session '${agentEndSessionKey}' matches statelessSessionPatterns`); + return; + } + } + // Derive bank ID from context — enrich ctx.senderId from the session cache. // event.messages in agent_end is clean history without OpenClaw's metadata blocks; // the sender ID was captured during before_prompt_build where event.prompt has them. diff --git a/hindsight-integrations/openclaw/src/session-patterns.test.ts b/hindsight-integrations/openclaw/src/session-patterns.test.ts new file mode 100644 index 00000000..0bf8bbb7 --- /dev/null +++ b/hindsight-integrations/openclaw/src/session-patterns.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + compileSessionPattern, + compileSessionPatterns, + matchesSessionPattern, +} from './session-patterns.js'; + +// --------------------------------------------------------------------------- +// compileSessionPattern +// --------------------------------------------------------------------------- + +describe('compileSessionPattern', () => { + it('matches an exact key', () => { + const p = compileSessionPattern('agent:main:sess-123'); + expect(p.test('agent:main:sess-123')).toBe(true); + expect(p.test('agent:main:sess-456')).toBe(false); + }); + + it('single * does not cross colon', () => { + const p = compileSessionPattern('agent:*:sess'); + expect(p.test('agent:main:sess')).toBe(true); + expect(p.test('agent:subagent:sess')).toBe(true); + expect(p.test('agent:a:b:sess')).toBe(false); + }); + + it('double ** crosses colons', () => { + const p = compileSessionPattern('agent:main:**'); + expect(p.test('agent:main:sess-abc123')).toBe(true); + expect(p.test('agent:main:a:b:c')).toBe(true); + expect(p.test('agent:other:sess-abc123')).toBe(false); + }); + + it('double ** at start matches any prefix', () => { + const p = compileSessionPattern('**:subagent:**'); + expect(p.test('claude-code:subagent:sess-abc')).toBe(true); + expect(p.test('mybot:subagent:sess-xyz')).toBe(true); + expect(p.test('mybot:main:sess-xyz')).toBe(false); + }); + + it('matches lossless-claw cron pattern', () => { + const p = compileSessionPattern('agent:*:cron:**'); + expect(p.test('agent:mybot:cron:sess-123')).toBe(true); + expect(p.test('agent:mybot:subagent:sess-123')).toBe(false); + }); + + it('matches lossless-claw subagent pattern', () => { + const p = compileSessionPattern('agent:*:subagent:**'); + expect(p.test('agent:main:subagent:sess-abc')).toBe(true); + expect(p.test('agent:x:subagent:sess-123')).toBe(true); + expect(p.test('agent:a:b:subagent:sess')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// matchesSessionPattern +// --------------------------------------------------------------------------- + +describe('matchesSessionPattern', () => { + it('returns true when any pattern matches', () => { + const patterns = compileSessionPatterns(['agent:main:**', 'agent:*:cron:**']); + expect(matchesSessionPattern('agent:main:sess-abc', patterns)).toBe(true); + expect(matchesSessionPattern('agent:mybot:cron:sess-xyz', patterns)).toBe(true); + }); + + it('returns false when no pattern matches', () => { + const patterns = compileSessionPatterns(['agent:main:**']); + expect(matchesSessionPattern('agent:subagent:sess-abc', patterns)).toBe(false); + }); + + it('returns false for empty pattern list', () => { + expect(matchesSessionPattern('agent:main:sess', [])).toBe(false); + }); + + it('lossless-claw ignoreSessionPatterns example', () => { + const patterns = compileSessionPatterns(['agent:main:**', 'agent:*:cron:**']); + expect(matchesSessionPattern('agent:main:sess-abc123', patterns)).toBe(true); + expect(matchesSessionPattern('agent:mybot:cron:sess-123', patterns)).toBe(true); + expect(matchesSessionPattern('agent:mybot:subagent:sess-123', patterns)).toBe(false); + }); + + it('lossless-claw statelessSessionPatterns example', () => { + const patterns = compileSessionPatterns(['agent:*:subagent:**', 'agent:*:heartbeat:**']); + expect(matchesSessionPattern('agent:main:subagent:sess-abc', patterns)).toBe(true); + expect(matchesSessionPattern('agent:main:heartbeat:sess-abc', patterns)).toBe(true); + expect(matchesSessionPattern('agent:main:sess-abc', patterns)).toBe(false); + }); +}); diff --git a/hindsight-integrations/openclaw/src/session-patterns.ts b/hindsight-integrations/openclaw/src/session-patterns.ts new file mode 100644 index 00000000..62c1fd1f --- /dev/null +++ b/hindsight-integrations/openclaw/src/session-patterns.ts @@ -0,0 +1,23 @@ +/** + * Compile a session glob into a regex. + * + * `*` matches any non-colon characters, while `**` can span colons. + */ +export function compileSessionPattern(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "\u0000") + .replace(/\*/g, "[^:]*") + .replace(/\u0000/g, ".*"); + return new RegExp(`^${escaped}$`); +} + +/** Compile all configured ignore patterns once at startup. */ +export function compileSessionPatterns(patterns: string[]): RegExp[] { + return patterns.map((pattern) => compileSessionPattern(pattern)); +} + +/** Check whether a session key matches any compiled ignore pattern. */ +export function matchesSessionPattern(sessionKey: string, patterns: RegExp[]): boolean { + return patterns.some((pattern) => pattern.test(sessionKey)); +} diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index cf5214f2..64c59802 100644 --- a/hindsight-integrations/openclaw/src/types.ts +++ b/hindsight-integrations/openclaw/src/types.ts @@ -83,6 +83,9 @@ export interface PluginConfig { recallMaxQueryChars?: number; // Max chars for composed recall query. Default: 800 recallPromptPreamble?: string; // Prompt preamble placed above recalled memories. Default: built-in guidance text. recallInjectionPosition?: 'prepend' | 'append' | 'user'; // Where to inject recalled memories. 'prepend' = start of system prompt (default), 'append' = end of system prompt (preserves prompt cache), 'user' = before user message. + ignoreSessionPatterns?: string[]; // Session key glob patterns to skip entirely (no recall, no retain). E.g. ["agent:main:**", "agent:*:cron:**"] + statelessSessionPatterns?: string[]; // Session key glob patterns for read-only sessions (recall allowed, retain skipped). E.g. ["agent:*:subagent:**"] + skipStatelessSessions?: boolean; // When true (default), stateless sessions also skip recall. When false, they recall but never retain. debug?: boolean; // Enable debug logging (default: false) logLevel?: 'off' | 'error' | 'warning' | 'info' | 'debug'; // Console log verbosity (default: 'info'). logSummaryIntervalMs?: number; // Batch retain/recall log summaries over this interval in ms. 0 = log every event. Default: 300000 (5 min). diff --git a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts index 67ee5d42..0921aa27 100644 --- a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts +++ b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts @@ -19,6 +19,7 @@ import type { MoltbotPluginAPI, PluginConfig } from '../src/types.js'; import type { RecallResponse, RetainResponse } from '../src/types.js'; const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888'; +const HINDSIGHT_API_TOKEN = process.env.HINDSIGHT_API_TOKEN || ''; // --------------------------------------------------------------------------- // Helpers @@ -145,7 +146,12 @@ beforeAll(async () => { recallContextTurns: 3, recallMaxQueryChars: 180, recallRoles: ['user'], + // Session pattern filtering — only affects keys matching these patterns + ignoreSessionPatterns: ['agent:main:**', 'agent:*:cron:**'], + statelessSessionPatterns: ['agent:*:subagent:**', 'agent:*:heartbeat:**'], + skipStatelessSessions: true, // No bankMission — keeps init lean + ...(HINDSIGHT_API_TOKEN ? { hindsightApiUrl: HINDSIGHT_API_URL, hindsightApiToken: HINDSIGHT_API_TOKEN } : {}), }); triggerHook = handle.trigger; stopServicesFn = handle.stopServices; @@ -569,3 +575,81 @@ describe('agent_end hook', () => { expect(req.metadata?.message_count).toBe('2'); }); }); + +// --------------------------------------------------------------------------- +// session pattern filtering +// --------------------------------------------------------------------------- + +describe('session pattern filtering', () => { + it('skips retain when session key matches ignoreSessionPatterns', async () => { + if (!apiReachable) return; + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: 'I love TypeScript.' }], + }, + { messageProvider: 'telegram', senderId: 'U100', sessionKey: 'agent:mybot:cron:sess-cron-001' }, + ); + + expect(retainSpy).not.toHaveBeenCalled(); + }); + + it('skips retain when session key matches statelessSessionPatterns', async () => { + if (!apiReachable) return; + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: 'I prefer Python.' }], + }, + { messageProvider: 'telegram', senderId: 'U101', sessionKey: 'agent:mybot:subagent:sess-sub-001' }, + ); + + expect(retainSpy).not.toHaveBeenCalled(); + }); + + it('skips recall when session key matches ignoreSessionPatterns', async () => { + if (!apiReachable) return; + + const result = await triggerHook( + 'before_prompt_build', + { rawMessage: 'What programming language do I like?', prompt: '', messages: [] }, + { messageProvider: 'telegram', senderId: 'U102', sessionKey: 'agent:mybot:cron:sess-cron-002' }, + ); + + expect(recallSpy).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('skips recall for stateless session when skipStatelessSessions is true (default)', async () => { + if (!apiReachable) return; + + const result = await triggerHook( + 'before_prompt_build', + { rawMessage: 'What programming language do I like?', prompt: '', messages: [] }, + { messageProvider: 'telegram', senderId: 'U103', sessionKey: 'agent:mybot:heartbeat:sess-hb-001' }, + ); + + expect(recallSpy).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('does not skip a main session that matches no pattern', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: 'I enjoy hiking.' }], + }, + { messageProvider: 'telegram', senderId: 'U104', sessionKey: 'agent:mybot:main:sess-main-001' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/hindsight-integrations/openclaw/tests/integration.test.ts b/hindsight-integrations/openclaw/tests/integration.test.ts index c21013d6..7bfa5ed1 100644 --- a/hindsight-integrations/openclaw/tests/integration.test.ts +++ b/hindsight-integrations/openclaw/tests/integration.test.ts @@ -26,6 +26,7 @@ const __dirname = dirname(__filename); // --------------------------------------------------------------------------- const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888'; +const HINDSIGHT_API_TOKEN = process.env.HINDSIGHT_API_TOKEN || ''; const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || ''; const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || ''; const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || ''; @@ -81,6 +82,7 @@ describe('HindsightClient – HTTP Mode', () => { llmApiKey: LLM_API_KEY || 'test-key', llmModel: LLM_MODEL || undefined, apiUrl: HINDSIGHT_API_URL, + apiToken: HINDSIGHT_API_TOKEN || undefined, }); });