hindclaw-openclaw 0.1.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.
@@ -0,0 +1,289 @@
1
+ import { spawn } from 'child_process';
2
+ import { readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ export class HindsightEmbedManager {
6
+ process = null;
7
+ port;
8
+ baseUrl;
9
+ embedDir;
10
+ llmProvider;
11
+ llmApiKey;
12
+ llmModel;
13
+ llmBaseUrl;
14
+ daemonIdleTimeout;
15
+ embedVersion;
16
+ embedPackagePath;
17
+ jwtSecret;
18
+ clientId;
19
+ constructor(port, llmProvider, llmApiKey, llmModel, llmBaseUrl, daemonIdleTimeout = 0, // Default: never timeout
20
+ embedVersion = 'latest', // Default: latest
21
+ embedPackagePath, // Local path to hindsight package
22
+ jwtSecret, clientId = 'openclaw') {
23
+ // Use the configured port (default: 9077 from config)
24
+ this.port = port;
25
+ this.baseUrl = `http://127.0.0.1:${port}`;
26
+ this.embedDir = join(homedir(), '.openclaw', 'hindsight-embed');
27
+ this.llmProvider = llmProvider;
28
+ this.llmApiKey = llmApiKey;
29
+ this.llmModel = llmModel;
30
+ this.llmBaseUrl = llmBaseUrl;
31
+ this.daemonIdleTimeout = daemonIdleTimeout;
32
+ this.embedVersion = embedVersion || 'latest';
33
+ this.embedPackagePath = embedPackagePath;
34
+ this.jwtSecret = jwtSecret;
35
+ this.clientId = clientId || 'openclaw';
36
+ }
37
+ /**
38
+ * Get the command to run hindsight-embed (either local or from PyPI)
39
+ */
40
+ getEmbedCommand() {
41
+ const extraPackages = this.getExtraEmbedPackages();
42
+ if (this.embedPackagePath) {
43
+ // Local package: uv run --directory <path> hindsight-embed
44
+ return ['uv', 'run', '--directory', this.embedPackagePath, ...extraPackages.flatMap((pkg) => ['--with', pkg]), 'hindsight-embed'];
45
+ }
46
+ else {
47
+ // PyPI package: uvx hindsight-embed@version
48
+ const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
49
+ return ['uvx', ...extraPackages.flatMap((pkg) => ['--with', pkg]), embedPackage];
50
+ }
51
+ }
52
+ getExtraEmbedPackages() {
53
+ const packages = [];
54
+ if (this.llmProvider === 'claude-code') {
55
+ packages.push('claude-agent-sdk');
56
+ }
57
+ if (this.jwtSecret) {
58
+ packages.push('hindclaw-extension');
59
+ }
60
+ return packages;
61
+ }
62
+ async start() {
63
+ // Skip if daemon is already running (prevents duplicate starts when gateway
64
+ // loads the plugin multiple times during startup/hot-reload)
65
+ if (await this.checkHealth()) {
66
+ console.log(`[Hindsight] Daemon already running on port ${this.port}, skipping start`);
67
+ return;
68
+ }
69
+ console.log(`[Hindsight] Starting hindsight-embed daemon...`);
70
+ // Build environment variables using standard HINDSIGHT_API_LLM_* variables
71
+ const env = {
72
+ ...process.env,
73
+ HINDSIGHT_API_LLM_PROVIDER: this.llmProvider,
74
+ HINDSIGHT_API_LLM_API_KEY: this.llmApiKey,
75
+ HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: this.jwtSecret ? '0' : this.daemonIdleTimeout.toString(),
76
+ };
77
+ if (this.llmModel) {
78
+ env['HINDSIGHT_API_LLM_MODEL'] = this.llmModel;
79
+ }
80
+ // Pass through base URL for OpenAI-compatible providers (OpenRouter, etc.)
81
+ if (this.llmBaseUrl) {
82
+ env['HINDSIGHT_API_LLM_BASE_URL'] = this.llmBaseUrl;
83
+ }
84
+ // On macOS, force CPU for embeddings/reranker to avoid MPS/Metal issues in daemon mode
85
+ if (process.platform === 'darwin') {
86
+ env['HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU'] = '1';
87
+ env['HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
88
+ }
89
+ if (this.jwtSecret) {
90
+ const pg0Port = await this.resolvePg0Port();
91
+ env['HINDSIGHT_API_TENANT_EXTENSION'] = 'hindclaw_ext:HindclawTenant';
92
+ env['HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION'] = 'hindclaw_ext:HindclawValidator';
93
+ env['HINDSIGHT_API_HTTP_EXTENSION'] = 'hindclaw_ext:HindclawHttp';
94
+ env['HINDCLAW_JWT_SECRET'] = this.jwtSecret;
95
+ env['HINDCLAW_ADMIN_CLIENTS'] = this.clientId;
96
+ env['HINDCLAW_DATABASE_URL'] = `postgresql://hindsight:hindsight@localhost:${pg0Port}/hindsight`;
97
+ }
98
+ // Configure "openclaw" profile using hindsight-embed configure (non-interactive)
99
+ console.log('[Hindsight] Configuring "openclaw" profile...');
100
+ await this.configureProfile(env);
101
+ // Start hindsight-embed daemon with openclaw profile
102
+ const embedCmd = this.getEmbedCommand();
103
+ const startDaemon = spawn(embedCmd[0], [...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'start'], {
104
+ stdio: 'pipe',
105
+ });
106
+ // Collect output
107
+ let output = '';
108
+ startDaemon.stdout?.on('data', (data) => {
109
+ const text = data.toString();
110
+ output += text;
111
+ console.log(`[Hindsight] ${text.trim()}`);
112
+ });
113
+ startDaemon.stderr?.on('data', (data) => {
114
+ const text = data.toString();
115
+ output += text;
116
+ console.error(`[Hindsight] ${text.trim()}`);
117
+ });
118
+ // Wait for daemon start command to complete
119
+ await new Promise((resolve, reject) => {
120
+ startDaemon.on('exit', (code) => {
121
+ if (code === 0) {
122
+ console.log('[Hindsight] Daemon start command completed');
123
+ resolve();
124
+ }
125
+ else {
126
+ reject(new Error(`Daemon start failed with code ${code}: ${output}`));
127
+ }
128
+ });
129
+ startDaemon.on('error', (error) => {
130
+ reject(error);
131
+ });
132
+ });
133
+ // Wait for server to be ready
134
+ await this.waitForReady();
135
+ console.log('[Hindsight] Daemon is ready');
136
+ }
137
+ async stop() {
138
+ console.log('[Hindsight] Stopping hindsight-embed daemon...');
139
+ const embedCmd = this.getEmbedCommand();
140
+ const stopDaemon = spawn(embedCmd[0], [...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'stop'], {
141
+ stdio: 'pipe',
142
+ });
143
+ await new Promise((resolve) => {
144
+ stopDaemon.on('exit', () => {
145
+ console.log('[Hindsight] Daemon stopped');
146
+ resolve();
147
+ });
148
+ stopDaemon.on('error', (error) => {
149
+ console.error('[Hindsight] Error stopping daemon:', error);
150
+ resolve(); // Resolve anyway
151
+ });
152
+ // Timeout after 5 seconds
153
+ setTimeout(() => {
154
+ console.log('[Hindsight] Daemon stop timeout');
155
+ resolve();
156
+ }, 5000);
157
+ });
158
+ }
159
+ async waitForReady(maxAttempts = 30) {
160
+ console.log('[Hindsight] Waiting for daemon to be ready...');
161
+ for (let i = 0; i < maxAttempts; i++) {
162
+ try {
163
+ const response = await fetch(`${this.baseUrl}/health`);
164
+ if (response.ok) {
165
+ console.log('[Hindsight] Daemon health check passed');
166
+ return;
167
+ }
168
+ }
169
+ catch {
170
+ // Not ready yet
171
+ }
172
+ await new Promise((resolve) => setTimeout(resolve, 1000));
173
+ }
174
+ throw new Error('Hindsight daemon failed to become ready within 30 seconds');
175
+ }
176
+ getBaseUrl() {
177
+ return this.baseUrl;
178
+ }
179
+ isRunning() {
180
+ return this.process !== null;
181
+ }
182
+ async checkHealth() {
183
+ try {
184
+ const response = await fetch(`${this.baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
185
+ return response.ok;
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
191
+ async configureProfile(env) {
192
+ // Build profile create command args with --merge, --port and --env flags
193
+ // Use --merge to allow updating existing profile
194
+ const createArgs = ['profile', 'create', 'openclaw', '--merge', '--port', this.port.toString()];
195
+ // Add all environment variables as --env flags
196
+ // Must include ALL HINDSIGHT_API_* vars that the daemon reads,
197
+ // otherwise configureProfile overwrites the profile and drops them.
198
+ const envVars = [
199
+ // LLM
200
+ 'HINDSIGHT_API_LLM_PROVIDER',
201
+ 'HINDSIGHT_API_LLM_MODEL',
202
+ 'HINDSIGHT_API_LLM_API_KEY',
203
+ 'HINDSIGHT_API_LLM_BASE_URL',
204
+ // Embeddings
205
+ 'HINDSIGHT_API_EMBEDDINGS_PROVIDER',
206
+ 'HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL',
207
+ 'HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU',
208
+ 'HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE',
209
+ 'HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY',
210
+ 'HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL',
211
+ 'HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL',
212
+ 'HINDSIGHT_API_EMBEDDINGS_TEI_URL',
213
+ 'HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY',
214
+ 'HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL',
215
+ 'HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL',
216
+ 'HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE',
217
+ 'HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY',
218
+ 'HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL',
219
+ 'HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY',
220
+ 'HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL',
221
+ 'HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE',
222
+ // Reranker
223
+ 'HINDSIGHT_API_RERANKER_PROVIDER',
224
+ 'HINDSIGHT_API_RERANKER_LOCAL_MODEL',
225
+ 'HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU',
226
+ 'HINDSIGHT_API_RERANKER_FLASHRANK_MODEL',
227
+ 'HINDSIGHT_API_RERANKER_COHERE_API_KEY',
228
+ 'HINDSIGHT_API_RERANKER_COHERE_MODEL',
229
+ // Daemon
230
+ 'HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT',
231
+ // Hindclaw extensions
232
+ 'HINDSIGHT_API_TENANT_EXTENSION',
233
+ 'HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION',
234
+ 'HINDSIGHT_API_HTTP_EXTENSION',
235
+ 'HINDCLAW_JWT_SECRET',
236
+ 'HINDCLAW_ADMIN_CLIENTS',
237
+ 'HINDCLAW_DATABASE_URL',
238
+ ];
239
+ for (const envVar of envVars) {
240
+ if (env[envVar]) {
241
+ createArgs.push('--env', `${envVar}=${env[envVar]}`);
242
+ }
243
+ }
244
+ // Run profile create command (non-interactive, overwrites if exists)
245
+ const embedCmd = this.getEmbedCommand();
246
+ const create = spawn(embedCmd[0], [...embedCmd.slice(1), ...createArgs], {
247
+ stdio: 'pipe',
248
+ });
249
+ let output = '';
250
+ create.stdout?.on('data', (data) => {
251
+ const text = data.toString();
252
+ output += text;
253
+ console.log(`[Hindsight] ${text.trim()}`);
254
+ });
255
+ create.stderr?.on('data', (data) => {
256
+ const text = data.toString();
257
+ output += text;
258
+ console.error(`[Hindsight] ${text.trim()}`);
259
+ });
260
+ await new Promise((resolve, reject) => {
261
+ create.on('exit', (code) => {
262
+ if (code === 0) {
263
+ console.log('[Hindsight] Profile "openclaw" configured successfully');
264
+ resolve();
265
+ }
266
+ else {
267
+ reject(new Error(`Profile create failed with code ${code}: ${output}`));
268
+ }
269
+ });
270
+ create.on('error', (error) => {
271
+ reject(error);
272
+ });
273
+ });
274
+ }
275
+ async resolvePg0Port() {
276
+ const instancePath = join(homedir(), '.pg0', 'instances', 'hindsight-embed-openclaw', 'instance.json');
277
+ try {
278
+ const raw = await readFile(instancePath, 'utf8');
279
+ const parsed = JSON.parse(raw);
280
+ if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
281
+ return parsed.port;
282
+ }
283
+ }
284
+ catch {
285
+ // Fall back to the default local pg0 port when the instance metadata is unavailable.
286
+ }
287
+ return 5432;
288
+ }
289
+ }
@@ -0,0 +1,4 @@
1
+ import type { MemoryResult } from './types.js';
2
+ export declare 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:";
3
+ export declare function formatCurrentTimeForRecall(date?: Date): string;
4
+ export declare function formatMemories(results: MemoryResult[]): string;
package/dist/format.js ADDED
@@ -0,0 +1,20 @@
1
+ export 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:';
2
+ export function formatCurrentTimeForRecall(date = new Date()) {
3
+ const year = date.getUTCFullYear();
4
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
5
+ const day = String(date.getUTCDate()).padStart(2, '0');
6
+ const hours = String(date.getUTCHours()).padStart(2, '0');
7
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
8
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
9
+ }
10
+ export function formatMemories(results) {
11
+ if (!results || results.length === 0)
12
+ return '';
13
+ return results
14
+ .map(r => {
15
+ const type = r.type ? ` [${r.type}]` : '';
16
+ const date = r.mentioned_at ? ` (${r.mentioned_at})` : '';
17
+ return `- ${r.text}${type}${date}`;
18
+ })
19
+ .join('\n\n');
20
+ }
@@ -0,0 +1,17 @@
1
+ import type { PluginHookAgentContext, PluginConfig, ResolvedConfig, MemoryResult } from '../types.js';
2
+ import type { HindsightClient } from '../client.js';
3
+ export { extractRecallQuery, composeRecallQuery, truncateRecallQuery, } from '../utils.js';
4
+ /** Clear module-level state. Called by service.stop() to prevent stale data after reinit. */
5
+ export declare function resetRecallState(): void;
6
+ /**
7
+ * Round-robin interleave results from multiple bank recall sets.
8
+ * Takes one result from each set in turn, handling uneven lengths.
9
+ */
10
+ export declare function interleaveResults(resultSets: MemoryResult[][]): MemoryResult[];
11
+ /**
12
+ * Handle the recall hook — supports single-bank, multi-bank, and reflect paths.
13
+ *
14
+ * Returns a formatted memory string to prepend to the agent context,
15
+ * or undefined if no relevant memories were found.
16
+ */
17
+ export declare function handleRecall(event: any, ctx: PluginHookAgentContext | undefined, agentConfig: ResolvedConfig, client: HindsightClient, pluginConfig: PluginConfig): Promise<string | undefined>;
@@ -0,0 +1,230 @@
1
+ import { createHash } from 'crypto';
2
+ import { HindsightHttpError } from '../client.js';
3
+ import { debug } from '../debug.js';
4
+ import { deriveBankId } from '../derive-bank-id.js';
5
+ import { formatMemories, formatCurrentTimeForRecall, DEFAULT_RECALL_PROMPT_PREAMBLE } from '../format.js';
6
+ import { extractRecallQuery, composeRecallQuery, truncateRecallQuery, } from '../utils.js';
7
+ // Re-export utilities for backward compatibility and testing
8
+ export { extractRecallQuery, composeRecallQuery, truncateRecallQuery, } from '../utils.js';
9
+ const RECALL_TIMEOUT_MS = 10_000;
10
+ // ── In-flight recall deduplication (I1) ─────────────────────────────
11
+ // Concurrent recalls for the same bank+query hash reuse one promise.
12
+ // Ported from native index.ts lines 31-33.
13
+ const inflightRecalls = new Map();
14
+ /** Clear module-level state. Called by service.stop() to prevent stale data after reinit. */
15
+ export function resetRecallState() {
16
+ inflightRecalls.clear();
17
+ }
18
+ /**
19
+ * Round-robin interleave results from multiple bank recall sets.
20
+ * Takes one result from each set in turn, handling uneven lengths.
21
+ */
22
+ export function interleaveResults(resultSets) {
23
+ const result = [];
24
+ const maxLen = Math.max(...resultSets.map(s => s.length), 0);
25
+ for (let i = 0; i < maxLen; i++) {
26
+ for (const set of resultSets) {
27
+ if (i < set.length)
28
+ result.push(set[i]);
29
+ }
30
+ }
31
+ return result;
32
+ }
33
+ /**
34
+ * Handle the recall hook — supports single-bank, multi-bank, and reflect paths.
35
+ *
36
+ * Returns a formatted memory string to prepend to the agent context,
37
+ * or undefined if no relevant memories were found.
38
+ */
39
+ export async function handleRecall(event, ctx, agentConfig, client, pluginConfig) {
40
+ // 1. Determine primary bank
41
+ const primaryBankId = deriveBankId(ctx, pluginConfig);
42
+ debug(`[Hindsight] before_prompt_build - bank: ${primaryBankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
43
+ debug(`[Hindsight] event keys: ${Object.keys(event ?? {}).join(', ')}`);
44
+ debug(`[Hindsight] event.context keys: ${Object.keys(event?.context ?? {}).join(', ')}`);
45
+ // 2. Extract query using full native pipeline (C1)
46
+ debug(`[Hindsight] extractRecallQuery input lengths - raw: ${event?.rawMessage?.length ?? 0}, prompt: ${event?.prompt?.length ?? 0}`);
47
+ const extracted = extractRecallQuery(event?.rawMessage, event?.prompt);
48
+ if (!extracted) {
49
+ debug('[Hindsight] extractRecallQuery returned null, skipping recall');
50
+ return undefined;
51
+ }
52
+ debug(`[Hindsight] extractRecallQuery result length: ${extracted.length}`);
53
+ // 3. Compose multi-turn query from session messages
54
+ const recallContextTurns = agentConfig.recallContextTurns ?? pluginConfig.recallContextTurns ?? 1;
55
+ const recallMaxQueryChars = agentConfig.recallMaxQueryChars ?? pluginConfig.recallMaxQueryChars ?? 800;
56
+ const recallRoles = agentConfig.recallRoles ?? pluginConfig.recallRoles ?? ['user', 'assistant'];
57
+ const sessionMessages = event?.context?.sessionEntry?.messages ?? event?.messages ?? [];
58
+ const messageCount = sessionMessages.length;
59
+ debug(`[Hindsight] event.messages count: ${messageCount}, roles: ${sessionMessages.map((m) => m.role).join(',')}`);
60
+ if (recallContextTurns > 1 && messageCount === 0) {
61
+ debug('[Hindsight] recallContextTurns > 1 but event.messages is empty — prior context unavailable at before_agent_start for this provider');
62
+ }
63
+ const composedQuery = composeRecallQuery(extracted, sessionMessages, recallContextTurns, recallRoles);
64
+ let query = truncateRecallQuery(composedQuery, extracted, recallMaxQueryChars);
65
+ // Final defensive cap (matches native)
66
+ if (query.length > recallMaxQueryChars) {
67
+ query = query.substring(0, recallMaxQueryChars);
68
+ }
69
+ // 4. Determine recall-from banks
70
+ const recallFrom = agentConfig._recallFrom;
71
+ // 5. Build common recall params
72
+ const budget = agentConfig.recallBudget;
73
+ const maxTokens = agentConfig.recallMaxTokens;
74
+ const types = agentConfig.recallTypes;
75
+ const topK = agentConfig.recallTopK;
76
+ // 6. Reflect path — use reflect instead of recall on primary bank
77
+ if (agentConfig._reflectOnRecall) {
78
+ debug(`[Hindsight] Reflect path for bank ${primaryBankId}, query:\n---\n${query}\n---`);
79
+ try {
80
+ const response = await client.reflect(primaryBankId, {
81
+ query,
82
+ budget: agentConfig._reflectBudget ?? budget,
83
+ max_tokens: agentConfig._reflectMaxTokens ?? maxTokens,
84
+ }, ctx);
85
+ if (!response.text) {
86
+ debug('[Hindsight] Reflect returned empty text, skipping memory injection');
87
+ return undefined;
88
+ }
89
+ debug(`[Hindsight] Reflect response length: ${response.text.length} chars`);
90
+ // Wrap in <hindsight_memories> tags (I4)
91
+ const preamble = agentConfig.recallPromptPreamble ?? DEFAULT_RECALL_PROMPT_PREAMBLE;
92
+ const timestamp = formatCurrentTimeForRecall();
93
+ return `<hindsight_memories>\n${preamble}\nCurrent time - ${timestamp}\n\n${response.text}\n</hindsight_memories>`;
94
+ }
95
+ catch (error) {
96
+ if (error instanceof HindsightHttpError && error.status === 403) {
97
+ debug(`[Hindsight] Reflect denied for bank ${primaryBankId}, skipping`);
98
+ return undefined;
99
+ }
100
+ // I5: Timeout-specific error handling
101
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
102
+ console.warn(`[Hindsight] Reflect timed out, skipping memory injection`);
103
+ }
104
+ else if (error instanceof Error && error.name === 'AbortError') {
105
+ console.warn(`[Hindsight] Reflect aborted, skipping memory injection`);
106
+ }
107
+ else {
108
+ console.warn(`[Hindsight] Reflect failed for bank ${primaryBankId}:`, error instanceof Error ? error.message : error);
109
+ }
110
+ return undefined;
111
+ }
112
+ }
113
+ // 7. Determine bank list
114
+ const banks = recallFrom ?? [{ bankId: primaryBankId }];
115
+ debug(`[Hindsight] Auto-recall for bank ${banks.map(b => b.bankId).join(', ')}, full query:\n---\n${query}\n---`);
116
+ // 8. Single bank — straightforward recall with deduplication (I1)
117
+ if (banks.length === 1) {
118
+ const bank = banks[0];
119
+ try {
120
+ const response = await recallWithDedup(client, bank.bankId, {
121
+ query,
122
+ budget: bank.budget ?? budget,
123
+ max_tokens: bank.maxTokens ?? maxTokens,
124
+ types: bank.types ?? types,
125
+ tag_groups: (bank.tagGroups ?? []).length > 0 ? bank.tagGroups : undefined,
126
+ }, ctx);
127
+ if (!response.results?.length) {
128
+ debug('[Hindsight] No memories found for auto-recall');
129
+ return undefined;
130
+ }
131
+ debug(`[Hindsight] Raw recall response (${response.results.length} results before topK):\n${response.results.map((r, i) => ` [${i}] score=${r.score?.toFixed(3) ?? 'n/a'} type=${r.type ?? 'n/a'}: ${JSON.stringify(r.content ?? r.text ?? r).substring(0, 200)}`).join('\n')}`);
132
+ const results = topK ? response.results.slice(0, topK) : response.results;
133
+ debug(`[Hindsight] After topK (${topK ?? 'unlimited'}): ${results.length} results injected`);
134
+ const output = formatRecallOutput(results, agentConfig);
135
+ if (output) {
136
+ debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bank.bankId}`);
137
+ }
138
+ return output;
139
+ }
140
+ catch (error) {
141
+ if (error instanceof HindsightHttpError && error.status === 403) {
142
+ debug(`[Hindsight] Recall denied for bank ${bank.bankId}, skipping`);
143
+ return undefined;
144
+ }
145
+ // I5: Timeout-specific error handling
146
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
147
+ console.warn(`[Hindsight] Recall timed out after ${RECALL_TIMEOUT_MS}ms for bank ${bank.bankId}, skipping memory injection`);
148
+ }
149
+ else if (error instanceof Error && error.name === 'AbortError') {
150
+ console.warn(`[Hindsight] Recall aborted after ${RECALL_TIMEOUT_MS}ms for bank ${bank.bankId}, skipping memory injection`);
151
+ }
152
+ else {
153
+ console.warn(`[Hindsight] Recall failed for bank ${bank.bankId}:`, error instanceof Error ? error.message : error);
154
+ }
155
+ return undefined;
156
+ }
157
+ }
158
+ // 9. Multi-bank — parallel recall + interleave (with dedup)
159
+ debug(`[Hindsight] Multi-bank recall across ${banks.length} banks: ${banks.map(b => b.bankId).join(', ')}`);
160
+ const results = await Promise.allSettled(banks.map(bank => recallWithDedup(client, bank.bankId, {
161
+ query,
162
+ budget: bank.budget ?? budget,
163
+ max_tokens: bank.maxTokens ?? maxTokens,
164
+ types: bank.types ?? types,
165
+ tag_groups: (bank.tagGroups ?? []).length > 0 ? bank.tagGroups : undefined,
166
+ }, ctx)));
167
+ const successSets = [];
168
+ for (const [i, result] of results.entries()) {
169
+ if (result.status === 'fulfilled' && result.value.results?.length) {
170
+ debug(`[Hindsight] Bank ${banks[i].bankId}: ${result.value.results.length} results`);
171
+ successSets.push(result.value.results);
172
+ }
173
+ else if (result.status === 'rejected') {
174
+ const bankId = banks[i].bankId;
175
+ const err = result.reason;
176
+ if (err instanceof HindsightHttpError && err.status === 403) {
177
+ debug(`[Hindsight] Recall denied for bank ${bankId}, skipping`);
178
+ }
179
+ else {
180
+ console.warn(`[Hindsight] Recall failed for bank ${bankId}:`, err instanceof Error ? err.message : err);
181
+ }
182
+ }
183
+ else if (result.status === 'fulfilled') {
184
+ debug(`[Hindsight] Bank ${banks[i].bankId}: no results`);
185
+ }
186
+ }
187
+ if (successSets.length === 0) {
188
+ debug('[Hindsight] No memories found for auto-recall (all banks empty)');
189
+ return undefined;
190
+ }
191
+ const merged = interleaveResults(successSets);
192
+ debug(`[Hindsight] Multi-bank merged: ${merged.length} total results from ${successSets.length} banks`);
193
+ const output = formatRecallOutput(merged, agentConfig);
194
+ if (output) {
195
+ debug(`[Hindsight] Auto-recall: Injecting ${merged.length} memories from ${successSets.length} banks`);
196
+ }
197
+ return output;
198
+ }
199
+ /**
200
+ * Recall with in-flight deduplication (I1).
201
+ * Concurrent recalls for the same bank+query hash reuse one promise.
202
+ * Ported from native index.ts lines 1107-1118.
203
+ */
204
+ async function recallWithDedup(client, bankId, request, ctx) {
205
+ const normalizedQuery = request.query.trim().toLowerCase().replace(/\s+/g, ' ');
206
+ const queryHash = createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16);
207
+ const recallKey = `${bankId}::${queryHash}`;
208
+ const existing = inflightRecalls.get(recallKey);
209
+ if (existing) {
210
+ debug(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
211
+ return existing;
212
+ }
213
+ const recallPromise = client.recall(bankId, request, RECALL_TIMEOUT_MS, ctx);
214
+ inflightRecalls.set(recallKey, recallPromise);
215
+ void recallPromise.catch(() => { }).finally(() => inflightRecalls.delete(recallKey));
216
+ return recallPromise;
217
+ }
218
+ /**
219
+ * Format recall results wrapped in <hindsight_memories> tags (I4).
220
+ * Native wraps in: <hindsight_memories>\n{content}\n</hindsight_memories>.
221
+ * This prevents the feedback loop where recalled memories get re-retained.
222
+ */
223
+ function formatRecallOutput(results, agentConfig) {
224
+ const formatted = formatMemories(results);
225
+ if (!formatted)
226
+ return undefined;
227
+ const preamble = agentConfig.recallPromptPreamble ?? DEFAULT_RECALL_PROMPT_PREAMBLE;
228
+ const timestamp = formatCurrentTimeForRecall();
229
+ return `<hindsight_memories>\n${preamble}\nCurrent time - ${timestamp}\n\n${formatted}\n</hindsight_memories>`;
230
+ }
@@ -0,0 +1,26 @@
1
+ import type { HindsightClient } from '../client.js';
2
+ import type { ResolvedConfig, PluginConfig, PluginHookAgentContext } from '../types.js';
3
+ export { stripMemoryTags } from '../utils.js';
4
+ /** Clear module-level state. Called by service.stop() to prevent stale data after reinit. */
5
+ export declare function resetRetainState(): void;
6
+ /**
7
+ * Prepare a retention transcript from messages.
8
+ * Ported from native index.ts lines 1292-1359.
9
+ *
10
+ * Key differences from our old implementation:
11
+ * - Finds last user message index and retains only from there (unless retainFullWindow)
12
+ * - Uses `[role: user]\ncontent\n[user:end]` format (not `user: content`)
13
+ * - Calls stripMemoryTags() AND stripMetadataEnvelopes() on each message
14
+ * - Returns `{ transcript, messageCount } | null`
15
+ * - Rejects transcripts < 10 chars
16
+ */
17
+ export declare function prepareRetentionTranscript(messages: any[], retainRoles: string[], retainFullWindow?: boolean): {
18
+ transcript: string;
19
+ messageCount: number;
20
+ } | null;
21
+ /**
22
+ * Handle the retain hook — supports chunked retention with turn counting.
23
+ *
24
+ * Ported from native index.ts lines 1162-1279.
25
+ */
26
+ export declare function handleRetain(event: any, ctx: PluginHookAgentContext | undefined, agentConfig: ResolvedConfig, client: HindsightClient, pluginConfig: PluginConfig): Promise<void>;