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.