@vectorize-io/hindsight-openclaw 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/client.d.ts +0 -4
- package/dist/client.js +6 -4
- package/dist/index.d.ts +21 -1
- package/dist/index.js +556 -139
- package/dist/types.d.ts +24 -0
- package/openclaw.plugin.json +177 -3
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { HindsightEmbedManager } from './embed-manager.js';
|
|
2
2
|
import { HindsightClient } from './client.js';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
3
4
|
import { dirname } from 'path';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
// Debug logging: silent by default, enable with debug: true in plugin config
|
|
7
|
+
let debugEnabled = false;
|
|
8
|
+
const debug = (...args) => {
|
|
9
|
+
if (debugEnabled)
|
|
10
|
+
console.log(...args);
|
|
11
|
+
};
|
|
5
12
|
// Module-level state
|
|
6
13
|
let embedManager = null;
|
|
7
14
|
let client = null;
|
|
15
|
+
let clientOptions = null;
|
|
8
16
|
let initPromise = null;
|
|
9
17
|
let isInitialized = false;
|
|
10
18
|
let usingExternalApi = false; // Track if using external API (skip daemon management)
|
|
@@ -12,12 +20,32 @@ let usingExternalApi = false; // Track if using external API (skip daemon manage
|
|
|
12
20
|
let currentPluginConfig = null;
|
|
13
21
|
// Track which banks have had their mission set (to avoid re-setting on every request)
|
|
14
22
|
const banksWithMissionSet = new Set();
|
|
23
|
+
// Use dedicated client instances per bank to avoid cross-session bankId mutation races.
|
|
24
|
+
const clientsByBankId = new Map();
|
|
25
|
+
const MAX_TRACKED_BANK_CLIENTS = 10_000;
|
|
15
26
|
const inflightRecalls = new Map();
|
|
27
|
+
const turnCountBySession = new Map();
|
|
28
|
+
const MAX_TRACKED_SESSIONS = 10_000;
|
|
16
29
|
const RECALL_TIMEOUT_MS = 10_000;
|
|
30
|
+
// Cache sender IDs discovered in before_prompt_build (where event.prompt has the metadata
|
|
31
|
+
// blocks) so agent_end can look them up — event.messages in agent_end is clean history.
|
|
32
|
+
const senderIdBySession = new Map();
|
|
33
|
+
// Guard against double hook registration on the same api instance
|
|
34
|
+
// Uses a WeakSet so each api instance can only register hooks once
|
|
35
|
+
const registeredApis = new WeakSet();
|
|
17
36
|
// Cooldown + guard to prevent concurrent reinit attempts
|
|
18
37
|
let lastReinitAttempt = 0;
|
|
19
38
|
let isReinitInProgress = false;
|
|
20
39
|
const REINIT_COOLDOWN_MS = 30_000;
|
|
40
|
+
const DEFAULT_RECALL_PROMPT_PREAMBLE = 'Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:';
|
|
41
|
+
function formatCurrentTimeForRecall(date = new Date()) {
|
|
42
|
+
const year = date.getUTCFullYear();
|
|
43
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
44
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
45
|
+
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
46
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
47
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
48
|
+
}
|
|
21
49
|
/**
|
|
22
50
|
* Lazy re-initialization after startup failure.
|
|
23
51
|
* Called by waitForReady when initPromise rejected but API may now be reachable.
|
|
@@ -40,7 +68,7 @@ async function lazyReinit() {
|
|
|
40
68
|
isReinitInProgress = false;
|
|
41
69
|
return; // Only external API mode supports lazy reinit
|
|
42
70
|
}
|
|
43
|
-
|
|
71
|
+
debug('[Hindsight] Attempting lazy re-initialization...');
|
|
44
72
|
try {
|
|
45
73
|
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
|
46
74
|
// Health check passed — set up env vars and create client
|
|
@@ -49,7 +77,10 @@ async function lazyReinit() {
|
|
|
49
77
|
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
|
|
50
78
|
}
|
|
51
79
|
const llmConfig = detectLLMConfig(config);
|
|
52
|
-
|
|
80
|
+
clientOptions = buildClientOptions(llmConfig, config, externalApi);
|
|
81
|
+
clientsByBankId.clear();
|
|
82
|
+
banksWithMissionSet.clear();
|
|
83
|
+
client = new HindsightClient(clientOptions);
|
|
53
84
|
const defaultBankId = deriveBankId(undefined, config);
|
|
54
85
|
client.setBankId(defaultBankId);
|
|
55
86
|
if (config.bankMission && !config.dynamicBankId) {
|
|
@@ -59,7 +90,7 @@ async function lazyReinit() {
|
|
|
59
90
|
isInitialized = true;
|
|
60
91
|
// Replace the rejected initPromise with a resolved one
|
|
61
92
|
initPromise = Promise.resolve();
|
|
62
|
-
|
|
93
|
+
debug('[Hindsight] ✓ Lazy re-initialization succeeded');
|
|
63
94
|
}
|
|
64
95
|
catch (error) {
|
|
65
96
|
console.warn(`[Hindsight] Lazy re-initialization failed (will retry in ${REINIT_COOLDOWN_MS / 1000}s):`, error instanceof Error ? error.message : error);
|
|
@@ -100,21 +131,39 @@ if (typeof global !== 'undefined') {
|
|
|
100
131
|
return null;
|
|
101
132
|
}
|
|
102
133
|
const config = currentPluginConfig || {};
|
|
134
|
+
if (config.dynamicBankId === false) {
|
|
135
|
+
return client;
|
|
136
|
+
}
|
|
103
137
|
const bankId = deriveBankId(ctx, config);
|
|
104
|
-
|
|
138
|
+
let bankClient = clientsByBankId.get(bankId);
|
|
139
|
+
if (!bankClient) {
|
|
140
|
+
if (!clientOptions) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
bankClient = new HindsightClient(clientOptions);
|
|
144
|
+
bankClient.setBankId(bankId);
|
|
145
|
+
clientsByBankId.set(bankId, bankClient);
|
|
146
|
+
if (clientsByBankId.size > MAX_TRACKED_BANK_CLIENTS) {
|
|
147
|
+
const oldestKey = clientsByBankId.keys().next().value;
|
|
148
|
+
if (oldestKey) {
|
|
149
|
+
clientsByBankId.delete(oldestKey);
|
|
150
|
+
banksWithMissionSet.delete(oldestKey);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
105
154
|
// Set bank mission on first use of this bank (if configured)
|
|
106
155
|
if (config.bankMission && config.dynamicBankId && !banksWithMissionSet.has(bankId)) {
|
|
107
156
|
try {
|
|
108
|
-
await
|
|
157
|
+
await bankClient.setBankMission(config.bankMission);
|
|
109
158
|
banksWithMissionSet.add(bankId);
|
|
110
|
-
|
|
159
|
+
debug(`[Hindsight] Set mission for new bank: ${bankId}`);
|
|
111
160
|
}
|
|
112
161
|
catch (error) {
|
|
113
162
|
// Log but don't fail - bank mission is not critical
|
|
114
163
|
console.warn(`[Hindsight] Could not set bank mission for ${bankId}: ${error}`);
|
|
115
164
|
}
|
|
116
165
|
}
|
|
117
|
-
return
|
|
166
|
+
return bankClient;
|
|
118
167
|
},
|
|
119
168
|
getPluginConfig: () => currentPluginConfig,
|
|
120
169
|
};
|
|
@@ -134,6 +183,40 @@ export function stripMemoryTags(content) {
|
|
|
134
183
|
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
|
|
135
184
|
return content;
|
|
136
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Extract sender_id from OpenClaw's injected inbound metadata blocks.
|
|
188
|
+
* Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks.
|
|
189
|
+
* Returns the first sender_id / id string found, or undefined if none.
|
|
190
|
+
*/
|
|
191
|
+
export function extractSenderIdFromText(text) {
|
|
192
|
+
if (!text)
|
|
193
|
+
return undefined;
|
|
194
|
+
const metaBlockRe = /[\w\s]+\(untrusted metadata\)[^\n]*\n```json\n([\s\S]*?)\n```/gi;
|
|
195
|
+
let match;
|
|
196
|
+
while ((match = metaBlockRe.exec(text)) !== null) {
|
|
197
|
+
try {
|
|
198
|
+
const obj = JSON.parse(match[1]);
|
|
199
|
+
const id = obj?.sender_id ?? obj?.id;
|
|
200
|
+
if (id && typeof id === 'string')
|
|
201
|
+
return id;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// continue to next block
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Strip OpenClaw sender/conversation metadata envelopes from message content.
|
|
211
|
+
* These blocks are injected by OpenClaw but are noise for memory storage and recall.
|
|
212
|
+
*/
|
|
213
|
+
export function stripMetadataEnvelopes(content) {
|
|
214
|
+
// Strip: ---\n<Label> (untrusted metadata):\n```json\n{...}\n```\n<message>\n---
|
|
215
|
+
content = content.replace(/^---\n[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n\n?/im, '').replace(/\n---$/, '');
|
|
216
|
+
// Strip: <Label> (untrusted metadata):\n```json\n{...}\n``` (without --- wrapper)
|
|
217
|
+
content = content.replace(/[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n?/gim, '');
|
|
218
|
+
return content.trim();
|
|
219
|
+
}
|
|
137
220
|
/**
|
|
138
221
|
* Extract a recall query from a hook event's rawMessage or prompt.
|
|
139
222
|
*
|
|
@@ -143,10 +226,25 @@ export function stripMemoryTags(content) {
|
|
|
143
226
|
* Returns null when no usable query (< 5 chars) can be extracted.
|
|
144
227
|
*/
|
|
145
228
|
export function extractRecallQuery(rawMessage, prompt) {
|
|
229
|
+
// Reject known metadata/system message patterns — these are not user queries
|
|
230
|
+
const METADATA_PATTERNS = [
|
|
231
|
+
/^\s*conversation info\s*\(untrusted metadata\)/i,
|
|
232
|
+
/^\s*\(untrusted metadata\)/i,
|
|
233
|
+
/^\s*system:/i,
|
|
234
|
+
];
|
|
235
|
+
const isMetadata = (s) => METADATA_PATTERNS.some(p => p.test(s));
|
|
146
236
|
let recallQuery = rawMessage;
|
|
147
|
-
|
|
237
|
+
// Strip sender metadata envelope before any checks
|
|
238
|
+
if (recallQuery) {
|
|
239
|
+
recallQuery = stripMetadataEnvelopes(recallQuery);
|
|
240
|
+
}
|
|
241
|
+
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5 || isMetadata(recallQuery)) {
|
|
148
242
|
recallQuery = prompt;
|
|
149
|
-
|
|
243
|
+
// Strip metadata envelopes from prompt too, then check if anything useful remains
|
|
244
|
+
if (recallQuery) {
|
|
245
|
+
recallQuery = stripMetadataEnvelopes(recallQuery);
|
|
246
|
+
}
|
|
247
|
+
if (!recallQuery || recallQuery.length < 5) {
|
|
150
248
|
return null;
|
|
151
249
|
}
|
|
152
250
|
// Strip envelope-formatted prompts from any channel
|
|
@@ -162,33 +260,172 @@ export function extractRecallQuery(rawMessage, prompt) {
|
|
|
162
260
|
}
|
|
163
261
|
// Remove trailing [from: SenderName] metadata (group chats)
|
|
164
262
|
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
|
|
263
|
+
// Strip metadata envelopes again after channel envelope extraction, in case
|
|
264
|
+
// the metadata block appeared after the [ChannelName] header
|
|
265
|
+
cleaned = stripMetadataEnvelopes(cleaned);
|
|
165
266
|
recallQuery = cleaned.trim() || recallQuery;
|
|
166
267
|
}
|
|
167
268
|
const trimmed = recallQuery.trim();
|
|
168
|
-
if (trimmed.length < 5)
|
|
269
|
+
if (trimmed.length < 5 || isMetadata(trimmed))
|
|
169
270
|
return null;
|
|
170
271
|
return trimmed;
|
|
171
272
|
}
|
|
273
|
+
export function composeRecallQuery(latestQuery, messages, recallContextTurns, recallRoles = ['user', 'assistant']) {
|
|
274
|
+
const latest = latestQuery.trim();
|
|
275
|
+
if (recallContextTurns <= 1 || !Array.isArray(messages) || messages.length === 0) {
|
|
276
|
+
return latest;
|
|
277
|
+
}
|
|
278
|
+
const allowedRoles = new Set(recallRoles);
|
|
279
|
+
const contextualMessages = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
|
|
280
|
+
const contextLines = contextualMessages
|
|
281
|
+
.map((msg) => {
|
|
282
|
+
const role = msg?.role;
|
|
283
|
+
if (!allowedRoles.has(role)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
let content = '';
|
|
287
|
+
if (typeof msg?.content === 'string') {
|
|
288
|
+
content = msg.content;
|
|
289
|
+
}
|
|
290
|
+
else if (Array.isArray(msg?.content)) {
|
|
291
|
+
content = msg.content
|
|
292
|
+
.filter((block) => block?.type === 'text' && typeof block?.text === 'string')
|
|
293
|
+
.map((block) => block.text)
|
|
294
|
+
.join('\n');
|
|
295
|
+
}
|
|
296
|
+
content = stripMemoryTags(content).trim();
|
|
297
|
+
content = stripMetadataEnvelopes(content);
|
|
298
|
+
if (!content) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (role === 'user' && content === latest) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return `${role}: ${content}`;
|
|
305
|
+
})
|
|
306
|
+
.filter((line) => Boolean(line));
|
|
307
|
+
if (contextLines.length === 0) {
|
|
308
|
+
return latest;
|
|
309
|
+
}
|
|
310
|
+
return [
|
|
311
|
+
'Prior context:',
|
|
312
|
+
contextLines.join('\n'),
|
|
313
|
+
latest,
|
|
314
|
+
].join('\n\n');
|
|
315
|
+
}
|
|
316
|
+
export function truncateRecallQuery(query, latestQuery, maxChars) {
|
|
317
|
+
if (maxChars <= 0) {
|
|
318
|
+
return query;
|
|
319
|
+
}
|
|
320
|
+
const latest = latestQuery.trim();
|
|
321
|
+
if (query.length <= maxChars) {
|
|
322
|
+
return query;
|
|
323
|
+
}
|
|
324
|
+
const latestOnly = latest.length <= maxChars ? latest : latest.slice(0, maxChars);
|
|
325
|
+
if (!query.includes('Prior context:')) {
|
|
326
|
+
return latestOnly;
|
|
327
|
+
}
|
|
328
|
+
// New order: Prior context at top, latest user message at bottom.
|
|
329
|
+
// Truncate by dropping oldest context lines first to preserve the suffix.
|
|
330
|
+
const contextMarker = 'Prior context:\n\n';
|
|
331
|
+
const markerIndex = query.indexOf(contextMarker);
|
|
332
|
+
if (markerIndex === -1) {
|
|
333
|
+
return latestOnly;
|
|
334
|
+
}
|
|
335
|
+
const suffixMarker = '\n\n' + latest;
|
|
336
|
+
const suffixIndex = query.lastIndexOf(suffixMarker);
|
|
337
|
+
if (suffixIndex === -1) {
|
|
338
|
+
return latestOnly;
|
|
339
|
+
}
|
|
340
|
+
const suffix = query.slice(suffixIndex); // \n\n<latest>
|
|
341
|
+
if (suffix.length >= maxChars) {
|
|
342
|
+
return latestOnly;
|
|
343
|
+
}
|
|
344
|
+
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
|
|
345
|
+
const contextLines = contextBody.split('\n').filter(Boolean);
|
|
346
|
+
const keptContextLines = [];
|
|
347
|
+
// Add context lines from newest (bottom) to oldest (top), stopping when we exceed maxChars
|
|
348
|
+
for (let i = contextLines.length - 1; i >= 0; i--) {
|
|
349
|
+
keptContextLines.unshift(contextLines[i]);
|
|
350
|
+
const candidate = `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
|
|
351
|
+
if (candidate.length > maxChars) {
|
|
352
|
+
keptContextLines.shift();
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (keptContextLines.length > 0) {
|
|
357
|
+
return `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
|
|
358
|
+
}
|
|
359
|
+
return latestOnly;
|
|
360
|
+
}
|
|
172
361
|
/**
|
|
173
362
|
* Derive a bank ID from the agent context.
|
|
174
|
-
*
|
|
363
|
+
* Uses configurable dynamicBankGranularity to determine bank segmentation.
|
|
175
364
|
* Falls back to default bank when context is unavailable.
|
|
176
365
|
*/
|
|
177
|
-
|
|
178
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Parse the OpenClaw sessionKey to extract context fields.
|
|
368
|
+
* Format: "agent:{agentId}:{provider}:{channelType}:{channelId}[:{extra}]"
|
|
369
|
+
* Example: "agent:c0der:telegram:group:-1003825475854:topic:42"
|
|
370
|
+
*/
|
|
371
|
+
function parseSessionKey(sessionKey) {
|
|
372
|
+
const parts = sessionKey.split(':');
|
|
373
|
+
if (parts.length < 5 || parts[0] !== 'agent')
|
|
374
|
+
return {};
|
|
375
|
+
// parts[1] = agentId, parts[2] = provider, parts[3] = channelType, parts[4..] = channelId + extras
|
|
376
|
+
return {
|
|
377
|
+
agentId: parts[1],
|
|
378
|
+
provider: parts[2],
|
|
379
|
+
// Rejoin from channelType onward as the channel identifier (e.g. "group:-1003825475854:topic:42")
|
|
380
|
+
channel: parts.slice(3).join(':'),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
export function deriveBankId(ctx, pluginConfig) {
|
|
179
384
|
if (pluginConfig.dynamicBankId === false) {
|
|
180
|
-
return pluginConfig.bankIdPrefix
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
385
|
+
return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
|
|
386
|
+
}
|
|
387
|
+
// When no context is available, fall back to the static default bank.
|
|
388
|
+
if (!ctx) {
|
|
389
|
+
return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
|
|
390
|
+
}
|
|
391
|
+
const fields = pluginConfig.dynamicBankGranularity?.length ? pluginConfig.dynamicBankGranularity : ['agent', 'channel', 'user'];
|
|
392
|
+
// Validate field names at runtime — typos silently produce 'unknown' segments
|
|
393
|
+
const validFields = new Set(['agent', 'channel', 'user', 'provider']);
|
|
394
|
+
for (const f of fields) {
|
|
395
|
+
if (!validFields.has(f)) {
|
|
396
|
+
console.warn(`[Hindsight] Unknown dynamicBankGranularity field "${f}" — will resolve to "unknown" in bank ID. Valid fields: agent, channel, user, provider`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Parse sessionKey as fallback when direct context fields are missing
|
|
400
|
+
const sessionParsed = ctx?.sessionKey ? parseSessionKey(ctx.sessionKey) : {};
|
|
401
|
+
// Warn when 'user' is in active fields but senderId is missing — bank ID will contain "anonymous"
|
|
402
|
+
if (fields.includes('user') && ctx && !ctx.senderId) {
|
|
403
|
+
debug('[Hindsight] senderId not available in context — bank ID will use "anonymous". Ensure your OpenClaw provider passes senderId.');
|
|
404
|
+
}
|
|
405
|
+
const fieldMap = {
|
|
406
|
+
agent: ctx?.agentId || sessionParsed.agentId || 'default',
|
|
407
|
+
channel: ctx?.channelId || sessionParsed.channel || 'unknown',
|
|
408
|
+
user: ctx?.senderId || 'anonymous',
|
|
409
|
+
provider: ctx?.messageProvider || sessionParsed.provider || 'unknown',
|
|
410
|
+
};
|
|
411
|
+
const baseBankId = fields
|
|
412
|
+
.map(f => encodeURIComponent(fieldMap[f] || 'unknown'))
|
|
413
|
+
.join('::');
|
|
188
414
|
return pluginConfig.bankIdPrefix
|
|
189
415
|
? `${pluginConfig.bankIdPrefix}-${baseBankId}`
|
|
190
416
|
: baseBankId;
|
|
191
417
|
}
|
|
418
|
+
export function formatMemories(results) {
|
|
419
|
+
if (!results || results.length === 0)
|
|
420
|
+
return '';
|
|
421
|
+
return results
|
|
422
|
+
.map(r => {
|
|
423
|
+
const type = r.type ? ` [${r.type}]` : '';
|
|
424
|
+
const date = r.mentioned_at ? ` (${r.mentioned_at})` : '';
|
|
425
|
+
return `- ${r.text}${type}${date}`;
|
|
426
|
+
})
|
|
427
|
+
.join('\n\n');
|
|
428
|
+
}
|
|
192
429
|
// Provider detection from standard env vars
|
|
193
430
|
const PROVIDER_DETECTION = [
|
|
194
431
|
{ name: 'openai', keyEnv: 'OPENAI_API_KEY', defaultModel: 'gpt-4o-mini' },
|
|
@@ -268,6 +505,17 @@ function detectLLMConfig(pluginConfig) {
|
|
|
268
505
|
}
|
|
269
506
|
}
|
|
270
507
|
// No configuration found - show helpful error
|
|
508
|
+
// Allow empty LLM config if using external Hindsight API (server handles LLM)
|
|
509
|
+
const externalApiCheck = detectExternalApi(pluginConfig);
|
|
510
|
+
if (externalApiCheck.apiUrl) {
|
|
511
|
+
return {
|
|
512
|
+
provider: undefined,
|
|
513
|
+
apiKey: undefined,
|
|
514
|
+
model: undefined,
|
|
515
|
+
baseUrl: undefined,
|
|
516
|
+
source: 'external-api-mode-no-llm',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
271
519
|
throw new Error(`No LLM configuration found for Hindsight memory plugin.\n\n` +
|
|
272
520
|
`Option 1: Set a standard provider API key (auto-detect):\n` +
|
|
273
521
|
` export OPENAI_API_KEY=sk-your-key # Uses gpt-4o-mini\n` +
|
|
@@ -300,8 +548,6 @@ function detectExternalApi(pluginConfig) {
|
|
|
300
548
|
*/
|
|
301
549
|
function buildClientOptions(llmConfig, pluginCfg, externalApi) {
|
|
302
550
|
return {
|
|
303
|
-
llmProvider: llmConfig.provider,
|
|
304
|
-
llmApiKey: llmConfig.apiKey,
|
|
305
551
|
llmModel: llmConfig.model,
|
|
306
552
|
embedVersion: pluginCfg.embedVersion,
|
|
307
553
|
embedPackagePath: pluginCfg.embedPackagePath,
|
|
@@ -319,7 +565,7 @@ async function checkExternalApiHealth(apiUrl, apiToken) {
|
|
|
319
565
|
const retryDelay = 2000;
|
|
320
566
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
321
567
|
try {
|
|
322
|
-
|
|
568
|
+
debug(`[Hindsight] Checking external API health at ${healthUrl}... (attempt ${attempt}/${maxRetries})`);
|
|
323
569
|
const headers = {};
|
|
324
570
|
if (apiToken) {
|
|
325
571
|
headers['Authorization'] = `Bearer ${apiToken}`;
|
|
@@ -329,12 +575,12 @@ async function checkExternalApiHealth(apiUrl, apiToken) {
|
|
|
329
575
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
330
576
|
}
|
|
331
577
|
const data = await response.json();
|
|
332
|
-
|
|
578
|
+
debug(`[Hindsight] External API health: ${JSON.stringify(data)}`);
|
|
333
579
|
return;
|
|
334
580
|
}
|
|
335
581
|
catch (error) {
|
|
336
582
|
if (attempt < maxRetries) {
|
|
337
|
-
|
|
583
|
+
debug(`[Hindsight] Health check attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
|
|
338
584
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
339
585
|
}
|
|
340
586
|
else {
|
|
@@ -363,37 +609,53 @@ function getPluginConfig(api) {
|
|
|
363
609
|
bankIdPrefix: config.bankIdPrefix,
|
|
364
610
|
excludeProviders: Array.isArray(config.excludeProviders) ? config.excludeProviders : [],
|
|
365
611
|
autoRecall: config.autoRecall !== false, // Default: true (on) — backward compatible
|
|
612
|
+
dynamicBankGranularity: Array.isArray(config.dynamicBankGranularity) ? config.dynamicBankGranularity : undefined,
|
|
613
|
+
autoRetain: config.autoRetain !== false, // Default: true
|
|
614
|
+
retainRoles: Array.isArray(config.retainRoles) ? config.retainRoles : undefined,
|
|
615
|
+
recallBudget: config.recallBudget || 'mid',
|
|
616
|
+
recallMaxTokens: config.recallMaxTokens || 1024,
|
|
617
|
+
recallTypes: Array.isArray(config.recallTypes) ? config.recallTypes : ['world', 'experience'],
|
|
618
|
+
recallRoles: Array.isArray(config.recallRoles) ? config.recallRoles : ['user', 'assistant'],
|
|
619
|
+
retainEveryNTurns: typeof config.retainEveryNTurns === 'number' && config.retainEveryNTurns >= 1 ? config.retainEveryNTurns : 1,
|
|
620
|
+
retainOverlapTurns: typeof config.retainOverlapTurns === 'number' && config.retainOverlapTurns >= 0 ? config.retainOverlapTurns : 0,
|
|
621
|
+
recallTopK: typeof config.recallTopK === 'number' ? config.recallTopK : undefined,
|
|
622
|
+
recallContextTurns: typeof config.recallContextTurns === 'number' && config.recallContextTurns >= 1 ? config.recallContextTurns : 1,
|
|
623
|
+
recallMaxQueryChars: typeof config.recallMaxQueryChars === 'number' && config.recallMaxQueryChars >= 1 ? config.recallMaxQueryChars : 800,
|
|
624
|
+
recallPromptPreamble: typeof config.recallPromptPreamble === 'string' && config.recallPromptPreamble.trim().length > 0
|
|
625
|
+
? config.recallPromptPreamble
|
|
626
|
+
: DEFAULT_RECALL_PROMPT_PREAMBLE,
|
|
627
|
+
debug: config.debug ?? false,
|
|
366
628
|
};
|
|
367
629
|
}
|
|
368
630
|
export default function (api) {
|
|
369
631
|
try {
|
|
370
|
-
|
|
371
|
-
// Get plugin config first (needed for LLM detection)
|
|
372
|
-
console.log('[Hindsight] Getting plugin config...');
|
|
632
|
+
debug('[Hindsight] Plugin loading...');
|
|
633
|
+
// Get plugin config first (needed for LLM detection and debug flag)
|
|
373
634
|
const pluginConfig = getPluginConfig(api);
|
|
635
|
+
debugEnabled = pluginConfig.debug ?? false;
|
|
374
636
|
// Store config globally for bank ID derivation in hooks
|
|
375
637
|
currentPluginConfig = pluginConfig;
|
|
376
638
|
// Detect LLM configuration (env vars > plugin config > auto-detect)
|
|
377
|
-
|
|
639
|
+
debug('[Hindsight] Detecting LLM config...');
|
|
378
640
|
const llmConfig = detectLLMConfig(pluginConfig);
|
|
379
641
|
const baseUrlInfo = llmConfig.baseUrl ? `, base URL: ${llmConfig.baseUrl}` : '';
|
|
380
642
|
const modelInfo = llmConfig.model || 'default';
|
|
381
643
|
if (llmConfig.provider === 'ollama') {
|
|
382
|
-
|
|
644
|
+
debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source})`);
|
|
383
645
|
}
|
|
384
646
|
else {
|
|
385
|
-
|
|
647
|
+
debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source}${baseUrlInfo})`);
|
|
386
648
|
}
|
|
387
649
|
if (pluginConfig.bankMission) {
|
|
388
|
-
|
|
650
|
+
debug(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
|
|
389
651
|
}
|
|
390
652
|
// Log dynamic bank ID mode
|
|
391
653
|
if (pluginConfig.dynamicBankId) {
|
|
392
654
|
const prefixInfo = pluginConfig.bankIdPrefix ? ` (prefix: ${pluginConfig.bankIdPrefix})` : '';
|
|
393
|
-
|
|
655
|
+
debug(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
|
|
394
656
|
}
|
|
395
657
|
else {
|
|
396
|
-
|
|
658
|
+
debug(`[Hindsight] Dynamic bank IDs disabled - using static bank: ${DEFAULT_BANK_NAME}`);
|
|
397
659
|
}
|
|
398
660
|
// Detect external API mode
|
|
399
661
|
const externalApi = detectExternalApi(pluginConfig);
|
|
@@ -402,64 +664,70 @@ export default function (api) {
|
|
|
402
664
|
if (externalApi.apiUrl) {
|
|
403
665
|
// External API mode - skip local daemon
|
|
404
666
|
usingExternalApi = true;
|
|
405
|
-
|
|
667
|
+
debug(`[Hindsight] ✓ Using external API: ${externalApi.apiUrl}`);
|
|
406
668
|
// Set env vars so CLI commands (uvx hindsight-embed) use external API
|
|
407
669
|
process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
|
|
408
670
|
if (externalApi.apiToken) {
|
|
409
671
|
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
|
|
410
|
-
|
|
672
|
+
debug('[Hindsight] API token configured');
|
|
411
673
|
}
|
|
412
674
|
}
|
|
413
675
|
else {
|
|
414
|
-
|
|
415
|
-
|
|
676
|
+
debug(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
|
|
677
|
+
debug(`[Hindsight] API Port: ${apiPort}`);
|
|
416
678
|
}
|
|
417
679
|
// Initialize in background (non-blocking)
|
|
418
|
-
|
|
680
|
+
debug('[Hindsight] Starting initialization in background...');
|
|
419
681
|
initPromise = (async () => {
|
|
420
682
|
try {
|
|
421
683
|
if (usingExternalApi && externalApi.apiUrl) {
|
|
422
684
|
// External API mode - check health, skip daemon startup
|
|
423
|
-
|
|
685
|
+
debug('[Hindsight] External API mode - skipping local daemon...');
|
|
424
686
|
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
|
425
687
|
// Initialize client with direct HTTP mode
|
|
426
|
-
|
|
427
|
-
|
|
688
|
+
debug('[Hindsight] Creating HindsightClient (HTTP mode)...');
|
|
689
|
+
clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
|
|
690
|
+
clientsByBankId.clear();
|
|
691
|
+
banksWithMissionSet.clear();
|
|
692
|
+
client = new HindsightClient(clientOptions);
|
|
428
693
|
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
|
429
694
|
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
|
430
|
-
|
|
695
|
+
debug(`[Hindsight] Default bank: ${defaultBankId}`);
|
|
431
696
|
client.setBankId(defaultBankId);
|
|
432
697
|
// Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
|
|
433
698
|
// For now, set it on the default bank
|
|
434
699
|
if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
|
|
435
|
-
|
|
700
|
+
debug(`[Hindsight] Setting bank mission...`);
|
|
436
701
|
await client.setBankMission(pluginConfig.bankMission);
|
|
437
702
|
}
|
|
438
703
|
isInitialized = true;
|
|
439
|
-
|
|
704
|
+
debug('[Hindsight] ✓ Ready (external API mode)');
|
|
440
705
|
}
|
|
441
706
|
else {
|
|
442
707
|
// Local daemon mode - start hindsight-embed daemon
|
|
443
|
-
|
|
444
|
-
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider, llmConfig.apiKey, llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
|
708
|
+
debug('[Hindsight] Creating HindsightEmbedManager...');
|
|
709
|
+
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, pluginConfig.daemonIdleTimeout, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
|
445
710
|
// Start the embedded server
|
|
446
|
-
|
|
711
|
+
debug('[Hindsight] Starting embedded server...');
|
|
447
712
|
await embedManager.start();
|
|
448
713
|
// Initialize client (local daemon mode — no apiUrl)
|
|
449
|
-
|
|
450
|
-
|
|
714
|
+
debug('[Hindsight] Creating HindsightClient (subprocess mode)...');
|
|
715
|
+
clientOptions = buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null });
|
|
716
|
+
clientsByBankId.clear();
|
|
717
|
+
banksWithMissionSet.clear();
|
|
718
|
+
client = new HindsightClient(clientOptions);
|
|
451
719
|
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
|
452
720
|
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
|
453
|
-
|
|
721
|
+
debug(`[Hindsight] Default bank: ${defaultBankId}`);
|
|
454
722
|
client.setBankId(defaultBankId);
|
|
455
723
|
// Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
|
|
456
724
|
// For now, set it on the default bank
|
|
457
725
|
if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
|
|
458
|
-
|
|
726
|
+
debug(`[Hindsight] Setting bank mission...`);
|
|
459
727
|
await client.setBankMission(pluginConfig.bankMission);
|
|
460
728
|
}
|
|
461
729
|
isInitialized = true;
|
|
462
|
-
|
|
730
|
+
debug('[Hindsight] ✓ Ready');
|
|
463
731
|
}
|
|
464
732
|
}
|
|
465
733
|
catch (error) {
|
|
@@ -470,11 +738,11 @@ export default function (api) {
|
|
|
470
738
|
// Suppress unhandled rejection — service.start() will await and handle errors
|
|
471
739
|
initPromise.catch(() => { });
|
|
472
740
|
// Register background service for cleanup
|
|
473
|
-
|
|
741
|
+
debug('[Hindsight] Registering service...');
|
|
474
742
|
api.registerService({
|
|
475
743
|
id: 'hindsight-memory',
|
|
476
744
|
async start() {
|
|
477
|
-
|
|
745
|
+
debug('[Hindsight] Service start called...');
|
|
478
746
|
// Wait for background init if still pending
|
|
479
747
|
if (initPromise) {
|
|
480
748
|
try {
|
|
@@ -491,13 +759,16 @@ export default function (api) {
|
|
|
491
759
|
if (externalApi.apiUrl && isInitialized) {
|
|
492
760
|
try {
|
|
493
761
|
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
|
494
|
-
|
|
762
|
+
debug('[Hindsight] External API is healthy');
|
|
495
763
|
return;
|
|
496
764
|
}
|
|
497
765
|
catch (error) {
|
|
498
766
|
console.error('[Hindsight] External API health check failed:', error);
|
|
499
767
|
// Reset state for reinitialization attempt
|
|
500
768
|
client = null;
|
|
769
|
+
clientOptions = null;
|
|
770
|
+
clientsByBankId.clear();
|
|
771
|
+
banksWithMissionSet.clear();
|
|
501
772
|
isInitialized = false;
|
|
502
773
|
}
|
|
503
774
|
}
|
|
@@ -507,19 +778,22 @@ export default function (api) {
|
|
|
507
778
|
if (embedManager && isInitialized) {
|
|
508
779
|
const healthy = await embedManager.checkHealth();
|
|
509
780
|
if (healthy) {
|
|
510
|
-
|
|
781
|
+
debug('[Hindsight] Daemon is healthy');
|
|
511
782
|
return;
|
|
512
783
|
}
|
|
513
|
-
|
|
784
|
+
debug('[Hindsight] Daemon is not responding - reinitializing...');
|
|
514
785
|
// Reset state for reinitialization
|
|
515
786
|
embedManager = null;
|
|
516
787
|
client = null;
|
|
788
|
+
clientOptions = null;
|
|
789
|
+
clientsByBankId.clear();
|
|
790
|
+
banksWithMissionSet.clear();
|
|
517
791
|
isInitialized = false;
|
|
518
792
|
}
|
|
519
793
|
}
|
|
520
794
|
// Reinitialize if needed (fresh start or recovery)
|
|
521
795
|
if (!isInitialized) {
|
|
522
|
-
|
|
796
|
+
debug('[Hindsight] Reinitializing...');
|
|
523
797
|
const reinitPluginConfig = getPluginConfig(api);
|
|
524
798
|
currentPluginConfig = reinitPluginConfig;
|
|
525
799
|
const llmConfig = detectLLMConfig(reinitPluginConfig);
|
|
@@ -533,41 +807,50 @@ export default function (api) {
|
|
|
533
807
|
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
|
|
534
808
|
}
|
|
535
809
|
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
|
536
|
-
|
|
810
|
+
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, externalApi);
|
|
811
|
+
clientsByBankId.clear();
|
|
812
|
+
banksWithMissionSet.clear();
|
|
813
|
+
client = new HindsightClient(clientOptions);
|
|
537
814
|
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
|
538
815
|
client.setBankId(defaultBankId);
|
|
539
816
|
if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
|
|
540
817
|
await client.setBankMission(reinitPluginConfig.bankMission);
|
|
541
818
|
}
|
|
542
819
|
isInitialized = true;
|
|
543
|
-
|
|
820
|
+
debug('[Hindsight] Reinitialization complete (external API mode)');
|
|
544
821
|
}
|
|
545
822
|
else {
|
|
546
823
|
// Local daemon mode
|
|
547
|
-
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider, llmConfig.apiKey, llmConfig.model, llmConfig.baseUrl, reinitPluginConfig.daemonIdleTimeout, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
|
|
824
|
+
embedManager = new HindsightEmbedManager(apiPort, llmConfig.provider || "", llmConfig.apiKey || "", llmConfig.model, llmConfig.baseUrl, reinitPluginConfig.daemonIdleTimeout, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
|
|
548
825
|
await embedManager.start();
|
|
549
|
-
|
|
826
|
+
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null });
|
|
827
|
+
clientsByBankId.clear();
|
|
828
|
+
banksWithMissionSet.clear();
|
|
829
|
+
client = new HindsightClient(clientOptions);
|
|
550
830
|
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
|
551
831
|
client.setBankId(defaultBankId);
|
|
552
832
|
if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
|
|
553
833
|
await client.setBankMission(reinitPluginConfig.bankMission);
|
|
554
834
|
}
|
|
555
835
|
isInitialized = true;
|
|
556
|
-
|
|
836
|
+
debug('[Hindsight] Reinitialization complete');
|
|
557
837
|
}
|
|
558
838
|
}
|
|
559
839
|
},
|
|
560
840
|
async stop() {
|
|
561
841
|
try {
|
|
562
|
-
|
|
842
|
+
debug('[Hindsight] Service stopping...');
|
|
563
843
|
// Only stop daemon if in local mode
|
|
564
844
|
if (!usingExternalApi && embedManager) {
|
|
565
845
|
await embedManager.stop();
|
|
566
846
|
embedManager = null;
|
|
567
847
|
}
|
|
568
848
|
client = null;
|
|
849
|
+
clientOptions = null;
|
|
850
|
+
clientsByBankId.clear();
|
|
851
|
+
banksWithMissionSet.clear();
|
|
569
852
|
isInitialized = false;
|
|
570
|
-
|
|
853
|
+
debug('[Hindsight] Service stopped');
|
|
571
854
|
}
|
|
572
855
|
catch (error) {
|
|
573
856
|
console.error('[Hindsight] Service stop error:', error);
|
|
@@ -575,87 +858,118 @@ export default function (api) {
|
|
|
575
858
|
}
|
|
576
859
|
},
|
|
577
860
|
});
|
|
578
|
-
|
|
861
|
+
debug('[Hindsight] Plugin loaded successfully');
|
|
579
862
|
// Register agent hooks for auto-recall and auto-retention
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
863
|
+
if (registeredApis.has(api)) {
|
|
864
|
+
debug('[Hindsight] Hooks already registered for this api instance, skipping duplicate registration');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
registeredApis.add(api);
|
|
868
|
+
debug('[Hindsight] Registering agent hooks...');
|
|
584
869
|
// Auto-recall: Inject relevant memories before agent processes the message
|
|
585
870
|
// Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
|
|
586
|
-
api.on('
|
|
871
|
+
api.on('before_prompt_build', async (event, ctx) => {
|
|
587
872
|
try {
|
|
588
|
-
// Capture session key and context for use in agent_end
|
|
589
|
-
if (ctx?.sessionKey) {
|
|
590
|
-
currentSessionKey = ctx.sessionKey;
|
|
591
|
-
}
|
|
592
|
-
currentAgentContext = ctx;
|
|
593
873
|
// Check if this provider is excluded
|
|
594
874
|
if (ctx?.messageProvider && pluginConfig.excludeProviders?.includes(ctx.messageProvider)) {
|
|
595
|
-
|
|
875
|
+
debug(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
|
|
596
876
|
return;
|
|
597
877
|
}
|
|
598
878
|
// Skip auto-recall when disabled (agent has its own recall tool)
|
|
599
879
|
if (!pluginConfig.autoRecall) {
|
|
600
|
-
|
|
880
|
+
debug('[Hindsight] Auto-recall disabled via config, skipping');
|
|
601
881
|
return;
|
|
602
882
|
}
|
|
603
|
-
// Derive bank ID from context
|
|
604
|
-
|
|
605
|
-
|
|
883
|
+
// Derive bank ID from context — enrich ctx.senderId from the inbound metadata
|
|
884
|
+
// block when it's missing (agent-phase hooks don't carry senderId in ctx directly).
|
|
885
|
+
const senderIdFromPrompt = !ctx?.senderId ? extractSenderIdFromText(event.prompt ?? event.rawMessage ?? '') : undefined;
|
|
886
|
+
const effectiveCtxForRecall = senderIdFromPrompt ? { ...ctx, senderId: senderIdFromPrompt } : ctx;
|
|
887
|
+
// Cache the resolved sender ID keyed by sessionKey so agent_end can use it.
|
|
888
|
+
// event.messages in agent_end is clean history without the metadata blocks.
|
|
889
|
+
const resolvedSenderId = effectiveCtxForRecall?.senderId;
|
|
890
|
+
const sessionKeyForCache = ctx?.sessionKey;
|
|
891
|
+
if (resolvedSenderId && sessionKeyForCache) {
|
|
892
|
+
senderIdBySession.set(sessionKeyForCache, resolvedSenderId);
|
|
893
|
+
if (senderIdBySession.size > MAX_TRACKED_SESSIONS) {
|
|
894
|
+
const oldest = senderIdBySession.keys().next().value;
|
|
895
|
+
if (oldest)
|
|
896
|
+
senderIdBySession.delete(oldest);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const bankId = deriveBankId(effectiveCtxForRecall, pluginConfig);
|
|
900
|
+
debug(`[Hindsight] before_prompt_build - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
|
|
901
|
+
debug(`[Hindsight] event keys: ${Object.keys(event).join(', ')}`);
|
|
902
|
+
debug(`[Hindsight] event.context keys: ${Object.keys(event.context ?? {}).join(', ')}`);
|
|
606
903
|
// Get the user's latest message for recall — only the raw user text, not the full prompt
|
|
607
904
|
// rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
|
|
905
|
+
debug(`[Hindsight] extractRecallQuery input lengths - raw: ${event.rawMessage?.length ?? 0}, prompt: ${event.prompt?.length ?? 0}`);
|
|
608
906
|
const extracted = extractRecallQuery(event.rawMessage, event.prompt);
|
|
609
907
|
if (!extracted) {
|
|
908
|
+
debug('[Hindsight] extractRecallQuery returned null, skipping recall');
|
|
610
909
|
return;
|
|
611
910
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
911
|
+
debug(`[Hindsight] extractRecallQuery result length: ${extracted.length}`);
|
|
912
|
+
const recallContextTurns = pluginConfig.recallContextTurns ?? 1;
|
|
913
|
+
const recallMaxQueryChars = pluginConfig.recallMaxQueryChars ?? 800;
|
|
914
|
+
const sessionMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
|
|
915
|
+
const messageCount = sessionMessages.length;
|
|
916
|
+
debug(`[Hindsight] event.messages count: ${messageCount}, roles: ${sessionMessages.map((m) => m.role).join(',')}`);
|
|
917
|
+
if (recallContextTurns > 1 && messageCount === 0) {
|
|
918
|
+
debug('[Hindsight] recallContextTurns > 1 but event.messages is empty — prior context unavailable at before_agent_start for this provider');
|
|
919
|
+
}
|
|
920
|
+
const recallRoles = pluginConfig.recallRoles ?? ['user', 'assistant'];
|
|
921
|
+
const composedPrompt = composeRecallQuery(extracted, sessionMessages, recallContextTurns, recallRoles);
|
|
922
|
+
let prompt = truncateRecallQuery(composedPrompt, extracted, recallMaxQueryChars);
|
|
923
|
+
// Final defensive cap
|
|
924
|
+
if (prompt.length > recallMaxQueryChars) {
|
|
925
|
+
prompt = prompt.substring(0, recallMaxQueryChars);
|
|
617
926
|
}
|
|
618
927
|
// Wait for client to be ready
|
|
619
928
|
const clientGlobal = global.__hindsightClient;
|
|
620
929
|
if (!clientGlobal) {
|
|
621
|
-
|
|
930
|
+
debug('[Hindsight] Client global not available, skipping auto-recall');
|
|
622
931
|
return;
|
|
623
932
|
}
|
|
624
933
|
await clientGlobal.waitForReady();
|
|
625
934
|
// Get client configured for this context's bank (async to handle mission setup)
|
|
626
|
-
const client = await clientGlobal.getClientForContext(
|
|
935
|
+
const client = await clientGlobal.getClientForContext(effectiveCtxForRecall);
|
|
627
936
|
if (!client) {
|
|
628
|
-
|
|
937
|
+
debug('[Hindsight] Client not initialized, skipping auto-recall');
|
|
629
938
|
return;
|
|
630
939
|
}
|
|
631
|
-
|
|
940
|
+
debug(`[Hindsight] Auto-recall for bank ${bankId}, full query:\n---\n${prompt}\n---`);
|
|
632
941
|
// Recall with deduplication: reuse in-flight request for same bank
|
|
633
|
-
const
|
|
942
|
+
const normalizedPrompt = prompt.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
943
|
+
const queryHash = createHash('sha256').update(normalizedPrompt).digest('hex').slice(0, 16);
|
|
944
|
+
const recallKey = `${bankId}::${queryHash}`;
|
|
634
945
|
const existing = inflightRecalls.get(recallKey);
|
|
635
946
|
let recallPromise;
|
|
636
947
|
if (existing) {
|
|
637
|
-
|
|
948
|
+
debug(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
|
|
638
949
|
recallPromise = existing;
|
|
639
950
|
}
|
|
640
951
|
else {
|
|
641
|
-
recallPromise = client.recall({ query: prompt, max_tokens:
|
|
952
|
+
recallPromise = client.recall({ query: prompt, max_tokens: pluginConfig.recallMaxTokens || 1024, budget: pluginConfig.recallBudget, types: pluginConfig.recallTypes }, RECALL_TIMEOUT_MS);
|
|
642
953
|
inflightRecalls.set(recallKey, recallPromise);
|
|
643
954
|
void recallPromise.catch(() => { }).finally(() => inflightRecalls.delete(recallKey));
|
|
644
955
|
}
|
|
645
956
|
const response = await recallPromise;
|
|
646
957
|
if (!response.results || response.results.length === 0) {
|
|
647
|
-
|
|
958
|
+
debug('[Hindsight] No memories found for auto-recall');
|
|
648
959
|
return;
|
|
649
960
|
}
|
|
961
|
+
debug(`[Hindsight] Raw recall response (${response.results.length} results before topK):\n${response.results.map((r, i) => ` [${i}] score=${r.score?.toFixed(3) ?? 'n/a'} type=${r.type ?? 'n/a'}: ${JSON.stringify(r.content ?? r.text ?? r).substring(0, 200)}`).join('\n')}`);
|
|
962
|
+
const results = pluginConfig.recallTopK ? response.results.slice(0, pluginConfig.recallTopK) : response.results;
|
|
963
|
+
debug(`[Hindsight] After topK (${pluginConfig.recallTopK ?? 'unlimited'}): ${results.length} results injected`);
|
|
650
964
|
// Format memories as JSON with all fields from recall
|
|
651
|
-
const
|
|
965
|
+
const memoriesFormatted = formatMemories(results);
|
|
652
966
|
const contextMessage = `<hindsight_memories>
|
|
653
|
-
|
|
654
|
-
${
|
|
967
|
+
${pluginConfig.recallPromptPreamble || DEFAULT_RECALL_PROMPT_PREAMBLE}
|
|
968
|
+
Current time - ${formatCurrentTimeForRecall()}
|
|
655
969
|
|
|
656
|
-
|
|
970
|
+
${memoriesFormatted}
|
|
657
971
|
</hindsight_memories>`;
|
|
658
|
-
|
|
972
|
+
debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
|
|
659
973
|
// Inject context before the user message
|
|
660
974
|
return { prependContext: contextMessage };
|
|
661
975
|
}
|
|
@@ -675,21 +989,71 @@ User message: ${prompt}
|
|
|
675
989
|
// Hook signature: (event, ctx) where event has {messages, success, error?, durationMs?}
|
|
676
990
|
api.on('agent_end', async (event, ctx) => {
|
|
677
991
|
try {
|
|
678
|
-
//
|
|
679
|
-
const
|
|
992
|
+
// Avoid cross-session contamination: only use context carried by this event.
|
|
993
|
+
const eventSessionKey = typeof event?.sessionKey === 'string' ? event.sessionKey : undefined;
|
|
994
|
+
const effectiveCtx = ctx || (eventSessionKey ? { sessionKey: eventSessionKey } : undefined);
|
|
680
995
|
// Check if this provider is excluded
|
|
681
996
|
if (effectiveCtx?.messageProvider && pluginConfig.excludeProviders?.includes(effectiveCtx.messageProvider)) {
|
|
682
|
-
|
|
997
|
+
debug(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
// Derive bank ID from context — enrich ctx.senderId from the session cache.
|
|
1001
|
+
// event.messages in agent_end is clean history without OpenClaw's metadata blocks;
|
|
1002
|
+
// the sender ID was captured during before_prompt_build where event.prompt has them.
|
|
1003
|
+
const sessionKeyForLookup = effectiveCtx?.sessionKey;
|
|
1004
|
+
const senderIdFromCache = !effectiveCtx?.senderId && sessionKeyForLookup
|
|
1005
|
+
? senderIdBySession.get(sessionKeyForLookup)
|
|
1006
|
+
: undefined;
|
|
1007
|
+
const effectiveCtxForRetain = senderIdFromCache ? { ...effectiveCtx, senderId: senderIdFromCache } : effectiveCtx;
|
|
1008
|
+
const bankId = deriveBankId(effectiveCtxForRetain, pluginConfig);
|
|
1009
|
+
debug(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
|
|
1010
|
+
if (event.success === false) {
|
|
1011
|
+
debug('[Hindsight Hook] Agent run failed, skipping retention');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (!Array.isArray(event.context?.sessionEntry?.messages ?? event.messages) || (event.context?.sessionEntry?.messages ?? event.messages ?? []).length === 0) {
|
|
1015
|
+
debug('[Hindsight Hook] No messages in event, skipping retention');
|
|
683
1016
|
return;
|
|
684
1017
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1018
|
+
if (pluginConfig.autoRetain === false) {
|
|
1019
|
+
debug('[Hindsight Hook] autoRetain is disabled, skipping retention');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// Chunked retention: skip non-Nth turns and use a sliding window when firing
|
|
1023
|
+
const retainEveryN = pluginConfig.retainEveryNTurns ?? 1;
|
|
1024
|
+
const allMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
|
|
1025
|
+
let messagesToRetain = allMessages;
|
|
1026
|
+
let retainFullWindow = false;
|
|
1027
|
+
if (retainEveryN > 1) {
|
|
1028
|
+
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`;
|
|
1029
|
+
const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
|
|
1030
|
+
turnCountBySession.set(sessionTrackingKey, turnCount);
|
|
1031
|
+
if (turnCountBySession.size > MAX_TRACKED_SESSIONS) {
|
|
1032
|
+
const oldestKey = turnCountBySession.keys().next().value;
|
|
1033
|
+
if (oldestKey) {
|
|
1034
|
+
turnCountBySession.delete(oldestKey);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (turnCount % retainEveryN !== 0) {
|
|
1038
|
+
const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN;
|
|
1039
|
+
debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
// Sliding window in turns: N turns + configured overlap turns.
|
|
1043
|
+
// We slice by actual turn boundaries (user-role messages), so this
|
|
1044
|
+
// remains stable even when system/tool messages are present.
|
|
1045
|
+
const overlapTurns = pluginConfig.retainOverlapTurns ?? 0;
|
|
1046
|
+
const windowTurns = retainEveryN + overlapTurns;
|
|
1047
|
+
messagesToRetain = sliceLastTurnsByUserBoundary(allMessages, windowTurns);
|
|
1048
|
+
retainFullWindow = true;
|
|
1049
|
+
debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
|
|
1050
|
+
}
|
|
1051
|
+
const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow);
|
|
1052
|
+
if (!retention) {
|
|
1053
|
+
debug('[Hindsight Hook] No messages to retain (filtered/short/no-user)');
|
|
691
1054
|
return;
|
|
692
1055
|
}
|
|
1056
|
+
const { transcript, messageCount } = retention;
|
|
693
1057
|
// Wait for client to be ready
|
|
694
1058
|
const clientGlobal = global.__hindsightClient;
|
|
695
1059
|
if (!clientGlobal) {
|
|
@@ -698,57 +1062,34 @@ User message: ${prompt}
|
|
|
698
1062
|
}
|
|
699
1063
|
await clientGlobal.waitForReady();
|
|
700
1064
|
// Get client configured for this context's bank (async to handle mission setup)
|
|
701
|
-
const client = await clientGlobal.getClientForContext(
|
|
1065
|
+
const client = await clientGlobal.getClientForContext(effectiveCtxForRetain);
|
|
702
1066
|
if (!client) {
|
|
703
1067
|
console.warn('[Hindsight] Client not initialized, skipping retain');
|
|
704
1068
|
return;
|
|
705
1069
|
}
|
|
706
|
-
// Format messages into a transcript
|
|
707
|
-
const transcript = event.messages
|
|
708
|
-
.map((msg) => {
|
|
709
|
-
const role = msg.role || 'unknown';
|
|
710
|
-
let content = '';
|
|
711
|
-
// Handle different content formats
|
|
712
|
-
if (typeof msg.content === 'string') {
|
|
713
|
-
content = msg.content;
|
|
714
|
-
}
|
|
715
|
-
else if (Array.isArray(msg.content)) {
|
|
716
|
-
content = msg.content
|
|
717
|
-
.filter((block) => block.type === 'text')
|
|
718
|
-
.map((block) => block.text)
|
|
719
|
-
.join('\n');
|
|
720
|
-
}
|
|
721
|
-
// Strip plugin-injected memory tags to prevent feedback loop
|
|
722
|
-
content = stripMemoryTags(content);
|
|
723
|
-
return `[role: ${role}]\n${content}\n[${role}:end]`;
|
|
724
|
-
})
|
|
725
|
-
.join('\n\n');
|
|
726
|
-
if (!transcript.trim() || transcript.length < 10) {
|
|
727
|
-
console.log('[Hindsight Hook] Transcript too short, skipping');
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
1070
|
// Use unique document ID per conversation (sessionKey + timestamp)
|
|
731
1071
|
// Static sessionKey (e.g. "agent:main:main") causes CASCADE delete of old memories
|
|
732
|
-
const documentId = `${effectiveCtx?.sessionKey ||
|
|
1072
|
+
const documentId = `${effectiveCtx?.sessionKey || 'session'}-${Date.now()}`;
|
|
733
1073
|
// Retain to Hindsight
|
|
1074
|
+
debug(`[Hindsight] Retaining to bank ${bankId}, document: ${documentId}, chars: ${transcript.length}\n---\n${transcript.substring(0, 500)}${transcript.length > 500 ? '\n...(truncated)' : ''}\n---`);
|
|
734
1075
|
await client.retain({
|
|
735
1076
|
content: transcript,
|
|
736
1077
|
document_id: documentId,
|
|
737
1078
|
metadata: {
|
|
738
1079
|
retained_at: new Date().toISOString(),
|
|
739
|
-
message_count: String(
|
|
1080
|
+
message_count: String(messageCount),
|
|
740
1081
|
channel_type: effectiveCtx?.messageProvider,
|
|
741
1082
|
channel_id: effectiveCtx?.channelId,
|
|
742
1083
|
sender_id: effectiveCtx?.senderId,
|
|
743
1084
|
},
|
|
744
1085
|
});
|
|
745
|
-
|
|
1086
|
+
debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${documentId}`);
|
|
746
1087
|
}
|
|
747
1088
|
catch (error) {
|
|
748
1089
|
console.error('[Hindsight] Error retaining messages:', error);
|
|
749
1090
|
}
|
|
750
1091
|
});
|
|
751
|
-
|
|
1092
|
+
debug('[Hindsight] Hooks registered');
|
|
752
1093
|
}
|
|
753
1094
|
catch (error) {
|
|
754
1095
|
console.error('[Hindsight] Plugin loading error:', error);
|
|
@@ -759,6 +1100,82 @@ User message: ${prompt}
|
|
|
759
1100
|
}
|
|
760
1101
|
}
|
|
761
1102
|
// Export client getter for tools
|
|
1103
|
+
export function prepareRetentionTranscript(messages, pluginConfig, retainFullWindow = false) {
|
|
1104
|
+
if (!messages || messages.length === 0) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
let targetMessages;
|
|
1108
|
+
if (retainFullWindow) {
|
|
1109
|
+
// Chunked retention: retain the full sliding window (already sliced by caller)
|
|
1110
|
+
targetMessages = messages;
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
// Default: retain only the last turn (user message + assistant responses)
|
|
1114
|
+
let lastUserIdx = -1;
|
|
1115
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1116
|
+
if (messages[i].role === 'user') {
|
|
1117
|
+
lastUserIdx = i;
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (lastUserIdx === -1) {
|
|
1122
|
+
return null; // No user message found in turn
|
|
1123
|
+
}
|
|
1124
|
+
targetMessages = messages.slice(lastUserIdx);
|
|
1125
|
+
}
|
|
1126
|
+
// Role filtering
|
|
1127
|
+
const allowedRoles = new Set(pluginConfig.retainRoles || ['user', 'assistant']);
|
|
1128
|
+
const filteredMessages = targetMessages.filter((m) => allowedRoles.has(m.role));
|
|
1129
|
+
if (filteredMessages.length === 0) {
|
|
1130
|
+
return null; // No messages to retain
|
|
1131
|
+
}
|
|
1132
|
+
// Format messages into a transcript
|
|
1133
|
+
const transcriptParts = filteredMessages
|
|
1134
|
+
.map((msg) => {
|
|
1135
|
+
const role = msg.role || 'unknown';
|
|
1136
|
+
let content = '';
|
|
1137
|
+
// Handle different content formats
|
|
1138
|
+
if (typeof msg.content === 'string') {
|
|
1139
|
+
content = msg.content;
|
|
1140
|
+
}
|
|
1141
|
+
else if (Array.isArray(msg.content)) {
|
|
1142
|
+
content = msg.content
|
|
1143
|
+
.filter((block) => block.type === 'text')
|
|
1144
|
+
.map((block) => block.text)
|
|
1145
|
+
.join('\n');
|
|
1146
|
+
}
|
|
1147
|
+
// Strip plugin-injected memory tags and metadata envelopes to prevent feedback loop
|
|
1148
|
+
content = stripMemoryTags(content);
|
|
1149
|
+
content = stripMetadataEnvelopes(content);
|
|
1150
|
+
return content.trim() ? `[role: ${role}]\n${content}\n[${role}:end]` : null;
|
|
1151
|
+
})
|
|
1152
|
+
.filter(Boolean);
|
|
1153
|
+
const transcript = transcriptParts.join('\n\n');
|
|
1154
|
+
if (!transcript.trim() || transcript.length < 10) {
|
|
1155
|
+
return null; // Transcript too short
|
|
1156
|
+
}
|
|
1157
|
+
return { transcript, messageCount: transcriptParts.length };
|
|
1158
|
+
}
|
|
1159
|
+
export function sliceLastTurnsByUserBoundary(messages, turns) {
|
|
1160
|
+
if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) {
|
|
1161
|
+
return [];
|
|
1162
|
+
}
|
|
1163
|
+
let userTurnsSeen = 0;
|
|
1164
|
+
let startIndex = -1;
|
|
1165
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1166
|
+
if (messages[i]?.role === 'user') {
|
|
1167
|
+
userTurnsSeen += 1;
|
|
1168
|
+
if (userTurnsSeen >= turns) {
|
|
1169
|
+
startIndex = i;
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (startIndex === -1) {
|
|
1175
|
+
return messages;
|
|
1176
|
+
}
|
|
1177
|
+
return messages.slice(startIndex);
|
|
1178
|
+
}
|
|
762
1179
|
export function getClient() {
|
|
763
1180
|
return client;
|
|
764
1181
|
}
|