From 249ae06d0892150ef9a785f97b172c7cf6c0e5a0 Mon Sep 17 00:00:00 2001 From: Aldous the Orchestrator Date: Thu, 9 Apr 2026 09:16:09 -0400 Subject: [PATCH 1/5] feat(openclaw): close remaining retain parity gaps --- .../openclaw/src/index.test.ts | 26 ++++++++++++++++ hindsight-integrations/openclaw/src/index.ts | 30 +++++++++++++++++-- hindsight-integrations/openclaw/src/types.ts | 4 ++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index f395d48786..43bdf5849d 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -295,6 +295,19 @@ describe('buildRetainRequest', () => { }); }); + it('merges configured retain tags with per-call tags', () => { + const request = buildRetainRequest('hello world', 1, { + sessionKey: 'agent:main:discord:channel:123', + }, { + retainTags: ['source_system:openclaw', 'agent:main'], + }, 1700000000000, { + turnIndex: 1, + tags: ['agent:main', 'kind:manual'], + }); + + expect(request.tags).toEqual(['source_system:openclaw', 'agent:main', 'kind:manual']); + }); + it('defaults source metadata to openclaw when unset', () => { const request = buildRetainRequest('hello world', 1, {}, {}, 1700000000000, { turnIndex: 1 }); expect(request.metadata?.source).toBe('openclaw'); @@ -323,6 +336,8 @@ describe('prepareRetentionTranscript', () => { const baseConfig: PluginConfig = { dynamicBankId: true, retainRoles: ['user', 'assistant'], + retainUserPrefix: 'User', + retainAssistantPrefix: 'Assistant', }; it('returns null if no user message found (turn boundary)', () => { @@ -348,6 +363,17 @@ describe('prepareRetentionTranscript', () => { expect(result?.transcript).not.toContain('Old user'); }); + it('uses configured prefixes for user and assistant turns', () => { + const config: PluginConfig = { ...baseConfig, retainUserPrefix: 'Operator', retainAssistantPrefix: 'Aldous' }; + const messages = [ + { role: 'user', content: 'Hello there' }, + { role: 'assistant', content: 'General Kenobi' } + ]; + const result = prepareRetentionTranscript(messages, config); + expect(result?.transcript).toContain('Operator: Hello there'); + expect(result?.transcript).toContain('Aldous: General Kenobi'); + }); + it('filters out excluded roles', () => { const config: PluginConfig = { ...baseConfig, retainRoles: ['user'] }; const messages = [ diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 1a33a46098..b859dab995 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -1013,6 +1013,26 @@ async function checkExternalApiHealth(apiUrl: string, apiToken?: string | null): } } +function normalizeRetainTags(value: unknown): string[] { + if (value == null) return []; + + const rawItems = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',') + : [value]; + + const seen = new Set(); + const normalized: string[] = []; + for (const item of rawItems) { + const tag = String(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,8 +1054,10 @@ 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, + retainUserPrefix: typeof config.retainUserPrefix === 'string' && config.retainUserPrefix.trim().length > 0 ? config.retainUserPrefix.trim() : 'User', + retainAssistantPrefix: typeof config.retainAssistantPrefix === 'string' && config.retainAssistantPrefix.trim().length > 0 ? config.retainAssistantPrefix.trim() : 'Assistant', excludeProviders: Array.isArray(config.excludeProviders) ? Array.from(new Set(['heartbeat', ...config.excludeProviders.filter((provider): provider is string => typeof provider === 'string')])) : ['heartbeat'], @@ -1874,7 +1896,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) : {}; @@ -1888,6 +1910,8 @@ export function buildRetainRequest( const channelType = effectiveCtx?.messageProvider; const threadId = extractThreadId(channelId); + const mergedTags = normalizeRetainTags([...(pluginConfig.retainTags || []), ...((options?.tags || []).filter(Boolean))]); + return { content: transcript, documentId: documentId, @@ -1906,7 +1930,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, }; } diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index dcce0b5bae..6397458da1 100644 --- a/hindsight-integrations/openclaw/src/types.ts +++ b/hindsight-integrations/openclaw/src/types.ts @@ -65,8 +65,10 @@ 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[]; // Default tags applied to retained documents; merged with per-call tags when provided retainSource?: string; // Source written into retained document metadata (default: 'openclaw') + retainUserPrefix?: string; // Label used before user turns in retained transcripts (default: 'User') + retainAssistantPrefix?: string; // Label used before assistant turns in retained transcripts (default: 'Assistant') 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. dynamicBankGranularity?: Array<'agent' | 'provider' | 'channel' | 'user'>; // Fields for bank ID derivation. Default: ['agent', 'channel', 'user'] From 433dac69f1be0a8ba142805f1220511b4d0f9fbc Mon Sep 17 00:00:00 2001 From: Aldous the Orchestrator Date: Thu, 9 Apr 2026 09:25:43 -0400 Subject: [PATCH 2/5] docs(openclaw): preserve transcript format for retain parity patch --- hindsight-integrations/openclaw/README.md | 2 ++ hindsight-integrations/openclaw/openclaw.plugin.json | 10 ++++++++++ hindsight-integrations/openclaw/src/index.test.ts | 6 +++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/hindsight-integrations/openclaw/README.md b/hindsight-integrations/openclaw/README.md index 16006faa2a..d96641d153 100644 --- a/hindsight-integrations/openclaw/README.md +++ b/hindsight-integrations/openclaw/README.md @@ -93,6 +93,8 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh | `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`) | | `retainSource` | `"openclaw"` | `source` value written into retained document metadata | +| `retainUserPrefix` | `"User"` | Reserved for future transcript-label parity work. Current retained transcripts still use structured role markers. | +| `retainAssistantPrefix` | `"Assistant"` | Reserved for future transcript-label parity work. Current retained transcripts still use structured role markers. | | `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`) | | `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. | diff --git a/hindsight-integrations/openclaw/openclaw.plugin.json b/hindsight-integrations/openclaw/openclaw.plugin.json index 79ae0dd205..0e91ce96a6 100644 --- a/hindsight-integrations/openclaw/openclaw.plugin.json +++ b/hindsight-integrations/openclaw/openclaw.plugin.json @@ -100,6 +100,16 @@ "description": "Source value written into retained document metadata. Defaults to 'openclaw'.", "default": "openclaw" }, + "retainUserPrefix": { + "type": "string", + "description": "Reserved for future transcript-label parity work. Currently retained transcripts continue using structured role markers.", + "default": "User" + }, + "retainAssistantPrefix": { + "type": "string", + "description": "Reserved for future transcript-label parity work. Currently retained transcripts continue using structured role markers.", + "default": "Assistant" + }, "autoRecall": { "type": "boolean", "description": "Automatically recall memories on every prompt and inject them as context. Set to false when agent has its own recall tool.", diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 43bdf5849d..1d72e30a17 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -363,15 +363,15 @@ describe('prepareRetentionTranscript', () => { expect(result?.transcript).not.toContain('Old user'); }); - it('uses configured prefixes for user and assistant turns', () => { + it('retains structured role markers even when prefixes are configured', () => { const config: PluginConfig = { ...baseConfig, retainUserPrefix: 'Operator', retainAssistantPrefix: 'Aldous' }; const messages = [ { role: 'user', content: 'Hello there' }, { role: 'assistant', content: 'General Kenobi' } ]; const result = prepareRetentionTranscript(messages, config); - expect(result?.transcript).toContain('Operator: Hello there'); - expect(result?.transcript).toContain('Aldous: General Kenobi'); + expect(result?.transcript).toContain('[role: user]\nHello there\n[user:end]'); + expect(result?.transcript).toContain('[role: assistant]\nGeneral Kenobi\n[assistant:end]'); }); it('filters out excluded roles', () => { From 8a7fa0383776d1acf3be47300f73ccde130052ca Mon Sep 17 00:00:00 2001 From: Aldous the Orchestrator Date: Thu, 9 Apr 2026 09:28:51 -0400 Subject: [PATCH 3/5] refactor(openclaw): drop unused retain prefix config --- hindsight-integrations/openclaw/README.md | 2 -- .../openclaw/openclaw.plugin.json | 10 ---------- hindsight-integrations/openclaw/src/index.test.ts | 13 ------------- hindsight-integrations/openclaw/src/index.ts | 2 -- hindsight-integrations/openclaw/src/types.ts | 2 -- 5 files changed, 29 deletions(-) diff --git a/hindsight-integrations/openclaw/README.md b/hindsight-integrations/openclaw/README.md index d96641d153..16006faa2a 100644 --- a/hindsight-integrations/openclaw/README.md +++ b/hindsight-integrations/openclaw/README.md @@ -93,8 +93,6 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh | `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`) | | `retainSource` | `"openclaw"` | `source` value written into retained document metadata | -| `retainUserPrefix` | `"User"` | Reserved for future transcript-label parity work. Current retained transcripts still use structured role markers. | -| `retainAssistantPrefix` | `"Assistant"` | Reserved for future transcript-label parity work. Current retained transcripts still use structured role markers. | | `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`) | | `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. | diff --git a/hindsight-integrations/openclaw/openclaw.plugin.json b/hindsight-integrations/openclaw/openclaw.plugin.json index 0e91ce96a6..79ae0dd205 100644 --- a/hindsight-integrations/openclaw/openclaw.plugin.json +++ b/hindsight-integrations/openclaw/openclaw.plugin.json @@ -100,16 +100,6 @@ "description": "Source value written into retained document metadata. Defaults to 'openclaw'.", "default": "openclaw" }, - "retainUserPrefix": { - "type": "string", - "description": "Reserved for future transcript-label parity work. Currently retained transcripts continue using structured role markers.", - "default": "User" - }, - "retainAssistantPrefix": { - "type": "string", - "description": "Reserved for future transcript-label parity work. Currently retained transcripts continue using structured role markers.", - "default": "Assistant" - }, "autoRecall": { "type": "boolean", "description": "Automatically recall memories on every prompt and inject them as context. Set to false when agent has its own recall tool.", diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 1d72e30a17..26d6e429c3 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -336,8 +336,6 @@ describe('prepareRetentionTranscript', () => { const baseConfig: PluginConfig = { dynamicBankId: true, retainRoles: ['user', 'assistant'], - retainUserPrefix: 'User', - retainAssistantPrefix: 'Assistant', }; it('returns null if no user message found (turn boundary)', () => { @@ -363,17 +361,6 @@ describe('prepareRetentionTranscript', () => { expect(result?.transcript).not.toContain('Old user'); }); - it('retains structured role markers even when prefixes are configured', () => { - const config: PluginConfig = { ...baseConfig, retainUserPrefix: 'Operator', retainAssistantPrefix: 'Aldous' }; - const messages = [ - { role: 'user', content: 'Hello there' }, - { role: 'assistant', content: 'General Kenobi' } - ]; - const result = prepareRetentionTranscript(messages, config); - expect(result?.transcript).toContain('[role: user]\nHello there\n[user:end]'); - expect(result?.transcript).toContain('[role: assistant]\nGeneral Kenobi\n[assistant:end]'); - }); - it('filters out excluded roles', () => { const config: PluginConfig = { ...baseConfig, retainRoles: ['user'] }; const messages = [ diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index b859dab995..26e148643c 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -1056,8 +1056,6 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig { bankIdPrefix: config.bankIdPrefix, retainTags: normalizeRetainTags(config.retainTags), retainSource: typeof config.retainSource === 'string' && config.retainSource.trim().length > 0 ? config.retainSource.trim() : undefined, - retainUserPrefix: typeof config.retainUserPrefix === 'string' && config.retainUserPrefix.trim().length > 0 ? config.retainUserPrefix.trim() : 'User', - retainAssistantPrefix: typeof config.retainAssistantPrefix === 'string' && config.retainAssistantPrefix.trim().length > 0 ? config.retainAssistantPrefix.trim() : 'Assistant', excludeProviders: Array.isArray(config.excludeProviders) ? Array.from(new Set(['heartbeat', ...config.excludeProviders.filter((provider): provider is string => typeof provider === 'string')])) : ['heartbeat'], diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index 6397458da1..d553712a87 100644 --- a/hindsight-integrations/openclaw/src/types.ts +++ b/hindsight-integrations/openclaw/src/types.ts @@ -67,8 +67,6 @@ export interface PluginConfig { bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123') retainTags?: string[]; // Default tags applied to retained documents; merged with per-call tags when provided retainSource?: string; // Source written into retained document metadata (default: 'openclaw') - retainUserPrefix?: string; // Label used before user turns in retained transcripts (default: 'User') - retainAssistantPrefix?: string; // Label used before assistant turns in retained transcripts (default: 'Assistant') 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. dynamicBankGranularity?: Array<'agent' | 'provider' | 'channel' | 'user'>; // Fields for bank ID derivation. Default: ['agent', 'channel', 'user'] From d39975eed1b6b44decd14f24b4f07c10e67df6df Mon Sep 17 00:00:00 2001 From: Aldous Date: Mon, 13 Apr 2026 09:44:33 -0400 Subject: [PATCH 4/5] fix(openclaw): keep retain tag normalization narrow --- .../openclaw/src/index.test.ts | 67 +++++++++++++++---- hindsight-integrations/openclaw/src/index.ts | 37 ++++++++-- hindsight-integrations/openclaw/src/types.ts | 2 +- 3 files changed, 84 insertions(+), 22 deletions(-) diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 26d6e429c3..02c114f60b 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,7 @@ import { getIdentitySkipReason, isEphemeralOperationalText, deriveBankId, + normalizeRetainTags, } from './index.js'; import type { PluginConfig, MemoryResult } from './types.js'; @@ -229,9 +232,58 @@ 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('buildRetainRequest', () => { it('adds configured source metadata and retain tags', () => { const request = buildRetainRequest('hello world', 2, { @@ -295,19 +347,6 @@ describe('buildRetainRequest', () => { }); }); - it('merges configured retain tags with per-call tags', () => { - const request = buildRetainRequest('hello world', 1, { - sessionKey: 'agent:main:discord:channel:123', - }, { - retainTags: ['source_system:openclaw', 'agent:main'], - }, 1700000000000, { - turnIndex: 1, - tags: ['agent:main', 'kind:manual'], - }); - - expect(request.tags).toEqual(['source_system:openclaw', 'agent:main', 'kind:manual']); - }); - it('defaults source metadata to openclaw when unset', () => { const request = buildRetainRequest('hello world', 1, {}, {}, 1700000000000, { turnIndex: 1 }); expect(request.metadata?.source).toBe('openclaw'); diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 26e148643c..793f1c646b 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -1013,19 +1013,20 @@ async function checkExternalApiHealth(apiUrl: string, apiToken?: string | null): } } -function normalizeRetainTags(value: unknown): string[] { +export function normalizeRetainTags(value: unknown): string[] { if (value == null) return []; const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') - : [value]; + : []; const seen = new Set(); const normalized: string[] = []; for (const item of rawItems) { - const tag = String(item ?? '').trim(); + if (typeof item !== 'string') continue; + const tag = item.trim(); if (!tag || seen.has(tag)) continue; seen.add(tag); normalized.push(tag); @@ -1894,7 +1895,7 @@ export function buildRetainRequest( effectiveCtx: PluginHookAgentContext | undefined, pluginConfig: PluginConfig, now = Date.now(), - options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number; tags?: string[] }, + options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number }, ): RetainRequest { const resolvedCtx = resolveSessionIdentity(effectiveCtx); const parsedSession = resolvedCtx?.sessionKey ? parseSessionKey(resolvedCtx.sessionKey) : {}; @@ -1908,8 +1909,6 @@ export function buildRetainRequest( const channelType = effectiveCtx?.messageProvider; const threadId = extractThreadId(channelId); - const mergedTags = normalizeRetainTags([...(pluginConfig.retainTags || []), ...((options?.tags || []).filter(Boolean))]); - return { content: transcript, documentId: documentId, @@ -1928,7 +1927,7 @@ export function buildRetainRequest( sender_id: resolvedCtx?.senderId, ...(options?.windowTurns !== undefined ? { window_turns: String(options.windowTurns) } : {}), }, - tags: mergedTags.length > 0 ? mergedTags : undefined, + tags: pluginConfig.retainTags && pluginConfig.retainTags.length > 0 ? pluginConfig.retainTags : undefined, }; } @@ -2118,6 +2117,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 d553712a87..1c1ac6c227 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[]; // Default tags applied to retained documents; merged with per-call tags when provided + retainTags?: string[]; // Tags applied to all retained documents after trimming and deduplication (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. From 8f542d465bee574ceb298e66fe882ed77f5276bd Mon Sep 17 00:00:00 2001 From: Aldous Date: Mon, 13 Apr 2026 10:04:15 -0400 Subject: [PATCH 5/5] feat(openclaw): merge inline retain tags with defaults --- hindsight-integrations/openclaw/README.md | 2 +- .../openclaw/src/index.test.ts | 45 +++++++++++++ hindsight-integrations/openclaw/src/index.ts | 63 ++++++++++++++++++- hindsight-integrations/openclaw/src/types.ts | 2 +- 4 files changed, 108 insertions(+), 4 deletions(-) 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 02c114f60b..1f99cd6697 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -17,6 +17,8 @@ import { isEphemeralOperationalText, deriveBankId, normalizeRetainTags, + extractInlineRetainTags, + stripInlineRetainTags, } from './index.js'; import type { PluginConfig, MemoryResult } from './types.js'; @@ -284,6 +286,21 @@ describe('normalizeRetainTags', () => { }); }); +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, { @@ -347,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'); @@ -432,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 793f1c646b..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. @@ -1781,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)'); @@ -1820,6 +1873,7 @@ ${memoriesFormatted} { retentionScope: retainFullWindow ? 'window' : 'turn', windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined, + tags: inlineRetainTags, }, ); @@ -1895,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) : {}; @@ -1908,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, @@ -1927,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, }; } @@ -1993,6 +2051,7 @@ export function prepareRetentionTranscript( } content = stripMemoryTags(content); + content = stripInlineRetainTags(content); content = stripMetadataEnvelopes(content); if (content.trim()) { diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index 1c1ac6c227..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 after trimming and deduplication (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.