@vectorize-io/hindsight-openclaw 0.4.15 → 0.4.17

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 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
- client = new HindsightClient(buildClientOptions(llmConfig, config, externalApi));
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
- client.setBankId(bankId);
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 client.setBankMission(config.bankMission);
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 client;
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
- if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) {
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
- if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
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
- * Creates per-user banks: {messageProvider}-{senderId}
363
+ * Uses configurable dynamicBankGranularity to determine bank segmentation.
182
364
  * Falls back to default bank when context is unavailable.
183
365
  */
184
- function deriveBankId(ctx, pluginConfig) {
185
- // If dynamic bank ID is disabled, use static bank
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
- ? `${pluginConfig.bankIdPrefix}-${DEFAULT_BANK_NAME}`
189
- : DEFAULT_BANK_NAME;
190
- }
191
- const channelType = ctx?.messageProvider || 'unknown';
192
- const userId = ctx?.senderId || 'default';
193
- // Build bank ID: {prefix?}-{channelType}-{senderId}
194
- const baseBankId = `${channelType}-${userId}`;
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
- retainEveryNTurns: config.retainEveryNTurns,
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
- client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, externalApi));
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
- client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null }));
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
- client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, externalApi));
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
- client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null }));
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('before_agent_start', async (event, ctx) => {
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
- const bankId = deriveBankId(ctx, pluginConfig);
614
- debug(`[Hindsight] before_agent_start - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
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
- let prompt = extracted;
622
- // Truncate Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
623
- const MAX_RECALL_QUERY_CHARS = 800;
624
- if (prompt.length > MAX_RECALL_QUERY_CHARS) {
625
- prompt = prompt.substring(0, MAX_RECALL_QUERY_CHARS);
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(ctx);
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}, prompt: ${prompt.substring(0, 50)}`);
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 recallKey = bankId;
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: 2048 }, RECALL_TIMEOUT_MS);
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 memoriesJson = JSON.stringify(response.results, null, 2);
965
+ const memoriesFormatted = formatMemories(results);
661
966
  const contextMessage = `<hindsight_memories>
662
- Relevant memories from past conversations (prioritize recent when conflicting):
663
- ${memoriesJson}
967
+ ${pluginConfig.recallPromptPreamble || DEFAULT_RECALL_PROMPT_PREAMBLE}
968
+ Current time - ${formatCurrentTimeForRecall()}
664
969
 
665
- User message: ${prompt}
970
+ ${memoriesFormatted}
666
971
  </hindsight_memories>`;
667
- debug(`[Hindsight] Auto-recall: Injecting ${response.results.length} memories from bank ${bankId}`);
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
- // Use context from this hook, or fall back to context captured in before_agent_start
688
- const effectiveCtx = ctx || currentAgentContext;
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
- const bankId = deriveBankId(effectiveCtx, pluginConfig);
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
- // Check event success and messages
698
- if (!event.success || !Array.isArray(event.messages) || event.messages.length === 0) {
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
- // Wait for client to be ready
703
- const clientGlobal = global.__hindsightClient;
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
- await clientGlobal.waitForReady();
709
- // Get client configured for this context's bank (async to handle mission setup)
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
- // --- Chunked retention: only retain every Nth turn ---
716
- const retainEveryN = pluginConfig.retainEveryNTurns ?? 10;
717
- let messagesToRetain = event.messages;
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 || currentSessionKey || 'session'}`;
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 nextRetain = Math.ceil(turnCount / retainEveryN) * retainEveryN;
724
- debug(`[Hindsight Hook] Skipping retain (turn ${turnCount}, next at ${nextRetain})`);
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 of new content + 2-turn overlap for context continuity
728
- const windowSize = retainEveryN * 2 + 4;
729
- messagesToRetain = event.messages.slice(-windowSize);
730
- debug(`[Hindsight Hook] Chunked retain at turn ${turnCount} \u2014 last ${messagesToRetain.length} msgs`);
731
- }
732
- // Format messages into a transcript
733
- const transcript = messagesToRetain
734
- .map((msg) => {
735
- const role = msg.role || 'unknown';
736
- let content = '';
737
- // Handle different content formats
738
- if (typeof msg.content === 'string') {
739
- content = msg.content;
740
- }
741
- else if (Array.isArray(msg.content)) {
742
- content = msg.content
743
- .filter((block) => block.type === 'text')
744
- .map((block) => block.text)
745
- .join('\n');
746
- }
747
- // Strip plugin-injected memory tags to prevent feedback loop
748
- content = stripMemoryTags(content);
749
- return `[role: ${role}]\n${content}\n[${role}:end]`;
750
- })
751
- .join('\n\n');
752
- if (!transcript.trim() || transcript.length < 10) {
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 || currentSessionKey || 'session'}-${Date.now()}`;
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(messagesToRetain.length),
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 ${messagesToRetain.length} messages to bank ${bankId} for session ${documentId}`);
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[];
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "bankMission": {
19
19
  "type": "string",
20
- "description": "Custom mission/context for the memory bank",
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": ["openai", "anthropic", "gemini", "groq", "ollama", "openai-codex", "claude-code"]
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": { "type": "string" },
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.15",
3
+ "version": "0.4.17",
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
  }