@lobu/openclaw-plugin 6.0.1 → 7.0.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 +30 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +301 -281
- package/dist/index.js.map +1 -1
- package/dist/{owletto-guidance.d.ts → lobu-guidance.d.ts} +1 -1
- package/dist/lobu-guidance.d.ts.map +1 -0
- package/dist/lobu-guidance.js +40 -0
- package/dist/lobu-guidance.js.map +1 -0
- package/dist/openclaw.plugin.json +15 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/openclaw.plugin.json +15 -1
- package/package.json +10 -3
- package/dist/owletto-guidance.d.ts.map +0 -1
- package/dist/owletto-guidance.js +0 -40
- package/dist/owletto-guidance.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -4,9 +4,55 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { renderFallbackSystemContext } from './
|
|
8
|
-
const AUTH_REQUIRED_MSG = 'Lobu memory is not connected. Call the
|
|
7
|
+
import { renderFallbackSystemContext } from './lobu-guidance.js';
|
|
8
|
+
const AUTH_REQUIRED_MSG = 'Lobu memory is not connected. Call the lobu_login tool to authenticate, then show the user the login URL and code. After the user completes login in their browser, call lobu_login_check to finish authentication.';
|
|
9
9
|
const DEFAULT_RECALL_LIMIT = 6;
|
|
10
|
+
// Lobu MCP server tools exposed via `tools/list` (see packages/server/src/tools/registry.ts).
|
|
11
|
+
// Each is registered with OpenClaw as `lobu_<name>`. OpenClaw 2026.5.x requires every
|
|
12
|
+
// runtime-registered tool to appear in `contracts.tools` in openclaw.plugin.json — keep the
|
|
13
|
+
// `lobu_*` entries there in sync with this set (a unit test enforces it). Server tools not
|
|
14
|
+
// listed here are skipped rather than registered (and rejected) until this plugin is updated
|
|
15
|
+
// to declare them.
|
|
16
|
+
export const KNOWN_MCP_TOOL_NAMES = new Set([
|
|
17
|
+
'search_memory',
|
|
18
|
+
'save_memory',
|
|
19
|
+
'list_organizations',
|
|
20
|
+
'search_sdk',
|
|
21
|
+
'query_sdk',
|
|
22
|
+
'query_sql',
|
|
23
|
+
'run_sdk',
|
|
24
|
+
]);
|
|
25
|
+
// Auth tools the plugin always registers in standalone mode (see register()).
|
|
26
|
+
export const LOGIN_TOOL_NAMES = ['lobu_login', 'lobu_login_check'];
|
|
27
|
+
// `before_prompt_build` / `before_agent_start` run inside OpenClaw's hook budget
|
|
28
|
+
// (~15s) — a slow `search_memory` must not blow that. Bound the recall round-trip
|
|
29
|
+
// well under it and degrade to "no recall" rather than letting OpenClaw kill the hook.
|
|
30
|
+
const RECALL_TIMEOUT_MS = 8_000;
|
|
31
|
+
/**
|
|
32
|
+
* Run `work` with a hard wall-clock deadline. On timeout, the supplied
|
|
33
|
+
* `AbortSignal` is aborted (so an in-flight `fetch` cancels instead of
|
|
34
|
+
* lingering) and `onTimeout` is returned regardless of what is still pending.
|
|
35
|
+
* `work` is responsible for swallowing its own rejections; if it rejects after
|
|
36
|
+
* the deadline, the rejection is observed and ignored.
|
|
37
|
+
*/
|
|
38
|
+
export async function runWithAbortDeadline(work, timeoutMs, onTimeout) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
let timer;
|
|
41
|
+
const deadline = new Promise((resolveDeadline) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
controller.abort();
|
|
44
|
+
resolveDeadline(onTimeout);
|
|
45
|
+
}, timeoutMs);
|
|
46
|
+
});
|
|
47
|
+
const guarded = work(controller.signal).catch(() => onTimeout);
|
|
48
|
+
try {
|
|
49
|
+
return await Promise.race([guarded, deadline]);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (timer)
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
10
56
|
// Minimal fallback context used before the workspace instructions are fetched.
|
|
11
57
|
// Initialized lazily per mode (gateway vs standalone) in register().
|
|
12
58
|
let FALLBACK_SYSTEM_CONTEXT = null;
|
|
@@ -37,7 +83,7 @@ let mcpSessionId = null;
|
|
|
37
83
|
const MCP_PROTOCOL_VERSION = '2025-03-26';
|
|
38
84
|
// Make an MCP JSON-RPC request with session management.
|
|
39
85
|
// Server returns plain JSON when Accept doesn't include text/event-stream.
|
|
40
|
-
async function mcpFetch(url, body, extraHeaders) {
|
|
86
|
+
async function mcpFetch(url, body, extraHeaders, signal) {
|
|
41
87
|
const headers = {
|
|
42
88
|
'Content-Type': 'application/json',
|
|
43
89
|
Accept: 'application/json',
|
|
@@ -50,6 +96,7 @@ async function mcpFetch(url, body, extraHeaders) {
|
|
|
50
96
|
method: 'POST',
|
|
51
97
|
headers,
|
|
52
98
|
body: JSON.stringify(body),
|
|
99
|
+
signal,
|
|
53
100
|
});
|
|
54
101
|
const newSessionId = response.headers.get('mcp-session-id');
|
|
55
102
|
if (newSessionId) {
|
|
@@ -61,7 +108,7 @@ async function mcpFetch(url, body, extraHeaders) {
|
|
|
61
108
|
// Worker daemon process (auto-started after login)
|
|
62
109
|
let workerProcess = null;
|
|
63
110
|
function getTokenStorePath() {
|
|
64
|
-
return resolve(homedir(), '.
|
|
111
|
+
return resolve(homedir(), '.lobu', 'openclaw-auth.json');
|
|
65
112
|
}
|
|
66
113
|
function normalizeMcpUrl(input) {
|
|
67
114
|
const url = new URL(input);
|
|
@@ -122,10 +169,10 @@ function saveStoredSession(mcpUrl, data) {
|
|
|
122
169
|
writeFileSync(storePath, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
|
|
123
170
|
}
|
|
124
171
|
const fallbackLogger = {
|
|
125
|
-
info: (msg) => console.log(`[openclaw-
|
|
126
|
-
warn: (msg) => console.warn(`[openclaw-
|
|
127
|
-
error: (msg) => console.error(`[openclaw-
|
|
128
|
-
debug: (msg) => console.debug(`[openclaw-
|
|
172
|
+
info: (msg) => console.log(`[openclaw-lobu-plugin] INFO: ${msg}`),
|
|
173
|
+
warn: (msg) => console.warn(`[openclaw-lobu-plugin] WARN: ${msg}`),
|
|
174
|
+
error: (msg) => console.error(`[openclaw-lobu-plugin] ERROR: ${msg}`),
|
|
175
|
+
debug: (msg) => console.debug(`[openclaw-lobu-plugin] DEBUG: ${msg}`),
|
|
129
176
|
};
|
|
130
177
|
function isRecord(value) {
|
|
131
178
|
return typeof value === 'object' && value !== null;
|
|
@@ -184,9 +231,9 @@ function readPluginConfig(api, pluginId) {
|
|
|
184
231
|
function resolvePluginConfig(api, pluginId) {
|
|
185
232
|
const cfg = readPluginConfig(api, pluginId);
|
|
186
233
|
const mcpUrl = asString(cfg.mcpUrl);
|
|
187
|
-
const webUrl = asString(cfg.webUrl) ?? asString(process.env.
|
|
188
|
-
const token = asString(cfg.token) ?? asString(process.env.
|
|
189
|
-
const tokenCommand = asString(cfg.tokenCommand) ?? asString(process.env.
|
|
234
|
+
const webUrl = asString(cfg.webUrl) ?? asString(process.env.LOBU_WEB_URL);
|
|
235
|
+
const token = asString(cfg.token) ?? asString(process.env.LOBU_MCP_TOKEN);
|
|
236
|
+
const tokenCommand = asString(cfg.tokenCommand) ?? asString(process.env.LOBU_MCP_TOKEN_COMMAND);
|
|
190
237
|
const gatewayAuthUrl = asString(cfg.gatewayAuthUrl) ?? asString(process.env.GATEWAY_AUTH_URL);
|
|
191
238
|
const headers = {};
|
|
192
239
|
if (isRecord(cfg.headers)) {
|
|
@@ -225,10 +272,10 @@ function parseErrorMessage(payload) {
|
|
|
225
272
|
}
|
|
226
273
|
return 'Unknown MCP error';
|
|
227
274
|
}
|
|
228
|
-
class
|
|
275
|
+
class LobuAuthError extends Error {
|
|
229
276
|
constructor(message) {
|
|
230
277
|
super(message);
|
|
231
|
-
this.name = '
|
|
278
|
+
this.name = 'LobuAuthError';
|
|
232
279
|
}
|
|
233
280
|
}
|
|
234
281
|
async function resolveAuthToken(config) {
|
|
@@ -261,55 +308,6 @@ function hasAuthConfigured(config) {
|
|
|
261
308
|
function getWorkerToken() {
|
|
262
309
|
return asString(process.env.WORKER_TOKEN);
|
|
263
310
|
}
|
|
264
|
-
async function gatewayDeviceAuthStart(gatewayAuthUrl) {
|
|
265
|
-
const workerToken = getWorkerToken();
|
|
266
|
-
if (!workerToken)
|
|
267
|
-
throw new Error('WORKER_TOKEN not set');
|
|
268
|
-
const response = await fetch(`${gatewayAuthUrl}/internal/device-auth/start`, {
|
|
269
|
-
method: 'POST',
|
|
270
|
-
headers: {
|
|
271
|
-
'Content-Type': 'application/json',
|
|
272
|
-
Authorization: `Bearer ${workerToken}`,
|
|
273
|
-
},
|
|
274
|
-
body: JSON.stringify({ mcpId: 'owletto' }),
|
|
275
|
-
});
|
|
276
|
-
if (!response.ok) {
|
|
277
|
-
const errText = await response.text();
|
|
278
|
-
throw new Error(`Gateway device auth start failed: ${errText}`);
|
|
279
|
-
}
|
|
280
|
-
return (await response.json());
|
|
281
|
-
}
|
|
282
|
-
async function gatewayDeviceAuthPoll(gatewayAuthUrl) {
|
|
283
|
-
const workerToken = getWorkerToken();
|
|
284
|
-
if (!workerToken)
|
|
285
|
-
throw new Error('WORKER_TOKEN not set');
|
|
286
|
-
const response = await fetch(`${gatewayAuthUrl}/internal/device-auth/poll`, {
|
|
287
|
-
method: 'POST',
|
|
288
|
-
headers: {
|
|
289
|
-
'Content-Type': 'application/json',
|
|
290
|
-
Authorization: `Bearer ${workerToken}`,
|
|
291
|
-
},
|
|
292
|
-
body: JSON.stringify({ mcpId: 'owletto' }),
|
|
293
|
-
});
|
|
294
|
-
return (await response.json());
|
|
295
|
-
}
|
|
296
|
-
async function gatewayDeviceAuthCheck(gatewayAuthUrl) {
|
|
297
|
-
const workerToken = getWorkerToken();
|
|
298
|
-
if (!workerToken)
|
|
299
|
-
return false;
|
|
300
|
-
try {
|
|
301
|
-
const response = await fetch(`${gatewayAuthUrl}/internal/device-auth/status?mcpId=owletto`, {
|
|
302
|
-
headers: { Authorization: `Bearer ${workerToken}` },
|
|
303
|
-
});
|
|
304
|
-
if (!response.ok)
|
|
305
|
-
return false;
|
|
306
|
-
const data = (await response.json());
|
|
307
|
-
return !!data.authenticated;
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
311
|
function clearSessionTokens() {
|
|
314
312
|
sessionToken = null;
|
|
315
313
|
_sessionRefreshToken = null;
|
|
@@ -325,7 +323,7 @@ function spawnWorkerDaemon(mcpUrl, accessToken, log) {
|
|
|
325
323
|
if (workerProcess) {
|
|
326
324
|
// Already running — check if the process is still alive
|
|
327
325
|
if (workerProcess.exitCode === null && !workerProcess.killed) {
|
|
328
|
-
log.info('
|
|
326
|
+
log.info('lobu: worker daemon already running');
|
|
329
327
|
return;
|
|
330
328
|
}
|
|
331
329
|
workerProcess = null;
|
|
@@ -338,7 +336,7 @@ function spawnWorkerDaemon(mcpUrl, accessToken, log) {
|
|
|
338
336
|
env: { ...process.env, WORKER_API_TOKEN: accessToken },
|
|
339
337
|
});
|
|
340
338
|
workerProcess.unref();
|
|
341
|
-
log.info(`
|
|
339
|
+
log.info(`lobu: worker daemon spawned (pid=${workerProcess.pid})`);
|
|
342
340
|
// Clean up on process exit
|
|
343
341
|
const cleanup = () => {
|
|
344
342
|
if (workerProcess && workerProcess.exitCode === null && !workerProcess.killed) {
|
|
@@ -355,7 +353,7 @@ function spawnWorkerDaemon(mcpUrl, accessToken, log) {
|
|
|
355
353
|
process.on('SIGTERM', cleanup);
|
|
356
354
|
}
|
|
357
355
|
catch (err) {
|
|
358
|
-
log.warn(`
|
|
356
|
+
log.warn(`lobu: failed to spawn worker daemon: ${err instanceof Error ? err.message : String(err)}`);
|
|
359
357
|
}
|
|
360
358
|
}
|
|
361
359
|
async function initiateDeviceLogin(mcpUrl, scope, resource) {
|
|
@@ -367,7 +365,7 @@ async function initiateDeviceLogin(mcpUrl, scope, resource) {
|
|
|
367
365
|
body: JSON.stringify({
|
|
368
366
|
grant_types: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
|
|
369
367
|
token_endpoint_auth_method: 'none',
|
|
370
|
-
client_name: 'OpenClaw
|
|
368
|
+
client_name: 'OpenClaw Lobu Plugin',
|
|
371
369
|
software_id: 'openclaw',
|
|
372
370
|
software_version: PLUGIN_VERSION,
|
|
373
371
|
scope,
|
|
@@ -443,6 +441,75 @@ async function pollDeviceLogin(state) {
|
|
|
443
441
|
const desc = typeof data.error_description === 'string' ? data.error_description : error;
|
|
444
442
|
return { status: 'error', message: desc || 'Unknown error during login' };
|
|
445
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Synchronous variant of {@link tryRefreshToken}, used at plugin `register()`
|
|
446
|
+
* time before the worker daemon is spawned. The daemon reads `WORKER_API_TOKEN`
|
|
447
|
+
* from its env once at process start, so a lazy refresh in `callMcpTool` (which
|
|
448
|
+
* only updates the in-process `sessionToken`) wouldn't reach it — we must hand
|
|
449
|
+
* the daemon a fresh token up front. Runs the refresh in a short-lived `node -e`
|
|
450
|
+
* subprocess; the OAuth params are passed via env vars, never interpolated into
|
|
451
|
+
* the script source.
|
|
452
|
+
*/
|
|
453
|
+
function refreshStoredTokenSync(mcpUrl) {
|
|
454
|
+
if (!_sessionRefreshToken || !sessionClientId || !sessionIssuer)
|
|
455
|
+
return;
|
|
456
|
+
const body = {
|
|
457
|
+
grant_type: 'refresh_token',
|
|
458
|
+
client_id: sessionClientId,
|
|
459
|
+
refresh_token: _sessionRefreshToken,
|
|
460
|
+
};
|
|
461
|
+
if (sessionClientSecret)
|
|
462
|
+
body.client_secret = sessionClientSecret;
|
|
463
|
+
const script = `
|
|
464
|
+
async function run() {
|
|
465
|
+
const r = await fetch(process.env.__TOKEN_URL, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: { 'Content-Type': 'application/json' },
|
|
468
|
+
body: process.env.__TOKEN_BODY,
|
|
469
|
+
});
|
|
470
|
+
if (!r.ok) return;
|
|
471
|
+
const d = await r.json();
|
|
472
|
+
process.stdout.write(JSON.stringify({ access_token: d.access_token, refresh_token: d.refresh_token }));
|
|
473
|
+
}
|
|
474
|
+
run().catch(() => {});
|
|
475
|
+
`;
|
|
476
|
+
try {
|
|
477
|
+
const out = spawnSync('node', ['-e', script], {
|
|
478
|
+
timeout: 10_000,
|
|
479
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
480
|
+
env: {
|
|
481
|
+
...process.env,
|
|
482
|
+
__TOKEN_URL: `${sessionIssuer}/oauth/token`,
|
|
483
|
+
__TOKEN_BODY: JSON.stringify(body),
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
.stdout?.toString()
|
|
487
|
+
.trim();
|
|
488
|
+
if (!out)
|
|
489
|
+
return;
|
|
490
|
+
const tokens = JSON.parse(out);
|
|
491
|
+
if (typeof tokens.access_token !== 'string')
|
|
492
|
+
return;
|
|
493
|
+
sessionToken = tokens.access_token;
|
|
494
|
+
if (typeof tokens.refresh_token === 'string')
|
|
495
|
+
_sessionRefreshToken = tokens.refresh_token;
|
|
496
|
+
try {
|
|
497
|
+
saveStoredSession(mcpUrl, {
|
|
498
|
+
issuer: sessionIssuer,
|
|
499
|
+
clientId: sessionClientId,
|
|
500
|
+
clientSecret: sessionClientSecret,
|
|
501
|
+
refreshToken: _sessionRefreshToken,
|
|
502
|
+
accessToken: sessionToken,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Best-effort persist.
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Best-effort refresh — fall back to the persisted (possibly stale) token.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
446
513
|
async function tryRefreshToken(mcpUrl) {
|
|
447
514
|
if (!_sessionRefreshToken || !sessionClientId || !sessionIssuer)
|
|
448
515
|
return false;
|
|
@@ -510,7 +577,7 @@ async function reinitializeMcpSession(config) {
|
|
|
510
577
|
params: {
|
|
511
578
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
512
579
|
capabilities: {},
|
|
513
|
-
clientInfo: { name: 'openclaw-
|
|
580
|
+
clientInfo: { name: 'openclaw-lobu', version: '1.0.0' },
|
|
514
581
|
},
|
|
515
582
|
}),
|
|
516
583
|
});
|
|
@@ -525,12 +592,15 @@ async function reinitializeMcpSession(config) {
|
|
|
525
592
|
return false;
|
|
526
593
|
}
|
|
527
594
|
}
|
|
528
|
-
async function callMcpTool(config, toolName, args) {
|
|
595
|
+
async function callMcpTool(config, toolName, args, options) {
|
|
529
596
|
if (!config.mcpUrl)
|
|
530
597
|
return null;
|
|
531
598
|
const token = await resolveAuthToken(config);
|
|
532
599
|
const rpcId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
533
600
|
const authHeaders = { ...config.headers };
|
|
601
|
+
if (options?.rawJson) {
|
|
602
|
+
authHeaders['X-MCP-Format'] = 'json';
|
|
603
|
+
}
|
|
534
604
|
if (token) {
|
|
535
605
|
authHeaders.Authorization = `Bearer ${token}`;
|
|
536
606
|
}
|
|
@@ -542,7 +612,7 @@ async function callMcpTool(config, toolName, args) {
|
|
|
542
612
|
};
|
|
543
613
|
let result;
|
|
544
614
|
try {
|
|
545
|
-
result = await mcpFetch(config.mcpUrl, rpcBody, authHeaders);
|
|
615
|
+
result = await mcpFetch(config.mcpUrl, rpcBody, authHeaders, options?.signal);
|
|
546
616
|
}
|
|
547
617
|
catch (err) {
|
|
548
618
|
throw new Error(`MCP fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -554,14 +624,14 @@ async function callMcpTool(config, toolName, args) {
|
|
|
554
624
|
if (refreshed && sessionToken) {
|
|
555
625
|
authHeaders.Authorization = `Bearer ${sessionToken}`;
|
|
556
626
|
const retryBody = { ...rpcBody, id: `${rpcId}-retry` };
|
|
557
|
-
const retry = await mcpFetch(config.mcpUrl, retryBody, authHeaders);
|
|
627
|
+
const retry = await mcpFetch(config.mcpUrl, retryBody, authHeaders, options?.signal);
|
|
558
628
|
data = retry.data;
|
|
559
629
|
response = retry.response;
|
|
560
630
|
}
|
|
561
631
|
}
|
|
562
632
|
if (response.status === 401 || response.status === 403) {
|
|
563
633
|
clearSessionTokens();
|
|
564
|
-
throw new
|
|
634
|
+
throw new LobuAuthError(AUTH_REQUIRED_MSG);
|
|
565
635
|
}
|
|
566
636
|
// Re-initialize MCP session on stale/missing session errors
|
|
567
637
|
if (response.status === 400 || response.status === 404) {
|
|
@@ -572,7 +642,7 @@ async function callMcpTool(config, toolName, args) {
|
|
|
572
642
|
const newSession = await reinitializeMcpSession(config);
|
|
573
643
|
if (newSession) {
|
|
574
644
|
const retryBody = { ...rpcBody, id: `${rpcId}-reinit` };
|
|
575
|
-
const retry = await mcpFetch(config.mcpUrl, retryBody, authHeaders);
|
|
645
|
+
const retry = await mcpFetch(config.mcpUrl, retryBody, authHeaders, options?.signal);
|
|
576
646
|
data = retry.data;
|
|
577
647
|
response = retry.response;
|
|
578
648
|
}
|
|
@@ -582,7 +652,7 @@ async function callMcpTool(config, toolName, args) {
|
|
|
582
652
|
const errMsg = parseErrorMessage(data);
|
|
583
653
|
if (isAuthErrorMessage(errMsg)) {
|
|
584
654
|
clearSessionTokens();
|
|
585
|
-
throw new
|
|
655
|
+
throw new LobuAuthError(errMsg);
|
|
586
656
|
}
|
|
587
657
|
throw new Error(errMsg);
|
|
588
658
|
}
|
|
@@ -591,7 +661,7 @@ async function callMcpTool(config, toolName, args) {
|
|
|
591
661
|
const errMsg = parseErrorMessage(rpcResponse.error);
|
|
592
662
|
if (isAuthErrorMessage(errMsg)) {
|
|
593
663
|
clearSessionTokens();
|
|
594
|
-
throw new
|
|
664
|
+
throw new LobuAuthError(errMsg);
|
|
595
665
|
}
|
|
596
666
|
throw new Error(errMsg);
|
|
597
667
|
}
|
|
@@ -609,7 +679,7 @@ async function callMcpTool(config, toolName, args) {
|
|
|
609
679
|
const errMsg = contentText || parseErrorMessage(rpcResult.error);
|
|
610
680
|
if (isAuthErrorMessage(errMsg)) {
|
|
611
681
|
clearSessionTokens();
|
|
612
|
-
throw new
|
|
682
|
+
throw new LobuAuthError(errMsg);
|
|
613
683
|
}
|
|
614
684
|
throw new Error(errMsg);
|
|
615
685
|
}
|
|
@@ -637,7 +707,7 @@ async function fetchWorkspaceInstructions(config, log) {
|
|
|
637
707
|
params: {
|
|
638
708
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
639
709
|
capabilities: {},
|
|
640
|
-
clientInfo: { name: 'openclaw-
|
|
710
|
+
clientInfo: { name: 'openclaw-lobu', version: '1.0.0' },
|
|
641
711
|
},
|
|
642
712
|
}, authHeaders);
|
|
643
713
|
if (!response.ok)
|
|
@@ -648,11 +718,11 @@ async function fetchWorkspaceInstructions(config, log) {
|
|
|
648
718
|
: null;
|
|
649
719
|
if (result && typeof result.instructions === 'string') {
|
|
650
720
|
cachedWorkspaceInstructions = result.instructions;
|
|
651
|
-
log.info('
|
|
721
|
+
log.info('lobu: loaded workspace instructions after login');
|
|
652
722
|
}
|
|
653
723
|
}
|
|
654
724
|
catch (err) {
|
|
655
|
-
log.warn(`
|
|
725
|
+
log.warn(`lobu: failed to fetch workspace instructions: ${err instanceof Error ? err.message : String(err)}`);
|
|
656
726
|
}
|
|
657
727
|
}
|
|
658
728
|
function fetchMcpBootstrapSync(config) {
|
|
@@ -682,7 +752,7 @@ function fetchMcpBootstrapSync(config) {
|
|
|
682
752
|
const base = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
683
753
|
if (token) base.Authorization = 'Bearer ' + token;
|
|
684
754
|
async function run() {
|
|
685
|
-
const initRes = await fetch(url, { method: 'POST', headers: base, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openclaw-
|
|
755
|
+
const initRes = await fetch(url, { method: 'POST', headers: base, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openclaw-lobu', version: '1.0.0' } } }) });
|
|
686
756
|
const initData = await initRes.json();
|
|
687
757
|
const sid = initRes.headers.get('mcp-session-id');
|
|
688
758
|
const h2 = { ...base };
|
|
@@ -721,28 +791,34 @@ function registerMcpTools(config, registerTool, log) {
|
|
|
721
791
|
}
|
|
722
792
|
if (instructions) {
|
|
723
793
|
cachedWorkspaceInstructions = instructions;
|
|
724
|
-
log.info('
|
|
794
|
+
log.info('lobu: loaded workspace instructions from MCP server');
|
|
725
795
|
}
|
|
726
796
|
if (tools.length === 0) {
|
|
727
|
-
log.warn('
|
|
797
|
+
log.warn('lobu: no MCP tools found (or fetch failed)');
|
|
728
798
|
return;
|
|
729
799
|
}
|
|
800
|
+
let registered = 0;
|
|
730
801
|
for (const tool of tools) {
|
|
802
|
+
if (!KNOWN_MCP_TOOL_NAMES.has(tool.name)) {
|
|
803
|
+
log.warn(`lobu: MCP server exposes tool "${tool.name}" not declared in contracts.tools; skipping. Update @lobu/openclaw-plugin to register it.`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
731
806
|
registerTool({
|
|
732
|
-
name: `
|
|
807
|
+
name: `lobu_${tool.name}`,
|
|
733
808
|
label: tool.name.replace(/_/g, ' '),
|
|
734
|
-
description: tool.description || `
|
|
809
|
+
description: tool.description || `Lobu MCP tool: ${tool.name}`,
|
|
735
810
|
parameters: tool.inputSchema || { type: 'object', properties: {} },
|
|
736
811
|
execute: async (_id, args) => {
|
|
737
812
|
const result = await callMcpTool(config, tool.name, args);
|
|
738
813
|
return { content: result?.content ?? [], details: {} };
|
|
739
814
|
},
|
|
740
815
|
});
|
|
816
|
+
registered++;
|
|
741
817
|
}
|
|
742
|
-
log.info(`
|
|
818
|
+
log.info(`lobu: registered ${registered} MCP tools`);
|
|
743
819
|
}
|
|
744
820
|
const plugin = {
|
|
745
|
-
id: 'openclaw-
|
|
821
|
+
id: 'openclaw-lobu',
|
|
746
822
|
name: 'Lobu Memory',
|
|
747
823
|
description: 'Lobu long-term memory plugin via MCP.',
|
|
748
824
|
kind: 'memory',
|
|
@@ -754,7 +830,7 @@ const plugin = {
|
|
|
754
830
|
: undefined;
|
|
755
831
|
const config = resolvePluginConfig(api, plugin.id);
|
|
756
832
|
if (!config.mcpUrl) {
|
|
757
|
-
log.warn('
|
|
833
|
+
log.warn('lobu: missing config.mcpUrl (plugins.entries.openclaw-lobu.config.mcpUrl)');
|
|
758
834
|
}
|
|
759
835
|
// Initialize fallback system context based on mode
|
|
760
836
|
FALLBACK_SYSTEM_CONTEXT = renderFallbackSystemContext({
|
|
@@ -774,57 +850,11 @@ const plugin = {
|
|
|
774
850
|
sessionClientId = stored.clientId || null;
|
|
775
851
|
sessionClientSecret = stored.clientSecret || null;
|
|
776
852
|
sessionIssuer = stored.issuer || null;
|
|
777
|
-
//
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
client_id: sessionClientId,
|
|
783
|
-
refresh_token: _sessionRefreshToken,
|
|
784
|
-
};
|
|
785
|
-
if (sessionClientSecret)
|
|
786
|
-
body.client_secret = sessionClientSecret;
|
|
787
|
-
// spawnSync imported at top-level (ESM-safe)
|
|
788
|
-
const scriptCode = [
|
|
789
|
-
'async function run() {',
|
|
790
|
-
` const r = await fetch(${JSON.stringify(sessionIssuer + '/oauth/token')}, {`,
|
|
791
|
-
' method: "POST",',
|
|
792
|
-
' headers: { "Content-Type": "application/json" },',
|
|
793
|
-
` body: ${JSON.stringify(JSON.stringify(body))},`,
|
|
794
|
-
' });',
|
|
795
|
-
' if (!r.ok) return;',
|
|
796
|
-
' const d = await r.json();',
|
|
797
|
-
' process.stdout.write(JSON.stringify({ access_token: d.access_token, refresh_token: d.refresh_token }));',
|
|
798
|
-
'}',
|
|
799
|
-
'run().catch(() => {});',
|
|
800
|
-
].join('\n');
|
|
801
|
-
const proc = spawnSync('node', ['-e', scriptCode], {
|
|
802
|
-
timeout: 10_000,
|
|
803
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
804
|
-
});
|
|
805
|
-
const out = proc.stdout?.toString().trim() ?? '';
|
|
806
|
-
if (out) {
|
|
807
|
-
const tokens = JSON.parse(out);
|
|
808
|
-
if (tokens.access_token) {
|
|
809
|
-
sessionToken = tokens.access_token;
|
|
810
|
-
if (tokens.refresh_token)
|
|
811
|
-
_sessionRefreshToken = tokens.refresh_token;
|
|
812
|
-
saveStoredSession(config.mcpUrl, {
|
|
813
|
-
issuer: sessionIssuer,
|
|
814
|
-
clientId: sessionClientId,
|
|
815
|
-
clientSecret: sessionClientSecret,
|
|
816
|
-
refreshToken: _sessionRefreshToken,
|
|
817
|
-
accessToken: sessionToken,
|
|
818
|
-
});
|
|
819
|
-
log.info('owletto: refreshed expired access token');
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
catch (refreshErr) {
|
|
824
|
-
log.warn(`owletto: token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
// Auto-start worker daemon with (possibly refreshed) token
|
|
853
|
+
// The persisted access token may be expired — refresh it before
|
|
854
|
+
// spawning the daemon, which captures WORKER_API_TOKEN at process start
|
|
855
|
+
// and won't see a later lazy refresh from callMcpTool.
|
|
856
|
+
refreshStoredTokenSync(config.mcpUrl);
|
|
857
|
+
// Auto-start worker daemon with the (possibly refreshed) token
|
|
828
858
|
spawnWorkerDaemon(config.mcpUrl, sessionToken, log);
|
|
829
859
|
}
|
|
830
860
|
}
|
|
@@ -835,52 +865,15 @@ const plugin = {
|
|
|
835
865
|
if (registerTool && config.mcpUrl && !config.gatewayAuthUrl) {
|
|
836
866
|
const mcpUrl = config.mcpUrl;
|
|
837
867
|
registerTool({
|
|
838
|
-
name: '
|
|
839
|
-
label: '
|
|
840
|
-
description: 'Start Lobu memory authentication. Only call this if other Lobu memory tools return authentication errors. If Lobu memory is already connected, skip this step. Returns a URL and code for the user to complete login in their browser. After the user completes login, call
|
|
868
|
+
name: 'lobu_login',
|
|
869
|
+
label: 'Lobu Login',
|
|
870
|
+
description: 'Start Lobu memory authentication. Only call this if other Lobu memory tools return authentication errors. If Lobu memory is already connected, skip this step. Returns a URL and code for the user to complete login in their browser. After the user completes login, call lobu_login_check to finish.',
|
|
841
871
|
parameters: {
|
|
842
872
|
type: 'object',
|
|
843
873
|
properties: {},
|
|
844
874
|
},
|
|
845
875
|
execute: async () => {
|
|
846
876
|
try {
|
|
847
|
-
// Gateway mode: delegate to gateway device-auth endpoints
|
|
848
|
-
if (config.gatewayAuthUrl) {
|
|
849
|
-
// Check if already authenticated via gateway
|
|
850
|
-
const alreadyAuth = await gatewayDeviceAuthCheck(config.gatewayAuthUrl);
|
|
851
|
-
if (alreadyAuth) {
|
|
852
|
-
return {
|
|
853
|
-
content: [
|
|
854
|
-
{
|
|
855
|
-
type: 'text',
|
|
856
|
-
text: JSON.stringify({
|
|
857
|
-
status: 'already_authenticated',
|
|
858
|
-
message: "You are already authenticated with Owletto. Do NOT call owletto_login again. Proceed directly with the user's request using the available owletto tools.",
|
|
859
|
-
}),
|
|
860
|
-
},
|
|
861
|
-
],
|
|
862
|
-
details: {},
|
|
863
|
-
};
|
|
864
|
-
}
|
|
865
|
-
const started = await gatewayDeviceAuthStart(config.gatewayAuthUrl);
|
|
866
|
-
return {
|
|
867
|
-
content: [
|
|
868
|
-
{
|
|
869
|
-
type: 'text',
|
|
870
|
-
text: JSON.stringify({
|
|
871
|
-
status: 'login_started',
|
|
872
|
-
message: 'Open this URL in your browser and enter the code to connect Owletto:',
|
|
873
|
-
verification_url: started.verificationUriComplete || started.verificationUri,
|
|
874
|
-
user_code: started.userCode,
|
|
875
|
-
expires_in_seconds: started.expiresIn,
|
|
876
|
-
next_step: 'After the user completes login in their browser, call owletto_login_check to finish authentication.',
|
|
877
|
-
}),
|
|
878
|
-
},
|
|
879
|
-
],
|
|
880
|
-
details: {},
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
// Standalone mode: direct device flow
|
|
884
877
|
if (sessionToken) {
|
|
885
878
|
return {
|
|
886
879
|
content: [
|
|
@@ -888,7 +881,7 @@ const plugin = {
|
|
|
888
881
|
type: 'text',
|
|
889
882
|
text: JSON.stringify({
|
|
890
883
|
status: 'already_authenticated',
|
|
891
|
-
message: "You are already authenticated with
|
|
884
|
+
message: "You are already authenticated with Lobu. Do NOT call lobu_login again. Proceed directly with the user's request using the available lobu tools (lobu_search_sdk to discover SDK methods, lobu_query_sdk for read-only TypeScript over the typed client SDK, lobu_run_sdk for full SDK execution, lobu_search_memory for memory search, lobu_save_memory to persist).",
|
|
892
885
|
}),
|
|
893
886
|
},
|
|
894
887
|
],
|
|
@@ -903,11 +896,11 @@ const plugin = {
|
|
|
903
896
|
type: 'text',
|
|
904
897
|
text: JSON.stringify({
|
|
905
898
|
status: 'login_started',
|
|
906
|
-
message: 'Open this URL in your browser and enter the code to connect
|
|
899
|
+
message: 'Open this URL in your browser and enter the code to connect Lobu:',
|
|
907
900
|
verification_url: activeDeviceLogin.verificationUriComplete,
|
|
908
901
|
user_code: activeDeviceLogin.userCode,
|
|
909
902
|
expires_in_seconds: activeDeviceLogin.expiresIn,
|
|
910
|
-
next_step: 'After the user completes login in their browser, call
|
|
903
|
+
next_step: 'After the user completes login in their browser, call lobu_login_check to finish authentication.',
|
|
911
904
|
}),
|
|
912
905
|
},
|
|
913
906
|
],
|
|
@@ -931,66 +924,15 @@ const plugin = {
|
|
|
931
924
|
},
|
|
932
925
|
});
|
|
933
926
|
registerTool({
|
|
934
|
-
name: '
|
|
935
|
-
label: '
|
|
936
|
-
description: 'Check if the user has completed
|
|
927
|
+
name: 'lobu_login_check',
|
|
928
|
+
label: 'Lobu Login Check',
|
|
929
|
+
description: 'Check if the user has completed Lobu login in their browser. Call this after lobu_login. Returns success when authenticated, or pending if still waiting.',
|
|
937
930
|
parameters: {
|
|
938
931
|
type: 'object',
|
|
939
932
|
properties: {},
|
|
940
933
|
},
|
|
941
934
|
execute: async () => {
|
|
942
935
|
try {
|
|
943
|
-
// Gateway mode: poll gateway for completion
|
|
944
|
-
if (config.gatewayAuthUrl) {
|
|
945
|
-
const result = await gatewayDeviceAuthPoll(config.gatewayAuthUrl);
|
|
946
|
-
if (result.status === 'complete') {
|
|
947
|
-
log.info('owletto: gateway device auth completed');
|
|
948
|
-
// Fetch workspace instructions now that we're authenticated
|
|
949
|
-
if (!cachedWorkspaceInstructions) {
|
|
950
|
-
fetchWorkspaceInstructions(config, log);
|
|
951
|
-
}
|
|
952
|
-
return {
|
|
953
|
-
content: [
|
|
954
|
-
{
|
|
955
|
-
type: 'text',
|
|
956
|
-
text: JSON.stringify({
|
|
957
|
-
status: 'authenticated',
|
|
958
|
-
message: 'Owletto login successful! Memory tools are now available for this session.',
|
|
959
|
-
}),
|
|
960
|
-
},
|
|
961
|
-
],
|
|
962
|
-
details: {},
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
if (result.status === 'pending') {
|
|
966
|
-
return {
|
|
967
|
-
content: [
|
|
968
|
-
{
|
|
969
|
-
type: 'text',
|
|
970
|
-
text: JSON.stringify({
|
|
971
|
-
status: 'pending',
|
|
972
|
-
message: 'Waiting for user to approve in browser...',
|
|
973
|
-
next_step: 'Wait a few seconds, then call owletto_login_check again.',
|
|
974
|
-
}),
|
|
975
|
-
},
|
|
976
|
-
],
|
|
977
|
-
details: {},
|
|
978
|
-
};
|
|
979
|
-
}
|
|
980
|
-
return {
|
|
981
|
-
content: [
|
|
982
|
-
{
|
|
983
|
-
type: 'text',
|
|
984
|
-
text: JSON.stringify({
|
|
985
|
-
status: 'error',
|
|
986
|
-
message: result.message || 'Device auth failed',
|
|
987
|
-
}),
|
|
988
|
-
},
|
|
989
|
-
],
|
|
990
|
-
details: {},
|
|
991
|
-
};
|
|
992
|
-
}
|
|
993
|
-
// Standalone mode: direct device flow polling
|
|
994
936
|
if (!activeDeviceLogin) {
|
|
995
937
|
return {
|
|
996
938
|
content: [
|
|
@@ -998,7 +940,7 @@ const plugin = {
|
|
|
998
940
|
type: 'text',
|
|
999
941
|
text: JSON.stringify({
|
|
1000
942
|
status: 'error',
|
|
1001
|
-
message: 'No login in progress. Call
|
|
943
|
+
message: 'No login in progress. Call lobu_login first.',
|
|
1002
944
|
}),
|
|
1003
945
|
},
|
|
1004
946
|
],
|
|
@@ -1021,10 +963,10 @@ const plugin = {
|
|
|
1021
963
|
refreshToken: result.refreshToken,
|
|
1022
964
|
accessToken: result.accessToken,
|
|
1023
965
|
});
|
|
1024
|
-
log.info('
|
|
966
|
+
log.info('lobu: persisted auth token to disk');
|
|
1025
967
|
}
|
|
1026
968
|
catch (err) {
|
|
1027
|
-
log.warn(`
|
|
969
|
+
log.warn(`lobu: failed to persist auth token: ${err instanceof Error ? err.message : String(err)}`);
|
|
1028
970
|
}
|
|
1029
971
|
}
|
|
1030
972
|
config.token = result.accessToken;
|
|
@@ -1039,7 +981,7 @@ const plugin = {
|
|
|
1039
981
|
type: 'text',
|
|
1040
982
|
text: JSON.stringify({
|
|
1041
983
|
status: 'authenticated',
|
|
1042
|
-
message: '
|
|
984
|
+
message: 'Lobu login successful! Memory tools are now available for this session.',
|
|
1043
985
|
}),
|
|
1044
986
|
},
|
|
1045
987
|
],
|
|
@@ -1054,7 +996,7 @@ const plugin = {
|
|
|
1054
996
|
text: JSON.stringify({
|
|
1055
997
|
status: 'pending',
|
|
1056
998
|
message: result.message,
|
|
1057
|
-
next_step: 'Wait a few seconds, then call
|
|
999
|
+
next_step: 'Wait a few seconds, then call lobu_login_check again.',
|
|
1058
1000
|
}),
|
|
1059
1001
|
},
|
|
1060
1002
|
],
|
|
@@ -1091,7 +1033,7 @@ const plugin = {
|
|
|
1091
1033
|
}
|
|
1092
1034
|
},
|
|
1093
1035
|
});
|
|
1094
|
-
log.info('
|
|
1036
|
+
log.info('lobu: registered login tools (lobu_login, lobu_login_check)');
|
|
1095
1037
|
}
|
|
1096
1038
|
// Dynamic tool registration from MCP server (synchronous so tools are
|
|
1097
1039
|
// available before OpenClaw builds the prompt).
|
|
@@ -1103,39 +1045,62 @@ const plugin = {
|
|
|
1103
1045
|
// When autoRecall is enabled, also inject recalled memories.
|
|
1104
1046
|
{
|
|
1105
1047
|
const getSystemContext = () => cachedWorkspaceInstructions
|
|
1106
|
-
? `<
|
|
1048
|
+
? `<lobu-system>\n${cachedWorkspaceInstructions}\n</lobu-system>`
|
|
1107
1049
|
: FALLBACK_SYSTEM_CONTEXT;
|
|
1108
|
-
const
|
|
1109
|
-
if (!config.autoRecall || !hasAuthConfigured(config)) {
|
|
1110
|
-
return '';
|
|
1111
|
-
}
|
|
1050
|
+
const recallOnce = async (query, signal) => {
|
|
1112
1051
|
try {
|
|
1113
|
-
const result = await callMcpTool(config, '
|
|
1052
|
+
const result = await callMcpTool(config, 'search_memory', {
|
|
1114
1053
|
query,
|
|
1115
1054
|
include_content: true,
|
|
1116
1055
|
content_limit: config.recallLimit,
|
|
1117
1056
|
include_connections: false,
|
|
1118
1057
|
limit: 3,
|
|
1119
|
-
});
|
|
1058
|
+
}, { signal });
|
|
1120
1059
|
if (!result)
|
|
1121
1060
|
return '';
|
|
1122
1061
|
const text = extractTextFromContent(result.content);
|
|
1123
1062
|
if (!text.trim())
|
|
1124
1063
|
return '';
|
|
1125
|
-
return ('<
|
|
1064
|
+
return ('<lobu-memory>\n' +
|
|
1126
1065
|
"Use these long-term memories only when directly relevant to the user's request.\n" +
|
|
1127
1066
|
'Do not mention this memory block unless needed.\n\n' +
|
|
1128
1067
|
text +
|
|
1129
|
-
'\n</
|
|
1068
|
+
'\n</lobu-memory>');
|
|
1130
1069
|
}
|
|
1131
1070
|
catch (err) {
|
|
1132
|
-
if (err instanceof
|
|
1071
|
+
if (err instanceof LobuAuthError)
|
|
1072
|
+
return '';
|
|
1073
|
+
if (signal.aborted ||
|
|
1074
|
+
(err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))) {
|
|
1075
|
+
log.warn(`lobu recall skipped: search_memory exceeded ${RECALL_TIMEOUT_MS}ms`);
|
|
1133
1076
|
return '';
|
|
1134
1077
|
}
|
|
1135
|
-
log.error(`
|
|
1078
|
+
log.error(`lobu recall failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1136
1079
|
return '';
|
|
1137
1080
|
}
|
|
1138
1081
|
};
|
|
1082
|
+
// Hard-bound the recall round-trip so it can never blow OpenClaw's hook
|
|
1083
|
+
// budget: abort the in-flight MCP fetch at RECALL_TIMEOUT_MS and degrade
|
|
1084
|
+
// to "no recall" no matter what is still pending (token command, network).
|
|
1085
|
+
//
|
|
1086
|
+
// OpenClaw fires both `before_prompt_build` and `before_agent_start` for
|
|
1087
|
+
// a turn (often back-to-back with the same prompt text). Memoize the last
|
|
1088
|
+
// (query → recallBlock) so the second event is a free cache hit instead
|
|
1089
|
+
// of a second `search_memory` round-trip.
|
|
1090
|
+
let lastRecallQuery = null;
|
|
1091
|
+
let lastRecallBlock = '';
|
|
1092
|
+
const doRecall = async (query) => {
|
|
1093
|
+
if (!config.autoRecall || !hasAuthConfigured(config)) {
|
|
1094
|
+
return '';
|
|
1095
|
+
}
|
|
1096
|
+
if (query === lastRecallQuery) {
|
|
1097
|
+
return lastRecallBlock;
|
|
1098
|
+
}
|
|
1099
|
+
const block = await runWithAbortDeadline((signal) => recallOnce(query, signal), RECALL_TIMEOUT_MS, '');
|
|
1100
|
+
lastRecallQuery = query;
|
|
1101
|
+
lastRecallBlock = block;
|
|
1102
|
+
return block;
|
|
1103
|
+
};
|
|
1139
1104
|
const buildPrependContext = (recallBlock) => ({
|
|
1140
1105
|
prependContext: getSystemContext() + (recallBlock ? '\n' + recallBlock : ''),
|
|
1141
1106
|
});
|
|
@@ -1187,57 +1152,112 @@ const plugin = {
|
|
|
1187
1152
|
}
|
|
1188
1153
|
if (config.autoCapture) {
|
|
1189
1154
|
let lastCapturedLen = 0;
|
|
1190
|
-
|
|
1155
|
+
const messageText = (m) => {
|
|
1156
|
+
if (!isRecord(m))
|
|
1157
|
+
return '';
|
|
1158
|
+
if (typeof m.content === 'string')
|
|
1159
|
+
return m.content;
|
|
1160
|
+
if (Array.isArray(m.content)) {
|
|
1161
|
+
return m.content
|
|
1162
|
+
.filter((p) => isRecord(p) && p.type === 'text')
|
|
1163
|
+
.map((p) => (isRecord(p) && typeof p.text === 'string' ? p.text : ''))
|
|
1164
|
+
.join('\n');
|
|
1165
|
+
}
|
|
1166
|
+
return '';
|
|
1167
|
+
};
|
|
1168
|
+
// Run on `agent_end` — the turn is complete and `messages` ends with the
|
|
1169
|
+
// assistant reply. (The worker's plugin-loader only honors
|
|
1170
|
+
// before_agent_start / agent_end; a before_prompt_build registration was
|
|
1171
|
+
// silently dropped, so autoCapture never ran inside Lobu.)
|
|
1172
|
+
on('agent_end', async (event) => {
|
|
1191
1173
|
if (!hasAuthConfigured(config))
|
|
1192
1174
|
return;
|
|
1193
1175
|
const messages = event.messages;
|
|
1194
1176
|
if (!Array.isArray(messages) || messages.length < 2)
|
|
1195
1177
|
return;
|
|
1196
|
-
// Only capture when new messages appeared since last capture
|
|
1197
1178
|
if (messages.length <= lastCapturedLen)
|
|
1198
1179
|
return;
|
|
1199
|
-
// Find the
|
|
1200
|
-
|
|
1201
|
-
|
|
1180
|
+
// Find the last assistant message, then the user message immediately
|
|
1181
|
+
// before it — pairing the answer with the question that prompted it
|
|
1182
|
+
// (not a trailing unanswered user message with the previous reply).
|
|
1183
|
+
let assistantIdx = -1;
|
|
1202
1184
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
.filter((p) => isRecord(p) && p.type === 'text')
|
|
1211
|
-
.map((p) => (isRecord(p) && typeof p.text === 'string' ? p.text : ''))
|
|
1212
|
-
.join('\n')
|
|
1213
|
-
: '';
|
|
1214
|
-
if (!text.trim())
|
|
1215
|
-
continue;
|
|
1216
|
-
if (m.role === 'assistant' && !lastAssistant)
|
|
1217
|
-
lastAssistant = text.trim();
|
|
1218
|
-
if (m.role === 'user' && !lastUser)
|
|
1219
|
-
lastUser = text.trim();
|
|
1220
|
-
if (lastUser && lastAssistant)
|
|
1221
|
-
break;
|
|
1185
|
+
if (isRecord(messages[i]) && messages[i].role === 'assistant') {
|
|
1186
|
+
const t = messageText(messages[i]);
|
|
1187
|
+
if (t.trim()) {
|
|
1188
|
+
assistantIdx = i;
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1222
1192
|
}
|
|
1193
|
+
if (assistantIdx < 1)
|
|
1194
|
+
return;
|
|
1195
|
+
let userIdx = -1;
|
|
1196
|
+
for (let i = assistantIdx - 1; i >= 0; i--) {
|
|
1197
|
+
if (isRecord(messages[i]) && messages[i].role === 'user') {
|
|
1198
|
+
const t = messageText(messages[i]);
|
|
1199
|
+
if (t.trim()) {
|
|
1200
|
+
userIdx = i;
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (userIdx < 0)
|
|
1206
|
+
return;
|
|
1207
|
+
const lastUser = messageText(messages[userIdx]).trim();
|
|
1208
|
+
const lastAssistant = messageText(messages[assistantIdx]).trim();
|
|
1223
1209
|
if (!lastUser || !lastAssistant)
|
|
1224
1210
|
return;
|
|
1225
1211
|
const combined = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
|
|
1226
|
-
if (combined.length < 16 || combined.includes('<
|
|
1212
|
+
if (combined.length < 16 || combined.includes('<lobu-memory>'))
|
|
1227
1213
|
return;
|
|
1228
1214
|
lastCapturedLen = messages.length;
|
|
1229
1215
|
const content = combined.length > 2000 ? combined.slice(0, 2000) : combined;
|
|
1230
|
-
// Fire-and-forget — don't block
|
|
1231
|
-
callMcpTool(config, '
|
|
1216
|
+
// Fire-and-forget — don't block the agent_end path.
|
|
1217
|
+
callMcpTool(config, 'save_memory', {
|
|
1232
1218
|
content,
|
|
1233
1219
|
semantic_type: 'observation',
|
|
1234
1220
|
metadata: {},
|
|
1235
1221
|
})
|
|
1236
|
-
.then(() => log.info('
|
|
1237
|
-
.catch((err) => log.warn(`
|
|
1222
|
+
.then(() => log.info('lobu: captured conversation observation'))
|
|
1223
|
+
.catch((err) => log.warn(`lobu: autoCapture failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
1238
1224
|
});
|
|
1239
1225
|
}
|
|
1240
|
-
log.info(`
|
|
1226
|
+
log.info(`lobu: initialized (configured=${!!config.mcpUrl}, token=${!!config.token}, tokenCommand=${!!config.tokenCommand}, tools=${!!registerTool})`);
|
|
1227
|
+
// OpenClaw 2026.5.x only surfaces plugin tools to agents when the host's
|
|
1228
|
+
// tool-policy allowlist explicitly opts them in. With no `tools.*` section
|
|
1229
|
+
// in the OpenClaw config, `registerTool` calls succeed but the agent's
|
|
1230
|
+
// tool list silently excludes every lobu_*, wiki_*, and memory_* tool —
|
|
1231
|
+
// the plugin appears healthy in logs while the agent has no way to call it.
|
|
1232
|
+
// Detect this and shout, with a copy-pasteable fix.
|
|
1233
|
+
if (registerTool && config.mcpUrl) {
|
|
1234
|
+
const cfg = isRecord(api.config) ? api.config : {};
|
|
1235
|
+
const topTools = isRecord(cfg.tools) ? cfg.tools : null;
|
|
1236
|
+
const agentDefaults = isRecord(cfg.agents) && isRecord(cfg.agents.defaults)
|
|
1237
|
+
? cfg.agents.defaults
|
|
1238
|
+
: null;
|
|
1239
|
+
const agentTools = agentDefaults && isRecord(agentDefaults.tools)
|
|
1240
|
+
? agentDefaults.tools
|
|
1241
|
+
: null;
|
|
1242
|
+
const hasToolPolicy = (t) => !!t &&
|
|
1243
|
+
(typeof t.profile === 'string' ||
|
|
1244
|
+
(Array.isArray(t.allow) && t.allow.length > 0) ||
|
|
1245
|
+
(Array.isArray(t.alsoAllow) && t.alsoAllow.length > 0));
|
|
1246
|
+
if (!hasToolPolicy(topTools) && !hasToolPolicy(agentTools)) {
|
|
1247
|
+
log.warn('lobu: no tools.* policy detected in OpenClaw config. Plugin tools ' +
|
|
1248
|
+
'(lobu_*, wiki_*, memory_*) register successfully but may not ' +
|
|
1249
|
+
'reach the agent on OpenClaw 2026.5.x — every plugin on the host ' +
|
|
1250
|
+
'is gated the same way. The autoRecall hook and autoCapture hook ' +
|
|
1251
|
+
'still write to Lobu in the background (they call MCP directly, ' +
|
|
1252
|
+
'not via registered agent tools), so memory continues to flow; ' +
|
|
1253
|
+
'only deliberate agent-driven tool calls during a conversation ' +
|
|
1254
|
+
'are affected. We have tested tools.profile="full", ' +
|
|
1255
|
+
'tools.allow with [group:plugins], [*], and explicit tool names, ' +
|
|
1256
|
+
'and tools.alsoAllow variants — none surface plugin tools on ' +
|
|
1257
|
+
'OpenClaw 2026.5.2. If you find a host config that works, please ' +
|
|
1258
|
+
'file at https://github.com/lobu-ai/lobu/issues.');
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1241
1261
|
},
|
|
1242
1262
|
};
|
|
1243
1263
|
export default plugin;
|