@vectorize-io/hindsight-openclaw 0.4.11 → 0.4.12

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/client.d.ts CHANGED
@@ -1,27 +1,13 @@
1
1
  import type { RetainRequest, RetainResponse, RecallRequest, RecallResponse } from './types.js';
2
- /**
3
- * Escape a string for use as a single-quoted shell argument.
4
- *
5
- * In POSIX shells, single-quoted strings treat ALL characters literally
6
- * except for the single quote itself. To include a literal single quote,
7
- * we use the pattern: end quote + escaped quote + start quote = '\''
8
- *
9
- * Example: "It's $100" becomes 'It'\''s $100'
10
- * Shell interprets: 'It' + \' + 's $100' = It's $100
11
- *
12
- * This handles ALL shell-special characters including:
13
- * - $ (variable expansion)
14
- * - ` (command substitution)
15
- * - ! (history expansion)
16
- * - ? * [ ] (glob patterns)
17
- * - ( ) { } (subshell/brace expansion)
18
- * - < > | & ; (redirection/control)
19
- * - \ " # ~ newlines
20
- *
21
- * @param arg - The string to escape
22
- * @returns The escaped string (without surrounding quotes - caller adds those)
23
- */
24
- export declare function escapeShellArg(arg: string): string;
2
+ export interface HindsightClientOptions {
3
+ llmProvider: string;
4
+ llmApiKey: string;
5
+ llmModel?: string;
6
+ embedVersion?: string;
7
+ embedPackagePath?: string;
8
+ apiUrl?: string;
9
+ apiToken?: string;
10
+ }
25
11
  export declare class HindsightClient {
26
12
  private bankId;
27
13
  private llmProvider;
@@ -29,13 +15,24 @@ export declare class HindsightClient {
29
15
  private llmModel?;
30
16
  private embedVersion;
31
17
  private embedPackagePath?;
32
- constructor(llmProvider: string, llmApiKey: string, llmModel?: string, embedVersion?: string, embedPackagePath?: string);
18
+ private apiUrl?;
19
+ private apiToken?;
20
+ constructor(opts: HindsightClientOptions);
21
+ private get httpMode();
33
22
  /**
34
- * Get the command prefix to run hindsight-embed (either local or from PyPI)
23
+ * Get the command and base args to run hindsight-embed.
24
+ * Returns [command, ...baseArgs] for use with execFile/spawn (no shell).
35
25
  */
36
- private getEmbedCommandPrefix;
26
+ private getEmbedCommand;
27
+ private httpHeaders;
37
28
  setBankId(bankId: string): void;
38
29
  setBankMission(mission: string): Promise<void>;
30
+ private setBankMissionHttp;
31
+ private setBankMissionSubprocess;
39
32
  retain(request: RetainRequest): Promise<RetainResponse>;
40
- recall(request: RecallRequest): Promise<RecallResponse>;
33
+ private retainHttp;
34
+ private retainSubprocess;
35
+ recall(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse>;
36
+ private recallHttp;
37
+ private recallSubprocess;
41
38
  }
package/dist/client.js CHANGED
@@ -1,74 +1,98 @@
1
- import { exec } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
- const execAsync = promisify(exec);
3
+ import { writeFile, mkdir, rm } from 'fs/promises';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { randomBytes } from 'crypto';
7
+ const execFileAsync = promisify(execFile);
8
+ const MAX_BUFFER = 5 * 1024 * 1024; // 5 MB — large transcripts can exceed default 1 MB
9
+ const DEFAULT_TIMEOUT_MS = 15_000;
10
+ /** Strip null bytes from strings — Node 22 rejects them in execFile() args */
11
+ const sanitize = (s) => s.replace(/\0/g, '');
4
12
  /**
5
- * Escape a string for use as a single-quoted shell argument.
6
- *
7
- * In POSIX shells, single-quoted strings treat ALL characters literally
8
- * except for the single quote itself. To include a literal single quote,
9
- * we use the pattern: end quote + escaped quote + start quote = '\''
10
- *
11
- * Example: "It's $100" becomes 'It'\''s $100'
12
- * Shell interprets: 'It' + \' + 's $100' = It's $100
13
- *
14
- * This handles ALL shell-special characters including:
15
- * - $ (variable expansion)
16
- * - ` (command substitution)
17
- * - ! (history expansion)
18
- * - ? * [ ] (glob patterns)
19
- * - ( ) { } (subshell/brace expansion)
20
- * - < > | & ; (redirection/control)
21
- * - \ " # ~ newlines
22
- *
23
- * @param arg - The string to escape
24
- * @returns The escaped string (without surrounding quotes - caller adds those)
13
+ * Sanitize a string for use as a cross-platform filename.
14
+ * Replaces characters illegal on Windows or Unix with underscores.
25
15
  */
26
- export function escapeShellArg(arg) {
27
- // Replace single quotes with the escape sequence: '\''
28
- // This ends the current single-quoted string, adds an escaped literal quote,
29
- // and starts a new single-quoted string.
30
- return arg.replace(/'/g, "'\\''");
16
+ function sanitizeFilename(name) {
17
+ // Replace characters illegal on Windows (\/:*?"<>|) and control chars
18
+ return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200) || 'content';
31
19
  }
32
20
  export class HindsightClient {
33
- bankId = 'default'; // Always use default bank
21
+ bankId = 'default';
34
22
  llmProvider;
35
23
  llmApiKey;
36
24
  llmModel;
37
25
  embedVersion;
38
26
  embedPackagePath;
39
- constructor(llmProvider, llmApiKey, llmModel, embedVersion = 'latest', embedPackagePath) {
40
- this.llmProvider = llmProvider;
41
- this.llmApiKey = llmApiKey;
42
- this.llmModel = llmModel;
43
- this.embedVersion = embedVersion || 'latest';
44
- this.embedPackagePath = embedPackagePath;
27
+ apiUrl;
28
+ apiToken;
29
+ constructor(opts) {
30
+ this.llmProvider = opts.llmProvider;
31
+ this.llmApiKey = opts.llmApiKey;
32
+ this.llmModel = opts.llmModel;
33
+ this.embedVersion = opts.embedVersion || 'latest';
34
+ this.embedPackagePath = opts.embedPackagePath;
35
+ this.apiUrl = opts.apiUrl?.replace(/\/$/, ''); // strip trailing slash
36
+ this.apiToken = opts.apiToken;
37
+ }
38
+ get httpMode() {
39
+ return !!this.apiUrl;
45
40
  }
46
41
  /**
47
- * Get the command prefix to run hindsight-embed (either local or from PyPI)
42
+ * Get the command and base args to run hindsight-embed.
43
+ * Returns [command, ...baseArgs] for use with execFile/spawn (no shell).
48
44
  */
49
- getEmbedCommandPrefix() {
45
+ getEmbedCommand() {
50
46
  if (this.embedPackagePath) {
51
- // Local package: uv run --directory <path> hindsight-embed
52
- return `uv run --directory ${this.embedPackagePath} hindsight-embed`;
47
+ return ['uv', 'run', '--directory', this.embedPackagePath, 'hindsight-embed'];
53
48
  }
54
- else {
55
- // PyPI package: uvx hindsight-embed@version
56
- const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
57
- return `uvx ${embedPackage}`;
49
+ const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
50
+ return ['uvx', embedPackage];
51
+ }
52
+ httpHeaders() {
53
+ const headers = { 'Content-Type': 'application/json' };
54
+ if (this.apiToken) {
55
+ headers['Authorization'] = `Bearer ${this.apiToken}`;
58
56
  }
57
+ return headers;
59
58
  }
60
59
  setBankId(bankId) {
61
60
  this.bankId = bankId;
62
61
  }
62
+ // --- setBankMission ---
63
63
  async setBankMission(mission) {
64
64
  if (!mission || mission.trim().length === 0) {
65
65
  return;
66
66
  }
67
- const escapedMission = escapeShellArg(mission);
68
- const embedCmd = this.getEmbedCommandPrefix();
69
- const cmd = `${embedCmd} --profile openclaw bank mission ${this.bankId} '${escapedMission}'`;
67
+ if (this.httpMode) {
68
+ return this.setBankMissionHttp(mission);
69
+ }
70
+ return this.setBankMissionSubprocess(mission);
71
+ }
72
+ async setBankMissionHttp(mission) {
70
73
  try {
71
- const { stdout } = await execAsync(cmd);
74
+ const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}`;
75
+ const res = await fetch(url, {
76
+ method: 'PUT',
77
+ headers: this.httpHeaders(),
78
+ body: JSON.stringify({ mission }),
79
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
80
+ });
81
+ if (!res.ok) {
82
+ const body = await res.text().catch(() => '');
83
+ throw new Error(`HTTP ${res.status}: ${body}`);
84
+ }
85
+ console.log(`[Hindsight] Bank mission set via HTTP`);
86
+ }
87
+ catch (error) {
88
+ console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`);
89
+ }
90
+ }
91
+ async setBankMissionSubprocess(mission) {
92
+ const [cmd, ...baseArgs] = this.getEmbedCommand();
93
+ const args = [...baseArgs, '--profile', 'openclaw', 'bank', 'mission', this.bankId, sanitize(mission)];
94
+ try {
95
+ const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
72
96
  console.log(`[Hindsight] Bank mission set: ${stdout.trim()}`);
73
97
  }
74
98
  catch (error) {
@@ -76,15 +100,55 @@ export class HindsightClient {
76
100
  console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`);
77
101
  }
78
102
  }
103
+ // --- retain ---
79
104
  async retain(request) {
80
- const content = escapeShellArg(request.content);
81
- const docId = escapeShellArg(request.document_id || 'conversation');
82
- const embedCmd = this.getEmbedCommandPrefix();
83
- const cmd = `${embedCmd} --profile openclaw memory retain ${this.bankId} '${content}' --doc-id '${docId}' --async`;
105
+ if (this.httpMode) {
106
+ return this.retainHttp(request);
107
+ }
108
+ return this.retainSubprocess(request);
109
+ }
110
+ async retainHttp(request) {
111
+ const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories`;
112
+ const body = {
113
+ items: [{
114
+ content: request.content,
115
+ document_id: request.document_id || 'conversation',
116
+ metadata: request.metadata,
117
+ }],
118
+ async: true,
119
+ };
120
+ const res = await fetch(url, {
121
+ method: 'POST',
122
+ headers: this.httpHeaders(),
123
+ body: JSON.stringify(body),
124
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
125
+ });
126
+ if (!res.ok) {
127
+ const text = await res.text().catch(() => '');
128
+ throw new Error(`Failed to retain memory (HTTP ${res.status}): ${text}`);
129
+ }
130
+ const data = await res.json();
131
+ console.log(`[Hindsight] Retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`);
132
+ return {
133
+ message: 'Memory queued for background processing',
134
+ document_id: request.document_id || 'conversation',
135
+ memory_unit_ids: [],
136
+ };
137
+ }
138
+ async retainSubprocess(request) {
139
+ const docId = request.document_id || 'conversation';
140
+ // Write content to a temp file to avoid E2BIG (ARG_MAX) errors when passing
141
+ // large conversations as arguments.
142
+ const tempDir = join(tmpdir(), `hindsight_${randomBytes(8).toString('hex')}`);
143
+ const safeFilename = sanitizeFilename(docId);
144
+ const tempFile = join(tempDir, `${safeFilename}.txt`);
84
145
  try {
85
- const { stdout } = await execAsync(cmd);
146
+ await mkdir(tempDir, { recursive: true });
147
+ await writeFile(tempFile, sanitize(request.content), 'utf8');
148
+ const [cmd, ...baseArgs] = this.getEmbedCommand();
149
+ const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'retain-files', this.bankId, tempFile, '--async'];
150
+ const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
86
151
  console.log(`[Hindsight] Retained (async): ${stdout.trim()}`);
87
- // Return a simple response
88
152
  return {
89
153
  message: 'Memory queued for background processing',
90
154
  document_id: docId,
@@ -92,33 +156,57 @@ export class HindsightClient {
92
156
  };
93
157
  }
94
158
  catch (error) {
95
- throw new Error(`Failed to retain memory: ${error}`);
159
+ throw new Error(`Failed to retain memory: ${error}`, { cause: error });
160
+ }
161
+ finally {
162
+ await rm(tempDir, { recursive: true, force: true }).catch(() => { });
163
+ }
164
+ }
165
+ // --- recall ---
166
+ async recall(request, timeoutMs) {
167
+ if (this.httpMode) {
168
+ return this.recallHttp(request, timeoutMs);
169
+ }
170
+ return this.recallSubprocess(request, timeoutMs);
171
+ }
172
+ async recallHttp(request, timeoutMs) {
173
+ const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories/recall`;
174
+ // Defense-in-depth: truncate query to stay under API's 500-token limit
175
+ const MAX_QUERY_CHARS = 800;
176
+ const query = request.query.length > MAX_QUERY_CHARS
177
+ ? (console.warn(`[Hindsight] Truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`),
178
+ request.query.substring(0, MAX_QUERY_CHARS))
179
+ : request.query;
180
+ const body = {
181
+ query,
182
+ max_tokens: request.max_tokens || 1024,
183
+ };
184
+ const res = await fetch(url, {
185
+ method: 'POST',
186
+ headers: this.httpHeaders(),
187
+ body: JSON.stringify(body),
188
+ signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
189
+ });
190
+ if (!res.ok) {
191
+ const text = await res.text().catch(() => '');
192
+ throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
96
193
  }
194
+ return res.json();
97
195
  }
98
- async recall(request) {
99
- const query = escapeShellArg(request.query);
196
+ async recallSubprocess(request, timeoutMs) {
197
+ const query = sanitize(request.query);
100
198
  const maxTokens = request.max_tokens || 1024;
101
- const embedCmd = this.getEmbedCommandPrefix();
102
- const cmd = `${embedCmd} --profile openclaw memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`;
199
+ const [cmd, ...baseArgs] = this.getEmbedCommand();
200
+ const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'recall', this.bankId, query, '--output', 'json', '--max-tokens', String(maxTokens)];
103
201
  try {
104
- const { stdout } = await execAsync(cmd);
105
- // Parse JSON output - returns { entities: {...}, results: [...] }
106
- const response = JSON.parse(stdout);
107
- const results = response.results || [];
108
- return {
109
- results: results.map((r) => ({
110
- content: r.text || r.content || '',
111
- score: 1.0, // CLI doesn't return scores
112
- metadata: {
113
- document_id: r.document_id,
114
- chunk_id: r.chunk_id,
115
- ...r.metadata,
116
- },
117
- })),
118
- };
202
+ const { stdout } = await execFileAsync(cmd, args, {
203
+ maxBuffer: MAX_BUFFER,
204
+ timeout: timeoutMs ?? 30_000, // subprocess gets a longer default
205
+ });
206
+ return JSON.parse(stdout);
119
207
  }
120
208
  catch (error) {
121
- throw new Error(`Failed to recall memories: ${error}`);
209
+ throw new Error(`Failed to recall memories: ${error}`, { cause: error });
122
210
  }
123
211
  }
124
212
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,19 @@
1
1
  import type { MoltbotPluginAPI } from './types.js';
2
2
  import { HindsightClient } from './client.js';
3
+ /**
4
+ * Strip plugin-injected memory tags from content to prevent retain feedback loop.
5
+ * Removes <hindsight_memories> and <relevant_memories> blocks that were injected
6
+ * during before_agent_start so they don't get re-stored into the memory bank.
7
+ */
8
+ export declare function stripMemoryTags(content: string): string;
9
+ /**
10
+ * Extract a recall query from a hook event's rawMessage or prompt.
11
+ *
12
+ * Prefers rawMessage (clean user text). Falls back to prompt, stripping
13
+ * envelope formatting (System: lines, [Channel ...] headers, [from: X] footers).
14
+ *
15
+ * Returns null when no usable query (< 5 chars) can be extracted.
16
+ */
17
+ export declare function extractRecallQuery(rawMessage: string | undefined, prompt: string | undefined): string | null;
3
18
  export default function (api: MoltbotPluginAPI): void;
4
19
  export declare function getClient(): HindsightClient | null;
package/dist/index.js CHANGED
@@ -12,15 +12,83 @@ let usingExternalApi = false; // Track if using external API (skip daemon manage
12
12
  let currentPluginConfig = null;
13
13
  // Track which banks have had their mission set (to avoid re-setting on every request)
14
14
  const banksWithMissionSet = new Set();
15
+ const inflightRecalls = new Map();
16
+ const RECALL_TIMEOUT_MS = 10_000;
17
+ // Cooldown + guard to prevent concurrent reinit attempts
18
+ let lastReinitAttempt = 0;
19
+ let isReinitInProgress = false;
20
+ const REINIT_COOLDOWN_MS = 30_000;
21
+ /**
22
+ * Lazy re-initialization after startup failure.
23
+ * Called by waitForReady when initPromise rejected but API may now be reachable.
24
+ * Throttled to one attempt per 30s to avoid hammering a down service.
25
+ */
26
+ async function lazyReinit() {
27
+ const now = Date.now();
28
+ if (now - lastReinitAttempt < REINIT_COOLDOWN_MS || isReinitInProgress) {
29
+ return;
30
+ }
31
+ isReinitInProgress = true;
32
+ lastReinitAttempt = now;
33
+ const config = currentPluginConfig;
34
+ if (!config) {
35
+ isReinitInProgress = false;
36
+ return;
37
+ }
38
+ const externalApi = detectExternalApi(config);
39
+ if (!externalApi.apiUrl) {
40
+ isReinitInProgress = false;
41
+ return; // Only external API mode supports lazy reinit
42
+ }
43
+ console.log('[Hindsight] Attempting lazy re-initialization...');
44
+ try {
45
+ await checkExternalApiHealth(externalApi.apiUrl);
46
+ // Health check passed — set up env vars and create client
47
+ process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
48
+ if (externalApi.apiToken) {
49
+ process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
50
+ }
51
+ const llmConfig = detectLLMConfig(config);
52
+ client = new HindsightClient(buildClientOptions(llmConfig, config, externalApi));
53
+ const defaultBankId = deriveBankId(undefined, config);
54
+ client.setBankId(defaultBankId);
55
+ if (config.bankMission && !config.dynamicBankId) {
56
+ await client.setBankMission(config.bankMission);
57
+ }
58
+ usingExternalApi = true;
59
+ isInitialized = true;
60
+ // Replace the rejected initPromise with a resolved one
61
+ initPromise = Promise.resolve();
62
+ console.log('[Hindsight] ✓ Lazy re-initialization succeeded');
63
+ }
64
+ catch (error) {
65
+ console.warn(`[Hindsight] Lazy re-initialization failed (will retry in ${REINIT_COOLDOWN_MS / 1000}s):`, error instanceof Error ? error.message : error);
66
+ }
67
+ finally {
68
+ isReinitInProgress = false;
69
+ }
70
+ }
15
71
  // Global access for hooks (Moltbot loads hooks separately)
16
72
  if (typeof global !== 'undefined') {
17
73
  global.__hindsightClient = {
18
74
  getClient: () => client,
19
75
  waitForReady: async () => {
20
- if (isInitialized)
76
+ if (isInitialized) {
21
77
  return;
22
- if (initPromise)
23
- await initPromise;
78
+ }
79
+ if (initPromise) {
80
+ try {
81
+ await initPromise;
82
+ }
83
+ catch {
84
+ // Init failed (e.g., health check timeout at startup).
85
+ // Attempt lazy re-initialization so Hindsight recovers
86
+ // once the API becomes reachable again.
87
+ if (!isInitialized) {
88
+ await lazyReinit();
89
+ }
90
+ }
91
+ }
24
92
  },
25
93
  /**
26
94
  * Get a client configured for a specific agent context.
@@ -28,8 +96,9 @@ if (typeof global !== 'undefined') {
28
96
  * Also ensures the bank mission is set on first use.
29
97
  */
30
98
  getClientForContext: async (ctx) => {
31
- if (!client)
99
+ if (!client) {
32
100
  return null;
101
+ }
33
102
  const config = currentPluginConfig || {};
34
103
  const bankId = deriveBankId(ctx, config);
35
104
  client.setBankId(bankId);
@@ -55,9 +124,54 @@ const __filename = fileURLToPath(import.meta.url);
55
124
  const __dirname = dirname(__filename);
56
125
  // Default bank name (fallback when channel context not available)
57
126
  const DEFAULT_BANK_NAME = 'openclaw';
127
+ /**
128
+ * Strip plugin-injected memory tags from content to prevent retain feedback loop.
129
+ * Removes <hindsight_memories> and <relevant_memories> blocks that were injected
130
+ * during before_agent_start so they don't get re-stored into the memory bank.
131
+ */
132
+ export function stripMemoryTags(content) {
133
+ content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
134
+ content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
135
+ return content;
136
+ }
137
+ /**
138
+ * Extract a recall query from a hook event's rawMessage or prompt.
139
+ *
140
+ * Prefers rawMessage (clean user text). Falls back to prompt, stripping
141
+ * envelope formatting (System: lines, [Channel ...] headers, [from: X] footers).
142
+ *
143
+ * Returns null when no usable query (< 5 chars) can be extracted.
144
+ */
145
+ export function extractRecallQuery(rawMessage, prompt) {
146
+ let recallQuery = rawMessage;
147
+ if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) {
148
+ recallQuery = prompt;
149
+ if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
150
+ return null;
151
+ }
152
+ // Strip envelope-formatted prompts from any channel
153
+ let cleaned = recallQuery;
154
+ // Remove leading "System: ..." lines (from prependSystemEvents)
155
+ cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
156
+ // Remove session abort hint
157
+ cleaned = cleaned.replace(/^Note: The previous agent run was aborted[^\n]*\n\n/, '');
158
+ // Extract message after [ChannelName ...] envelope header
159
+ const envelopeMatch = cleaned.match(/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/);
160
+ if (envelopeMatch) {
161
+ cleaned = envelopeMatch[1];
162
+ }
163
+ // Remove trailing [from: SenderName] metadata (group chats)
164
+ cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
165
+ recallQuery = cleaned.trim() || recallQuery;
166
+ }
167
+ const trimmed = recallQuery.trim();
168
+ if (trimmed.length < 5)
169
+ return null;
170
+ return trimmed;
171
+ }
58
172
  /**
59
173
  * Derive a bank ID from the agent context.
60
- * Creates channel-specific banks: {messageProvider}-{channelId}
174
+ * Creates per-user banks: {messageProvider}-{senderId}
61
175
  * Falls back to default bank when context is unavailable.
62
176
  */
63
177
  function deriveBankId(ctx, pluginConfig) {
@@ -68,9 +182,9 @@ function deriveBankId(ctx, pluginConfig) {
68
182
  : DEFAULT_BANK_NAME;
69
183
  }
70
184
  const channelType = ctx?.messageProvider || 'unknown';
71
- const channelId = ctx?.channelId || 'default';
72
- // Build bank ID: {prefix?}-{channelType}-{channelId}
73
- const baseBankId = `${channelType}-${channelId}`;
185
+ const userId = ctx?.senderId || 'default';
186
+ // Build bank ID: {prefix?}-{channelType}-{senderId}
187
+ const baseBankId = `${channelType}-${userId}`;
74
188
  return pluginConfig.bankIdPrefix
75
189
  ? `${pluginConfig.bankIdPrefix}-${baseBankId}`
76
190
  : baseBankId;
@@ -181,22 +295,48 @@ function detectExternalApi(pluginConfig) {
181
295
  const apiToken = process.env.HINDSIGHT_EMBED_API_TOKEN || pluginConfig?.hindsightApiToken || null;
182
296
  return { apiUrl, apiToken };
183
297
  }
298
+ /**
299
+ * Build HindsightClientOptions from LLM config, plugin config, and external API settings.
300
+ */
301
+ function buildClientOptions(llmConfig, pluginCfg, externalApi) {
302
+ return {
303
+ llmProvider: llmConfig.provider,
304
+ llmApiKey: llmConfig.apiKey,
305
+ llmModel: llmConfig.model,
306
+ embedVersion: pluginCfg.embedVersion,
307
+ embedPackagePath: pluginCfg.embedPackagePath,
308
+ apiUrl: externalApi.apiUrl ?? undefined,
309
+ apiToken: externalApi.apiToken ?? undefined,
310
+ };
311
+ }
184
312
  /**
185
313
  * Health check for external Hindsight API.
314
+ * Retries up to 3 times with 2s delay — container DNS may not be ready on first boot.
186
315
  */
187
316
  async function checkExternalApiHealth(apiUrl) {
188
317
  const healthUrl = `${apiUrl.replace(/\/$/, '')}/health`;
189
- console.log(`[Hindsight] Checking external API health at ${healthUrl}...`);
190
- try {
191
- const response = await fetch(healthUrl, { signal: AbortSignal.timeout(10000) });
192
- if (!response.ok) {
193
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
318
+ const maxRetries = 3;
319
+ const retryDelay = 2000;
320
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
321
+ try {
322
+ console.log(`[Hindsight] Checking external API health at ${healthUrl}... (attempt ${attempt}/${maxRetries})`);
323
+ const response = await fetch(healthUrl, { signal: AbortSignal.timeout(10000) });
324
+ if (!response.ok) {
325
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
326
+ }
327
+ const data = await response.json();
328
+ console.log(`[Hindsight] External API health: ${JSON.stringify(data)}`);
329
+ return;
330
+ }
331
+ catch (error) {
332
+ if (attempt < maxRetries) {
333
+ console.log(`[Hindsight] Health check attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
334
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
335
+ }
336
+ else {
337
+ throw new Error(`Cannot connect to external Hindsight API at ${apiUrl}: ${error}`, { cause: error });
338
+ }
194
339
  }
195
- const data = await response.json();
196
- console.log(`[Hindsight] External API health: ${JSON.stringify(data)}`);
197
- }
198
- catch (error) {
199
- throw new Error(`Cannot connect to external Hindsight API at ${apiUrl}: ${error}`);
200
340
  }
201
341
  }
202
342
  function getPluginConfig(api) {
@@ -277,9 +417,9 @@ export default function (api) {
277
417
  // External API mode - check health, skip daemon startup
278
418
  console.log('[Hindsight] External API mode - skipping local daemon...');
279
419
  await checkExternalApiHealth(externalApi.apiUrl);
280
- // Initialize client (CLI commands will use external API via env vars)
281
- console.log('[Hindsight] Creating HindsightClient...');
282
- client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
420
+ // Initialize client with direct HTTP mode
421
+ console.log('[Hindsight] Creating HindsightClient (HTTP mode)...');
422
+ client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, externalApi));
283
423
  // Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
284
424
  const defaultBankId = deriveBankId(undefined, pluginConfig);
285
425
  console.log(`[Hindsight] Default bank: ${defaultBankId}`);
@@ -300,9 +440,9 @@ export default function (api) {
300
440
  // Start the embedded server
301
441
  console.log('[Hindsight] Starting embedded server...');
302
442
  await embedManager.start();
303
- // Initialize client
304
- console.log('[Hindsight] Creating HindsightClient...');
305
- client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
443
+ // Initialize client (local daemon mode — no apiUrl)
444
+ console.log('[Hindsight] Creating HindsightClient (subprocess mode)...');
445
+ client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null }));
306
446
  // Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
307
447
  const defaultBankId = deriveBankId(undefined, pluginConfig);
308
448
  console.log(`[Hindsight] Default bank: ${defaultBankId}`);
@@ -322,7 +462,8 @@ export default function (api) {
322
462
  throw error;
323
463
  }
324
464
  })();
325
- // Don't await - let it initialize in background
465
+ // Suppress unhandled rejection service.start() will await and handle errors
466
+ initPromise.catch(() => { });
326
467
  // Register background service for cleanup
327
468
  console.log('[Hindsight] Registering service...');
328
469
  api.registerService({
@@ -387,7 +528,7 @@ export default function (api) {
387
528
  process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
388
529
  }
389
530
  await checkExternalApiHealth(externalApi.apiUrl);
390
- client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
531
+ client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, externalApi));
391
532
  const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
392
533
  client.setBankId(defaultBankId);
393
534
  if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
@@ -400,7 +541,7 @@ export default function (api) {
400
541
  // Local daemon mode
401
542
  embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider, llmConfig.apiKey, llmConfig.model, llmConfig.baseUrl, reinitPluginConfig.daemonIdleTimeout, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
402
543
  await embedManager.start();
403
- client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
544
+ client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null }));
404
545
  const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
405
546
  client.setBankId(defaultBankId);
406
547
  if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
@@ -452,31 +593,17 @@ export default function (api) {
452
593
  // Derive bank ID from context
453
594
  const bankId = deriveBankId(ctx, pluginConfig);
454
595
  console.log(`[Hindsight] before_agent_start - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
455
- // Get the user's latest message for recall
456
- // Prefer rawMessage (clean user text) over prompt (envelope-formatted)
457
- let prompt = event.rawMessage ?? event.prompt;
458
- if (!prompt || typeof prompt !== 'string' || prompt.length < 5) {
459
- return; // Skip very short messages
460
- }
461
- // Strip envelope-formatted prompts from any channel
462
- // The prompt may contain: System: lines, abort hints, [Channel ...] header, [from: ...] suffix
463
- let cleaned = prompt;
464
- // Remove leading "System: ..." lines (from prependSystemEvents)
465
- cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
466
- // Remove session abort hint
467
- cleaned = cleaned.replace(/^Note: The previous agent run was aborted[^\n]*\n\n/, '');
468
- // Extract message after [ChannelName ...] envelope header
469
- // Handles any channel: Telegram, Slack, Discord, WhatsApp, Signal, etc.
470
- // Uses [\s\S]+ instead of .+ to support multiline messages
471
- const envelopeMatch = cleaned.match(/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/);
472
- if (envelopeMatch) {
473
- cleaned = envelopeMatch[1];
596
+ // Get the user's latest message for recall — only the raw user text, not the full prompt
597
+ // rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
598
+ const extracted = extractRecallQuery(event.rawMessage, event.prompt);
599
+ if (!extracted) {
600
+ return;
474
601
  }
475
- // Remove trailing [from: SenderName] metadata (group chats)
476
- cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
477
- prompt = cleaned.trim() || prompt;
478
- if (prompt.length < 5) {
479
- return; // Skip very short messages after extraction
602
+ let prompt = extracted;
603
+ // Truncate Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
604
+ const MAX_RECALL_QUERY_CHARS = 800;
605
+ if (prompt.length > MAX_RECALL_QUERY_CHARS) {
606
+ prompt = prompt.substring(0, MAX_RECALL_QUERY_CHARS);
480
607
  }
481
608
  // Wait for client to be ready
482
609
  const clientGlobal = global.__hindsightClient;
@@ -492,11 +619,20 @@ export default function (api) {
492
619
  return;
493
620
  }
494
621
  console.log(`[Hindsight] Auto-recall for bank ${bankId}, prompt: ${prompt.substring(0, 50)}`);
495
- // Recall relevant memories
496
- const response = await client.recall({
497
- query: prompt,
498
- max_tokens: 2048,
499
- });
622
+ // Recall with deduplication: reuse in-flight request for same bank
623
+ const recallKey = bankId;
624
+ const existing = inflightRecalls.get(recallKey);
625
+ let recallPromise;
626
+ if (existing) {
627
+ console.log(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
628
+ recallPromise = existing;
629
+ }
630
+ else {
631
+ recallPromise = client.recall({ query: prompt, max_tokens: 2048 }, RECALL_TIMEOUT_MS);
632
+ inflightRecalls.set(recallKey, recallPromise);
633
+ void recallPromise.catch(() => { }).finally(() => inflightRecalls.delete(recallKey));
634
+ }
635
+ const response = await recallPromise;
500
636
  if (!response.results || response.results.length === 0) {
501
637
  console.log('[Hindsight] No memories found for auto-recall');
502
638
  return;
@@ -504,7 +640,7 @@ export default function (api) {
504
640
  // Format memories as JSON with all fields from recall
505
641
  const memoriesJson = JSON.stringify(response.results, null, 2);
506
642
  const contextMessage = `<hindsight_memories>
507
- Relevant memories from past conversations (score 1=highest, prioritize recent when conflicting):
643
+ Relevant memories from past conversations (prioritize recent when conflicting):
508
644
  ${memoriesJson}
509
645
 
510
646
  User message: ${prompt}
@@ -514,7 +650,15 @@ User message: ${prompt}
514
650
  return { prependContext: contextMessage };
515
651
  }
516
652
  catch (error) {
517
- console.error('[Hindsight] Auto-recall error:', error);
653
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
654
+ console.warn(`[Hindsight] Auto-recall timed out after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`);
655
+ }
656
+ else if (error instanceof Error && error.name === 'AbortError') {
657
+ console.warn(`[Hindsight] Auto-recall aborted after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`);
658
+ }
659
+ else {
660
+ console.error('[Hindsight] Auto-recall error:', error);
661
+ }
518
662
  return;
519
663
  }
520
664
  });
@@ -565,10 +709,7 @@ User message: ${prompt}
565
709
  .join('\n');
566
710
  }
567
711
  // Strip plugin-injected memory tags to prevent feedback loop
568
- // Remove <hindsight_memories> blocks injected during before_agent_start
569
- content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
570
- // Remove any <relevant_memories> blocks (legacy/alternative format)
571
- content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
712
+ content = stripMemoryTags(content);
572
713
  return `[role: ${role}]\n${content}\n[${role}:end]`;
573
714
  })
574
715
  .join('\n\n');
@@ -585,7 +726,7 @@ User message: ${prompt}
585
726
  document_id: documentId,
586
727
  metadata: {
587
728
  retained_at: new Date().toISOString(),
588
- message_count: event.messages.length,
729
+ message_count: String(event.messages.length),
589
730
  channel_type: effectiveCtx?.messageProvider,
590
731
  channel_id: effectiveCtx?.channelId,
591
732
  sender_id: effectiveCtx?.senderId,
package/dist/types.d.ts CHANGED
@@ -61,15 +61,23 @@ export interface RecallRequest {
61
61
  }
62
62
  export interface RecallResponse {
63
63
  results: MemoryResult[];
64
+ entities: Record<string, unknown> | null;
65
+ trace: unknown | null;
66
+ chunks: unknown | null;
64
67
  }
65
68
  export interface MemoryResult {
66
- content: string;
67
- score: number;
68
- metadata?: {
69
- document_id?: string;
70
- created_at?: string;
71
- source?: string;
72
- };
69
+ id: string;
70
+ text: string;
71
+ type: string;
72
+ entities: string[];
73
+ context: string;
74
+ occurred_start: string | null;
75
+ occurred_end: string | null;
76
+ mentioned_at: string | null;
77
+ document_id: string | null;
78
+ metadata: Record<string, unknown> | null;
79
+ chunk_id: string | null;
80
+ tags: string[];
73
81
  }
74
82
  export interface CreateBankRequest {
75
83
  name: string;
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "dynamicBankId": {
59
59
  "type": "boolean",
60
- "description": "Enable per-channel memory banks. When true, memories are isolated by channel (e.g., slack-C123, telegram-456). When false, all channels share a single 'openclaw' bank.",
60
+ "description": "Enable per-user memory banks. When true, memories are isolated by user per channel (e.g., slack-U123, telegram-456789). When false, all users share a single 'openclaw' bank.",
61
61
  "default": true
62
62
  },
63
63
  "bankIdPrefix": {
64
64
  "type": "string",
65
- "description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-C123'). Useful for separating environments."
65
+ "description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-U123'). Useful for separating environments."
66
66
  }
67
67
  },
68
68
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectorize-io/hindsight-openclaw",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "Hindsight memory plugin for OpenClaw - biomimetic long-term memory with fact extraction",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -34,8 +34,9 @@
34
34
  "build": "tsc",
35
35
  "dev": "tsc --watch",
36
36
  "clean": "rm -rf dist",
37
- "test": "vitest run",
38
- "test:watch": "vitest",
37
+ "test": "vitest run src",
38
+ "test:watch": "vitest src",
39
+ "test:integration": "vitest run --config vitest.integration.config.ts",
39
40
  "prepublishOnly": "npm run clean && npm run build"
40
41
  },
41
42
  "dependencies": {