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.
- package/dist/client.d.ts +41 -0
- package/dist/client.js +211 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +132 -0
- package/dist/debug.d.ts +3 -0
- package/dist/debug.js +15 -0
- package/dist/derive-bank-id.d.ts +17 -0
- package/dist/derive-bank-id.js +60 -0
- package/dist/embed-manager.d.ts +32 -0
- package/dist/embed-manager.js +289 -0
- package/dist/format.d.ts +4 -0
- package/dist/format.js +20 -0
- package/dist/hooks/recall.d.ts +17 -0
- package/dist/hooks/recall.js +230 -0
- package/dist/hooks/retain.d.ts +26 -0
- package/dist/hooks/retain.js +178 -0
- package/dist/hooks/session-start.d.ts +3 -0
- package/dist/hooks/session-start.js +61 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +577 -0
- package/dist/moltbot-types.d.ts +27 -0
- package/dist/moltbot-types.js +3 -0
- package/dist/types.d.ts +256 -0
- package/dist/types.js +2 -0
- package/dist/utils.d.ts +51 -0
- package/dist/utils.js +230 -0
- package/openclaw.plugin.json +344 -0
- package/package.json +58 -0
|
@@ -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
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -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>;
|