@vectorize-io/hindsight-openclaw 0.4.15 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/client.d.ts +0 -4
- package/dist/client.js +6 -4
- package/dist/index.d.ts +21 -1
- package/dist/index.js +496 -105
- package/dist/types.d.ts +22 -0
- package/openclaw.plugin.json +177 -3
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -24,6 +24,47 @@ openclaw gateway
|
|
|
24
24
|
|
|
25
25
|
That's it! The plugin will automatically start capturing and recalling memories.
|
|
26
26
|
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Auto-capture** and **auto-recall** of memories each turn
|
|
30
|
+
- **Memory isolation** — configurable per agent, channel, user, or provider via `dynamicBankGranularity`
|
|
31
|
+
- **Retention controls** — choose which message roles to retain and toggle auto-retain on/off
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsight-openclaw.config`:
|
|
36
|
+
|
|
37
|
+
| Option | Default | Description |
|
|
38
|
+
|--------|---------|-------------|
|
|
39
|
+
| `apiPort` | `9077` | Port for the local Hindsight daemon |
|
|
40
|
+
| `daemonIdleTimeout` | `0` | Seconds before daemon shuts down from inactivity (0 = never) |
|
|
41
|
+
| `embedPort` | `0` | Port for `hindsight-embed` server (`0` = auto-assign) |
|
|
42
|
+
| `embedVersion` | `"latest"` | hindsight-embed version |
|
|
43
|
+
| `embedPackagePath` | — | Local path to `hindsight-embed` package for development |
|
|
44
|
+
| `bankMission` | — | Agent identity/purpose stored on the memory bank. Helps the engine understand context for better fact extraction. Set once per bank — not a recall prompt. |
|
|
45
|
+
| `llmProvider` | auto-detect | LLM provider override for memory extraction (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code`) |
|
|
46
|
+
| `llmModel` | provider default | LLM model override used with `llmProvider` |
|
|
47
|
+
| `llmApiKeyEnv` | provider standard env var | Custom env var name for the provider API key |
|
|
48
|
+
| `dynamicBankId` | `true` | Enable per-context memory banks |
|
|
49
|
+
| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) |
|
|
50
|
+
| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` |
|
|
51
|
+
| `excludeProviders` | `[]` | Message providers to skip for recall/retain (e.g. `slack`, `telegram`, `discord`) |
|
|
52
|
+
| `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. |
|
|
53
|
+
| `autoRetain` | `true` | Auto-retain conversations after each turn |
|
|
54
|
+
| `retainRoles` | `["user", "assistant"]` | Which message roles to retain. Options: `user`, `assistant`, `system`, `tool` |
|
|
55
|
+
| `retainEveryNTurns` | `1` | Retain every Nth turn. `1` = every turn (default). Values > 1 enable chunked retention with a sliding window. |
|
|
56
|
+
| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns`. Only applies when `retainEveryNTurns > 1`. |
|
|
57
|
+
| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. |
|
|
58
|
+
| `recallMaxTokens` | `1024` | Max tokens for recall response. Controls how much memory context is injected per turn. |
|
|
59
|
+
| `recallTypes` | `["world", "experience"]` | Memory types to recall. Options: `world`, `experience`, `observation`. Excludes verbose `observation` entries by default. |
|
|
60
|
+
| `recallRoles` | `["user", "assistant"]` | Roles included when building prior context for recall query composition. Options: `user`, `assistant`, `system`, `tool`. |
|
|
61
|
+
| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. |
|
|
62
|
+
| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. |
|
|
63
|
+
| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. |
|
|
64
|
+
| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `<hindsight_memories>` block. |
|
|
65
|
+
| `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) |
|
|
66
|
+
| `hindsightApiToken` | — | Auth token for external API |
|
|
67
|
+
|
|
27
68
|
## Documentation
|
|
28
69
|
|
|
29
70
|
For full documentation, configuration options, troubleshooting, and development guide, see:
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { RetainRequest, RetainResponse, RecallRequest, RecallResponse } from './types.js';
|
|
2
2
|
export interface HindsightClientOptions {
|
|
3
|
-
llmProvider: string;
|
|
4
|
-
llmApiKey: string;
|
|
5
3
|
llmModel?: string;
|
|
6
4
|
embedVersion?: string;
|
|
7
5
|
embedPackagePath?: string;
|
|
@@ -10,8 +8,6 @@ export interface HindsightClientOptions {
|
|
|
10
8
|
}
|
|
11
9
|
export declare class HindsightClient {
|
|
12
10
|
private bankId;
|
|
13
|
-
private llmProvider;
|
|
14
|
-
private llmApiKey;
|
|
15
11
|
private llmModel?;
|
|
16
12
|
private embedVersion;
|
|
17
13
|
private embedPackagePath?;
|
package/dist/client.js
CHANGED
|
@@ -19,16 +19,12 @@ function sanitizeFilename(name) {
|
|
|
19
19
|
}
|
|
20
20
|
export class HindsightClient {
|
|
21
21
|
bankId = 'default';
|
|
22
|
-
llmProvider;
|
|
23
|
-
llmApiKey;
|
|
24
22
|
llmModel;
|
|
25
23
|
embedVersion;
|
|
26
24
|
embedPackagePath;
|
|
27
25
|
apiUrl;
|
|
28
26
|
apiToken;
|
|
29
27
|
constructor(opts) {
|
|
30
|
-
this.llmProvider = opts.llmProvider;
|
|
31
|
-
this.llmApiKey = opts.llmApiKey;
|
|
32
28
|
this.llmModel = opts.llmModel;
|
|
33
29
|
this.embedVersion = opts.embedVersion || 'latest';
|
|
34
30
|
this.embedPackagePath = opts.embedPackagePath;
|
|
@@ -181,6 +177,12 @@ export class HindsightClient {
|
|
|
181
177
|
query,
|
|
182
178
|
max_tokens: request.max_tokens || 1024,
|
|
183
179
|
};
|
|
180
|
+
if (request.budget) {
|
|
181
|
+
body.budget = request.budget;
|
|
182
|
+
}
|
|
183
|
+
if (request.types) {
|
|
184
|
+
body.types = request.types;
|
|
185
|
+
}
|
|
184
186
|
const res = await fetch(url, {
|
|
185
187
|
method: 'POST',
|
|
186
188
|
headers: this.httpHeaders(),
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MoltbotPluginAPI } from './types.js';
|
|
1
|
+
import type { MoltbotPluginAPI, PluginConfig, PluginHookAgentContext, MemoryResult } from './types.js';
|
|
2
2
|
import { HindsightClient } from './client.js';
|
|
3
3
|
/**
|
|
4
4
|
* Strip plugin-injected memory tags from content to prevent retain feedback loop.
|
|
@@ -6,6 +6,17 @@ import { HindsightClient } from './client.js';
|
|
|
6
6
|
* during before_agent_start so they don't get re-stored into the memory bank.
|
|
7
7
|
*/
|
|
8
8
|
export declare function stripMemoryTags(content: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Extract sender_id from OpenClaw's injected inbound metadata blocks.
|
|
11
|
+
* Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks.
|
|
12
|
+
* Returns the first sender_id / id string found, or undefined if none.
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractSenderIdFromText(text: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Strip OpenClaw sender/conversation metadata envelopes from message content.
|
|
17
|
+
* These blocks are injected by OpenClaw but are noise for memory storage and recall.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripMetadataEnvelopes(content: string): string;
|
|
9
20
|
/**
|
|
10
21
|
* Extract a recall query from a hook event's rawMessage or prompt.
|
|
11
22
|
*
|
|
@@ -15,5 +26,14 @@ export declare function stripMemoryTags(content: string): string;
|
|
|
15
26
|
* Returns null when no usable query (< 5 chars) can be extracted.
|
|
16
27
|
*/
|
|
17
28
|
export declare function extractRecallQuery(rawMessage: string | undefined, prompt: string | undefined): string | null;
|
|
29
|
+
export declare function composeRecallQuery(latestQuery: string, messages: any[] | undefined, recallContextTurns: number, recallRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>): string;
|
|
30
|
+
export declare function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string;
|
|
31
|
+
export declare function deriveBankId(ctx: PluginHookAgentContext | undefined, pluginConfig: PluginConfig): string;
|
|
32
|
+
export declare function formatMemories(results: MemoryResult[]): string;
|
|
18
33
|
export default function (api: MoltbotPluginAPI): void;
|
|
34
|
+
export declare function prepareRetentionTranscript(messages: any[], pluginConfig: PluginConfig, retainFullWindow?: boolean): {
|
|
35
|
+
transcript: string;
|
|
36
|
+
messageCount: number;
|
|
37
|
+
} | null;
|
|
38
|
+
export declare function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[];
|
|
19
39
|
export declare function getClient(): HindsightClient | null;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HindsightEmbedManager } from './embed-manager.js';
|
|
2
2
|
import { HindsightClient } from './client.js';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
3
4
|
import { dirname } from 'path';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
// Debug logging: silent by default, enable with debug: true in plugin config
|
|
@@ -11,6 +12,7 @@ const debug = (...args) => {
|
|
|
11
12
|
// Module-level state
|
|
12
13
|
let embedManager = null;
|
|
13
14
|
let client = null;
|
|
15
|
+
let clientOptions = null;
|
|
14
16
|
let initPromise = null;
|
|
15
17
|
let isInitialized = false;
|
|
16
18
|
let usingExternalApi = false; // Track if using external API (skip daemon management)
|
|
@@ -18,13 +20,32 @@ let usingExternalApi = false; // Track if using external API (skip daemon manage
|
|
|
18
20
|
let currentPluginConfig = null;
|
|
19
21
|
// Track which banks have had their mission set (to avoid re-setting on every request)
|
|
20
22
|
const banksWithMissionSet = new Set();
|
|
23
|
+
// Use dedicated client instances per bank to avoid cross-session bankId mutation races.
|
|
24
|
+
const clientsByBankId = new Map();
|
|
25
|
+
const MAX_TRACKED_BANK_CLIENTS = 10_000;
|
|
21
26
|
const inflightRecalls = new Map();
|
|
22
27
|
const turnCountBySession = new Map();
|
|
28
|
+
const MAX_TRACKED_SESSIONS = 10_000;
|
|
23
29
|
const RECALL_TIMEOUT_MS = 10_000;
|
|
30
|
+
// Cache sender IDs discovered in before_prompt_build (where event.prompt has the metadata
|
|
31
|
+
// blocks) so agent_end can look them up — event.messages in agent_end is clean history.
|
|
32
|
+
const senderIdBySession = new Map();
|
|
33
|
+
// Guard against double hook registration on the same api instance
|
|
34
|
+
// Uses a WeakSet so each api instance can only register hooks once
|
|
35
|
+
const registeredApis = new WeakSet();
|
|
24
36
|
// Cooldown + guard to prevent concurrent reinit attempts
|
|
25
37
|
let lastReinitAttempt = 0;
|
|
26
38
|
let isReinitInProgress = false;
|
|
27
39
|
const REINIT_COOLDOWN_MS = 30_000;
|
|
40
|
+
const DEFAULT_RECALL_PROMPT_PREAMBLE = 'Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:';
|
|
41
|
+
function formatCurrentTimeForRecall(date = new Date()) {
|
|
42
|
+
const year = date.getUTCFullYear();
|
|
43
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
44
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
45
|
+
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
46
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
47
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
48
|
+
}
|
|
28
49
|
/**
|
|
29
50
|
* Lazy re-initialization after startup failure.
|
|
30
51
|
* Called by waitForReady when initPromise rejected but API may now be reachable.
|
|
@@ -56,7 +77,10 @@ async function lazyReinit() {
|
|
|
56
77
|
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
|
|
57
78
|
}
|
|
58
79
|
const llmConfig = detectLLMConfig(config);
|
|
59
|
-
|
|
80
|
+
clientOptions = buildClientOptions(llmConfig, config, externalApi);
|
|
81
|
+
clientsByBankId.clear();
|
|
82
|
+
banksWithMissionSet.clear();
|
|
83
|
+
client = new HindsightClient(clientOptions);
|
|
60
84
|
const defaultBankId = deriveBankId(undefined, config);
|
|
61
85
|
client.setBankId(defaultBankId);
|
|
62
86
|
if (config.bankMission && !config.dynamicBankId) {
|
|
@@ -107,12 +131,30 @@ if (typeof global !== 'undefined') {
|
|
|
107
131
|
return null;
|
|
108
132
|
}
|
|
109
133
|
const config = currentPluginConfig || {};
|
|
134
|
+
if (config.dynamicBankId === false) {
|
|
135
|
+
return client;
|
|
136
|
+
}
|
|
110
137
|
const bankId = deriveBankId(ctx, config);
|
|
111
|
-
|
|
138
|
+
let bankClient = clientsByBankId.get(bankId);
|
|
139
|
+
if (!bankClient) {
|
|
140
|
+
if (!clientOptions) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
bankClient = new HindsightClient(clientOptions);
|
|
144
|
+
bankClient.setBankId(bankId);
|
|
145
|
+
clientsByBankId.set(bankId, bankClient);
|
|
146
|
+
if (clientsByBankId.size > MAX_TRACKED_BANK_CLIENTS) {
|
|
147
|
+
const oldestKey = clientsByBankId.keys().next().value;
|
|
148
|
+
if (oldestKey) {
|
|
149
|
+
clientsByBankId.delete(oldestKey);
|
|
150
|
+
banksWithMissionSet.delete(oldestKey);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
112
154
|
// Set bank mission on first use of this bank (if configured)
|
|
113
155
|
if (config.bankMission && config.dynamicBankId && !banksWithMissionSet.has(bankId)) {
|
|
114
156
|
try {
|
|
115
|
-
await
|
|
157
|
+
await bankClient.setBankMission(config.bankMission);
|
|
116
158
|
banksWithMissionSet.add(bankId);
|
|
117
159
|
debug(`[Hindsight] Set mission for new bank: ${bankId}`);
|
|
118
160
|
}
|
|
@@ -121,7 +163,7 @@ if (typeof global !== 'undefined') {
|
|
|
121
163
|
console.warn(`[Hindsight] Could not set bank mission for ${bankId}: ${error}`);
|
|
122
164
|
}
|
|
123
165
|
}
|
|
124
|
-
return
|
|
166
|
+
return bankClient;
|
|
125
167
|
},
|
|
126
168
|
getPluginConfig: () => currentPluginConfig,
|
|
127
169
|
};
|
|
@@ -141,6 +183,40 @@ export function stripMemoryTags(content) {
|
|
|
141
183
|
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
|
|
142
184
|
return content;
|
|
143
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Extract sender_id from OpenClaw's injected inbound metadata blocks.
|
|
188
|
+
* Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks.
|
|
189
|
+
* Returns the first sender_id / id string found, or undefined if none.
|
|
190
|
+
*/
|
|
191
|
+
export function extractSenderIdFromText(text) {
|
|
192
|
+
if (!text)
|
|
193
|
+
return undefined;
|
|
194
|
+
const metaBlockRe = /[\w\s]+\(untrusted metadata\)[^\n]*\n```json\n([\s\S]*?)\n```/gi;
|
|
195
|
+
let match;
|
|
196
|
+
while ((match = metaBlockRe.exec(text)) !== null) {
|
|
197
|
+
try {
|
|
198
|
+
const obj = JSON.parse(match[1]);
|
|
199
|
+
const id = obj?.sender_id ?? obj?.id;
|
|
200
|
+
if (id && typeof id === 'string')
|
|
201
|
+
return id;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// continue to next block
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Strip OpenClaw sender/conversation metadata envelopes from message content.
|
|
211
|
+
* These blocks are injected by OpenClaw but are noise for memory storage and recall.
|
|
212
|
+
*/
|
|
213
|
+
export function stripMetadataEnvelopes(content) {
|
|
214
|
+
// Strip: ---\n<Label> (untrusted metadata):\n```json\n{...}\n```\n<message>\n---
|
|
215
|
+
content = content.replace(/^---\n[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n\n?/im, '').replace(/\n---$/, '');
|
|
216
|
+
// Strip: <Label> (untrusted metadata):\n```json\n{...}\n``` (without --- wrapper)
|
|
217
|
+
content = content.replace(/[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n?/gim, '');
|
|
218
|
+
return content.trim();
|
|
219
|
+
}
|
|
144
220
|
/**
|
|
145
221
|
* Extract a recall query from a hook event's rawMessage or prompt.
|
|
146
222
|
*
|
|
@@ -150,10 +226,25 @@ export function stripMemoryTags(content) {
|
|
|
150
226
|
* Returns null when no usable query (< 5 chars) can be extracted.
|
|
151
227
|
*/
|
|
152
228
|
export function extractRecallQuery(rawMessage, prompt) {
|
|
229
|
+
// Reject known metadata/system message patterns — these are not user queries
|
|
230
|
+
const METADATA_PATTERNS = [
|
|
231
|
+
/^\s*conversation info\s*\(untrusted metadata\)/i,
|
|
232
|
+
/^\s*\(untrusted metadata\)/i,
|
|
233
|
+
/^\s*system:/i,
|
|
234
|
+
];
|
|
235
|
+
const isMetadata = (s) => METADATA_PATTERNS.some(p => p.test(s));
|
|
153
236
|
let recallQuery = rawMessage;
|
|
154
|
-
|
|
237
|
+
// Strip sender metadata envelope before any checks
|
|
238
|
+
if (recallQuery) {
|
|
239
|
+
recallQuery = stripMetadataEnvelopes(recallQuery);
|
|
240
|
+
}
|
|
241
|
+
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5 || isMetadata(recallQuery)) {
|
|
155
242
|
recallQuery = prompt;
|
|
156
|
-
|
|
243
|
+
// Strip metadata envelopes from prompt too, then check if anything useful remains
|
|
244
|
+
if (recallQuery) {
|
|
245
|
+
recallQuery = stripMetadataEnvelopes(recallQuery);
|
|
246
|
+
}
|
|
247
|
+
if (!recallQuery || recallQuery.length < 5) {
|
|
157
248
|
return null;
|
|
158
249
|
}
|
|
159
250
|
// Strip envelope-formatted prompts from any channel
|
|
@@ -169,33 +260,172 @@ export function extractRecallQuery(rawMessage, prompt) {
|
|
|
169
260
|
}
|
|
170
261
|
// Remove trailing [from: SenderName] metadata (group chats)
|
|
171
262
|
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
|
|
263
|
+
// Strip metadata envelopes again after channel envelope extraction, in case
|
|
264
|
+
// the metadata block appeared after the [ChannelName] header
|
|
265
|
+
cleaned = stripMetadataEnvelopes(cleaned);
|
|
172
266
|
recallQuery = cleaned.trim() || recallQuery;
|
|
173
267
|
}
|
|
174
268
|
const trimmed = recallQuery.trim();
|
|
175
|
-
if (trimmed.length < 5)
|
|
269
|
+
if (trimmed.length < 5 || isMetadata(trimmed))
|
|
176
270
|
return null;
|
|
177
271
|
return trimmed;
|
|
178
272
|
}
|
|
273
|
+
export function composeRecallQuery(latestQuery, messages, recallContextTurns, recallRoles = ['user', 'assistant']) {
|
|
274
|
+
const latest = latestQuery.trim();
|
|
275
|
+
if (recallContextTurns <= 1 || !Array.isArray(messages) || messages.length === 0) {
|
|
276
|
+
return latest;
|
|
277
|
+
}
|
|
278
|
+
const allowedRoles = new Set(recallRoles);
|
|
279
|
+
const contextualMessages = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
|
|
280
|
+
const contextLines = contextualMessages
|
|
281
|
+
.map((msg) => {
|
|
282
|
+
const role = msg?.role;
|
|
283
|
+
if (!allowedRoles.has(role)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
let content = '';
|
|
287
|
+
if (typeof msg?.content === 'string') {
|
|
288
|
+
content = msg.content;
|
|
289
|
+
}
|
|
290
|
+
else if (Array.isArray(msg?.content)) {
|
|
291
|
+
content = msg.content
|
|
292
|
+
.filter((block) => block?.type === 'text' && typeof block?.text === 'string')
|
|
293
|
+
.map((block) => block.text)
|
|
294
|
+
.join('\n');
|
|
295
|
+
}
|
|
296
|
+
content = stripMemoryTags(content).trim();
|
|
297
|
+
content = stripMetadataEnvelopes(content);
|
|
298
|
+
if (!content) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (role === 'user' && content === latest) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return `${role}: ${content}`;
|
|
305
|
+
})
|
|
306
|
+
.filter((line) => Boolean(line));
|
|
307
|
+
if (contextLines.length === 0) {
|
|
308
|
+
return latest;
|
|
309
|
+
}
|
|
310
|
+
return [
|
|
311
|
+
'Prior context:',
|
|
312
|
+
contextLines.join('\n'),
|
|
313
|
+
latest,
|
|
314
|
+
].join('\n\n');
|
|
315
|
+
}
|
|
316
|
+
export function truncateRecallQuery(query, latestQuery, maxChars) {
|
|
317
|
+
if (maxChars <= 0) {
|
|
318
|
+
return query;
|
|
319
|
+
}
|
|
320
|
+
const latest = latestQuery.trim();
|
|
321
|
+
if (query.length <= maxChars) {
|
|
322
|
+
return query;
|
|
323
|
+
}
|
|
324
|
+
const latestOnly = latest.length <= maxChars ? latest : latest.slice(0, maxChars);
|
|
325
|
+
if (!query.includes('Prior context:')) {
|
|
326
|
+
return latestOnly;
|
|
327
|
+
}
|
|
328
|
+
// New order: Prior context at top, latest user message at bottom.
|
|
329
|
+
// Truncate by dropping oldest context lines first to preserve the suffix.
|
|
330
|
+
const contextMarker = 'Prior context:\n\n';
|
|
331
|
+
const markerIndex = query.indexOf(contextMarker);
|
|
332
|
+
if (markerIndex === -1) {
|
|
333
|
+
return latestOnly;
|
|
334
|
+
}
|
|
335
|
+
const suffixMarker = '\n\n' + latest;
|
|
336
|
+
const suffixIndex = query.lastIndexOf(suffixMarker);
|
|
337
|
+
if (suffixIndex === -1) {
|
|
338
|
+
return latestOnly;
|
|
339
|
+
}
|
|
340
|
+
const suffix = query.slice(suffixIndex); // \n\n<latest>
|
|
341
|
+
if (suffix.length >= maxChars) {
|
|
342
|
+
return latestOnly;
|
|
343
|
+
}
|
|
344
|
+
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
|
|
345
|
+
const contextLines = contextBody.split('\n').filter(Boolean);
|
|
346
|
+
const keptContextLines = [];
|
|
347
|
+
// Add context lines from newest (bottom) to oldest (top), stopping when we exceed maxChars
|
|
348
|
+
for (let i = contextLines.length - 1; i >= 0; i--) {
|
|
349
|
+
keptContextLines.unshift(contextLines[i]);
|
|
350
|
+
const candidate = `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
|
|
351
|
+
if (candidate.length > maxChars) {
|
|
352
|
+
keptContextLines.shift();
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (keptContextLines.length > 0) {
|
|
357
|
+
return `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
|
|
358
|
+
}
|
|
359
|
+
return latestOnly;
|
|
360
|
+
}
|
|
179
361
|
/**
|
|
180
362
|
* Derive a bank ID from the agent context.
|
|
181
|
-
*
|
|
363
|
+
* Uses configurable dynamicBankGranularity to determine bank segmentation.
|
|
182
364
|
* Falls back to default bank when context is unavailable.
|
|
183
365
|
*/
|
|
184
|
-
|
|
185
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Parse the OpenClaw sessionKey to extract context fields.
|
|
368
|
+
* Format: "agent:{agentId}:{provider}:{channelType}:{channelId}[:{extra}]"
|
|
369
|
+
* Example: "agent:c0der:telegram:group:-1003825475854:topic:42"
|
|
370
|
+
*/
|
|
371
|
+
function parseSessionKey(sessionKey) {
|
|
372
|
+
const parts = sessionKey.split(':');
|
|
373
|
+
if (parts.length < 5 || parts[0] !== 'agent')
|
|
374
|
+
return {};
|
|
375
|
+
// parts[1] = agentId, parts[2] = provider, parts[3] = channelType, parts[4..] = channelId + extras
|
|
376
|
+
return {
|
|
377
|
+
agentId: parts[1],
|
|
378
|
+
provider: parts[2],
|
|
379
|
+
// Rejoin from channelType onward as the channel identifier (e.g. "group:-1003825475854:topic:42")
|
|
380
|
+
channel: parts.slice(3).join(':'),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
export function deriveBankId(ctx, pluginConfig) {
|
|
186
384
|
if (pluginConfig.dynamicBankId === false) {
|
|
187
|
-
return pluginConfig.bankIdPrefix
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
385
|
+
return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
|
|
386
|
+
}
|
|
387
|
+
// When no context is available, fall back to the static default bank.
|
|
388
|
+
if (!ctx) {
|
|
389
|
+
return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
|
|
390
|
+
}
|
|
391
|
+
const fields = pluginConfig.dynamicBankGranularity?.length ? pluginConfig.dynamicBankGranularity : ['agent', 'channel', 'user'];
|
|
392
|
+
// Validate field names at runtime — typos silently produce 'unknown' segments
|
|
393
|
+
const validFields = new Set(['agent', 'channel', 'user', 'provider']);
|
|
394
|
+
for (const f of fields) {
|
|
395
|
+
if (!validFields.has(f)) {
|
|
396
|
+
console.warn(`[Hindsight] Unknown dynamicBankGranularity field "${f}" — will resolve to "unknown" in bank ID. Valid fields: agent, channel, user, provider`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Parse sessionKey as fallback when direct context fields are missing
|
|
400
|
+
const sessionParsed = ctx?.sessionKey ? parseSessionKey(ctx.sessionKey) : {};
|
|
401
|
+
// Warn when 'user' is in active fields but senderId is missing — bank ID will contain "anonymous"
|
|
402
|
+
if (fields.includes('user') && ctx && !ctx.senderId) {
|
|
403
|
+
debug('[Hindsight] senderId not available in context — bank ID will use "anonymous". Ensure your OpenClaw provider passes senderId.');
|
|
404
|
+
}
|
|
405
|
+
const fieldMap = {
|
|
406
|
+
agent: ctx?.agentId || sessionParsed.agentId || 'default',
|
|
407
|
+
channel: ctx?.channelId || sessionParsed.channel || 'unknown',
|
|
408
|
+
user: ctx?.senderId || 'anonymous',
|
|
409
|
+
provider: ctx?.messageProvider || sessionParsed.provider || 'unknown',
|
|
410
|
+
};
|
|
411
|
+
const baseBankId = fields
|
|
412
|
+
.map(f => encodeURIComponent(fieldMap[f] || 'unknown'))
|
|
413
|
+
.join('::');
|
|
195
414
|
return pluginConfig.bankIdPrefix
|
|
196
415
|
? `${pluginConfig.bankIdPrefix}-${baseBankId}`
|
|
197
416
|
: baseBankId;
|
|
198
417
|
}
|
|
418
|
+
export function formatMemories(results) {
|
|
419
|
+
if (!results || results.length === 0)
|
|
420
|
+
return '';
|
|
421
|
+
return results
|
|
422
|
+
.map(r => {
|
|
423
|
+
const type = r.type ? ` [${r.type}]` : '';
|
|
424
|
+
const date = r.mentioned_at ? ` (${r.mentioned_at})` : '';
|
|
425
|
+
return `- ${r.text}${type}${date}`;
|
|
426
|
+
})
|
|
427
|
+
.join('\n\n');
|
|
428
|
+
}
|
|
199
429
|
// Provider detection from standard env vars
|
|
200
430
|
const PROVIDER_DETECTION = [
|
|
201
431
|
{ name: 'openai', keyEnv: 'OPENAI_API_KEY', defaultModel: 'gpt-4o-mini' },
|
|
@@ -275,6 +505,17 @@ function detectLLMConfig(pluginConfig) {
|
|
|
275
505
|
}
|
|
276
506
|
}
|
|
277
507
|
// No configuration found - show helpful error
|
|
508
|
+
// Allow empty LLM config if using external Hindsight API (server handles LLM)
|
|
509
|
+
const externalApiCheck = detectExternalApi(pluginConfig);
|
|
510
|
+
if (externalApiCheck.apiUrl) {
|
|
511
|
+
return {
|
|
512
|
+
provider: undefined,
|
|
513
|
+
apiKey: undefined,
|
|
514
|
+
model: undefined,
|
|
515
|
+
baseUrl: undefined,
|
|
516
|
+
source: 'external-api-mode-no-llm',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
278
519
|
throw new Error(`No LLM configuration found for Hindsight memory plugin.\n\n` +
|
|
279
520
|
`Option 1: Set a standard provider API key (auto-detect):\n` +
|
|
280
521
|
` export OPENAI_API_KEY=sk-your-key # Uses gpt-4o-mini\n` +
|
|
@@ -307,8 +548,6 @@ function detectExternalApi(pluginConfig) {
|
|
|
307
548
|
*/
|
|
308
549
|
function buildClientOptions(llmConfig, pluginCfg, externalApi) {
|
|
309
550
|
return {
|
|
310
|
-
llmProvider: llmConfig.provider,
|
|
311
|
-
llmApiKey: llmConfig.apiKey,
|
|
312
551
|
llmModel: llmConfig.model,
|
|
313
552
|
embedVersion: pluginCfg.embedVersion,
|
|
314
553
|
embedPackagePath: pluginCfg.embedPackagePath,
|
|
@@ -370,7 +609,21 @@ function getPluginConfig(api) {
|
|
|
370
609
|
bankIdPrefix: config.bankIdPrefix,
|
|
371
610
|
excludeProviders: Array.isArray(config.excludeProviders) ? config.excludeProviders : [],
|
|
372
611
|
autoRecall: config.autoRecall !== false, // Default: true (on) — backward compatible
|
|
373
|
-
|
|
612
|
+
dynamicBankGranularity: Array.isArray(config.dynamicBankGranularity) ? config.dynamicBankGranularity : undefined,
|
|
613
|
+
autoRetain: config.autoRetain !== false, // Default: true
|
|
614
|
+
retainRoles: Array.isArray(config.retainRoles) ? config.retainRoles : undefined,
|
|
615
|
+
recallBudget: config.recallBudget || 'mid',
|
|
616
|
+
recallMaxTokens: config.recallMaxTokens || 1024,
|
|
617
|
+
recallTypes: Array.isArray(config.recallTypes) ? config.recallTypes : ['world', 'experience'],
|
|
618
|
+
recallRoles: Array.isArray(config.recallRoles) ? config.recallRoles : ['user', 'assistant'],
|
|
619
|
+
retainEveryNTurns: typeof config.retainEveryNTurns === 'number' && config.retainEveryNTurns >= 1 ? config.retainEveryNTurns : 1,
|
|
620
|
+
retainOverlapTurns: typeof config.retainOverlapTurns === 'number' && config.retainOverlapTurns >= 0 ? config.retainOverlapTurns : 0,
|
|
621
|
+
recallTopK: typeof config.recallTopK === 'number' ? config.recallTopK : undefined,
|
|
622
|
+
recallContextTurns: typeof config.recallContextTurns === 'number' && config.recallContextTurns >= 1 ? config.recallContextTurns : 1,
|
|
623
|
+
recallMaxQueryChars: typeof config.recallMaxQueryChars === 'number' && config.recallMaxQueryChars >= 1 ? config.recallMaxQueryChars : 800,
|
|
624
|
+
recallPromptPreamble: typeof config.recallPromptPreamble === 'string' && config.recallPromptPreamble.trim().length > 0
|
|
625
|
+
? config.recallPromptPreamble
|
|
626
|
+
: DEFAULT_RECALL_PROMPT_PREAMBLE,
|
|
374
627
|
debug: config.debug ?? false,
|
|
375
628
|
};
|
|
376
629
|
}
|
|
@@ -433,7 +686,10 @@ export default function (api) {
|
|
|
433
686
|
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
|
434
687
|
// Initialize client with direct HTTP mode
|
|
435
688
|
debug('[Hindsight] Creating HindsightClient (HTTP mode)...');
|
|
436
|
-
|
|
689
|
+
clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
|
|
690
|
+
clientsByBankId.clear();
|
|
691
|
+
banksWithMissionSet.clear();
|
|
692
|
+
client = new HindsightClient(clientOptions);
|
|
437
693
|
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
|
438
694
|
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
|
439
695
|
debug(`[Hindsight] Default bank: ${defaultBankId}`);
|
|
@@ -450,13 +706,16 @@ export default function (api) {
|
|
|
450
706
|
else {
|
|
451
707
|
// Local daemon mode - start hindsight-embed daemon
|
|
452
708
|
debug('[Hindsight] Creating HindsightEmbedManager...');
|
|
453
|
-
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider, llmConfig.apiKey, llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
|
709
|
+
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
|
454
710
|
// Start the embedded server
|
|
455
711
|
debug('[Hindsight] Starting embedded server...');
|
|
456
712
|
await embedManager.start();
|
|
457
713
|
// Initialize client (local daemon mode — no apiUrl)
|
|
458
714
|
debug('[Hindsight] Creating HindsightClient (subprocess mode)...');
|
|
459
|
-
|
|
715
|
+
clientOptions = buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null });
|
|
716
|
+
clientsByBankId.clear();
|
|
717
|
+
banksWithMissionSet.clear();
|
|
718
|
+
client = new HindsightClient(clientOptions);
|
|
460
719
|
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
|
461
720
|
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
|
462
721
|
debug(`[Hindsight] Default bank: ${defaultBankId}`);
|
|
@@ -507,6 +766,9 @@ export default function (api) {
|
|
|
507
766
|
console.error('[Hindsight] External API health check failed:', error);
|
|
508
767
|
// Reset state for reinitialization attempt
|
|
509
768
|
client = null;
|
|
769
|
+
clientOptions = null;
|
|
770
|
+
clientsByBankId.clear();
|
|
771
|
+
banksWithMissionSet.clear();
|
|
510
772
|
isInitialized = false;
|
|
511
773
|
}
|
|
512
774
|
}
|
|
@@ -523,6 +785,9 @@ export default function (api) {
|
|
|
523
785
|
// Reset state for reinitialization
|
|
524
786
|
embedManager = null;
|
|
525
787
|
client = null;
|
|
788
|
+
clientOptions = null;
|
|
789
|
+
clientsByBankId.clear();
|
|
790
|
+
banksWithMissionSet.clear();
|
|
526
791
|
isInitialized = false;
|
|
527
792
|
}
|
|
528
793
|
}
|
|
@@ -542,7 +807,10 @@ export default function (api) {
|
|
|
542
807
|
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
|
|
543
808
|
}
|
|
544
809
|
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
|
545
|
-
|
|
810
|
+
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, externalApi);
|
|
811
|
+
clientsByBankId.clear();
|
|
812
|
+
banksWithMissionSet.clear();
|
|
813
|
+
client = new HindsightClient(clientOptions);
|
|
546
814
|
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
|
547
815
|
client.setBankId(defaultBankId);
|
|
548
816
|
if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
|
|
@@ -553,9 +821,12 @@ export default function (api) {
|
|
|
553
821
|
}
|
|
554
822
|
else {
|
|
555
823
|
// Local daemon mode
|
|
556
|
-
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider, llmConfig.apiKey, llmConfig.model, llmConfig.baseUrl, reinitPluginConfig.daemonIdleTimeout, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
|
|
824
|
+
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, reinitPluginConfig.daemonIdleTimeout, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
|
|
557
825
|
await embedManager.start();
|
|
558
|
-
|
|
826
|
+
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null });
|
|
827
|
+
clientsByBankId.clear();
|
|
828
|
+
banksWithMissionSet.clear();
|
|
829
|
+
client = new HindsightClient(clientOptions);
|
|
559
830
|
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
|
560
831
|
client.setBankId(defaultBankId);
|
|
561
832
|
if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
|
|
@@ -575,6 +846,9 @@ export default function (api) {
|
|
|
575
846
|
embedManager = null;
|
|
576
847
|
}
|
|
577
848
|
client = null;
|
|
849
|
+
clientOptions = null;
|
|
850
|
+
clientsByBankId.clear();
|
|
851
|
+
banksWithMissionSet.clear();
|
|
578
852
|
isInitialized = false;
|
|
579
853
|
debug('[Hindsight] Service stopped');
|
|
580
854
|
}
|
|
@@ -586,19 +860,16 @@ export default function (api) {
|
|
|
586
860
|
});
|
|
587
861
|
debug('[Hindsight] Plugin loaded successfully');
|
|
588
862
|
// Register agent hooks for auto-recall and auto-retention
|
|
863
|
+
if (registeredApis.has(api)) {
|
|
864
|
+
debug('[Hindsight] Hooks already registered for this api instance, skipping duplicate registration');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
registeredApis.add(api);
|
|
589
868
|
debug('[Hindsight] Registering agent hooks...');
|
|
590
|
-
// Store session key and context for retention
|
|
591
|
-
let currentSessionKey;
|
|
592
|
-
let currentAgentContext;
|
|
593
869
|
// Auto-recall: Inject relevant memories before agent processes the message
|
|
594
870
|
// Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
|
|
595
|
-
api.on('
|
|
871
|
+
api.on('before_prompt_build', async (event, ctx) => {
|
|
596
872
|
try {
|
|
597
|
-
// Capture session key and context for use in agent_end
|
|
598
|
-
if (ctx?.sessionKey) {
|
|
599
|
-
currentSessionKey = ctx.sessionKey;
|
|
600
|
-
}
|
|
601
|
-
currentAgentContext = ctx;
|
|
602
873
|
// Check if this provider is excluded
|
|
603
874
|
if (ctx?.messageProvider && pluginConfig.excludeProviders?.includes(ctx.messageProvider)) {
|
|
604
875
|
debug(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
|
|
@@ -609,20 +880,49 @@ export default function (api) {
|
|
|
609
880
|
debug('[Hindsight] Auto-recall disabled via config, skipping');
|
|
610
881
|
return;
|
|
611
882
|
}
|
|
612
|
-
// Derive bank ID from context
|
|
613
|
-
|
|
614
|
-
|
|
883
|
+
// Derive bank ID from context — enrich ctx.senderId from the inbound metadata
|
|
884
|
+
// block when it's missing (agent-phase hooks don't carry senderId in ctx directly).
|
|
885
|
+
const senderIdFromPrompt = !ctx?.senderId ? extractSenderIdFromText(event.prompt ?? event.rawMessage ?? '') : undefined;
|
|
886
|
+
const effectiveCtxForRecall = senderIdFromPrompt ? { ...ctx, senderId: senderIdFromPrompt } : ctx;
|
|
887
|
+
// Cache the resolved sender ID keyed by sessionKey so agent_end can use it.
|
|
888
|
+
// event.messages in agent_end is clean history without the metadata blocks.
|
|
889
|
+
const resolvedSenderId = effectiveCtxForRecall?.senderId;
|
|
890
|
+
const sessionKeyForCache = ctx?.sessionKey;
|
|
891
|
+
if (resolvedSenderId && sessionKeyForCache) {
|
|
892
|
+
senderIdBySession.set(sessionKeyForCache, resolvedSenderId);
|
|
893
|
+
if (senderIdBySession.size > MAX_TRACKED_SESSIONS) {
|
|
894
|
+
const oldest = senderIdBySession.keys().next().value;
|
|
895
|
+
if (oldest)
|
|
896
|
+
senderIdBySession.delete(oldest);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const bankId = deriveBankId(effectiveCtxForRecall, pluginConfig);
|
|
900
|
+
debug(`[Hindsight] before_prompt_build - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
|
|
901
|
+
debug(`[Hindsight] event keys: ${Object.keys(event).join(', ')}`);
|
|
902
|
+
debug(`[Hindsight] event.context keys: ${Object.keys(event.context ?? {}).join(', ')}`);
|
|
615
903
|
// Get the user's latest message for recall — only the raw user text, not the full prompt
|
|
616
904
|
// rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
|
|
905
|
+
debug(`[Hindsight] extractRecallQuery input lengths - raw: ${event.rawMessage?.length ?? 0}, prompt: ${event.prompt?.length ?? 0}`);
|
|
617
906
|
const extracted = extractRecallQuery(event.rawMessage, event.prompt);
|
|
618
907
|
if (!extracted) {
|
|
908
|
+
debug('[Hindsight] extractRecallQuery returned null, skipping recall');
|
|
619
909
|
return;
|
|
620
910
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
911
|
+
debug(`[Hindsight] extractRecallQuery result length: ${extracted.length}`);
|
|
912
|
+
const recallContextTurns = pluginConfig.recallContextTurns ?? 1;
|
|
913
|
+
const recallMaxQueryChars = pluginConfig.recallMaxQueryChars ?? 800;
|
|
914
|
+
const sessionMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
|
|
915
|
+
const messageCount = sessionMessages.length;
|
|
916
|
+
debug(`[Hindsight] event.messages count: ${messageCount}, roles: ${sessionMessages.map((m) => m.role).join(',')}`);
|
|
917
|
+
if (recallContextTurns > 1 && messageCount === 0) {
|
|
918
|
+
debug('[Hindsight] recallContextTurns > 1 but event.messages is empty — prior context unavailable at before_agent_start for this provider');
|
|
919
|
+
}
|
|
920
|
+
const recallRoles = pluginConfig.recallRoles ?? ['user', 'assistant'];
|
|
921
|
+
const composedPrompt = composeRecallQuery(extracted, sessionMessages, recallContextTurns, recallRoles);
|
|
922
|
+
let prompt = truncateRecallQuery(composedPrompt, extracted, recallMaxQueryChars);
|
|
923
|
+
// Final defensive cap
|
|
924
|
+
if (prompt.length > recallMaxQueryChars) {
|
|
925
|
+
prompt = prompt.substring(0, recallMaxQueryChars);
|
|
626
926
|
}
|
|
627
927
|
// Wait for client to be ready
|
|
628
928
|
const clientGlobal = global.__hindsightClient;
|
|
@@ -632,14 +932,16 @@ export default function (api) {
|
|
|
632
932
|
}
|
|
633
933
|
await clientGlobal.waitForReady();
|
|
634
934
|
// Get client configured for this context's bank (async to handle mission setup)
|
|
635
|
-
const client = await clientGlobal.getClientForContext(
|
|
935
|
+
const client = await clientGlobal.getClientForContext(effectiveCtxForRecall);
|
|
636
936
|
if (!client) {
|
|
637
937
|
debug('[Hindsight] Client not initialized, skipping auto-recall');
|
|
638
938
|
return;
|
|
639
939
|
}
|
|
640
|
-
debug(`[Hindsight] Auto-recall for bank ${bankId},
|
|
940
|
+
debug(`[Hindsight] Auto-recall for bank ${bankId}, full query:\n---\n${prompt}\n---`);
|
|
641
941
|
// Recall with deduplication: reuse in-flight request for same bank
|
|
642
|
-
const
|
|
942
|
+
const normalizedPrompt = prompt.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
943
|
+
const queryHash = createHash('sha256').update(normalizedPrompt).digest('hex').slice(0, 16);
|
|
944
|
+
const recallKey = `${bankId}::${queryHash}`;
|
|
643
945
|
const existing = inflightRecalls.get(recallKey);
|
|
644
946
|
let recallPromise;
|
|
645
947
|
if (existing) {
|
|
@@ -647,7 +949,7 @@ export default function (api) {
|
|
|
647
949
|
recallPromise = existing;
|
|
648
950
|
}
|
|
649
951
|
else {
|
|
650
|
-
recallPromise = client.recall({ query: prompt, max_tokens:
|
|
952
|
+
recallPromise = client.recall({ query: prompt, max_tokens: pluginConfig.recallMaxTokens || 1024, budget: pluginConfig.recallBudget, types: pluginConfig.recallTypes }, RECALL_TIMEOUT_MS);
|
|
651
953
|
inflightRecalls.set(recallKey, recallPromise);
|
|
652
954
|
void recallPromise.catch(() => { }).finally(() => inflightRecalls.delete(recallKey));
|
|
653
955
|
}
|
|
@@ -656,15 +958,18 @@ export default function (api) {
|
|
|
656
958
|
debug('[Hindsight] No memories found for auto-recall');
|
|
657
959
|
return;
|
|
658
960
|
}
|
|
961
|
+
debug(`[Hindsight] Raw recall response (${response.results.length} results before topK):\n${response.results.map((r, i) => ` [${i}] score=${r.score?.toFixed(3) ?? 'n/a'} type=${r.type ?? 'n/a'}: ${JSON.stringify(r.content ?? r.text ?? r).substring(0, 200)}`).join('\n')}`);
|
|
962
|
+
const results = pluginConfig.recallTopK ? response.results.slice(0, pluginConfig.recallTopK) : response.results;
|
|
963
|
+
debug(`[Hindsight] After topK (${pluginConfig.recallTopK ?? 'unlimited'}): ${results.length} results injected`);
|
|
659
964
|
// Format memories as JSON with all fields from recall
|
|
660
|
-
const
|
|
965
|
+
const memoriesFormatted = formatMemories(results);
|
|
661
966
|
const contextMessage = `<hindsight_memories>
|
|
662
|
-
|
|
663
|
-
${
|
|
967
|
+
${pluginConfig.recallPromptPreamble || DEFAULT_RECALL_PROMPT_PREAMBLE}
|
|
968
|
+
Current time - ${formatCurrentTimeForRecall()}
|
|
664
969
|
|
|
665
|
-
|
|
970
|
+
${memoriesFormatted}
|
|
666
971
|
</hindsight_memories>`;
|
|
667
|
-
debug(`[Hindsight] Auto-recall: Injecting ${
|
|
972
|
+
debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
|
|
668
973
|
// Inject context before the user message
|
|
669
974
|
return { prependContext: contextMessage };
|
|
670
975
|
}
|
|
@@ -684,91 +989,101 @@ User message: ${prompt}
|
|
|
684
989
|
// Hook signature: (event, ctx) where event has {messages, success, error?, durationMs?}
|
|
685
990
|
api.on('agent_end', async (event, ctx) => {
|
|
686
991
|
try {
|
|
687
|
-
//
|
|
688
|
-
const
|
|
992
|
+
// Avoid cross-session contamination: only use context carried by this event.
|
|
993
|
+
const eventSessionKey = typeof event?.sessionKey === 'string' ? event.sessionKey : undefined;
|
|
994
|
+
const effectiveCtx = ctx || (eventSessionKey ? { sessionKey: eventSessionKey } : undefined);
|
|
689
995
|
// Check if this provider is excluded
|
|
690
996
|
if (effectiveCtx?.messageProvider && pluginConfig.excludeProviders?.includes(effectiveCtx.messageProvider)) {
|
|
691
997
|
debug(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
|
|
692
998
|
return;
|
|
693
999
|
}
|
|
694
|
-
// Derive bank ID from context
|
|
695
|
-
|
|
1000
|
+
// Derive bank ID from context — enrich ctx.senderId from the session cache.
|
|
1001
|
+
// event.messages in agent_end is clean history without OpenClaw's metadata blocks;
|
|
1002
|
+
// the sender ID was captured during before_prompt_build where event.prompt has them.
|
|
1003
|
+
const sessionKeyForLookup = effectiveCtx?.sessionKey;
|
|
1004
|
+
const senderIdFromCache = !effectiveCtx?.senderId && sessionKeyForLookup
|
|
1005
|
+
? senderIdBySession.get(sessionKeyForLookup)
|
|
1006
|
+
: undefined;
|
|
1007
|
+
const effectiveCtxForRetain = senderIdFromCache ? { ...effectiveCtx, senderId: senderIdFromCache } : effectiveCtx;
|
|
1008
|
+
const bankId = deriveBankId(effectiveCtxForRetain, pluginConfig);
|
|
696
1009
|
debug(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
debug('[Hindsight Hook] Skipping: success:', event.success, 'messages:', event.messages?.length);
|
|
1010
|
+
if (event.success === false) {
|
|
1011
|
+
debug('[Hindsight Hook] Agent run failed, skipping retention');
|
|
700
1012
|
return;
|
|
701
1013
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if (!clientGlobal) {
|
|
705
|
-
console.warn('[Hindsight] Client global not found, skipping retain');
|
|
1014
|
+
if (!Array.isArray(event.context?.sessionEntry?.messages ?? event.messages) || (event.context?.sessionEntry?.messages ?? event.messages ?? []).length === 0) {
|
|
1015
|
+
debug('[Hindsight Hook] No messages in event, skipping retention');
|
|
706
1016
|
return;
|
|
707
1017
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const client = await clientGlobal.getClientForContext(effectiveCtx);
|
|
711
|
-
if (!client) {
|
|
712
|
-
console.warn('[Hindsight] Client not initialized, skipping retain');
|
|
1018
|
+
if (pluginConfig.autoRetain === false) {
|
|
1019
|
+
debug('[Hindsight Hook] autoRetain is disabled, skipping retention');
|
|
713
1020
|
return;
|
|
714
1021
|
}
|
|
715
|
-
//
|
|
716
|
-
const retainEveryN = pluginConfig.retainEveryNTurns ??
|
|
717
|
-
|
|
1022
|
+
// Chunked retention: skip non-Nth turns and use a sliding window when firing
|
|
1023
|
+
const retainEveryN = pluginConfig.retainEveryNTurns ?? 1;
|
|
1024
|
+
const allMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
|
|
1025
|
+
let messagesToRetain = allMessages;
|
|
1026
|
+
let retainFullWindow = false;
|
|
718
1027
|
if (retainEveryN > 1) {
|
|
719
|
-
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey ||
|
|
1028
|
+
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`;
|
|
720
1029
|
const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
|
|
721
1030
|
turnCountBySession.set(sessionTrackingKey, turnCount);
|
|
1031
|
+
if (turnCountBySession.size > MAX_TRACKED_SESSIONS) {
|
|
1032
|
+
const oldestKey = turnCountBySession.keys().next().value;
|
|
1033
|
+
if (oldestKey) {
|
|
1034
|
+
turnCountBySession.delete(oldestKey);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
722
1037
|
if (turnCount % retainEveryN !== 0) {
|
|
723
|
-
const
|
|
724
|
-
debug(`[Hindsight Hook]
|
|
1038
|
+
const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN;
|
|
1039
|
+
debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
|
|
725
1040
|
return;
|
|
726
1041
|
}
|
|
727
|
-
// Sliding window: N turns
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
debug('[Hindsight Hook] Transcript too short, skipping');
|
|
1042
|
+
// Sliding window in turns: N turns + configured overlap turns.
|
|
1043
|
+
// We slice by actual turn boundaries (user-role messages), so this
|
|
1044
|
+
// remains stable even when system/tool messages are present.
|
|
1045
|
+
const overlapTurns = pluginConfig.retainOverlapTurns ?? 0;
|
|
1046
|
+
const windowTurns = retainEveryN + overlapTurns;
|
|
1047
|
+
messagesToRetain = sliceLastTurnsByUserBoundary(allMessages, windowTurns);
|
|
1048
|
+
retainFullWindow = true;
|
|
1049
|
+
debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
|
|
1050
|
+
}
|
|
1051
|
+
const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow);
|
|
1052
|
+
if (!retention) {
|
|
1053
|
+
debug('[Hindsight Hook] No messages to retain (filtered/short/no-user)');
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const { transcript, messageCount } = retention;
|
|
1057
|
+
// Wait for client to be ready
|
|
1058
|
+
const clientGlobal = global.__hindsightClient;
|
|
1059
|
+
if (!clientGlobal) {
|
|
1060
|
+
console.warn('[Hindsight] Client global not found, skipping retain');
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
await clientGlobal.waitForReady();
|
|
1064
|
+
// Get client configured for this context's bank (async to handle mission setup)
|
|
1065
|
+
const client = await clientGlobal.getClientForContext(effectiveCtxForRetain);
|
|
1066
|
+
if (!client) {
|
|
1067
|
+
console.warn('[Hindsight] Client not initialized, skipping retain');
|
|
754
1068
|
return;
|
|
755
1069
|
}
|
|
756
1070
|
// Use unique document ID per conversation (sessionKey + timestamp)
|
|
757
1071
|
// Static sessionKey (e.g. "agent:main:main") causes CASCADE delete of old memories
|
|
758
|
-
const documentId = `${effectiveCtx?.sessionKey ||
|
|
1072
|
+
const documentId = `${effectiveCtx?.sessionKey || 'session'}-${Date.now()}`;
|
|
759
1073
|
// Retain to Hindsight
|
|
1074
|
+
debug(`[Hindsight] Retaining to bank ${bankId}, document: ${documentId}, chars: ${transcript.length}\n---\n${transcript.substring(0, 500)}${transcript.length > 500 ? '\n...(truncated)' : ''}\n---`);
|
|
760
1075
|
await client.retain({
|
|
761
1076
|
content: transcript,
|
|
762
1077
|
document_id: documentId,
|
|
763
1078
|
metadata: {
|
|
764
1079
|
retained_at: new Date().toISOString(),
|
|
765
|
-
message_count: String(
|
|
1080
|
+
message_count: String(messageCount),
|
|
766
1081
|
channel_type: effectiveCtx?.messageProvider,
|
|
767
1082
|
channel_id: effectiveCtx?.channelId,
|
|
768
1083
|
sender_id: effectiveCtx?.senderId,
|
|
769
1084
|
},
|
|
770
1085
|
});
|
|
771
|
-
debug(`[Hindsight] Retained ${
|
|
1086
|
+
debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${documentId}`);
|
|
772
1087
|
}
|
|
773
1088
|
catch (error) {
|
|
774
1089
|
console.error('[Hindsight] Error retaining messages:', error);
|
|
@@ -785,6 +1100,82 @@ User message: ${prompt}
|
|
|
785
1100
|
}
|
|
786
1101
|
}
|
|
787
1102
|
// Export client getter for tools
|
|
1103
|
+
export function prepareRetentionTranscript(messages, pluginConfig, retainFullWindow = false) {
|
|
1104
|
+
if (!messages || messages.length === 0) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
let targetMessages;
|
|
1108
|
+
if (retainFullWindow) {
|
|
1109
|
+
// Chunked retention: retain the full sliding window (already sliced by caller)
|
|
1110
|
+
targetMessages = messages;
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
// Default: retain only the last turn (user message + assistant responses)
|
|
1114
|
+
let lastUserIdx = -1;
|
|
1115
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1116
|
+
if (messages[i].role === 'user') {
|
|
1117
|
+
lastUserIdx = i;
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (lastUserIdx === -1) {
|
|
1122
|
+
return null; // No user message found in turn
|
|
1123
|
+
}
|
|
1124
|
+
targetMessages = messages.slice(lastUserIdx);
|
|
1125
|
+
}
|
|
1126
|
+
// Role filtering
|
|
1127
|
+
const allowedRoles = new Set(pluginConfig.retainRoles || ['user', 'assistant']);
|
|
1128
|
+
const filteredMessages = targetMessages.filter((m) => allowedRoles.has(m.role));
|
|
1129
|
+
if (filteredMessages.length === 0) {
|
|
1130
|
+
return null; // No messages to retain
|
|
1131
|
+
}
|
|
1132
|
+
// Format messages into a transcript
|
|
1133
|
+
const transcriptParts = filteredMessages
|
|
1134
|
+
.map((msg) => {
|
|
1135
|
+
const role = msg.role || 'unknown';
|
|
1136
|
+
let content = '';
|
|
1137
|
+
// Handle different content formats
|
|
1138
|
+
if (typeof msg.content === 'string') {
|
|
1139
|
+
content = msg.content;
|
|
1140
|
+
}
|
|
1141
|
+
else if (Array.isArray(msg.content)) {
|
|
1142
|
+
content = msg.content
|
|
1143
|
+
.filter((block) => block.type === 'text')
|
|
1144
|
+
.map((block) => block.text)
|
|
1145
|
+
.join('\n');
|
|
1146
|
+
}
|
|
1147
|
+
// Strip plugin-injected memory tags and metadata envelopes to prevent feedback loop
|
|
1148
|
+
content = stripMemoryTags(content);
|
|
1149
|
+
content = stripMetadataEnvelopes(content);
|
|
1150
|
+
return content.trim() ? `[role: ${role}]\n${content}\n[${role}:end]` : null;
|
|
1151
|
+
})
|
|
1152
|
+
.filter(Boolean);
|
|
1153
|
+
const transcript = transcriptParts.join('\n\n');
|
|
1154
|
+
if (!transcript.trim() || transcript.length < 10) {
|
|
1155
|
+
return null; // Transcript too short
|
|
1156
|
+
}
|
|
1157
|
+
return { transcript, messageCount: transcriptParts.length };
|
|
1158
|
+
}
|
|
1159
|
+
export function sliceLastTurnsByUserBoundary(messages, turns) {
|
|
1160
|
+
if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) {
|
|
1161
|
+
return [];
|
|
1162
|
+
}
|
|
1163
|
+
let userTurnsSeen = 0;
|
|
1164
|
+
let startIndex = -1;
|
|
1165
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1166
|
+
if (messages[i]?.role === 'user') {
|
|
1167
|
+
userTurnsSeen += 1;
|
|
1168
|
+
if (userTurnsSeen >= turns) {
|
|
1169
|
+
startIndex = i;
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (startIndex === -1) {
|
|
1175
|
+
return messages;
|
|
1176
|
+
}
|
|
1177
|
+
return messages.slice(startIndex);
|
|
1178
|
+
}
|
|
788
1179
|
export function getClient() {
|
|
789
1180
|
return client;
|
|
790
1181
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,14 @@ export interface MoltbotConfig {
|
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
+
export interface PluginHookAgentContext {
|
|
28
|
+
agentId?: string;
|
|
29
|
+
sessionKey?: string;
|
|
30
|
+
workspaceDir?: string;
|
|
31
|
+
messageProvider?: string;
|
|
32
|
+
channelId?: string;
|
|
33
|
+
senderId?: string;
|
|
34
|
+
}
|
|
27
35
|
export interface PluginConfig {
|
|
28
36
|
bankMission?: string;
|
|
29
37
|
embedPort?: number;
|
|
@@ -40,7 +48,19 @@ export interface PluginConfig {
|
|
|
40
48
|
bankIdPrefix?: string;
|
|
41
49
|
excludeProviders?: string[];
|
|
42
50
|
autoRecall?: boolean;
|
|
51
|
+
dynamicBankGranularity?: Array<'agent' | 'provider' | 'channel' | 'user'>;
|
|
52
|
+
autoRetain?: boolean;
|
|
53
|
+
retainRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>;
|
|
54
|
+
recallBudget?: 'low' | 'mid' | 'high';
|
|
55
|
+
recallMaxTokens?: number;
|
|
56
|
+
recallTypes?: Array<'world' | 'experience' | 'observation'>;
|
|
57
|
+
recallRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>;
|
|
43
58
|
retainEveryNTurns?: number;
|
|
59
|
+
retainOverlapTurns?: number;
|
|
60
|
+
recallTopK?: number;
|
|
61
|
+
recallContextTurns?: number;
|
|
62
|
+
recallMaxQueryChars?: number;
|
|
63
|
+
recallPromptPreamble?: string;
|
|
44
64
|
debug?: boolean;
|
|
45
65
|
}
|
|
46
66
|
export interface ServiceConfig {
|
|
@@ -61,6 +81,8 @@ export interface RetainResponse {
|
|
|
61
81
|
export interface RecallRequest {
|
|
62
82
|
query: string;
|
|
63
83
|
max_tokens?: number;
|
|
84
|
+
budget?: 'low' | 'mid' | 'high';
|
|
85
|
+
types?: Array<'world' | 'experience' | 'observation'>;
|
|
64
86
|
}
|
|
65
87
|
export interface RecallResponse {
|
|
66
88
|
results: MemoryResult[];
|
package/openclaw.plugin.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"bankMission": {
|
|
19
19
|
"type": "string",
|
|
20
|
-
"description": "
|
|
20
|
+
"description": "Agent identity/purpose stored on the memory bank. Helps the memory engine understand context for better fact extraction during retain. Set once per bank on first use — this is not a recall prompt.",
|
|
21
21
|
"default": "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."
|
|
22
22
|
},
|
|
23
23
|
"embedVersion": {
|
|
@@ -28,7 +28,15 @@
|
|
|
28
28
|
"llmProvider": {
|
|
29
29
|
"type": "string",
|
|
30
30
|
"description": "LLM provider for Hindsight memory (e.g. 'openai', 'anthropic', 'gemini', 'groq', 'ollama', 'openai-codex', 'claude-code'). Takes priority over auto-detection but not over HINDSIGHT_API_LLM_PROVIDER env var.",
|
|
31
|
-
"enum": [
|
|
31
|
+
"enum": [
|
|
32
|
+
"openai",
|
|
33
|
+
"anthropic",
|
|
34
|
+
"gemini",
|
|
35
|
+
"groq",
|
|
36
|
+
"ollama",
|
|
37
|
+
"openai-codex",
|
|
38
|
+
"claude-code"
|
|
39
|
+
]
|
|
32
40
|
},
|
|
33
41
|
"llmModel": {
|
|
34
42
|
"type": "string",
|
|
@@ -71,8 +79,119 @@
|
|
|
71
79
|
},
|
|
72
80
|
"excludeProviders": {
|
|
73
81
|
"type": "array",
|
|
74
|
-
"items": {
|
|
82
|
+
"items": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
75
85
|
"description": "Message providers to exclude from recall and retain (e.g. ['telegram', 'discord'])"
|
|
86
|
+
},
|
|
87
|
+
"dynamicBankGranularity": {
|
|
88
|
+
"type": "array",
|
|
89
|
+
"items": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"enum": [
|
|
92
|
+
"agent",
|
|
93
|
+
"channel",
|
|
94
|
+
"user",
|
|
95
|
+
"provider"
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
"description": "Fields used to derive bank ID. Controls memory isolation granularity. Default: ['agent', 'channel', 'user'].",
|
|
99
|
+
"default": [
|
|
100
|
+
"agent",
|
|
101
|
+
"channel",
|
|
102
|
+
"user"
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
"autoRetain": {
|
|
106
|
+
"type": "boolean",
|
|
107
|
+
"description": "Automatically retain conversation as memories after each interaction. Set to false to disable.",
|
|
108
|
+
"default": true
|
|
109
|
+
},
|
|
110
|
+
"retainRoles": {
|
|
111
|
+
"type": "array",
|
|
112
|
+
"items": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"enum": [
|
|
115
|
+
"user",
|
|
116
|
+
"assistant",
|
|
117
|
+
"system",
|
|
118
|
+
"tool"
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
"description": "Message roles to include in retained transcript. Default: ['user', 'assistant'].",
|
|
122
|
+
"default": [
|
|
123
|
+
"user",
|
|
124
|
+
"assistant"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"retainEveryNTurns": {
|
|
128
|
+
"type": "integer",
|
|
129
|
+
"description": "Retain every Nth turn instead of every turn. 1 = every turn (default). Values > 1 enable chunked retention with a sliding window.",
|
|
130
|
+
"minimum": 1,
|
|
131
|
+
"default": 1
|
|
132
|
+
},
|
|
133
|
+
"retainOverlapTurns": {
|
|
134
|
+
"type": "integer",
|
|
135
|
+
"description": "Extra prior turns to include when chunked retention fires. Window = retainEveryNTurns + retainOverlapTurns. Only applies when retainEveryNTurns > 1.",
|
|
136
|
+
"minimum": 0,
|
|
137
|
+
"default": 0
|
|
138
|
+
},
|
|
139
|
+
"recallBudget": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "Recall effort level. Higher budgets use more retrieval strategies for better results but take longer.",
|
|
142
|
+
"enum": ["low", "mid", "high"],
|
|
143
|
+
"default": "mid"
|
|
144
|
+
},
|
|
145
|
+
"recallMaxTokens": {
|
|
146
|
+
"type": "integer",
|
|
147
|
+
"description": "Maximum tokens for recall response. Controls how much memory context is injected per turn.",
|
|
148
|
+
"minimum": 1,
|
|
149
|
+
"default": 1024
|
|
150
|
+
},
|
|
151
|
+
"recallTypes": {
|
|
152
|
+
"type": "array",
|
|
153
|
+
"items": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"enum": ["world", "experience", "observation"]
|
|
156
|
+
},
|
|
157
|
+
"description": "Memory types to recall. Defaults to ['world', 'experience'] — excludes verbose observation entries.",
|
|
158
|
+
"default": ["world", "experience"]
|
|
159
|
+
},
|
|
160
|
+
"recallRoles": {
|
|
161
|
+
"type": "array",
|
|
162
|
+
"items": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"enum": ["user", "assistant", "system", "tool"]
|
|
165
|
+
},
|
|
166
|
+
"description": "Roles to include when composing contextual recall query. Default: ['user', 'assistant'].",
|
|
167
|
+
"default": ["user", "assistant"]
|
|
168
|
+
},
|
|
169
|
+
"recallContextTurns": {
|
|
170
|
+
"type": "integer",
|
|
171
|
+
"minimum": 1,
|
|
172
|
+
"description": "Number of user turns to include in recall query context. 1 keeps latest-message-only behavior.",
|
|
173
|
+
"default": 1
|
|
174
|
+
},
|
|
175
|
+
"recallMaxQueryChars": {
|
|
176
|
+
"type": "integer",
|
|
177
|
+
"minimum": 1,
|
|
178
|
+
"description": "Maximum character length for composed recall query before calling recall.",
|
|
179
|
+
"default": 800
|
|
180
|
+
},
|
|
181
|
+
"recallTopK": {
|
|
182
|
+
"type": "integer",
|
|
183
|
+
"minimum": 1,
|
|
184
|
+
"description": "Maximum number of memories to inject per turn. Applied after API response as a hard cap."
|
|
185
|
+
},
|
|
186
|
+
"recallPromptPreamble": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"description": "Text shown above recalled memories in the injected context block.",
|
|
189
|
+
"default": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:"
|
|
190
|
+
},
|
|
191
|
+
"debug": {
|
|
192
|
+
"type": "boolean",
|
|
193
|
+
"description": "Enable debug logging for Hindsight plugin operations.",
|
|
194
|
+
"default": false
|
|
76
195
|
}
|
|
77
196
|
},
|
|
78
197
|
"additionalProperties": false
|
|
@@ -137,6 +256,61 @@
|
|
|
137
256
|
"excludeProviders": {
|
|
138
257
|
"label": "Excluded Providers",
|
|
139
258
|
"placeholder": "e.g. telegram, discord"
|
|
259
|
+
},
|
|
260
|
+
"dynamicBankGranularity": {
|
|
261
|
+
"label": "Bank Granularity",
|
|
262
|
+
"placeholder": "e.g. ['agent', 'channel', 'user']"
|
|
263
|
+
},
|
|
264
|
+
"autoRetain": {
|
|
265
|
+
"label": "Auto-Retain",
|
|
266
|
+
"placeholder": "true (enable auto-retention)"
|
|
267
|
+
},
|
|
268
|
+
"retainRoles": {
|
|
269
|
+
"label": "Retain Roles",
|
|
270
|
+
"placeholder": "e.g. ['user', 'assistant']"
|
|
271
|
+
},
|
|
272
|
+
"retainEveryNTurns": {
|
|
273
|
+
"label": "Retain Every N Turns",
|
|
274
|
+
"placeholder": "1 (every turn, default)"
|
|
275
|
+
},
|
|
276
|
+
"retainOverlapTurns": {
|
|
277
|
+
"label": "Retain Overlap Turns",
|
|
278
|
+
"placeholder": "0 (no overlap, default)"
|
|
279
|
+
},
|
|
280
|
+
"recallBudget": {
|
|
281
|
+
"label": "Recall Budget",
|
|
282
|
+
"placeholder": "low, mid, or high"
|
|
283
|
+
},
|
|
284
|
+
"recallMaxTokens": {
|
|
285
|
+
"label": "Recall Max Tokens",
|
|
286
|
+
"placeholder": "1024 (default)"
|
|
287
|
+
},
|
|
288
|
+
"recallTypes": {
|
|
289
|
+
"label": "Recall Types",
|
|
290
|
+
"placeholder": "e.g. ['world', 'experience']"
|
|
291
|
+
},
|
|
292
|
+
"recallRoles": {
|
|
293
|
+
"label": "Recall Roles",
|
|
294
|
+
"placeholder": "e.g. ['user', 'assistant']"
|
|
295
|
+
},
|
|
296
|
+
"recallContextTurns": {
|
|
297
|
+
"label": "Recall Context Turns",
|
|
298
|
+
"placeholder": "1 (latest only, default)"
|
|
299
|
+
},
|
|
300
|
+
"recallMaxQueryChars": {
|
|
301
|
+
"label": "Recall Max Query Chars",
|
|
302
|
+
"placeholder": "800 (default)"
|
|
303
|
+
},
|
|
304
|
+
"recallTopK": {
|
|
305
|
+
"label": "Recall Top K",
|
|
306
|
+
"placeholder": "e.g. 5 (no limit by default)"
|
|
307
|
+
},
|
|
308
|
+
"recallPromptPreamble": {
|
|
309
|
+
"label": "Recall Prompt Preamble",
|
|
310
|
+
"placeholder": "Instruction shown above recalled memories in injected context"
|
|
311
|
+
},
|
|
312
|
+
"debug": {
|
|
313
|
+
"label": "Debug"
|
|
140
314
|
}
|
|
141
315
|
}
|
|
142
316
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vectorize-io/hindsight-openclaw",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.16",
|
|
4
4
|
"description": "Hindsight memory plugin for OpenClaw - biomimetic long-term memory with fact extraction",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -50,5 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": ">=22"
|
|
53
|
+
},
|
|
54
|
+
"overrides": {
|
|
55
|
+
"rollup": "^4.59.0"
|
|
53
56
|
}
|
|
54
57
|
}
|