@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/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 './owletto-guidance.js';
8
- const AUTH_REQUIRED_MSG = 'Lobu memory is not connected. Call the owletto_login tool to authenticate, then show the user the login URL and code. After the user completes login in their browser, call owletto_login_check to finish authentication.';
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(), '.owletto', 'openclaw-auth.json');
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-owletto-plugin] INFO: ${msg}`),
126
- warn: (msg) => console.warn(`[openclaw-owletto-plugin] WARN: ${msg}`),
127
- error: (msg) => console.error(`[openclaw-owletto-plugin] ERROR: ${msg}`),
128
- debug: (msg) => console.debug(`[openclaw-owletto-plugin] DEBUG: ${msg}`),
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.OWLETTO_WEB_URL);
188
- const token = asString(cfg.token) ?? asString(process.env.OWLETTO_MCP_TOKEN);
189
- const tokenCommand = asString(cfg.tokenCommand) ?? asString(process.env.OWLETTO_MCP_TOKEN_COMMAND);
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 OwlettoAuthError extends Error {
275
+ class LobuAuthError extends Error {
229
276
  constructor(message) {
230
277
  super(message);
231
- this.name = 'OwlettoAuthError';
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('owletto: worker daemon already running');
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(`owletto: worker daemon spawned (pid=${workerProcess.pid})`);
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(`owletto: failed to spawn worker daemon: ${err instanceof Error ? err.message : String(err)}`);
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 Owletto Plugin',
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-owletto', version: '1.0.0' },
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 OwlettoAuthError(AUTH_REQUIRED_MSG);
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 OwlettoAuthError(errMsg);
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 OwlettoAuthError(errMsg);
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 OwlettoAuthError(errMsg);
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-owletto', version: '1.0.0' },
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('owletto: loaded workspace instructions after login');
721
+ log.info('lobu: loaded workspace instructions after login');
652
722
  }
653
723
  }
654
724
  catch (err) {
655
- log.warn(`owletto: failed to fetch workspace instructions: ${err instanceof Error ? err.message : String(err)}`);
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-owletto', version: '1.0.0' } } }) });
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('owletto: loaded workspace instructions from MCP server');
794
+ log.info('lobu: loaded workspace instructions from MCP server');
725
795
  }
726
796
  if (tools.length === 0) {
727
- log.warn('owletto: no MCP tools found (or fetch failed)');
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: `owletto_${tool.name}`,
807
+ name: `lobu_${tool.name}`,
733
808
  label: tool.name.replace(/_/g, ' '),
734
- description: tool.description || `Owletto MCP tool: ${tool.name}`,
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(`owletto: registered ${tools.length} MCP tools`);
818
+ log.info(`lobu: registered ${registered} MCP tools`);
743
819
  }
744
820
  const plugin = {
745
- id: 'openclaw-owletto',
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('owletto: missing config.mcpUrl (plugins.entries.openclaw-owletto.config.mcpUrl)');
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
- // Proactively refresh the token — the persisted access token may be expired
778
- if (_sessionRefreshToken && sessionIssuer && sessionClientId) {
779
- try {
780
- const body = {
781
- grant_type: 'refresh_token',
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: 'owletto_login',
839
- label: 'Owletto Login',
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 owletto_login_check to finish.',
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 Owletto. Do NOT call owletto_login again. Proceed directly with the user's request using the available owletto tools (owletto_search to discover SDK methods, owletto_execute to run TypeScript over the typed client SDK, owletto_search_knowledge for entity/knowledge search, owletto_save_knowledge to persist).",
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 Owletto:',
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 owletto_login_check to finish authentication.',
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: 'owletto_login_check',
935
- label: 'Owletto Login Check',
936
- description: 'Check if the user has completed Owletto login in their browser. Call this after owletto_login. Returns success when authenticated, or pending if still waiting.',
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 owletto_login first.',
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('owletto: persisted auth token to disk');
966
+ log.info('lobu: persisted auth token to disk');
1025
967
  }
1026
968
  catch (err) {
1027
- log.warn(`owletto: failed to persist auth token: ${err instanceof Error ? err.message : String(err)}`);
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: 'Owletto login successful! Memory tools are now available for this session.',
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 owletto_login_check again.',
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('owletto: registered login tools (owletto_login, owletto_login_check)');
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
- ? `<owletto-system>\n${cachedWorkspaceInstructions}\n</owletto-system>`
1048
+ ? `<lobu-system>\n${cachedWorkspaceInstructions}\n</lobu-system>`
1107
1049
  : FALLBACK_SYSTEM_CONTEXT;
1108
- const doRecall = async (query) => {
1109
- if (!config.autoRecall || !hasAuthConfigured(config)) {
1110
- return '';
1111
- }
1050
+ const recallOnce = async (query, signal) => {
1112
1051
  try {
1113
- const result = await callMcpTool(config, 'search_knowledge', {
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 ('<owletto-memory>\n' +
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</owletto-memory>');
1068
+ '\n</lobu-memory>');
1130
1069
  }
1131
1070
  catch (err) {
1132
- if (err instanceof OwlettoAuthError) {
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(`owletto recall failed: ${err instanceof Error ? err.message : String(err)}`);
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
- on('before_prompt_build', async (event) => {
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 most recent assistant+user pair (the previous turn)
1200
- let lastUser = null;
1201
- let lastAssistant = null;
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
- const m = messages[i];
1204
- if (!isRecord(m))
1205
- continue;
1206
- const text = typeof m.content === 'string'
1207
- ? m.content
1208
- : Array.isArray(m.content)
1209
- ? m.content
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('<owletto-memory>'))
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 prompt build
1231
- callMcpTool(config, 'save_knowledge', {
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('owletto: captured conversation observation'))
1237
- .catch((err) => log.warn(`owletto: autoCapture failed: ${err instanceof Error ? err.message : String(err)}`));
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(`owletto: initialized (configured=${!!config.mcpUrl}, token=${!!config.token}, tokenCommand=${!!config.tokenCommand}, tools=${!!registerTool})`);
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;