@vectorize-io/hindsight-openclaw 0.4.14 → 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/dist/index.js CHANGED
@@ -1,10 +1,18 @@
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';
6
+ // Debug logging: silent by default, enable with debug: true in plugin config
7
+ let debugEnabled = false;
8
+ const debug = (...args) => {
9
+ if (debugEnabled)
10
+ console.log(...args);
11
+ };
5
12
  // Module-level state
6
13
  let embedManager = null;
7
14
  let client = null;
15
+ let clientOptions = null;
8
16
  let initPromise = null;
9
17
  let isInitialized = false;
10
18
  let usingExternalApi = false; // Track if using external API (skip daemon management)
@@ -12,12 +20,32 @@ let usingExternalApi = false; // Track if using external API (skip daemon manage
12
20
  let currentPluginConfig = null;
13
21
  // Track which banks have had their mission set (to avoid re-setting on every request)
14
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;
15
26
  const inflightRecalls = new Map();
27
+ const turnCountBySession = new Map();
28
+ const MAX_TRACKED_SESSIONS = 10_000;
16
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();
17
36
  // Cooldown + guard to prevent concurrent reinit attempts
18
37
  let lastReinitAttempt = 0;
19
38
  let isReinitInProgress = false;
20
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
+ }
21
49
  /**
22
50
  * Lazy re-initialization after startup failure.
23
51
  * Called by waitForReady when initPromise rejected but API may now be reachable.
@@ -40,7 +68,7 @@ async function lazyReinit() {
40
68
  isReinitInProgress = false;
41
69
  return; // Only external API mode supports lazy reinit
42
70
  }
43
- console.log('[Hindsight] Attempting lazy re-initialization...');
71
+ debug('[Hindsight] Attempting lazy re-initialization...');
44
72
  try {
45
73
  await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
46
74
  // Health check passed — set up env vars and create client
@@ -49,7 +77,10 @@ async function lazyReinit() {
49
77
  process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
50
78
  }
51
79
  const llmConfig = detectLLMConfig(config);
52
- client = new HindsightClient(buildClientOptions(llmConfig, config, externalApi));
80
+ clientOptions = buildClientOptions(llmConfig, config, externalApi);
81
+ clientsByBankId.clear();
82
+ banksWithMissionSet.clear();
83
+ client = new HindsightClient(clientOptions);
53
84
  const defaultBankId = deriveBankId(undefined, config);
54
85
  client.setBankId(defaultBankId);
55
86
  if (config.bankMission && !config.dynamicBankId) {
@@ -59,7 +90,7 @@ async function lazyReinit() {
59
90
  isInitialized = true;
60
91
  // Replace the rejected initPromise with a resolved one
61
92
  initPromise = Promise.resolve();
62
- console.log('[Hindsight] ✓ Lazy re-initialization succeeded');
93
+ debug('[Hindsight] ✓ Lazy re-initialization succeeded');
63
94
  }
64
95
  catch (error) {
65
96
  console.warn(`[Hindsight] Lazy re-initialization failed (will retry in ${REINIT_COOLDOWN_MS / 1000}s):`, error instanceof Error ? error.message : error);
@@ -100,21 +131,39 @@ if (typeof global !== 'undefined') {
100
131
  return null;
101
132
  }
102
133
  const config = currentPluginConfig || {};
134
+ if (config.dynamicBankId === false) {
135
+ return client;
136
+ }
103
137
  const bankId = deriveBankId(ctx, config);
104
- 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
+ }
105
154
  // Set bank mission on first use of this bank (if configured)
106
155
  if (config.bankMission && config.dynamicBankId && !banksWithMissionSet.has(bankId)) {
107
156
  try {
108
- await client.setBankMission(config.bankMission);
157
+ await bankClient.setBankMission(config.bankMission);
109
158
  banksWithMissionSet.add(bankId);
110
- console.log(`[Hindsight] Set mission for new bank: ${bankId}`);
159
+ debug(`[Hindsight] Set mission for new bank: ${bankId}`);
111
160
  }
112
161
  catch (error) {
113
162
  // Log but don't fail - bank mission is not critical
114
163
  console.warn(`[Hindsight] Could not set bank mission for ${bankId}: ${error}`);
115
164
  }
116
165
  }
117
- return client;
166
+ return bankClient;
118
167
  },
119
168
  getPluginConfig: () => currentPluginConfig,
120
169
  };
@@ -134,6 +183,40 @@ export function stripMemoryTags(content) {
134
183
  content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
135
184
  return content;
136
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
+ }
137
220
  /**
138
221
  * Extract a recall query from a hook event's rawMessage or prompt.
139
222
  *
@@ -143,10 +226,25 @@ export function stripMemoryTags(content) {
143
226
  * Returns null when no usable query (< 5 chars) can be extracted.
144
227
  */
145
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));
146
236
  let recallQuery = rawMessage;
147
- 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)) {
148
242
  recallQuery = prompt;
149
- 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) {
150
248
  return null;
151
249
  }
152
250
  // Strip envelope-formatted prompts from any channel
@@ -162,33 +260,172 @@ export function extractRecallQuery(rawMessage, prompt) {
162
260
  }
163
261
  // Remove trailing [from: SenderName] metadata (group chats)
164
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);
165
266
  recallQuery = cleaned.trim() || recallQuery;
166
267
  }
167
268
  const trimmed = recallQuery.trim();
168
- if (trimmed.length < 5)
269
+ if (trimmed.length < 5 || isMetadata(trimmed))
169
270
  return null;
170
271
  return trimmed;
171
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
+ }
172
361
  /**
173
362
  * Derive a bank ID from the agent context.
174
- * Creates per-user banks: {messageProvider}-{senderId}
363
+ * Uses configurable dynamicBankGranularity to determine bank segmentation.
175
364
  * Falls back to default bank when context is unavailable.
176
365
  */
177
- function deriveBankId(ctx, pluginConfig) {
178
- // 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) {
179
384
  if (pluginConfig.dynamicBankId === false) {
180
- return pluginConfig.bankIdPrefix
181
- ? `${pluginConfig.bankIdPrefix}-${DEFAULT_BANK_NAME}`
182
- : DEFAULT_BANK_NAME;
183
- }
184
- const channelType = ctx?.messageProvider || 'unknown';
185
- const userId = ctx?.senderId || 'default';
186
- // Build bank ID: {prefix?}-{channelType}-{senderId}
187
- 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('::');
188
414
  return pluginConfig.bankIdPrefix
189
415
  ? `${pluginConfig.bankIdPrefix}-${baseBankId}`
190
416
  : baseBankId;
191
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
+ }
192
429
  // Provider detection from standard env vars
193
430
  const PROVIDER_DETECTION = [
194
431
  { name: 'openai', keyEnv: 'OPENAI_API_KEY', defaultModel: 'gpt-4o-mini' },
@@ -268,6 +505,17 @@ function detectLLMConfig(pluginConfig) {
268
505
  }
269
506
  }
270
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
+ }
271
519
  throw new Error(`No LLM configuration found for Hindsight memory plugin.\n\n` +
272
520
  `Option 1: Set a standard provider API key (auto-detect):\n` +
273
521
  ` export OPENAI_API_KEY=sk-your-key # Uses gpt-4o-mini\n` +
@@ -300,8 +548,6 @@ function detectExternalApi(pluginConfig) {
300
548
  */
301
549
  function buildClientOptions(llmConfig, pluginCfg, externalApi) {
302
550
  return {
303
- llmProvider: llmConfig.provider,
304
- llmApiKey: llmConfig.apiKey,
305
551
  llmModel: llmConfig.model,
306
552
  embedVersion: pluginCfg.embedVersion,
307
553
  embedPackagePath: pluginCfg.embedPackagePath,
@@ -319,7 +565,7 @@ async function checkExternalApiHealth(apiUrl, apiToken) {
319
565
  const retryDelay = 2000;
320
566
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
321
567
  try {
322
- console.log(`[Hindsight] Checking external API health at ${healthUrl}... (attempt ${attempt}/${maxRetries})`);
568
+ debug(`[Hindsight] Checking external API health at ${healthUrl}... (attempt ${attempt}/${maxRetries})`);
323
569
  const headers = {};
324
570
  if (apiToken) {
325
571
  headers['Authorization'] = `Bearer ${apiToken}`;
@@ -329,12 +575,12 @@ async function checkExternalApiHealth(apiUrl, apiToken) {
329
575
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
330
576
  }
331
577
  const data = await response.json();
332
- console.log(`[Hindsight] External API health: ${JSON.stringify(data)}`);
578
+ debug(`[Hindsight] External API health: ${JSON.stringify(data)}`);
333
579
  return;
334
580
  }
335
581
  catch (error) {
336
582
  if (attempt < maxRetries) {
337
- console.log(`[Hindsight] Health check attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
583
+ debug(`[Hindsight] Health check attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
338
584
  await new Promise(resolve => setTimeout(resolve, retryDelay));
339
585
  }
340
586
  else {
@@ -363,37 +609,53 @@ function getPluginConfig(api) {
363
609
  bankIdPrefix: config.bankIdPrefix,
364
610
  excludeProviders: Array.isArray(config.excludeProviders) ? config.excludeProviders : [],
365
611
  autoRecall: config.autoRecall !== false, // Default: true (on) — backward compatible
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,
627
+ debug: config.debug ?? false,
366
628
  };
367
629
  }
368
630
  export default function (api) {
369
631
  try {
370
- console.log('[Hindsight] Plugin loading...');
371
- // Get plugin config first (needed for LLM detection)
372
- console.log('[Hindsight] Getting plugin config...');
632
+ debug('[Hindsight] Plugin loading...');
633
+ // Get plugin config first (needed for LLM detection and debug flag)
373
634
  const pluginConfig = getPluginConfig(api);
635
+ debugEnabled = pluginConfig.debug ?? false;
374
636
  // Store config globally for bank ID derivation in hooks
375
637
  currentPluginConfig = pluginConfig;
376
638
  // Detect LLM configuration (env vars > plugin config > auto-detect)
377
- console.log('[Hindsight] Detecting LLM config...');
639
+ debug('[Hindsight] Detecting LLM config...');
378
640
  const llmConfig = detectLLMConfig(pluginConfig);
379
641
  const baseUrlInfo = llmConfig.baseUrl ? `, base URL: ${llmConfig.baseUrl}` : '';
380
642
  const modelInfo = llmConfig.model || 'default';
381
643
  if (llmConfig.provider === 'ollama') {
382
- console.log(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source})`);
644
+ debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source})`);
383
645
  }
384
646
  else {
385
- console.log(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source}${baseUrlInfo})`);
647
+ debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source}${baseUrlInfo})`);
386
648
  }
387
649
  if (pluginConfig.bankMission) {
388
- console.log(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
650
+ debug(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
389
651
  }
390
652
  // Log dynamic bank ID mode
391
653
  if (pluginConfig.dynamicBankId) {
392
654
  const prefixInfo = pluginConfig.bankIdPrefix ? ` (prefix: ${pluginConfig.bankIdPrefix})` : '';
393
- console.log(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
655
+ debug(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
394
656
  }
395
657
  else {
396
- console.log(`[Hindsight] Dynamic bank IDs disabled - using static bank: ${DEFAULT_BANK_NAME}`);
658
+ debug(`[Hindsight] Dynamic bank IDs disabled - using static bank: ${DEFAULT_BANK_NAME}`);
397
659
  }
398
660
  // Detect external API mode
399
661
  const externalApi = detectExternalApi(pluginConfig);
@@ -402,64 +664,70 @@ export default function (api) {
402
664
  if (externalApi.apiUrl) {
403
665
  // External API mode - skip local daemon
404
666
  usingExternalApi = true;
405
- console.log(`[Hindsight] ✓ Using external API: ${externalApi.apiUrl}`);
667
+ debug(`[Hindsight] ✓ Using external API: ${externalApi.apiUrl}`);
406
668
  // Set env vars so CLI commands (uvx hindsight-embed) use external API
407
669
  process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
408
670
  if (externalApi.apiToken) {
409
671
  process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
410
- console.log('[Hindsight] API token configured');
672
+ debug('[Hindsight] API token configured');
411
673
  }
412
674
  }
413
675
  else {
414
- console.log(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
415
- console.log(`[Hindsight] API Port: ${apiPort}`);
676
+ debug(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
677
+ debug(`[Hindsight] API Port: ${apiPort}`);
416
678
  }
417
679
  // Initialize in background (non-blocking)
418
- console.log('[Hindsight] Starting initialization in background...');
680
+ debug('[Hindsight] Starting initialization in background...');
419
681
  initPromise = (async () => {
420
682
  try {
421
683
  if (usingExternalApi && externalApi.apiUrl) {
422
684
  // External API mode - check health, skip daemon startup
423
- console.log('[Hindsight] External API mode - skipping local daemon...');
685
+ debug('[Hindsight] External API mode - skipping local daemon...');
424
686
  await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
425
687
  // Initialize client with direct HTTP mode
426
- console.log('[Hindsight] Creating HindsightClient (HTTP mode)...');
427
- client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, externalApi));
688
+ debug('[Hindsight] Creating HindsightClient (HTTP mode)...');
689
+ clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
690
+ clientsByBankId.clear();
691
+ banksWithMissionSet.clear();
692
+ client = new HindsightClient(clientOptions);
428
693
  // Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
429
694
  const defaultBankId = deriveBankId(undefined, pluginConfig);
430
- console.log(`[Hindsight] Default bank: ${defaultBankId}`);
695
+ debug(`[Hindsight] Default bank: ${defaultBankId}`);
431
696
  client.setBankId(defaultBankId);
432
697
  // Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
433
698
  // For now, set it on the default bank
434
699
  if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
435
- console.log(`[Hindsight] Setting bank mission...`);
700
+ debug(`[Hindsight] Setting bank mission...`);
436
701
  await client.setBankMission(pluginConfig.bankMission);
437
702
  }
438
703
  isInitialized = true;
439
- console.log('[Hindsight] ✓ Ready (external API mode)');
704
+ debug('[Hindsight] ✓ Ready (external API mode)');
440
705
  }
441
706
  else {
442
707
  // Local daemon mode - start hindsight-embed daemon
443
- console.log('[Hindsight] Creating HindsightEmbedManager...');
444
- embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider, llmConfig.apiKey, llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
708
+ debug('[Hindsight] Creating HindsightEmbedManager...');
709
+ embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
445
710
  // Start the embedded server
446
- console.log('[Hindsight] Starting embedded server...');
711
+ debug('[Hindsight] Starting embedded server...');
447
712
  await embedManager.start();
448
713
  // Initialize client (local daemon mode — no apiUrl)
449
- console.log('[Hindsight] Creating HindsightClient (subprocess mode)...');
450
- client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null }));
714
+ debug('[Hindsight] Creating HindsightClient (subprocess mode)...');
715
+ clientOptions = buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null });
716
+ clientsByBankId.clear();
717
+ banksWithMissionSet.clear();
718
+ client = new HindsightClient(clientOptions);
451
719
  // Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
452
720
  const defaultBankId = deriveBankId(undefined, pluginConfig);
453
- console.log(`[Hindsight] Default bank: ${defaultBankId}`);
721
+ debug(`[Hindsight] Default bank: ${defaultBankId}`);
454
722
  client.setBankId(defaultBankId);
455
723
  // Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
456
724
  // For now, set it on the default bank
457
725
  if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
458
- console.log(`[Hindsight] Setting bank mission...`);
726
+ debug(`[Hindsight] Setting bank mission...`);
459
727
  await client.setBankMission(pluginConfig.bankMission);
460
728
  }
461
729
  isInitialized = true;
462
- console.log('[Hindsight] ✓ Ready');
730
+ debug('[Hindsight] ✓ Ready');
463
731
  }
464
732
  }
465
733
  catch (error) {
@@ -470,11 +738,11 @@ export default function (api) {
470
738
  // Suppress unhandled rejection — service.start() will await and handle errors
471
739
  initPromise.catch(() => { });
472
740
  // Register background service for cleanup
473
- console.log('[Hindsight] Registering service...');
741
+ debug('[Hindsight] Registering service...');
474
742
  api.registerService({
475
743
  id: 'hindsight-memory',
476
744
  async start() {
477
- console.log('[Hindsight] Service start called...');
745
+ debug('[Hindsight] Service start called...');
478
746
  // Wait for background init if still pending
479
747
  if (initPromise) {
480
748
  try {
@@ -491,13 +759,16 @@ export default function (api) {
491
759
  if (externalApi.apiUrl && isInitialized) {
492
760
  try {
493
761
  await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
494
- console.log('[Hindsight] External API is healthy');
762
+ debug('[Hindsight] External API is healthy');
495
763
  return;
496
764
  }
497
765
  catch (error) {
498
766
  console.error('[Hindsight] External API health check failed:', error);
499
767
  // Reset state for reinitialization attempt
500
768
  client = null;
769
+ clientOptions = null;
770
+ clientsByBankId.clear();
771
+ banksWithMissionSet.clear();
501
772
  isInitialized = false;
502
773
  }
503
774
  }
@@ -507,19 +778,22 @@ export default function (api) {
507
778
  if (embedManager && isInitialized) {
508
779
  const healthy = await embedManager.checkHealth();
509
780
  if (healthy) {
510
- console.log('[Hindsight] Daemon is healthy');
781
+ debug('[Hindsight] Daemon is healthy');
511
782
  return;
512
783
  }
513
- console.log('[Hindsight] Daemon is not responding - reinitializing...');
784
+ debug('[Hindsight] Daemon is not responding - reinitializing...');
514
785
  // Reset state for reinitialization
515
786
  embedManager = null;
516
787
  client = null;
788
+ clientOptions = null;
789
+ clientsByBankId.clear();
790
+ banksWithMissionSet.clear();
517
791
  isInitialized = false;
518
792
  }
519
793
  }
520
794
  // Reinitialize if needed (fresh start or recovery)
521
795
  if (!isInitialized) {
522
- console.log('[Hindsight] Reinitializing...');
796
+ debug('[Hindsight] Reinitializing...');
523
797
  const reinitPluginConfig = getPluginConfig(api);
524
798
  currentPluginConfig = reinitPluginConfig;
525
799
  const llmConfig = detectLLMConfig(reinitPluginConfig);
@@ -533,41 +807,50 @@ export default function (api) {
533
807
  process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
534
808
  }
535
809
  await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
536
- client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, externalApi));
810
+ clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, externalApi);
811
+ clientsByBankId.clear();
812
+ banksWithMissionSet.clear();
813
+ client = new HindsightClient(clientOptions);
537
814
  const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
538
815
  client.setBankId(defaultBankId);
539
816
  if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
540
817
  await client.setBankMission(reinitPluginConfig.bankMission);
541
818
  }
542
819
  isInitialized = true;
543
- console.log('[Hindsight] Reinitialization complete (external API mode)');
820
+ debug('[Hindsight] Reinitialization complete (external API mode)');
544
821
  }
545
822
  else {
546
823
  // Local daemon mode
547
- 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);
548
825
  await embedManager.start();
549
- 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);
550
830
  const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
551
831
  client.setBankId(defaultBankId);
552
832
  if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
553
833
  await client.setBankMission(reinitPluginConfig.bankMission);
554
834
  }
555
835
  isInitialized = true;
556
- console.log('[Hindsight] Reinitialization complete');
836
+ debug('[Hindsight] Reinitialization complete');
557
837
  }
558
838
  }
559
839
  },
560
840
  async stop() {
561
841
  try {
562
- console.log('[Hindsight] Service stopping...');
842
+ debug('[Hindsight] Service stopping...');
563
843
  // Only stop daemon if in local mode
564
844
  if (!usingExternalApi && embedManager) {
565
845
  await embedManager.stop();
566
846
  embedManager = null;
567
847
  }
568
848
  client = null;
849
+ clientOptions = null;
850
+ clientsByBankId.clear();
851
+ banksWithMissionSet.clear();
569
852
  isInitialized = false;
570
- console.log('[Hindsight] Service stopped');
853
+ debug('[Hindsight] Service stopped');
571
854
  }
572
855
  catch (error) {
573
856
  console.error('[Hindsight] Service stop error:', error);
@@ -575,87 +858,118 @@ export default function (api) {
575
858
  }
576
859
  },
577
860
  });
578
- console.log('[Hindsight] Plugin loaded successfully');
861
+ debug('[Hindsight] Plugin loaded successfully');
579
862
  // Register agent hooks for auto-recall and auto-retention
580
- console.log('[Hindsight] Registering agent hooks...');
581
- // Store session key and context for retention
582
- let currentSessionKey;
583
- let currentAgentContext;
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);
868
+ debug('[Hindsight] Registering agent hooks...');
584
869
  // Auto-recall: Inject relevant memories before agent processes the message
585
870
  // Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
586
- api.on('before_agent_start', async (event, ctx) => {
871
+ api.on('before_prompt_build', async (event, ctx) => {
587
872
  try {
588
- // Capture session key and context for use in agent_end
589
- if (ctx?.sessionKey) {
590
- currentSessionKey = ctx.sessionKey;
591
- }
592
- currentAgentContext = ctx;
593
873
  // Check if this provider is excluded
594
874
  if (ctx?.messageProvider && pluginConfig.excludeProviders?.includes(ctx.messageProvider)) {
595
- console.log(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
875
+ debug(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
596
876
  return;
597
877
  }
598
878
  // Skip auto-recall when disabled (agent has its own recall tool)
599
879
  if (!pluginConfig.autoRecall) {
600
- console.log('[Hindsight] Auto-recall disabled via config, skipping');
880
+ debug('[Hindsight] Auto-recall disabled via config, skipping');
601
881
  return;
602
882
  }
603
- // Derive bank ID from context
604
- const bankId = deriveBankId(ctx, pluginConfig);
605
- console.log(`[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(', ')}`);
606
903
  // Get the user's latest message for recall — only the raw user text, not the full prompt
607
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}`);
608
906
  const extracted = extractRecallQuery(event.rawMessage, event.prompt);
609
907
  if (!extracted) {
908
+ debug('[Hindsight] extractRecallQuery returned null, skipping recall');
610
909
  return;
611
910
  }
612
- let prompt = extracted;
613
- // Truncate Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
614
- const MAX_RECALL_QUERY_CHARS = 800;
615
- if (prompt.length > MAX_RECALL_QUERY_CHARS) {
616
- 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);
617
926
  }
618
927
  // Wait for client to be ready
619
928
  const clientGlobal = global.__hindsightClient;
620
929
  if (!clientGlobal) {
621
- console.log('[Hindsight] Client global not available, skipping auto-recall');
930
+ debug('[Hindsight] Client global not available, skipping auto-recall');
622
931
  return;
623
932
  }
624
933
  await clientGlobal.waitForReady();
625
934
  // Get client configured for this context's bank (async to handle mission setup)
626
- const client = await clientGlobal.getClientForContext(ctx);
935
+ const client = await clientGlobal.getClientForContext(effectiveCtxForRecall);
627
936
  if (!client) {
628
- console.log('[Hindsight] Client not initialized, skipping auto-recall');
937
+ debug('[Hindsight] Client not initialized, skipping auto-recall');
629
938
  return;
630
939
  }
631
- console.log(`[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---`);
632
941
  // Recall with deduplication: reuse in-flight request for same bank
633
- 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}`;
634
945
  const existing = inflightRecalls.get(recallKey);
635
946
  let recallPromise;
636
947
  if (existing) {
637
- console.log(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
948
+ debug(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
638
949
  recallPromise = existing;
639
950
  }
640
951
  else {
641
- 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);
642
953
  inflightRecalls.set(recallKey, recallPromise);
643
954
  void recallPromise.catch(() => { }).finally(() => inflightRecalls.delete(recallKey));
644
955
  }
645
956
  const response = await recallPromise;
646
957
  if (!response.results || response.results.length === 0) {
647
- console.log('[Hindsight] No memories found for auto-recall');
958
+ debug('[Hindsight] No memories found for auto-recall');
648
959
  return;
649
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`);
650
964
  // Format memories as JSON with all fields from recall
651
- const memoriesJson = JSON.stringify(response.results, null, 2);
965
+ const memoriesFormatted = formatMemories(results);
652
966
  const contextMessage = `<hindsight_memories>
653
- Relevant memories from past conversations (prioritize recent when conflicting):
654
- ${memoriesJson}
967
+ ${pluginConfig.recallPromptPreamble || DEFAULT_RECALL_PROMPT_PREAMBLE}
968
+ Current time - ${formatCurrentTimeForRecall()}
655
969
 
656
- User message: ${prompt}
970
+ ${memoriesFormatted}
657
971
  </hindsight_memories>`;
658
- console.log(`[Hindsight] Auto-recall: Injecting ${response.results.length} memories from bank ${bankId}`);
972
+ debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
659
973
  // Inject context before the user message
660
974
  return { prependContext: contextMessage };
661
975
  }
@@ -675,21 +989,71 @@ User message: ${prompt}
675
989
  // Hook signature: (event, ctx) where event has {messages, success, error?, durationMs?}
676
990
  api.on('agent_end', async (event, ctx) => {
677
991
  try {
678
- // Use context from this hook, or fall back to context captured in before_agent_start
679
- 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);
680
995
  // Check if this provider is excluded
681
996
  if (effectiveCtx?.messageProvider && pluginConfig.excludeProviders?.includes(effectiveCtx.messageProvider)) {
682
- console.log(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
997
+ debug(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
998
+ return;
999
+ }
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);
1009
+ debug(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
1010
+ if (event.success === false) {
1011
+ debug('[Hindsight Hook] Agent run failed, skipping retention');
1012
+ return;
1013
+ }
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');
683
1016
  return;
684
1017
  }
685
- // Derive bank ID from context
686
- const bankId = deriveBankId(effectiveCtx, pluginConfig);
687
- console.log(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
688
- // Check event success and messages
689
- if (!event.success || !Array.isArray(event.messages) || event.messages.length === 0) {
690
- console.log('[Hindsight Hook] Skipping: success:', event.success, 'messages:', event.messages?.length);
1018
+ if (pluginConfig.autoRetain === false) {
1019
+ debug('[Hindsight Hook] autoRetain is disabled, skipping retention');
1020
+ return;
1021
+ }
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;
1027
+ if (retainEveryN > 1) {
1028
+ const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`;
1029
+ const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
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
+ }
1037
+ if (turnCount % retainEveryN !== 0) {
1038
+ const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN;
1039
+ debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
1040
+ return;
1041
+ }
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)');
691
1054
  return;
692
1055
  }
1056
+ const { transcript, messageCount } = retention;
693
1057
  // Wait for client to be ready
694
1058
  const clientGlobal = global.__hindsightClient;
695
1059
  if (!clientGlobal) {
@@ -698,57 +1062,34 @@ User message: ${prompt}
698
1062
  }
699
1063
  await clientGlobal.waitForReady();
700
1064
  // Get client configured for this context's bank (async to handle mission setup)
701
- const client = await clientGlobal.getClientForContext(effectiveCtx);
1065
+ const client = await clientGlobal.getClientForContext(effectiveCtxForRetain);
702
1066
  if (!client) {
703
1067
  console.warn('[Hindsight] Client not initialized, skipping retain');
704
1068
  return;
705
1069
  }
706
- // Format messages into a transcript
707
- const transcript = event.messages
708
- .map((msg) => {
709
- const role = msg.role || 'unknown';
710
- let content = '';
711
- // Handle different content formats
712
- if (typeof msg.content === 'string') {
713
- content = msg.content;
714
- }
715
- else if (Array.isArray(msg.content)) {
716
- content = msg.content
717
- .filter((block) => block.type === 'text')
718
- .map((block) => block.text)
719
- .join('\n');
720
- }
721
- // Strip plugin-injected memory tags to prevent feedback loop
722
- content = stripMemoryTags(content);
723
- return `[role: ${role}]\n${content}\n[${role}:end]`;
724
- })
725
- .join('\n\n');
726
- if (!transcript.trim() || transcript.length < 10) {
727
- console.log('[Hindsight Hook] Transcript too short, skipping');
728
- return;
729
- }
730
1070
  // Use unique document ID per conversation (sessionKey + timestamp)
731
1071
  // Static sessionKey (e.g. "agent:main:main") causes CASCADE delete of old memories
732
- const documentId = `${effectiveCtx?.sessionKey || currentSessionKey || 'session'}-${Date.now()}`;
1072
+ const documentId = `${effectiveCtx?.sessionKey || 'session'}-${Date.now()}`;
733
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---`);
734
1075
  await client.retain({
735
1076
  content: transcript,
736
1077
  document_id: documentId,
737
1078
  metadata: {
738
1079
  retained_at: new Date().toISOString(),
739
- message_count: String(event.messages.length),
1080
+ message_count: String(messageCount),
740
1081
  channel_type: effectiveCtx?.messageProvider,
741
1082
  channel_id: effectiveCtx?.channelId,
742
1083
  sender_id: effectiveCtx?.senderId,
743
1084
  },
744
1085
  });
745
- console.log(`[Hindsight] Retained ${event.messages.length} messages to bank ${bankId} for session ${documentId}`);
1086
+ debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${documentId}`);
746
1087
  }
747
1088
  catch (error) {
748
1089
  console.error('[Hindsight] Error retaining messages:', error);
749
1090
  }
750
1091
  });
751
- console.log('[Hindsight] Hooks registered');
1092
+ debug('[Hindsight] Hooks registered');
752
1093
  }
753
1094
  catch (error) {
754
1095
  console.error('[Hindsight] Plugin loading error:', error);
@@ -759,6 +1100,82 @@ User message: ${prompt}
759
1100
  }
760
1101
  }
761
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
+ }
762
1179
  export function getClient() {
763
1180
  return client;
764
1181
  }