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