From 5a61ac50e919aff36e9c32906aec0dadeeab82ef Mon Sep 17 00:00:00 2001 From: Rutimka <48045755+Rutimka@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:43:35 +0200 Subject: [PATCH] feat(openclaw): add session pattern filtering for ignore and stateless sessions (#909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(openclaw): add session pattern filtering for ignore and stateless sessions Adds three new config options to the OpenClaw plugin that allow filtering sessions by key pattern before recall and retain operations fire: - `ignoreSessionPatterns`: glob patterns for sessions to skip entirely (no recall, no retain). Useful for cron/scheduled agent sessions. - `statelessSessionPatterns`: glob patterns for read-only sessions — retain is always skipped; recall is also skipped when `skipStatelessSessions` is true (default). - `skipStatelessSessions`: boolean (default: true). When false, sessions matching statelessSessionPatterns can still recall but never retain. Pattern syntax mirrors lossless-claw: `*` matches non-colon characters, `**` matches anything including colons. Session keys follow the OpenClaw format `agent:::`. Example config: ignoreSessionPatterns: ["agent:*:cron:**"] statelessSessionPatterns: ["agent:*:subagent:**", "agent:*:heartbeat:**"] skipStatelessSessions: true Implementation: - New `session-patterns.ts` module with compile/match utilities - Session filter applied in `before_prompt_build` and `agent_end` hooks immediately after the existing `excludeProviders` check - New fields wired through `getPluginConfig` - Schema added to `openclaw.plugin.json` (additionalProperties: false was already set, causing config validation errors without this) - 11 unit tests in `session-patterns.test.ts` - 5 integration tests added to `hooks.integration.test.ts` Co-Authored-By: Claude Sonnet 4.6 * test(openclaw): support HINDSIGHT_API_TOKEN in integration tests Pass HINDSIGHT_API_TOKEN env var through to HindsightClient and plugin config in integration tests so tests work against authenticated APIs. Co-Authored-By: Claude Sonnet 4.6 * docs(openclaw): document session pattern filtering options Add ignoreSessionPatterns, statelessSessionPatterns, and skipStatelessSessions to the README config table with glob syntax reference and usage examples. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Marco Rutsch Co-authored-by: Claude Sonnet 4.6 --- hindsight-integrations/openclaw/README.md | 34 ++++++++ .../openclaw/openclaw.plugin.json | 27 ++++++ hindsight-integrations/openclaw/src/index.ts | 37 ++++++++ .../openclaw/src/session-patterns.test.ts | 87 +++++++++++++++++++ .../openclaw/src/session-patterns.ts | 23 +++++ hindsight-integrations/openclaw/src/types.ts | 3 + .../openclaw/tests/hooks.integration.test.ts | 84 ++++++++++++++++++ .../openclaw/tests/integration.test.ts | 2 + 8 files changed, 297 insertions(+) create mode 100644 hindsight-integrations/openclaw/src/session-patterns.test.ts create mode 100644 hindsight-integrations/openclaw/src/session-patterns.ts 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, }); });