@vectorize-io/hindsight-openclaw 0.5.0 → 0.6.0

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,23 @@
1
- import { HindsightEmbedManager } from './embed-manager.js';
2
- import { HindsightClient } from './client.js';
1
+ import { HindsightServer } from '@vectorize-io/hindsight-all';
2
+ import { HindsightClient } from '@vectorize-io/hindsight-client';
3
+ import { RetainQueue } from './retain-queue.js';
4
+ import { compileSessionPatterns, matchesSessionPattern } from './session-patterns.js';
3
5
  import { createHash } from 'crypto';
4
- import { dirname } from 'path';
6
+ import { dirname, join } from 'path';
5
7
  import { fileURLToPath } from 'url';
6
8
  import * as log from './logger.js';
7
9
  import { configureLogger, setApiLogger, stopLogger } from './logger.js';
10
+ import { mkdirSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ // Logger adapter that routes the embed wrapper's output through openclaw's
13
+ // batched structured logger so messages share the same prefix and respect
14
+ // the configured log level.
15
+ const embedLogger = {
16
+ debug: (msg) => log.verbose(msg),
17
+ info: (msg) => log.info(msg),
18
+ warn: (msg) => log.warn(msg),
19
+ error: (msg) => log.error(msg),
20
+ };
8
21
  // Debug logging: silent by default, enable with debug: true or logLevel: 'debug'
9
22
  let debugEnabled = false;
10
23
  const debug = (...args) => {
@@ -12,7 +25,7 @@ const debug = (...args) => {
12
25
  log.verbose(args.map(a => typeof a === 'string' ? a.replace(/^\[Hindsight\]\s*/, '') : String(a)).join(' '));
13
26
  };
14
27
  // Module-level state
15
- let embedManager = null;
28
+ let hindsightServer = null;
16
29
  let client = null;
17
30
  let clientOptions = null;
18
31
  let initPromise = null;
@@ -20,25 +33,134 @@ let isInitialized = false;
20
33
  let usingExternalApi = false; // Track if using external API (skip daemon management)
21
34
  // Store the current plugin config for bank ID derivation
22
35
  let currentPluginConfig = null;
23
- // Track which banks have had their mission set (to avoid re-setting on every request)
36
+ // Track which banks have had their mission set (to avoid re-setting on every request).
37
+ // Under the old bespoke client we also cached a client instance per bank because the
38
+ // client carried a mutable bankId. HindsightClient takes bankId as a parameter on every
39
+ // call, so no per-bank caching is needed anymore — one module-level client is enough.
24
40
  const banksWithMissionSet = new Set();
25
- // Use dedicated client instances per bank to avoid cross-session bankId mutation races.
26
- const clientsByBankId = new Map();
27
- const MAX_TRACKED_BANK_CLIENTS = 10_000;
28
41
  const inflightRecalls = new Map();
42
+ function scopeClient(c, bankId) {
43
+ return {
44
+ bankId,
45
+ async retain(req) {
46
+ await c.retain(bankId, req.content, {
47
+ documentId: req.documentId,
48
+ metadata: toStringMetadata(req.metadata),
49
+ tags: req.tags,
50
+ async: true,
51
+ });
52
+ },
53
+ async recall(req, timeoutMs) {
54
+ const call = c.recall(bankId, req.query, {
55
+ maxTokens: req.maxTokens,
56
+ budget: req.budget,
57
+ types: req.types,
58
+ });
59
+ if (!timeoutMs)
60
+ return call;
61
+ // The generated client doesn't accept a per-call AbortSignal, so we race
62
+ // against a TimeoutError here. The before_prompt_build caller already
63
+ // special-cases `DOMException { name: 'TimeoutError' }` from the old
64
+ // bespoke client, so we preserve that contract.
65
+ return Promise.race([
66
+ call,
67
+ new Promise((_, reject) => setTimeout(() => reject(new DOMException(`Recall timed out after ${timeoutMs}ms`, 'TimeoutError')), timeoutMs)),
68
+ ]);
69
+ },
70
+ async setMission(mission) {
71
+ // createBank upserts the reflect mission. openclaw's old setBankMission
72
+ // went through a dedicated PUT endpoint; this call lands on the same
73
+ // server-side handler via the non-deprecated path.
74
+ await c.createBank(bankId, { reflectMission: mission });
75
+ },
76
+ };
77
+ }
78
+ /**
79
+ * The generated client's metadata type is `Record<string, string>`; the
80
+ * openclaw builder uses `Record<string, unknown>` because some fields come
81
+ * from optional plugin context. Drop undefined/null, stringify the rest.
82
+ */
83
+ function toStringMetadata(input) {
84
+ if (!input)
85
+ return undefined;
86
+ const out = {};
87
+ for (const [k, v] of Object.entries(input)) {
88
+ if (v === undefined || v === null)
89
+ continue;
90
+ out[k] = typeof v === 'string' ? v : String(v);
91
+ }
92
+ return out;
93
+ }
29
94
  const turnCountBySession = new Map();
30
95
  const MAX_TRACKED_SESSIONS = 10_000;
31
96
  const DEFAULT_RECALL_TIMEOUT_MS = 10_000;
32
97
  // Cache sender IDs discovered in before_prompt_build (where event.prompt has the metadata
33
98
  // blocks) so agent_end can look them up — event.messages in agent_end is clean history.
34
99
  const senderIdBySession = new Map();
35
- // Guard against double hook registration on the same api instance
36
- // Uses a WeakSet so each api instance can only register hooks once
37
- const registeredApis = new WeakSet();
100
+ const documentSequenceBySession = new Map();
101
+ // Guard against duplicate hook registration within a single runtime load.
102
+ // Do not tie this to api instance identity, which can be brittle across loader phases.
103
+ let hooksRegistered = false;
38
104
  // Cooldown + guard to prevent concurrent reinit attempts
39
105
  let lastReinitAttempt = 0;
40
106
  let isReinitInProgress = false;
41
107
  const REINIT_COOLDOWN_MS = 30_000;
108
+ // Retain queue (external API mode only)
109
+ let retainQueue = null;
110
+ let retainQueueFlushTimer = null;
111
+ let isFlushInProgress = false;
112
+ const DEFAULT_FLUSH_INTERVAL_MS = 60_000; // 1 min
113
+ /**
114
+ * Attempt to flush pending retains from the queue.
115
+ * Each item is sent exactly as it would have been originally — same bank, payload, metadata.
116
+ */
117
+ async function flushRetainQueue() {
118
+ if (!retainQueue || isFlushInProgress)
119
+ return;
120
+ const pending = retainQueue.size();
121
+ if (pending === 0)
122
+ return;
123
+ isFlushInProgress = true;
124
+ let flushed = 0;
125
+ let failed = 0;
126
+ try {
127
+ if (!client)
128
+ return; // no client yet — can't flush
129
+ // Cleanup expired items first
130
+ retainQueue.cleanup();
131
+ const items = retainQueue.peek(50);
132
+ const flushedIds = [];
133
+ for (const item of items) {
134
+ try {
135
+ await client.retain(item.bankId, item.content, {
136
+ documentId: item.documentId,
137
+ metadata: toStringMetadata(item.metadata),
138
+ tags: item.tags,
139
+ async: true,
140
+ });
141
+ flushedIds.push(item.id);
142
+ flushed++;
143
+ }
144
+ catch {
145
+ // API still down — stop trying this batch
146
+ failed++;
147
+ break;
148
+ }
149
+ }
150
+ if (flushedIds.length > 0)
151
+ retainQueue.removeMany(flushedIds);
152
+ const remaining = retainQueue.size();
153
+ if (flushed > 0) {
154
+ log.info(`queue flush: ${flushed} queued retains delivered${remaining > 0 ? `, ${remaining} still pending` : ', queue empty'}`);
155
+ }
156
+ else if (failed > 0) {
157
+ debug(`[Hindsight] Queue flush: API still unreachable, ${remaining} retains pending`);
158
+ }
159
+ }
160
+ finally {
161
+ isFlushInProgress = false;
162
+ }
163
+ }
42
164
  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:';
43
165
  function formatCurrentTimeForRecall(date = new Date()) {
44
166
  const year = date.getUTCFullYear();
@@ -52,19 +174,22 @@ function formatCurrentTimeForRecall(date = new Date()) {
52
174
  * Lazy re-initialization after startup failure.
53
175
  * Called by waitForReady when initPromise rejected but API may now be reachable.
54
176
  * Throttled to one attempt per 30s to avoid hammering a down service.
177
+ * Only works if initialization was attempted at least once (isInitialized guard).
55
178
  */
56
- async function lazyReinit() {
179
+ async function lazyReinit(configOverride) {
57
180
  const now = Date.now();
58
181
  if (now - lastReinitAttempt < REINIT_COOLDOWN_MS || isReinitInProgress) {
59
182
  return;
60
183
  }
61
- isReinitInProgress = true;
62
- lastReinitAttempt = now;
63
- const config = currentPluginConfig;
184
+ const config = configOverride ?? currentPluginConfig;
64
185
  if (!config) {
65
- isReinitInProgress = false;
186
+ debug('[Hindsight] lazyReinit skipped - no plugin config available');
66
187
  return;
67
188
  }
189
+ // Persist config if we only have it from the live hook registration path.
190
+ currentPluginConfig = config;
191
+ isReinitInProgress = true;
192
+ lastReinitAttempt = now;
68
193
  const externalApi = detectExternalApi(config);
69
194
  if (!externalApi.apiUrl) {
70
195
  isReinitInProgress = false;
@@ -73,20 +198,19 @@ async function lazyReinit() {
73
198
  debug('[Hindsight] Attempting lazy re-initialization...');
74
199
  try {
75
200
  await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
76
- // Health check passed — set up env vars and create client
77
- process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
78
- if (externalApi.apiToken) {
79
- process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
80
- }
81
201
  const llmConfig = detectLLMConfig(config);
82
202
  clientOptions = buildClientOptions(llmConfig, config, externalApi);
83
- clientsByBankId.clear();
84
203
  banksWithMissionSet.clear();
85
204
  client = new HindsightClient(clientOptions);
86
- const defaultBankId = deriveBankId(undefined, config);
87
- client.setBankId(defaultBankId);
88
- if (config.bankMission && !config.dynamicBankId) {
89
- await client.setBankMission(config.bankMission);
205
+ if (config.bankMission && usesStaticBank(config)) {
206
+ const bankId = getStaticBankId(config);
207
+ try {
208
+ await scopeClient(client, bankId).setMission(config.bankMission);
209
+ banksWithMissionSet.add(bankId);
210
+ }
211
+ catch (err) {
212
+ log.warn(`could not set bank mission for ${bankId}: ${err instanceof Error ? err.message : err}`);
213
+ }
90
214
  }
91
215
  usingExternalApi = true;
92
216
  isInitialized = true;
@@ -109,54 +233,44 @@ if (typeof global !== 'undefined') {
109
233
  if (isInitialized) {
110
234
  return;
111
235
  }
112
- if (initPromise) {
113
- try {
114
- await initPromise;
236
+ // If initPromise is null, it means service.start() hasn't been called yet
237
+ // (CLI mode, not gateway mode). Hooks should gracefully no-op.
238
+ if (!initPromise) {
239
+ if (currentPluginConfig) {
240
+ log.warn('waitForReady called before service.start() — attempting lazy initialization fallback');
241
+ await lazyReinit(currentPluginConfig);
242
+ return;
115
243
  }
116
- catch {
117
- // Init failed (e.g., health check timeout at startup).
118
- // Attempt lazy re-initialization so Hindsight recovers
119
- // once the API becomes reachable again.
120
- if (!isInitialized) {
121
- await lazyReinit();
122
- }
244
+ log.warn('waitForReady called before service.start() — hooks will no-op (expected in CLI mode)');
245
+ return;
246
+ }
247
+ try {
248
+ await initPromise;
249
+ }
250
+ catch {
251
+ // Init failed (e.g., health check timeout at startup).
252
+ // Attempt lazy re-initialization so Hindsight recovers
253
+ // once the API becomes reachable again.
254
+ if (!isInitialized) {
255
+ await lazyReinit();
123
256
  }
124
257
  }
125
258
  },
126
259
  /**
127
- * Get a client configured for a specific agent context.
128
- * Derives the bank ID from the context for per-channel isolation.
129
- * Also ensures the bank mission is set on first use.
260
+ * Get a bank-scoped client handle for a specific agent context.
261
+ * Derives the bank ID from the context for per-channel isolation and
262
+ * ensures the bank mission is set on first use.
130
263
  */
131
264
  getClientForContext: async (ctx) => {
132
- if (!client) {
265
+ if (!client)
133
266
  return null;
134
- }
135
267
  const config = currentPluginConfig || {};
136
- if (config.dynamicBankId === false) {
137
- return client;
138
- }
139
- const bankId = deriveBankId(ctx, config);
140
- let bankClient = clientsByBankId.get(bankId);
141
- if (!bankClient) {
142
- if (!clientOptions) {
143
- return null;
144
- }
145
- bankClient = new HindsightClient(clientOptions);
146
- bankClient.setBankId(bankId);
147
- clientsByBankId.set(bankId, bankClient);
148
- if (clientsByBankId.size > MAX_TRACKED_BANK_CLIENTS) {
149
- const oldestKey = clientsByBankId.keys().next().value;
150
- if (oldestKey) {
151
- clientsByBankId.delete(oldestKey);
152
- banksWithMissionSet.delete(oldestKey);
153
- }
154
- }
155
- }
156
- // Set bank mission on first use of this bank (if configured)
157
- if (config.bankMission && config.dynamicBankId && !banksWithMissionSet.has(bankId)) {
268
+ const bankId = usesStaticBank(config) ? getStaticBankId(config) : deriveBankId(ctx, config);
269
+ const scoped = scopeClient(client, bankId);
270
+ // Set bank mission on first use of this bank (if configured).
271
+ if (config.bankMission && !banksWithMissionSet.has(bankId)) {
158
272
  try {
159
- await bankClient.setBankMission(config.bankMission);
273
+ await scoped.setMission(config.bankMission);
160
274
  banksWithMissionSet.add(bankId);
161
275
  debug(`[Hindsight] Set mission for new bank: ${bankId}`);
162
276
  }
@@ -165,7 +279,7 @@ if (typeof global !== 'undefined') {
165
279
  log.warn(`could not set bank mission for ${bankId}: ${error}`);
166
280
  }
167
281
  }
168
- return bankClient;
282
+ return scoped;
169
283
  },
170
284
  getPluginConfig: () => currentPluginConfig,
171
285
  };
@@ -175,6 +289,24 @@ const __filename = fileURLToPath(import.meta.url);
175
289
  const __dirname = dirname(__filename);
176
290
  // Default bank name (fallback when channel context not available)
177
291
  const DEFAULT_BANK_NAME = 'openclaw';
292
+ function getConfiguredBankId(pluginConfig) {
293
+ if (typeof pluginConfig.bankId !== 'string') {
294
+ return undefined;
295
+ }
296
+ const trimmed = pluginConfig.bankId.trim();
297
+ return trimmed.length > 0 ? trimmed : undefined;
298
+ }
299
+ function usesStaticBank(pluginConfig) {
300
+ return pluginConfig.dynamicBankId === false;
301
+ }
302
+ function getDefaultBankId(pluginConfig) {
303
+ return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-${DEFAULT_BANK_NAME}` : DEFAULT_BANK_NAME;
304
+ }
305
+ function getStaticBankId(pluginConfig) {
306
+ const configuredBankId = getConfiguredBankId(pluginConfig);
307
+ const baseBankId = configuredBankId || DEFAULT_BANK_NAME;
308
+ return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-${baseBankId}` : baseBankId;
309
+ }
178
310
  /**
179
311
  * Strip plugin-injected memory tags from content to prevent retain feedback loop.
180
312
  * Removes <hindsight_memories> and <relevant_memories> blocks that were injected
@@ -370,6 +502,21 @@ export function truncateRecallQuery(query, latestQuery, maxChars) {
370
502
  * Format: "agent:{agentId}:{provider}:{channelType}:{channelId}[:{extra}]"
371
503
  * Example: "agent:c0der:telegram:group:-1003825475854:topic:42"
372
504
  */
505
+ // Some OpenClaw hook contexts populate `ctx.channelId` with the provider name
506
+ // (e.g. "discord") instead of the actual channel ID. Treat those as missing so
507
+ // we fall through to the sessionKey-derived channel. See issue #854.
508
+ const PROVIDER_CHANNEL_ID_TOKENS = new Set([
509
+ 'discord', 'telegram', 'slack', 'matrix', 'whatsapp', 'signal', 'messenger', 'sms', 'email', 'web', 'cli',
510
+ ]);
511
+ function sanitizeChannelId(channelId, provider) {
512
+ if (!channelId)
513
+ return undefined;
514
+ if (provider && channelId === provider)
515
+ return undefined;
516
+ if (PROVIDER_CHANNEL_ID_TOKENS.has(channelId.toLowerCase()))
517
+ return undefined;
518
+ return channelId;
519
+ }
373
520
  function parseSessionKey(sessionKey) {
374
521
  const parts = sessionKey.split(':');
375
522
  if (parts.length < 5 || parts[0] !== 'agent')
@@ -384,11 +531,11 @@ function parseSessionKey(sessionKey) {
384
531
  }
385
532
  export function deriveBankId(ctx, pluginConfig) {
386
533
  if (pluginConfig.dynamicBankId === false) {
387
- return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
534
+ return getStaticBankId(pluginConfig);
388
535
  }
389
536
  // When no context is available, fall back to the static default bank.
390
537
  if (!ctx) {
391
- return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
538
+ return getDefaultBankId(pluginConfig);
392
539
  }
393
540
  const fields = pluginConfig.dynamicBankGranularity?.length ? pluginConfig.dynamicBankGranularity : ['agent', 'channel', 'user'];
394
541
  // Validate field names at runtime — typos silently produce 'unknown' segments
@@ -406,7 +553,7 @@ export function deriveBankId(ctx, pluginConfig) {
406
553
  }
407
554
  const fieldMap = {
408
555
  agent: ctx?.agentId || sessionParsed.agentId || 'default',
409
- channel: ctx?.channelId || sessionParsed.channel || 'unknown',
556
+ channel: sanitizeChannelId(ctx?.channelId, ctx?.messageProvider || sessionParsed.provider) || sessionParsed.channel || 'unknown',
410
557
  user: ctx?.senderId || 'anonymous',
411
558
  provider: ctx?.messageProvider || sessionParsed.provider || 'unknown',
412
559
  };
@@ -428,85 +575,10 @@ export function formatMemories(results) {
428
575
  })
429
576
  .join('\n\n');
430
577
  }
431
- // Provider detection from standard env vars
432
- const PROVIDER_DETECTION = [
433
- { name: 'openai', keyEnv: 'OPENAI_API_KEY' },
434
- { name: 'anthropic', keyEnv: 'ANTHROPIC_API_KEY' },
435
- { name: 'gemini', keyEnv: 'GEMINI_API_KEY' },
436
- { name: 'groq', keyEnv: 'GROQ_API_KEY' },
437
- { name: 'ollama', keyEnv: '' },
438
- { name: 'openai-codex', keyEnv: '' },
439
- { name: 'claude-code', keyEnv: '' },
440
- ];
441
- function detectLLMConfig(pluginConfig) {
442
- // Override values from HINDSIGHT_API_LLM_* env vars (highest priority)
443
- const overrideProvider = process.env.HINDSIGHT_API_LLM_PROVIDER;
444
- const overrideModel = process.env.HINDSIGHT_API_LLM_MODEL;
445
- const overrideKey = process.env.HINDSIGHT_API_LLM_API_KEY;
446
- const overrideBaseUrl = process.env.HINDSIGHT_API_LLM_BASE_URL;
447
- // Priority 1: If provider is explicitly set via env var, use that
448
- if (overrideProvider) {
449
- // Providers that don't require an API key (use OAuth or local models)
450
- const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
451
- if (!overrideKey && !noKeyRequired.includes(overrideProvider)) {
452
- throw new Error(`HINDSIGHT_API_LLM_PROVIDER is set to "${overrideProvider}" but HINDSIGHT_API_LLM_API_KEY is not set.\n` +
453
- `Please set: export HINDSIGHT_API_LLM_API_KEY=your-api-key`);
454
- }
455
- return {
456
- provider: overrideProvider,
457
- apiKey: overrideKey || '',
458
- model: overrideModel,
459
- baseUrl: overrideBaseUrl,
460
- source: 'HINDSIGHT_API_LLM_PROVIDER override',
461
- };
462
- }
463
- // Priority 2: Plugin config llmProvider/llmModel
464
- if (pluginConfig?.llmProvider) {
465
- const providerInfo = PROVIDER_DETECTION.find(p => p.name === pluginConfig.llmProvider);
466
- // Resolve API key: llmApiKeyEnv > provider's standard keyEnv
467
- let apiKey = '';
468
- if (pluginConfig.llmApiKeyEnv) {
469
- apiKey = process.env[pluginConfig.llmApiKeyEnv] || '';
470
- }
471
- else if (providerInfo?.keyEnv) {
472
- apiKey = process.env[providerInfo.keyEnv] || '';
473
- }
474
- // Providers that don't require an API key (use OAuth or local models)
475
- const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
476
- if (!apiKey && !noKeyRequired.includes(pluginConfig.llmProvider)) {
477
- const keySource = pluginConfig.llmApiKeyEnv || providerInfo?.keyEnv || 'unknown';
478
- throw new Error(`Plugin config llmProvider is set to "${pluginConfig.llmProvider}" but no API key found.\n` +
479
- `Expected env var: ${keySource}\n` +
480
- `Set the env var or use llmApiKeyEnv in plugin config to specify a custom env var name.`);
481
- }
482
- return {
483
- provider: pluginConfig.llmProvider,
484
- apiKey,
485
- model: pluginConfig.llmModel || overrideModel,
486
- baseUrl: overrideBaseUrl,
487
- source: 'plugin config',
488
- };
489
- }
490
- // Priority 3: Auto-detect from standard provider env vars
491
- for (const providerInfo of PROVIDER_DETECTION) {
492
- const apiKey = providerInfo.keyEnv ? process.env[providerInfo.keyEnv] : '';
493
- // Skip providers that don't use API keys in auto-detection (must be explicitly requested)
494
- const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
495
- if (noKeyRequired.includes(providerInfo.name)) {
496
- continue;
497
- }
498
- if (apiKey) {
499
- return {
500
- provider: providerInfo.name,
501
- apiKey,
502
- model: overrideModel,
503
- baseUrl: overrideBaseUrl,
504
- source: `auto-detected from ${providerInfo.keyEnv}`,
505
- };
506
- }
507
- }
508
- // No configuration found - show helpful error
509
- // Allow empty LLM config if using external Hindsight API (server handles LLM)
578
+ // Providers that authenticate via OAuth or run locally — no API key needed.
579
+ const NO_KEY_REQUIRED_PROVIDERS = new Set(['ollama', 'openai-codex', 'claude-code']);
580
+ export function detectLLMConfig(pluginConfig) {
581
+ // External API mode: the daemon handles LLM credentials, plugin doesn't need them.
510
582
  const externalApiCheck = detectExternalApi(pluginConfig);
511
583
  if (externalApiCheck.apiUrl) {
512
584
  return {
@@ -517,42 +589,54 @@ function detectLLMConfig(pluginConfig) {
517
589
  source: 'external-api-mode-no-llm',
518
590
  };
519
591
  }
520
- throw new Error(`No LLM configuration found for Hindsight memory plugin.\n\n` +
521
- `Option 1: Set a standard provider API key (auto-detect):\n` +
522
- ` export OPENAI_API_KEY=sk-your-key\n` +
523
- ` export ANTHROPIC_API_KEY=your-key\n` +
524
- ` export GEMINI_API_KEY=your-key\n` +
525
- ` export GROQ_API_KEY=your-key\n\n` +
526
- `Option 2: Use Codex or Claude Code (no API key needed):\n` +
527
- ` export HINDSIGHT_API_LLM_PROVIDER=openai-codex # Requires 'codex auth login'\n` +
528
- ` export HINDSIGHT_API_LLM_PROVIDER=claude-code # Requires Claude Code CLI\n\n` +
529
- `Option 3: Set llmProvider in openclaw.json plugin config:\n` +
530
- ` "llmProvider": "openai"\n\n` +
531
- `Option 4: Override with Hindsight-specific env vars:\n` +
532
- ` export HINDSIGHT_API_LLM_PROVIDER=openai\n` +
533
- ` export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n` +
534
- ` export HINDSIGHT_API_LLM_BASE_URL=https://openrouter.ai/api/v1 # Optional\n\n` +
535
- `The model will be selected automatically by Hindsight. To override: export HINDSIGHT_API_LLM_MODEL=your-model`);
592
+ const provider = pluginConfig?.llmProvider;
593
+ if (!provider) {
594
+ throw new Error(`No LLM provider configured for the Hindsight memory plugin.\n\n` +
595
+ `Set the provider via 'openclaw config set':\n` +
596
+ ` openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai\n\n` +
597
+ `For providers that need an API key, configure it as a SecretRef so the value\n` +
598
+ `is read from an env var (or file/exec source) at runtime instead of stored in plain text:\n` +
599
+ ` openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey \\\n` +
600
+ ` --ref-source env --ref-provider default --ref-id OPENAI_API_KEY\n\n` +
601
+ `Providers that don't need an API key: ${[...NO_KEY_REQUIRED_PROVIDERS].join(', ')}.\n` +
602
+ `Or point the plugin at an external Hindsight API by setting hindsightApiUrl instead.`);
603
+ }
604
+ const apiKey = pluginConfig?.llmApiKey ?? '';
605
+ if (!apiKey && !NO_KEY_REQUIRED_PROVIDERS.has(provider)) {
606
+ throw new Error(`llmProvider is set to "${provider}" but llmApiKey is empty.\n\n` +
607
+ `Configure it via 'openclaw config set' as a SecretRef:\n` +
608
+ ` openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey \\\n` +
609
+ ` --ref-source env --ref-provider default --ref-id OPENAI_API_KEY`);
610
+ }
611
+ return {
612
+ provider,
613
+ apiKey,
614
+ model: pluginConfig?.llmModel,
615
+ baseUrl: pluginConfig?.llmBaseUrl,
616
+ source: 'plugin config',
617
+ };
536
618
  }
537
619
  /**
538
- * Detect external Hindsight API configuration.
539
- * Priority: env vars > plugin config
620
+ * Detect external Hindsight API configuration from plugin config.
540
621
  */
541
- function detectExternalApi(pluginConfig) {
542
- const apiUrl = process.env.HINDSIGHT_EMBED_API_URL || pluginConfig?.hindsightApiUrl || null;
543
- const apiToken = process.env.HINDSIGHT_EMBED_API_TOKEN || pluginConfig?.hindsightApiToken || null;
544
- return { apiUrl, apiToken };
622
+ export function detectExternalApi(pluginConfig) {
623
+ return {
624
+ apiUrl: pluginConfig?.hindsightApiUrl ?? null,
625
+ apiToken: pluginConfig?.hindsightApiToken ?? null,
626
+ };
545
627
  }
546
628
  /**
547
- * Build HindsightClientOptions from LLM config, plugin config, and external API settings.
629
+ * Build HindsightClientOptions for the generated hindsight-client. In
630
+ * external-API mode we use the configured URL/token; in local daemon mode
631
+ * the caller overrides with the daemon's base URL after start().
632
+ * The llmConfig parameter is currently only consumed by the daemon manager
633
+ * (via env vars); it's kept on the client builder signature so callers
634
+ * don't need to branch and so future features can forward it.
548
635
  */
549
- function buildClientOptions(llmConfig, pluginCfg, externalApi) {
636
+ export function buildClientOptions(_llmConfig, _pluginCfg, externalApi) {
550
637
  return {
551
- llmModel: llmConfig.model,
552
- embedVersion: pluginCfg.embedVersion,
553
- embedPackagePath: pluginCfg.embedPackagePath,
554
- apiUrl: externalApi.apiUrl ?? undefined,
555
- apiToken: externalApi.apiToken ?? undefined,
638
+ baseUrl: externalApi.apiUrl ?? '',
639
+ apiKey: externalApi.apiToken ?? undefined,
556
640
  };
557
641
  }
558
642
  /**
@@ -600,14 +684,20 @@ function getPluginConfig(api) {
600
684
  embedPackagePath: config.embedPackagePath,
601
685
  llmProvider: config.llmProvider,
602
686
  llmModel: config.llmModel,
603
- llmApiKeyEnv: config.llmApiKeyEnv,
687
+ llmApiKey: config.llmApiKey,
688
+ llmBaseUrl: config.llmBaseUrl,
604
689
  hindsightApiUrl: config.hindsightApiUrl,
605
690
  hindsightApiToken: config.hindsightApiToken,
606
691
  apiPort: config.apiPort || 9077,
607
692
  // Dynamic bank ID options (default: enabled)
608
693
  dynamicBankId: config.dynamicBankId !== false,
694
+ bankId: typeof config.bankId === 'string' && config.bankId.trim().length > 0 ? config.bankId.trim() : undefined,
609
695
  bankIdPrefix: config.bankIdPrefix,
610
- excludeProviders: Array.isArray(config.excludeProviders) ? config.excludeProviders : [],
696
+ retainTags: Array.isArray(config.retainTags) ? config.retainTags.filter((tag) => typeof tag === 'string') : undefined,
697
+ retainSource: typeof config.retainSource === 'string' && config.retainSource.trim().length > 0 ? config.retainSource.trim() : undefined,
698
+ excludeProviders: Array.isArray(config.excludeProviders)
699
+ ? Array.from(new Set(['heartbeat', ...config.excludeProviders.filter((provider) => typeof provider === 'string')]))
700
+ : ['heartbeat'],
611
701
  autoRecall: config.autoRecall !== false, // Default: true (on) — backward compatible
612
702
  dynamicBankGranularity: Array.isArray(config.dynamicBankGranularity) ? config.dynamicBankGranularity : undefined,
613
703
  autoRetain: config.autoRetain !== false, // Default: true
@@ -626,13 +716,17 @@ function getPluginConfig(api) {
626
716
  : DEFAULT_RECALL_PROMPT_PREAMBLE,
627
717
  recallInjectionPosition: typeof config.recallInjectionPosition === 'string' && ['prepend', 'append', 'user'].includes(config.recallInjectionPosition) ? config.recallInjectionPosition : undefined,
628
718
  recallTimeoutMs: typeof config.recallTimeoutMs === 'number' && config.recallTimeoutMs >= 1000 ? config.recallTimeoutMs : undefined,
719
+ ignoreSessionPatterns: Array.isArray(config.ignoreSessionPatterns) ? config.ignoreSessionPatterns : [],
720
+ statelessSessionPatterns: Array.isArray(config.statelessSessionPatterns) ? config.statelessSessionPatterns : [],
721
+ skipStatelessSessions: config.skipStatelessSessions !== false,
629
722
  debug: config.debug ?? false,
630
723
  };
631
724
  }
632
725
  export default function (api) {
633
726
  try {
727
+ log.info('plugin entry invoked');
634
728
  debug('[Hindsight] Plugin loading...');
635
- // Get plugin config first (needed for LLM detection and debug flag)
729
+ // Get plugin config first (needed for debug flag and service registration)
636
730
  const pluginConfig = getPluginConfig(api);
637
731
  // If logLevel is 'debug', also enable legacy debug flag
638
732
  debugEnabled = pluginConfig.debug ?? (pluginConfig.logLevel === 'debug');
@@ -645,136 +739,179 @@ export default function (api) {
645
739
  });
646
740
  // Store config globally for bank ID derivation in hooks
647
741
  currentPluginConfig = pluginConfig;
648
- // Detect LLM configuration (env vars > plugin config > auto-detect)
649
- debug('[Hindsight] Detecting LLM config...');
650
- const llmConfig = detectLLMConfig(pluginConfig);
651
- const baseUrlInfo = llmConfig.baseUrl ? `, base URL: ${llmConfig.baseUrl}` : '';
652
- const modelInfo = llmConfig.model || 'default';
653
- if (llmConfig.provider === 'ollama') {
654
- debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source})`);
655
- }
656
- else {
657
- debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source}${baseUrlInfo})`);
658
- }
659
- if (pluginConfig.bankMission) {
660
- debug(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
661
- }
662
- // Log dynamic bank ID mode
663
- if (pluginConfig.dynamicBankId) {
664
- const prefixInfo = pluginConfig.bankIdPrefix ? ` (prefix: ${pluginConfig.bankIdPrefix})` : '';
665
- debug(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
666
- }
667
- else {
668
- debug(`[Hindsight] Dynamic bank IDs disabled - using static bank: ${DEFAULT_BANK_NAME}`);
669
- }
670
- // Detect external API mode
671
- const externalApi = detectExternalApi(pluginConfig);
672
- // Get API port from config (default: 9077)
673
- const apiPort = pluginConfig.apiPort || 9077;
674
- if (externalApi.apiUrl) {
675
- // External API mode - skip local daemon
676
- usingExternalApi = true;
677
- debug(`[Hindsight] ✓ Using external API: ${externalApi.apiUrl}`);
678
- // Set env vars so CLI commands (uvx hindsight-embed) use external API
679
- process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
680
- if (externalApi.apiToken) {
681
- process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
682
- debug('[Hindsight] API token configured');
683
- }
684
- }
685
- else {
686
- debug(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
687
- debug(`[Hindsight] API Port: ${apiPort}`);
688
- }
689
- // Initialize in background (non-blocking)
690
- debug('[Hindsight] Starting initialization in background...');
691
- initPromise = (async () => {
692
- try {
693
- if (usingExternalApi && externalApi.apiUrl) {
694
- // External API mode - check health, skip daemon startup
695
- debug('[Hindsight] External API mode - skipping local daemon...');
696
- await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
697
- // Initialize client with direct HTTP mode
698
- debug('[Hindsight] Creating HindsightClient (HTTP mode)...');
699
- clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
700
- clientsByBankId.clear();
701
- banksWithMissionSet.clear();
702
- client = new HindsightClient(clientOptions);
703
- // Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
704
- const defaultBankId = deriveBankId(undefined, pluginConfig);
705
- debug(`[Hindsight] Default bank: ${defaultBankId}`);
706
- client.setBankId(defaultBankId);
707
- // Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
708
- // For now, set it on the default bank
709
- if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
710
- debug(`[Hindsight] Setting bank mission...`);
711
- await client.setBankMission(pluginConfig.bankMission);
712
- }
713
- if (!isInitialized) {
714
- const mode = 'external API';
715
- const autoRecall = pluginConfig.autoRecall !== false;
716
- const autoRetain = pluginConfig.autoRetain !== false;
717
- log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`);
718
- }
719
- isInitialized = true;
720
- debug('[Hindsight] ✓ Ready (external API mode)');
721
- }
722
- else {
723
- // Local daemon mode - start hindsight-embed daemon
724
- debug('[Hindsight] Creating HindsightEmbedManager...');
725
- embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
726
- // Start the embedded server
727
- debug('[Hindsight] Starting embedded server...');
728
- await embedManager.start();
729
- // Initialize client (local daemon mode — no apiUrl)
730
- debug('[Hindsight] Creating HindsightClient (subprocess mode)...');
731
- clientOptions = buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null });
732
- clientsByBankId.clear();
733
- banksWithMissionSet.clear();
734
- client = new HindsightClient(clientOptions);
735
- // Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
736
- const defaultBankId = deriveBankId(undefined, pluginConfig);
737
- debug(`[Hindsight] Default bank: ${defaultBankId}`);
738
- client.setBankId(defaultBankId);
739
- // Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
740
- // For now, set it on the default bank
741
- if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
742
- debug(`[Hindsight] Setting bank mission...`);
743
- await client.setBankMission(pluginConfig.bankMission);
744
- }
745
- if (!isInitialized) {
746
- const mode = 'local daemon';
747
- const autoRecall = pluginConfig.autoRecall !== false;
748
- const autoRetain = pluginConfig.autoRetain !== false;
749
- log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`);
750
- }
751
- isInitialized = true;
752
- debug('[Hindsight] ✓ Ready');
753
- }
754
- }
755
- catch (error) {
756
- log.error('initialization error', error);
757
- throw error;
758
- }
759
- })();
760
- // Suppress unhandled rejection — service.start() will await and handle errors
761
- initPromise.catch(() => { });
742
+ debug('[Hindsight] Plugin loaded successfully (deferred heavy init to gateway start)');
762
743
  // Register background service for cleanup
744
+ // IMPORTANT: Heavy initialization (LLM detection, daemon start, API health checks)
745
+ // happens in service.start() which is ONLY called on gateway start,
746
+ // not on every CLI command.
763
747
  debug('[Hindsight] Registering service...');
748
+ log.info('registering plugin service');
764
749
  api.registerService({
765
750
  id: 'hindsight-memory',
766
751
  async start() {
767
- debug('[Hindsight] Service start called...');
768
- // Wait for background init if still pending
769
- if (initPromise) {
752
+ log.info('service.start invoked');
753
+ debug('[Hindsight] Service start called - beginning heavy initialization...');
754
+ // Detect LLM configuration (env vars > plugin config > auto-detect)
755
+ debug('[Hindsight] Detecting LLM config...');
756
+ const llmConfig = detectLLMConfig(pluginConfig);
757
+ const baseUrlInfo = llmConfig.baseUrl ? `, base URL: ${llmConfig.baseUrl}` : '';
758
+ const modelInfo = llmConfig.model || 'default';
759
+ if (llmConfig.provider === 'ollama') {
760
+ debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source})`);
761
+ }
762
+ else {
763
+ debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source}${baseUrlInfo})`);
764
+ }
765
+ if (pluginConfig.bankMission) {
766
+ debug(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
767
+ }
768
+ // Log bank ID mode
769
+ if (pluginConfig.dynamicBankId) {
770
+ const prefixInfo = pluginConfig.bankIdPrefix ? ` (prefix: ${pluginConfig.bankIdPrefix})` : '';
771
+ debug(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
772
+ }
773
+ else {
774
+ const sourceInfo = getConfiguredBankId(pluginConfig) ? 'configured' : 'default';
775
+ debug(`[Hindsight] Dynamic bank IDs disabled - using ${sourceInfo} static bank: ${getStaticBankId(pluginConfig)}`);
776
+ }
777
+ // Detect external API mode
778
+ const externalApi = detectExternalApi(pluginConfig);
779
+ // Get API port from config (default: 9077)
780
+ const apiPort = pluginConfig.apiPort || 9077;
781
+ if (externalApi.apiUrl) {
782
+ // External API mode - skip local daemon
783
+ usingExternalApi = true;
784
+ debug(`[Hindsight] ✓ Using external API: ${externalApi.apiUrl}`);
785
+ // Initialize retain queue (external API mode only)
770
786
  try {
771
- await initPromise;
787
+ const queueDir = pluginConfig.retainQueuePath
788
+ ? dirname(pluginConfig.retainQueuePath)
789
+ : join(homedir(), '.openclaw', 'data');
790
+ mkdirSync(queueDir, { recursive: true });
791
+ const queuePath = pluginConfig.retainQueuePath || join(queueDir, 'hindsight-retain-queue.jsonl');
792
+ const queueFlushInterval = pluginConfig.retainQueueFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
793
+ const queueMaxAge = pluginConfig.retainQueueMaxAgeMs ?? -1;
794
+ retainQueue = new RetainQueue({ filePath: queuePath, maxAgeMs: queueMaxAge });
795
+ const pending = retainQueue.size();
796
+ if (pending > 0) {
797
+ log.info(`retain queue: ${pending} items pending from previous session, will flush shortly`);
798
+ }
799
+ debug(`[Hindsight] Retain queue initialized: ${queuePath}`);
800
+ // Periodic flush timer
801
+ if (queueFlushInterval > 0) {
802
+ retainQueueFlushTimer = setInterval(flushRetainQueue, queueFlushInterval);
803
+ retainQueueFlushTimer.unref?.();
804
+ }
772
805
  }
773
806
  catch (error) {
774
- log.error('initial initialization failed', error);
775
- // Continue to health check below
807
+ log.warn(`could not initialize retain queue: ${error}`);
808
+ }
809
+ if (externalApi.apiToken) {
810
+ debug('[Hindsight] API token configured');
776
811
  }
777
812
  }
813
+ else {
814
+ debug(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
815
+ debug(`[Hindsight] API Port: ${apiPort}`);
816
+ }
817
+ // Initialize (runs synchronously in service.start())
818
+ debug('[Hindsight] Starting initialization...');
819
+ initPromise = (async () => {
820
+ try {
821
+ if (usingExternalApi && externalApi.apiUrl) {
822
+ // External API mode - check health, skip daemon startup
823
+ debug('[Hindsight] External API mode - skipping local daemon...');
824
+ await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
825
+ // Initialize client for external API
826
+ debug('[Hindsight] Creating HindsightClient (external API)...');
827
+ clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
828
+ banksWithMissionSet.clear();
829
+ client = new HindsightClient(clientOptions);
830
+ const defaultBankId = deriveBankId(undefined, pluginConfig);
831
+ debug(`[Hindsight] Default bank: ${defaultBankId}`);
832
+ // Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
833
+ // For now, set it on the static default bank only.
834
+ if (pluginConfig.bankMission && usesStaticBank(pluginConfig)) {
835
+ debug(`[Hindsight] Setting bank mission...`);
836
+ try {
837
+ await scopeClient(client, defaultBankId).setMission(pluginConfig.bankMission);
838
+ banksWithMissionSet.add(defaultBankId);
839
+ }
840
+ catch (err) {
841
+ log.warn(`could not set bank mission for ${defaultBankId}: ${err instanceof Error ? err.message : err}`);
842
+ }
843
+ }
844
+ if (!isInitialized) {
845
+ const mode = 'external API';
846
+ const autoRecall = pluginConfig.autoRecall !== false;
847
+ const autoRetain = pluginConfig.autoRetain !== false;
848
+ log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`);
849
+ }
850
+ isInitialized = true;
851
+ debug('[Hindsight] ✓ Ready (external API mode)');
852
+ }
853
+ else {
854
+ // Local daemon mode - start hindsight-embed daemon
855
+ debug('[Hindsight] Creating HindsightServer...');
856
+ hindsightServer = new HindsightServer({
857
+ profile: 'openclaw',
858
+ port: apiPort,
859
+ embedVersion: pluginConfig.embedVersion,
860
+ embedPackagePath: pluginConfig.embedPackagePath,
861
+ env: {
862
+ HINDSIGHT_API_LLM_PROVIDER: llmConfig.provider || '',
863
+ HINDSIGHT_API_LLM_API_KEY: llmConfig.apiKey || '',
864
+ HINDSIGHT_API_LLM_MODEL: llmConfig.model,
865
+ HINDSIGHT_API_LLM_BASE_URL: llmConfig.baseUrl,
866
+ HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: String(pluginConfig.daemonIdleTimeout ?? 0),
867
+ },
868
+ logger: embedLogger,
869
+ });
870
+ // Start the embedded server
871
+ debug('[Hindsight] Starting embedded server...');
872
+ await hindsightServer.start();
873
+ // Initialize client pointed at the local daemon URL
874
+ debug('[Hindsight] Creating HindsightClient (local daemon)...');
875
+ clientOptions = { baseUrl: hindsightServer.getBaseUrl() };
876
+ banksWithMissionSet.clear();
877
+ client = new HindsightClient(clientOptions);
878
+ const defaultBankId = deriveBankId(undefined, pluginConfig);
879
+ debug(`[Hindsight] Default bank: ${defaultBankId}`);
880
+ // Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
881
+ // For now, set it on the static default bank only.
882
+ if (pluginConfig.bankMission && usesStaticBank(pluginConfig)) {
883
+ debug(`[Hindsight] Setting bank mission...`);
884
+ try {
885
+ await scopeClient(client, defaultBankId).setMission(pluginConfig.bankMission);
886
+ banksWithMissionSet.add(defaultBankId);
887
+ }
888
+ catch (err) {
889
+ log.warn(`could not set bank mission for ${defaultBankId}: ${err instanceof Error ? err.message : err}`);
890
+ }
891
+ }
892
+ if (!isInitialized) {
893
+ const mode = 'local daemon';
894
+ const autoRecall = pluginConfig.autoRecall !== false;
895
+ const autoRetain = pluginConfig.autoRetain !== false;
896
+ log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`);
897
+ }
898
+ isInitialized = true;
899
+ debug('[Hindsight] ✓ Ready');
900
+ }
901
+ }
902
+ catch (error) {
903
+ log.error('initialization error', error);
904
+ throw error;
905
+ }
906
+ })();
907
+ // Wait for initialization to complete
908
+ try {
909
+ await initPromise;
910
+ }
911
+ catch (error) {
912
+ log.error('initial initialization failed', error);
913
+ // Continue to health check below
914
+ }
778
915
  // External API mode: check external API health
779
916
  if (usingExternalApi) {
780
917
  const externalApi = detectExternalApi(pluginConfig);
@@ -789,7 +926,6 @@ export default function (api) {
789
926
  // Reset state for reinitialization attempt
790
927
  client = null;
791
928
  clientOptions = null;
792
- clientsByBankId.clear();
793
929
  banksWithMissionSet.clear();
794
930
  isInitialized = false;
795
931
  }
@@ -797,18 +933,17 @@ export default function (api) {
797
933
  }
798
934
  else {
799
935
  // Local daemon mode: check daemon health (handles SIGUSR1 restart case)
800
- if (embedManager && isInitialized) {
801
- const healthy = await embedManager.checkHealth();
936
+ if (hindsightServer && isInitialized) {
937
+ const healthy = await hindsightServer.checkHealth();
802
938
  if (healthy) {
803
939
  debug('[Hindsight] Daemon is healthy');
804
940
  return;
805
941
  }
806
942
  debug('[Hindsight] Daemon is not responding - reinitializing...');
807
943
  // Reset state for reinitialization
808
- embedManager = null;
944
+ hindsightServer = null;
809
945
  client = null;
810
946
  clientOptions = null;
811
- clientsByBankId.clear();
812
947
  banksWithMissionSet.clear();
813
948
  isInitialized = false;
814
949
  }
@@ -824,35 +959,52 @@ export default function (api) {
824
959
  if (externalApi.apiUrl) {
825
960
  // External API mode
826
961
  usingExternalApi = true;
827
- process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
828
- if (externalApi.apiToken) {
829
- process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
830
- }
831
962
  await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
832
963
  clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, externalApi);
833
- clientsByBankId.clear();
834
964
  banksWithMissionSet.clear();
835
965
  client = new HindsightClient(clientOptions);
836
966
  const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
837
- client.setBankId(defaultBankId);
838
- if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
839
- await client.setBankMission(reinitPluginConfig.bankMission);
967
+ if (reinitPluginConfig.bankMission && usesStaticBank(reinitPluginConfig)) {
968
+ try {
969
+ await scopeClient(client, defaultBankId).setMission(reinitPluginConfig.bankMission);
970
+ banksWithMissionSet.add(defaultBankId);
971
+ }
972
+ catch (err) {
973
+ log.warn(`could not set bank mission for ${defaultBankId}: ${err instanceof Error ? err.message : err}`);
974
+ }
840
975
  }
841
976
  isInitialized = true;
842
977
  debug('[Hindsight] Reinitialization complete (external API mode)');
843
978
  }
844
979
  else {
845
980
  // Local daemon mode
846
- embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, reinitPluginConfig.daemonIdleTimeout, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
847
- await embedManager.start();
848
- clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null });
849
- clientsByBankId.clear();
981
+ hindsightServer = new HindsightServer({
982
+ profile: 'openclaw',
983
+ port: apiPort,
984
+ embedVersion: reinitPluginConfig.embedVersion,
985
+ embedPackagePath: reinitPluginConfig.embedPackagePath,
986
+ env: {
987
+ HINDSIGHT_API_LLM_PROVIDER: llmConfig.provider || '',
988
+ HINDSIGHT_API_LLM_API_KEY: llmConfig.apiKey || '',
989
+ HINDSIGHT_API_LLM_MODEL: llmConfig.model,
990
+ HINDSIGHT_API_LLM_BASE_URL: llmConfig.baseUrl,
991
+ HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: String(reinitPluginConfig.daemonIdleTimeout ?? 0),
992
+ },
993
+ logger: embedLogger,
994
+ });
995
+ await hindsightServer.start();
996
+ clientOptions = { baseUrl: hindsightServer.getBaseUrl() };
850
997
  banksWithMissionSet.clear();
851
998
  client = new HindsightClient(clientOptions);
852
999
  const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
853
- client.setBankId(defaultBankId);
854
- if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
855
- await client.setBankMission(reinitPluginConfig.bankMission);
1000
+ if (reinitPluginConfig.bankMission && usesStaticBank(reinitPluginConfig)) {
1001
+ try {
1002
+ await scopeClient(client, defaultBankId).setMission(reinitPluginConfig.bankMission);
1003
+ banksWithMissionSet.add(defaultBankId);
1004
+ }
1005
+ catch (err) {
1006
+ log.warn(`could not set bank mission for ${defaultBankId}: ${err instanceof Error ? err.message : err}`);
1007
+ }
856
1008
  }
857
1009
  isInitialized = true;
858
1010
  debug('[Hindsight] Reinitialization complete');
@@ -863,13 +1015,25 @@ export default function (api) {
863
1015
  try {
864
1016
  debug('[Hindsight] Service stopping...');
865
1017
  // Only stop daemon if in local mode
866
- if (!usingExternalApi && embedManager) {
867
- await embedManager.stop();
868
- embedManager = null;
1018
+ if (!usingExternalApi && hindsightServer) {
1019
+ await hindsightServer.stop();
1020
+ hindsightServer = null;
1021
+ }
1022
+ // Close retain queue
1023
+ if (retainQueueFlushTimer) {
1024
+ clearInterval(retainQueueFlushTimer);
1025
+ retainQueueFlushTimer = null;
1026
+ }
1027
+ if (retainQueue) {
1028
+ const pending = retainQueue.size();
1029
+ if (pending > 0) {
1030
+ debug(`[Hindsight] Service stopping with ${pending} queued retains (will resume on next start)`);
1031
+ }
1032
+ retainQueue.close();
1033
+ retainQueue = null;
869
1034
  }
870
1035
  client = null;
871
1036
  clientOptions = null;
872
- clientsByBankId.clear();
873
1037
  banksWithMissionSet.clear();
874
1038
  isInitialized = false;
875
1039
  stopLogger();
@@ -883,12 +1047,13 @@ export default function (api) {
883
1047
  });
884
1048
  debug('[Hindsight] Plugin loaded successfully');
885
1049
  // Register agent hooks for auto-recall and auto-retention
886
- if (registeredApis.has(api)) {
887
- debug('[Hindsight] Hooks already registered for this api instance, skipping duplicate registration');
1050
+ if (hooksRegistered) {
1051
+ debug('[Hindsight] Hooks already registered in this runtime, skipping duplicate hook registration');
888
1052
  return;
889
1053
  }
890
- registeredApis.add(api);
1054
+ hooksRegistered = true;
891
1055
  debug('[Hindsight] Registering agent hooks...');
1056
+ log.info('registering agent hooks');
892
1057
  // Auto-recall: Inject relevant memories before agent processes the message
893
1058
  // Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
894
1059
  api.on('before_prompt_build', async (event, ctx) => {
@@ -898,6 +1063,23 @@ export default function (api) {
898
1063
  debug(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
899
1064
  return;
900
1065
  }
1066
+ // Session pattern filtering
1067
+ const sessionKey = ctx?.sessionKey;
1068
+ if (sessionKey) {
1069
+ const ignorePatterns = compileSessionPatterns(pluginConfig.ignoreSessionPatterns ?? []);
1070
+ if (ignorePatterns.length > 0 && matchesSessionPattern(sessionKey, ignorePatterns)) {
1071
+ debug(`[Hindsight] Skipping recall: session '${sessionKey}' matches ignoreSessionPatterns`);
1072
+ return;
1073
+ }
1074
+ const skipStateless = pluginConfig.skipStatelessSessions !== false;
1075
+ if (skipStateless) {
1076
+ const statelessPatterns = compileSessionPatterns(pluginConfig.statelessSessionPatterns ?? []);
1077
+ if (statelessPatterns.length > 0 && matchesSessionPattern(sessionKey, statelessPatterns)) {
1078
+ debug(`[Hindsight] Skipping recall: session '${sessionKey}' matches statelessSessionPatterns (skipStatelessSessions=true)`);
1079
+ return;
1080
+ }
1081
+ }
1082
+ }
901
1083
  // Skip auto-recall when disabled (agent has its own recall tool)
902
1084
  if (!pluginConfig.autoRecall) {
903
1085
  debug('[Hindsight] Auto-recall disabled via config, skipping');
@@ -973,7 +1155,7 @@ export default function (api) {
973
1155
  }
974
1156
  else {
975
1157
  const recallTimeoutMs = pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS;
976
- recallPromise = client.recall({ query: prompt, max_tokens: pluginConfig.recallMaxTokens || 1024, budget: pluginConfig.recallBudget, types: pluginConfig.recallTypes }, recallTimeoutMs);
1158
+ recallPromise = client.recall({ query: prompt, maxTokens: pluginConfig.recallMaxTokens || 1024, budget: pluginConfig.recallBudget, types: pluginConfig.recallTypes }, recallTimeoutMs);
977
1159
  inflightRecalls.set(recallKey, recallPromise);
978
1160
  void recallPromise.catch(() => { }).finally(() => inflightRecalls.delete(recallKey));
979
1161
  }
@@ -1033,6 +1215,20 @@ ${memoriesFormatted}
1033
1215
  debug(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
1034
1216
  return;
1035
1217
  }
1218
+ // Session pattern filtering
1219
+ const agentEndSessionKey = effectiveCtx?.sessionKey;
1220
+ if (agentEndSessionKey) {
1221
+ const ignorePatterns = compileSessionPatterns(pluginConfig.ignoreSessionPatterns ?? []);
1222
+ if (ignorePatterns.length > 0 && matchesSessionPattern(agentEndSessionKey, ignorePatterns)) {
1223
+ debug(`[Hindsight] Skipping retain: session '${agentEndSessionKey}' matches ignoreSessionPatterns`);
1224
+ return;
1225
+ }
1226
+ const statelessPatterns = compileSessionPatterns(pluginConfig.statelessSessionPatterns ?? []);
1227
+ if (statelessPatterns.length > 0 && matchesSessionPattern(agentEndSessionKey, statelessPatterns)) {
1228
+ debug(`[Hindsight] Skipping retain: session '${agentEndSessionKey}' matches statelessSessionPatterns`);
1229
+ return;
1230
+ }
1231
+ }
1036
1232
  // Derive bank ID from context — enrich ctx.senderId from the session cache.
1037
1233
  // event.messages in agent_end is clean history without OpenClaw's metadata blocks;
1038
1234
  // the sender ID was captured during before_prompt_build where event.prompt has them.
@@ -1103,30 +1299,40 @@ ${memoriesFormatted}
1103
1299
  log.warn('client not initialized, skipping retain');
1104
1300
  return;
1105
1301
  }
1106
- // Use unique document ID per conversation (sessionKey + timestamp)
1107
- // Static sessionKey (e.g. "agent:main:main") causes CASCADE delete of old memories
1108
- const documentId = `${effectiveCtx?.sessionKey || 'session'}-${Date.now()}`;
1109
- // Retain to Hindsight
1110
- debug(`[Hindsight] Retaining to bank ${bankId}, document: ${documentId}, chars: ${transcript.length}\n---\n${transcript.substring(0, 500)}${transcript.length > 500 ? '\n...(truncated)' : ''}\n---`);
1111
- await client.retain({
1112
- content: transcript,
1113
- document_id: documentId,
1114
- metadata: {
1115
- retained_at: new Date().toISOString(),
1116
- message_count: String(messageCount),
1117
- channel_type: effectiveCtx?.messageProvider,
1118
- channel_id: effectiveCtx?.channelId,
1119
- sender_id: effectiveCtx?.senderId,
1120
- },
1302
+ const retainNow = Date.now();
1303
+ const retainRequest = buildRetainRequest(transcript, messageCount, effectiveCtxForRetain, pluginConfig, retainNow, {
1304
+ retentionScope: retainFullWindow ? 'window' : 'turn',
1305
+ windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined,
1121
1306
  });
1122
- log.trackRetain(bankId, messageCount);
1123
- debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${documentId}`);
1307
+ // Retain to Hindsight
1308
+ debug(`[Hindsight] Retaining to bank ${bankId}, document: ${retainRequest.documentId}, chars: ${transcript.length}\n---\n${transcript.substring(0, 500)}${transcript.length > 500 ? '\n...(truncated)' : ''}\n---`);
1309
+ try {
1310
+ await client.retain(retainRequest);
1311
+ log.trackRetain(bankId, messageCount);
1312
+ debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${retainRequest.documentId}`);
1313
+ // After a successful retain, try flushing any queued items
1314
+ if (retainQueue && retainQueue.size() > 0) {
1315
+ flushRetainQueue().catch(() => { });
1316
+ }
1317
+ }
1318
+ catch (retainError) {
1319
+ // Queue the failed retain for later delivery (external API mode only)
1320
+ if (retainQueue) {
1321
+ retainQueue.enqueue(bankId, retainRequest, retainRequest.metadata);
1322
+ const pending = retainQueue.size();
1323
+ log.warn(`API unreachable — retain queued (${pending} pending, bank: ${bankId}): ${retainError instanceof Error ? retainError.message : retainError}`);
1324
+ }
1325
+ else {
1326
+ log.error('error retaining messages', retainError);
1327
+ }
1328
+ }
1124
1329
  }
1125
1330
  catch (error) {
1126
1331
  log.error('error retaining messages', error);
1127
1332
  }
1128
1333
  });
1129
1334
  debug('[Hindsight] Hooks registered');
1335
+ log.info('agent hooks registered');
1130
1336
  }
1131
1337
  catch (error) {
1132
1338
  log.error('plugin loading error', error);
@@ -1137,6 +1343,68 @@ ${memoriesFormatted}
1137
1343
  }
1138
1344
  }
1139
1345
  // Export client getter for tools
1346
+ function sanitizeDocumentIdPart(value, fallback) {
1347
+ const normalized = (value || '').trim();
1348
+ if (!normalized)
1349
+ return fallback;
1350
+ return normalized
1351
+ .replace(/[^a-zA-Z0-9:_-]+/g, '_')
1352
+ .replace(/_+/g, '_')
1353
+ .replace(/^_+|_+$/g, '') || fallback;
1354
+ }
1355
+ function getSessionDocumentBase(effectiveCtx) {
1356
+ const sessionKeyPart = sanitizeDocumentIdPart(effectiveCtx?.sessionKey, 'session');
1357
+ return `openclaw:${sessionKeyPart}`;
1358
+ }
1359
+ function nextDocumentSequence(effectiveCtx) {
1360
+ const sequenceKey = effectiveCtx?.sessionKey || 'session';
1361
+ const next = (documentSequenceBySession.get(sequenceKey) || 0) + 1;
1362
+ documentSequenceBySession.set(sequenceKey, next);
1363
+ if (documentSequenceBySession.size > MAX_TRACKED_SESSIONS) {
1364
+ const oldestKey = documentSequenceBySession.keys().next().value;
1365
+ if (oldestKey) {
1366
+ documentSequenceBySession.delete(oldestKey);
1367
+ }
1368
+ }
1369
+ return next;
1370
+ }
1371
+ function extractThreadId(channelId) {
1372
+ if (!channelId)
1373
+ return undefined;
1374
+ const match = channelId.match(/(?:^|:)topic:([^:]+)$/);
1375
+ return match?.[1];
1376
+ }
1377
+ export function buildRetainRequest(transcript, messageCount, effectiveCtx, pluginConfig, now = Date.now(), options) {
1378
+ const parsedSession = effectiveCtx?.sessionKey ? parseSessionKey(effectiveCtx.sessionKey) : {};
1379
+ const turnIndex = options?.turnIndex ?? nextDocumentSequence(effectiveCtx);
1380
+ const retentionScope = options?.retentionScope || 'turn';
1381
+ const documentBase = getSessionDocumentBase(effectiveCtx);
1382
+ const documentKind = retentionScope === 'window' ? 'window' : 'turn';
1383
+ const documentId = `${documentBase}:${documentKind}:${String(turnIndex).padStart(6, '0')}`;
1384
+ const provider = effectiveCtx?.messageProvider || parsedSession.provider;
1385
+ const channelId = sanitizeChannelId(effectiveCtx?.channelId, provider) || parsedSession.channel;
1386
+ const threadId = extractThreadId(channelId);
1387
+ return {
1388
+ content: transcript,
1389
+ documentId: documentId,
1390
+ metadata: {
1391
+ retained_at: new Date(now).toISOString(),
1392
+ message_count: String(messageCount),
1393
+ source: pluginConfig.retainSource || 'openclaw',
1394
+ retention_scope: retentionScope,
1395
+ turn_index: String(turnIndex),
1396
+ session_key: effectiveCtx?.sessionKey,
1397
+ agent_id: effectiveCtx?.agentId || parsedSession.agentId,
1398
+ provider,
1399
+ channel_type: effectiveCtx?.messageProvider,
1400
+ channel_id: channelId,
1401
+ thread_id: threadId,
1402
+ sender_id: effectiveCtx?.senderId,
1403
+ ...(options?.windowTurns !== undefined ? { window_turns: String(options.windowTurns) } : {}),
1404
+ },
1405
+ tags: pluginConfig.retainTags && pluginConfig.retainTags.length > 0 ? pluginConfig.retainTags : undefined,
1406
+ };
1407
+ }
1140
1408
  export function prepareRetentionTranscript(messages, pluginConfig, retainFullWindow = false) {
1141
1409
  if (!messages || messages.length === 0) {
1142
1410
  return null;