fix(openclaw): inject recalled memories as system context (#548)
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
This commit is contained in:
parent
e210953d05
commit
b17f338e17
4 changed files with 28 additions and 19 deletions
|
|
@ -26,7 +26,7 @@ That's it! The plugin will automatically start capturing and recalling memories.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Auto-capture** and **auto-recall** of memories each turn
|
- **Auto-capture** and **auto-recall** of memories each turn, injected into system prompt space so recalled memories stay out of the visible chat transcript
|
||||||
- **Memory isolation** — configurable per agent, channel, user, or provider via `dynamicBankGranularity`
|
- **Memory isolation** — configurable per agent, channel, user, or provider via `dynamicBankGranularity`
|
||||||
- **Retention controls** — choose which message roles to retain and toggle auto-retain on/off
|
- **Retention controls** — choose which message roles to retain and toggle auto-retain on/off
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh
|
||||||
| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. |
|
| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. |
|
||||||
| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. |
|
| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. |
|
||||||
| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. |
|
| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. |
|
||||||
| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `<hindsight_memories>` block. |
|
| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `<hindsight_memories>` system-context block. |
|
||||||
| `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) |
|
| `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) |
|
||||||
| `hindsightApiToken` | — | Auth token for external API |
|
| `hindsightApiToken` | — | Auth token for external API |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1143,8 +1143,9 @@ ${memoriesFormatted}
|
||||||
|
|
||||||
debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
|
debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
|
||||||
|
|
||||||
// Inject context before the user message
|
// Inject recalled memories into system prompt space so they stay hidden from
|
||||||
return { prependContext: contextMessage };
|
// the end-user transcript/UI while still being available to the model.
|
||||||
|
return { prependSystemContext: contextMessage };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
||||||
console.warn(`[Hindsight] Auto-recall timed out after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`);
|
console.warn(`[Hindsight] Auto-recall timed out after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
// Moltbot plugin API types (minimal subset needed for this plugin)
|
// Moltbot plugin API types (minimal subset needed for this plugin)
|
||||||
|
|
||||||
|
export interface PluginPromptHookResult {
|
||||||
|
prependContext?: string;
|
||||||
|
prependSystemContext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MoltbotPluginAPI {
|
export interface MoltbotPluginAPI {
|
||||||
config: MoltbotConfig;
|
config: MoltbotConfig;
|
||||||
registerService(config: ServiceConfig): void;
|
registerService(config: ServiceConfig): void;
|
||||||
// OpenClaw hook handler signature: (event, ctx?) where ctx contains channel/sender info
|
// OpenClaw hook handler signature: (event, ctx?) where ctx contains channel/sender info
|
||||||
on(event: string, handler: (event: any, ctx?: any) => void | Promise<void | { prependContext?: string }>): void;
|
on(event: string, handler: (event: any, ctx?: any) => void | Promise<void | PluginPromptHookResult>): void;
|
||||||
// Add more as needed
|
// Add more as needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ describe('before_prompt_build hook', () => {
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { prependContext } with <hindsight_memories> when recall returns results', async () => {
|
it('returns { prependSystemContext } with <hindsight_memories> when recall returns results', async () => {
|
||||||
if (!apiReachable) return;
|
if (!apiReachable) return;
|
||||||
recallSpy.mockResolvedValue({
|
recallSpy.mockResolvedValue({
|
||||||
results: [makeMemoryResult('User likes Python')],
|
results: [makeMemoryResult('User likes Python')],
|
||||||
|
|
@ -236,15 +236,16 @@ describe('before_prompt_build hook', () => {
|
||||||
'before_prompt_build',
|
'before_prompt_build',
|
||||||
{ rawMessage: 'What programming language do I prefer?', prompt: '', messages: [] },
|
{ rawMessage: 'What programming language do I prefer?', prompt: '', messages: [] },
|
||||||
{ messageProvider: 'telegram', senderId: 'U003' },
|
{ messageProvider: 'telegram', senderId: 'U003' },
|
||||||
)) as { prependContext: string };
|
)) as { prependSystemContext: string; prependContext?: string };
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result.prependContext).toContain('<hindsight_memories>');
|
expect(result.prependContext).toBeUndefined();
|
||||||
expect(result.prependContext).toContain('User likes Python');
|
expect(result.prependSystemContext).toContain('<hindsight_memories>');
|
||||||
expect(result.prependContext).toContain('</hindsight_memories>');
|
expect(result.prependSystemContext).toContain('User likes Python');
|
||||||
|
expect(result.prependSystemContext).toContain('</hindsight_memories>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('injects all memory result fields in the prependContext', async () => {
|
it('injects all memory result fields in the prependSystemContext', async () => {
|
||||||
if (!apiReachable) return;
|
if (!apiReachable) return;
|
||||||
const mem = makeMemoryResult('User prefers dark mode');
|
const mem = makeMemoryResult('User prefers dark mode');
|
||||||
mem.tags = ['preference'];
|
mem.tags = ['preference'];
|
||||||
|
|
@ -260,12 +261,13 @@ describe('before_prompt_build hook', () => {
|
||||||
'before_prompt_build',
|
'before_prompt_build',
|
||||||
{ rawMessage: 'Do I prefer dark or light mode?', prompt: '', messages: [] },
|
{ rawMessage: 'Do I prefer dark or light mode?', prompt: '', messages: [] },
|
||||||
{ messageProvider: 'telegram', senderId: 'U004' },
|
{ messageProvider: 'telegram', senderId: 'U004' },
|
||||||
)) as { prependContext: string };
|
)) as { prependSystemContext: string; prependContext?: string };
|
||||||
|
|
||||||
// formatMemories returns a bullet list, not JSON
|
// formatMemories returns a bullet list, not JSON
|
||||||
expect(result.prependContext).toContain('- User prefers dark mode');
|
expect(result.prependContext).toBeUndefined();
|
||||||
expect(result.prependContext).toContain('<hindsight_memories>');
|
expect(result.prependSystemContext).toContain('- User prefers dark mode');
|
||||||
expect(result.prependContext).toContain('</hindsight_memories>');
|
expect(result.prependSystemContext).toContain('<hindsight_memories>');
|
||||||
|
expect(result.prependSystemContext).toContain('</hindsight_memories>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
|
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
|
||||||
|
|
@ -328,7 +330,7 @@ describe('before_prompt_build hook', () => {
|
||||||
expect(callArgs.max_tokens).toBeGreaterThan(0);
|
expect(callArgs.max_tokens).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes recalled memories in the prependContext block', async () => {
|
it('includes recalled memories in the prependSystemContext block', async () => {
|
||||||
if (!apiReachable) return;
|
if (!apiReachable) return;
|
||||||
recallSpy.mockResolvedValue({
|
recallSpy.mockResolvedValue({
|
||||||
results: [makeMemoryResult('User loves hiking')],
|
results: [makeMemoryResult('User loves hiking')],
|
||||||
|
|
@ -341,10 +343,11 @@ describe('before_prompt_build hook', () => {
|
||||||
'before_prompt_build',
|
'before_prompt_build',
|
||||||
{ rawMessage: 'What outdoor activities do I enjoy?', prompt: '', messages: [] },
|
{ rawMessage: 'What outdoor activities do I enjoy?', prompt: '', messages: [] },
|
||||||
{ messageProvider: 'telegram', senderId: 'U007' },
|
{ messageProvider: 'telegram', senderId: 'U007' },
|
||||||
)) as { prependContext: string };
|
)) as { prependSystemContext: string; prependContext?: string };
|
||||||
|
|
||||||
expect(result.prependContext).toContain('User loves hiking');
|
expect(result.prependContext).toBeUndefined();
|
||||||
expect(result.prependContext).toContain('<hindsight_memories>');
|
expect(result.prependSystemContext).toContain('User loves hiking');
|
||||||
|
expect(result.prependSystemContext).toContain('<hindsight_memories>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue