diff --git a/hindsight-integrations/openclaw/README.md b/hindsight-integrations/openclaw/README.md index 16006faa2a..88c6d01f3f 100644 --- a/hindsight-integrations/openclaw/README.md +++ b/hindsight-integrations/openclaw/README.md @@ -91,7 +91,7 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh | `dynamicBankId` | `true` | Enable per-context memory banks | | `bankId` | — | Static bank ID used when `dynamicBankId` is `false`. | | `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) | -| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`) | +| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`). Auto-retain also merges inline per-message tags from `...` or `...` blocks in user messages. | | `retainSource` | `"openclaw"` | `source` value written into retained document metadata | | `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` | | `excludeProviders` | `["heartbeat"]` | Message providers to skip for recall/retain (e.g. `heartbeat`, `slack`, `telegram`, `discord`) | diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index f395d48786..1f99cd6697 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -4,6 +4,8 @@ import { extractRecallQuery, formatMemories, prepareRetentionTranscript, + countUserTurns, + getRetentionTurnIndex, sliceLastTurnsByUserBoundary, composeRecallQuery, truncateRecallQuery, @@ -14,6 +16,9 @@ import { getIdentitySkipReason, isEphemeralOperationalText, deriveBankId, + normalizeRetainTags, + extractInlineRetainTags, + stripInlineRetainTags, } from './index.js'; import type { PluginConfig, MemoryResult } from './types.js'; @@ -229,9 +234,73 @@ describe('formatMemories', () => { }); // --------------------------------------------------------------------------- -// prepareRetentionTranscript +// retention helpers // --------------------------------------------------------------------------- +describe('countUserTurns', () => { + it('counts user messages across a resumed conversation history', () => { + expect(countUserTurns([ + { role: 'user', content: 'turn 1' }, + { role: 'assistant', content: 'reply 1' }, + { role: 'system', content: 'meta' }, + { role: 'user', content: 'turn 2' }, + { role: 'assistant', content: 'reply 2' }, + { role: 'user', content: 'turn 3' }, + ])).toBe(3); + }); +}); + +describe('getRetentionTurnIndex', () => { + it('uses the full conversation turn count for per-turn retention', () => { + expect(getRetentionTurnIndex(7, 1)).toBe(7); + }); + + it('derives a stable window sequence for chunked retention', () => { + expect(getRetentionTurnIndex(6, 3)).toBe(2); + }); + + it('returns null when a chunk boundary has not been reached', () => { + expect(getRetentionTurnIndex(5, 3)).toBeNull(); + }); +}); + +describe('normalizeRetainTags', () => { + it('trims, deduplicates, and preserves order for string arrays', () => { + expect(normalizeRetainTags([' source_system:openclaw ', 'agent:main', 'agent:main', ''])).toEqual([ + 'source_system:openclaw', + 'agent:main', + ]); + }); + + it('drops non-string values instead of stringifying them', () => { + expect(normalizeRetainTags(['agent:main', { a: 1 } as unknown as string, 42 as unknown as string, null as unknown as string])).toEqual([ + 'agent:main', + ]); + }); + + it('accepts comma-separated strings', () => { + expect(normalizeRetainTags(' source_system:openclaw, agent:main , agent:main ')).toEqual([ + 'source_system:openclaw', + 'agent:main', + ]); + }); +}); + +describe('inline retain tag helpers', () => { + it('extracts retain tags from inline directives', () => { + expect(extractInlineRetainTags('hello client:acme, type:decision, client:acme world')).toEqual([ + 'client:acme', + 'type:decision', + ]); + }); + + it('supports hindsight_retain_tags alias and strips directives from content', () => { + const input = 'Keep this.\nscope:user\nNot the directive.'; + expect(extractInlineRetainTags(input)).toEqual(['scope:user']); + expect(stripInlineRetainTags(input)).toBe('Keep this.\n\nNot the directive.'); + }); +}); + describe('buildRetainRequest', () => { it('adds configured source metadata and retain tags', () => { const request = buildRetainRequest('hello world', 2, { @@ -295,6 +364,21 @@ describe('buildRetainRequest', () => { }); }); + it('merges configured retain tags with inline per-message tags', () => { + const request = buildRetainRequest('hello world', 1, {}, { + retainTags: ['source_system:openclaw', 'agent:main'], + }, 1700000000000, { + turnIndex: 1, + tags: ['client:acme', 'agent:main'], + }); + + expect(request.tags).toEqual([ + 'source_system:openclaw', + 'agent:main', + 'client:acme', + ]); + }); + it('defaults source metadata to openclaw when unset', () => { const request = buildRetainRequest('hello world', 1, {}, {}, 1700000000000, { turnIndex: 1 }); expect(request.metadata?.source).toBe('openclaw'); @@ -380,6 +464,19 @@ describe('prepareRetentionTranscript', () => { expect(result?.transcript).toContain('Here is how to enable dark mode.'); }); + it('strips inline retain-tag directives from retained content', () => { + const messages = [ + { role: 'user', content: 'Remember this.\nclient:acme, type:decision\nActual content.' }, + { role: 'assistant', content: 'Got it.' } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result).not.toBeNull(); + expect(result?.transcript).toContain('Remember this.'); + expect(result?.transcript).toContain('Actual content.'); + expect(result?.transcript).not.toContain(''); + expect(result?.transcript).not.toContain('client:acme'); + }); + it('strips memory tags from user message when prependContext is prepended to it', () => { // Simulates the host prepending prependContext to the user message content const userContent = `\nRelevant memories:\n- User prefers dark mode [world]\n\nUser message: What is dark mode?\n\nWhat is dark mode?`; diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 1a33a46098..341364b1a5 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -370,6 +370,40 @@ export function stripMemoryTags(content: string): string { return content; } +/** + * Extract per-message retain tag overrides from inline user content. + * + * Supported forms: + * - tag:a, tag:b + * - tag:a, tag:b + */ +export function extractInlineRetainTags(content: string): string[] { + if (!content) return []; + + const tags: string[] = []; + const blockRe = /<(?:hindsight_)?retain_tags>([\s\S]*?)<\/(?:hindsight_)?retain_tags>/gi; + let match: RegExpExecArray | null; + + while ((match = blockRe.exec(content)) !== null) { + const normalized = normalizeRetainTags(match[1]); + for (const tag of normalized) { + if (!tags.includes(tag)) { + tags.push(tag); + } + } + } + + return tags; +} + +/** + * Remove inline retain tag directives from message content before storing it. + */ +export function stripInlineRetainTags(content: string): string { + if (!content) return content; + return content.replace(/<(?:hindsight_)?retain_tags>[\s\S]*?<\/(?:hindsight_)?retain_tags>/gi, ''); +} + /** * Extract sender_id from OpenClaw's injected inbound metadata blocks. * Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks. @@ -1013,6 +1047,27 @@ async function checkExternalApiHealth(apiUrl: string, apiToken?: string | null): } } +export function normalizeRetainTags(value: unknown): string[] { + if (value == null) return []; + + const rawItems = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',') + : []; + + const seen = new Set(); + const normalized: string[] = []; + for (const item of rawItems) { + if (typeof item !== 'string') continue; + const tag = item.trim(); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + normalized.push(tag); + } + return normalized; +} + function getPluginConfig(api: MoltbotPluginAPI): PluginConfig { const config = api.config.plugins?.entries?.['hindsight-openclaw']?.config || {}; const defaultMission = 'You are an AI assistant helping users across multiple communication channels (Telegram, Slack, Discord, etc.). Remember user preferences, instructions, and important context from conversations to provide personalized assistance.'; @@ -1034,7 +1089,7 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig { dynamicBankId: config.dynamicBankId !== false, bankId: typeof config.bankId === 'string' && config.bankId.trim().length > 0 ? config.bankId.trim() : undefined, bankIdPrefix: config.bankIdPrefix, - retainTags: Array.isArray(config.retainTags) ? config.retainTags.filter((tag): tag is string => typeof tag === 'string') : undefined, + retainTags: normalizeRetainTags(config.retainTags), retainSource: typeof config.retainSource === 'string' && config.retainSource.trim().length > 0 ? config.retainSource.trim() : undefined, excludeProviders: Array.isArray(config.excludeProviders) ? Array.from(new Set(['heartbeat', ...config.excludeProviders.filter((provider): provider is string => typeof provider === 'string')])) @@ -1760,6 +1815,25 @@ ${memoriesFormatted} debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`); } + const inlineRetainTags = normalizeRetainTags( + messagesToRetain.flatMap((msg: any) => { + if (msg?.role !== 'user') { + return []; + } + + const content = typeof msg?.content === 'string' + ? msg.content + : Array.isArray(msg?.content) + ? msg.content + .filter((block: any) => block?.type === 'text' && typeof block?.text === 'string') + .map((block: any) => block.text) + .join('\n') + : ''; + + return extractInlineRetainTags(content); + }), + ); + const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow); if (!retention) { debug('[Hindsight Hook] No messages to retain (filtered/short/no-user)'); @@ -1799,6 +1873,7 @@ ${memoriesFormatted} { retentionScope: retainFullWindow ? 'window' : 'turn', windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined, + tags: inlineRetainTags, }, ); @@ -1874,7 +1949,7 @@ export function buildRetainRequest( effectiveCtx: PluginHookAgentContext | undefined, pluginConfig: PluginConfig, now = Date.now(), - options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number }, + options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number; tags?: string[] }, ): RetainRequest { const resolvedCtx = resolveSessionIdentity(effectiveCtx); const parsedSession = resolvedCtx?.sessionKey ? parseSessionKey(resolvedCtx.sessionKey) : {}; @@ -1887,6 +1962,10 @@ export function buildRetainRequest( const channelId = sanitizeChannelId(effectiveCtx?.channelId, provider) || parsedSession.channel; const channelType = effectiveCtx?.messageProvider; const threadId = extractThreadId(channelId); + const mergedTags = normalizeRetainTags([ + ...(pluginConfig.retainTags ?? []), + ...(options?.tags ?? []), + ]); return { content: transcript, @@ -1906,7 +1985,7 @@ export function buildRetainRequest( sender_id: resolvedCtx?.senderId, ...(options?.windowTurns !== undefined ? { window_turns: String(options.windowTurns) } : {}), }, - tags: pluginConfig.retainTags && pluginConfig.retainTags.length > 0 ? pluginConfig.retainTags : undefined, + tags: mergedTags.length > 0 ? mergedTags : undefined, }; } @@ -1972,6 +2051,7 @@ export function prepareRetentionTranscript( } content = stripMemoryTags(content); + content = stripInlineRetainTags(content); content = stripMetadataEnvelopes(content); if (content.trim()) { @@ -2096,6 +2176,30 @@ function buildToolResultBlock(msg: any): any | null { return block; } +export function countUserTurns(messages: any[]): number { + if (!Array.isArray(messages) || messages.length === 0) { + return 0; + } + + return messages.reduce((count: number, message: any) => count + (message?.role === 'user' ? 1 : 0), 0); +} + +export function getRetentionTurnIndex(conversationTurnCount: number, retainEveryN: number): number | null { + if (conversationTurnCount <= 0 || retainEveryN <= 0) { + return null; + } + + if (retainEveryN === 1) { + return conversationTurnCount; + } + + if (conversationTurnCount % retainEveryN !== 0) { + return null; + } + + return Math.floor(conversationTurnCount / retainEveryN); +} + export function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[] { if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) { return []; diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index dcce0b5bae..7eee36c7ee 100644 --- a/hindsight-integrations/openclaw/src/types.ts +++ b/hindsight-integrations/openclaw/src/types.ts @@ -65,7 +65,7 @@ export interface PluginConfig { dynamicBankId?: boolean; // Enable per-channel memory banks (default: true) bankId?: string; // Static bank ID used when dynamicBankId is false. bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123') - retainTags?: string[]; // Tags applied to all retained documents (e.g. ['source_system:openclaw', 'agent:agentname']) + retainTags?: string[]; // Tags applied to all retained documents after trimming and deduplication; auto-retain merges these with inline per-message retain-tag directives (e.g. ['source_system:openclaw', 'agent:agentname']) retainSource?: string; // Source written into retained document metadata (default: 'openclaw') excludeProviders?: string[]; // Message providers to exclude from recall/retain (e.g. ['telegram', 'discord']) autoRecall?: boolean; // Auto-recall memories on every prompt (default: true). Set to false when agent has its own recall tool.