@martian-engineering/lossless-claw 0.6.3 → 0.7.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/src/lcm-log.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { LcmDependencies } from "./types.js";
3
+
4
+ export type LcmLogger = LcmDependencies["log"];
5
+
6
+ /** Silent logger used when a caller does not provide an explicit sink. */
7
+ export const NOOP_LCM_LOGGER: LcmLogger = {
8
+ info: () => {},
9
+ warn: () => {},
10
+ error: () => {},
11
+ debug: () => {},
12
+ };
13
+
14
+ /** Format unknown failures into stable one-line log text. */
15
+ export function describeLogError(error: unknown): string {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+
19
+ /** Create the LCM logger, preferring OpenClaw's file-backed runtime logger. */
20
+ export function createLcmLogger(api: OpenClawPluginApi): LcmLogger {
21
+ const runtimeLogger = api.runtime.logging?.getChildLogger?.({ plugin: "lossless-claw" });
22
+ if (runtimeLogger) {
23
+ return {
24
+ info: (message) => runtimeLogger.info(message),
25
+ warn: (message) => runtimeLogger.warn(message),
26
+ error: (message) => runtimeLogger.error(message),
27
+ debug: (message) => runtimeLogger.debug?.(message),
28
+ };
29
+ }
30
+
31
+ return {
32
+ info: (message) => api.logger.info(message),
33
+ warn: (message) => api.logger.warn(message),
34
+ error: (message) => api.logger.error(message),
35
+ debug: (message) => api.logger.debug?.(message),
36
+ };
37
+ }
@@ -6,11 +6,15 @@
6
6
  */
7
7
  import { readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
+ import type { DatabaseSync } from "node:sqlite";
9
10
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
11
  import { resolveLcmConfig } from "../db/config.js";
11
- import { createLcmDatabaseConnection } from "../db/connection.js";
12
+ import { closeLcmConnection, createLcmDatabaseConnection, normalizePath } from "../db/connection.js";
12
13
  import { LcmContextEngine } from "../engine.js";
14
+ import { createLcmLogger, describeLogError } from "../lcm-log.js";
13
15
  import { logStartupBannerOnce } from "../startup-banner-log.js";
16
+ import { getSharedInit, setSharedInit, removeSharedInit } from "./shared-init.js";
17
+ import type { SharedLcmInit } from "./shared-init.js";
14
18
  import { createLcmDescribeTool } from "../tools/lcm-describe-tool.js";
15
19
  import { createLcmExpandQueryTool } from "../tools/lcm-expand-query-tool.js";
16
20
  import { createLcmExpandTool } from "../tools/lcm-expand-tool.js";
@@ -64,6 +68,29 @@ type CompleteSimpleOptions = {
64
68
 
65
69
  type RuntimeModelAuthResult = {
66
70
  apiKey?: string;
71
+ baseUrl?: string;
72
+ request?: RuntimeModelRequestTransportOverrides;
73
+ expiresAt?: number;
74
+ };
75
+
76
+ type RuntimeModelRequestAuthOverride =
77
+ | {
78
+ mode: "provider-default";
79
+ }
80
+ | {
81
+ mode: "authorization-bearer";
82
+ token: string;
83
+ }
84
+ | {
85
+ mode: "header";
86
+ headerName: string;
87
+ value: string;
88
+ prefix?: string;
89
+ };
90
+
91
+ type RuntimeModelRequestTransportOverrides = {
92
+ headers?: Record<string, string>;
93
+ auth?: RuntimeModelRequestAuthOverride;
67
94
  };
68
95
 
69
96
  type SessionEndLifecycleEvent = {
@@ -104,6 +131,13 @@ type RuntimeModelAuth = {
104
131
  profileId?: string;
105
132
  preferredProfile?: string;
106
133
  }) => Promise<RuntimeModelAuthResult | undefined>;
134
+ getRuntimeAuthForModel?: (params: {
135
+ model: RuntimeModelAuthModel;
136
+ cfg?: OpenClawPluginApi["config"];
137
+ profileId?: string;
138
+ preferredProfile?: string;
139
+ workspaceDir?: string;
140
+ }) => Promise<RuntimeModelAuthResult | undefined>;
107
141
  };
108
142
 
109
143
  const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
@@ -136,12 +170,20 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
136
170
  "Recall order for compacted conversation history:",
137
171
  "1. `lcm_grep` — search by regex or full-text across messages and summaries",
138
172
  "2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
139
- "3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, returns answer with cited summary IDs (~120s, don't ration it)",
173
+ "3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, and returns answer plus cited summary IDs in tool output for follow-up (~120s, don't ration it)",
174
+ "",
175
+ "**`lcm_grep` routing guidance:**",
176
+ '- Prefer `mode: "full_text"` for keyword or topical recall; keep `mode: "regex"` for literal patterns.',
177
+ '- Wrap exact multi-word phrases in quotes, for example `"error handling"`.',
178
+ '- Keep the default `sort: "recency"` for "what just happened?" lookups.',
179
+ '- Use `sort: "relevance"` when hunting for the best older match on a topic.',
180
+ '- Use `sort: "hybrid"` when relevance matters but newer context should still get a boost.',
140
181
  "",
141
182
  "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
142
183
  "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
143
184
  "- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
144
185
  "- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
186
+ "- Keep raw summary IDs out of normal user-facing prose unless the user explicitly asks for sources or IDs.",
145
187
  "",
146
188
  "These precedence rules apply only to compacted conversation history. Lossless-claw does not supersede memory tools globally.",
147
189
  "",
@@ -587,6 +629,78 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
587
629
  return apiKey ? apiKey : undefined;
588
630
  }
589
631
 
632
+ /** Normalize a runtime auth override base URL when present. */
633
+ function resolveBaseUrlFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
634
+ const baseUrl = auth?.baseUrl?.trim();
635
+ return baseUrl ? baseUrl : undefined;
636
+ }
637
+
638
+ /** Normalize raw runtime auth headers into plain string headers. */
639
+ function resolveRuntimeAuthHeaders(
640
+ request: RuntimeModelRequestTransportOverrides | undefined,
641
+ ): Record<string, string> | undefined {
642
+ if (!request) {
643
+ return undefined;
644
+ }
645
+
646
+ const headers: Record<string, string> = {};
647
+ if (isRecord(request.headers)) {
648
+ for (const [key, value] of Object.entries(request.headers)) {
649
+ if (typeof value !== "string") {
650
+ continue;
651
+ }
652
+ const headerName = key.trim();
653
+ const headerValue = value.trim();
654
+ if (headerName && headerValue) {
655
+ headers[headerName] = headerValue;
656
+ }
657
+ }
658
+ }
659
+
660
+ const auth = request.auth;
661
+ if (auth?.mode === "authorization-bearer") {
662
+ const token = auth.token.trim();
663
+ if (token) {
664
+ for (const key of Object.keys(headers)) {
665
+ if (key.toLowerCase() === "authorization") {
666
+ delete headers[key];
667
+ }
668
+ }
669
+ headers.Authorization = `Bearer ${token}`;
670
+ }
671
+ } else if (auth?.mode === "header") {
672
+ const headerName = auth.headerName.trim();
673
+ const value = auth.value.trim();
674
+ if (headerName && value) {
675
+ const normalizedHeader = headerName.toLowerCase();
676
+ for (const key of Object.keys(headers)) {
677
+ if (
678
+ key.toLowerCase() === normalizedHeader ||
679
+ (normalizedHeader !== "authorization" && key.toLowerCase() === "authorization")
680
+ ) {
681
+ delete headers[key];
682
+ }
683
+ }
684
+ headers[headerName] = `${auth.prefix?.trim() ?? ""}${value}`;
685
+ }
686
+ }
687
+
688
+ return Object.keys(headers).length > 0 ? headers : undefined;
689
+ }
690
+
691
+ /** Attach OpenClaw transport overrides to a model for runtimes that inspect the shared symbol. */
692
+ function attachRuntimeAuthRequestTransport<TModel extends object>(
693
+ model: TModel,
694
+ request: RuntimeModelRequestTransportOverrides | undefined,
695
+ ): TModel {
696
+ if (!request) {
697
+ return model;
698
+ }
699
+ const next = { ...model } as TModel & Record<symbol, unknown>;
700
+ next[Symbol.for("openclaw.modelProviderRequestTransport")] = request;
701
+ return next;
702
+ }
703
+
590
704
  function buildLegacyAuthFallbackWarning(): string {
591
705
  return [
592
706
  "[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
@@ -1102,6 +1216,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1102
1216
  ? api.pluginConfig
1103
1217
  : undefined;
1104
1218
  const config = resolveLcmConfig(process.env, pluginConfig);
1219
+ const log = createLcmLogger(api);
1105
1220
 
1106
1221
  // Read model overrides from plugin config
1107
1222
  if (pluginConfig) {
@@ -1116,7 +1231,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1116
1231
  }
1117
1232
 
1118
1233
  if (!modelAuth) {
1119
- api.logger.warn(buildLegacyAuthFallbackWarning());
1234
+ log.warn(buildLegacyAuthFallbackWarning());
1120
1235
  }
1121
1236
 
1122
1237
  /** Resolve the best config object to hand to runtime.modelAuth for this lookup. */
@@ -1194,6 +1309,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1194
1309
  authProfileId,
1195
1310
  agentDir,
1196
1311
  runtimeConfig,
1312
+ skipModelAuth,
1197
1313
  messages,
1198
1314
  system,
1199
1315
  maxTokens,
@@ -1213,6 +1329,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1213
1329
  if (!providerId || !modelId) {
1214
1330
  return { content: [] };
1215
1331
  }
1332
+ const workspaceDir = agentDir?.trim() || api.resolvePath(".");
1216
1333
 
1217
1334
  // When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
1218
1335
  // passes legacyParams without config), fall back to the plugin API so
@@ -1243,6 +1360,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1243
1360
  return first.api.trim();
1244
1361
  })() ||
1245
1362
  inferApiFromProvider(providerId);
1363
+ const modelAuthConfig = resolveModelAuthConfig(effectiveRuntimeConfig);
1246
1364
 
1247
1365
  // Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
1248
1366
  // Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
@@ -1259,7 +1377,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1259
1377
  return isRecord(cfg) ? cfg : {};
1260
1378
  })();
1261
1379
 
1262
- const resolvedModel =
1380
+ let resolvedModel =
1263
1381
  isRecord(knownModel) &&
1264
1382
  typeof knownModel.api === "string" &&
1265
1383
  typeof knownModel.provider === "string" &&
@@ -1307,8 +1425,50 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1307
1425
  : {}),
1308
1426
  };
1309
1427
 
1428
+ let runtimeAuth: RuntimeModelAuthResult | undefined;
1429
+ if (modelAuth && skipModelAuth !== true && typeof modelAuth.getRuntimeAuthForModel === "function") {
1430
+ try {
1431
+ runtimeAuth = await modelAuth.getRuntimeAuthForModel({
1432
+ model: buildModelAuthLookupModel({
1433
+ provider: providerId,
1434
+ model: modelId,
1435
+ api: resolvedModel.api,
1436
+ }),
1437
+ cfg: modelAuthConfig,
1438
+ ...(authProfileId ? { profileId: authProfileId } : {}),
1439
+ workspaceDir,
1440
+ });
1441
+ } catch (err) {
1442
+ console.error(
1443
+ `[lcm] modelAuth.getRuntimeAuthForModel FAILED:`,
1444
+ err instanceof Error ? err.message : err,
1445
+ );
1446
+ }
1447
+ }
1448
+
1449
+ const runtimeAuthBaseUrl = resolveBaseUrlFromAuthResult(runtimeAuth);
1450
+ const runtimeAuthHeaders = resolveRuntimeAuthHeaders(runtimeAuth?.request);
1451
+ resolvedModel = attachRuntimeAuthRequestTransport(
1452
+ {
1453
+ ...resolvedModel,
1454
+ ...(runtimeAuthBaseUrl ? { baseUrl: runtimeAuthBaseUrl } : {}),
1455
+ ...(runtimeAuthHeaders
1456
+ ? {
1457
+ headers: {
1458
+ ...(isRecord(resolvedModel.headers) ? resolvedModel.headers : {}),
1459
+ ...runtimeAuthHeaders,
1460
+ },
1461
+ }
1462
+ : {}),
1463
+ },
1464
+ runtimeAuth?.request,
1465
+ );
1466
+
1310
1467
  let resolvedApiKey = apiKey?.trim();
1311
- if (!resolvedApiKey && modelAuth) {
1468
+ if (!resolvedApiKey) {
1469
+ resolvedApiKey = resolveApiKeyFromAuthResult(runtimeAuth);
1470
+ }
1471
+ if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1312
1472
  try {
1313
1473
  resolvedApiKey = resolveApiKeyFromAuthResult(
1314
1474
  await modelAuth.getApiKeyForModel({
@@ -1317,31 +1477,25 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1317
1477
  model: modelId,
1318
1478
  api: resolvedModel.api,
1319
1479
  }),
1320
- cfg: api.config,
1480
+ cfg: modelAuthConfig,
1321
1481
  ...(authProfileId ? { profileId: authProfileId } : {}),
1322
1482
  }),
1323
1483
  );
1324
1484
  } catch (err) {
1325
- console.error(
1326
- `[lcm] modelAuth.getApiKeyForModel FAILED:`,
1327
- err instanceof Error ? err.message : err,
1328
- );
1485
+ log.warn(`[lcm] modelAuth.getApiKeyForModel FAILED: ${describeLogError(err)}`);
1329
1486
  }
1330
1487
  }
1331
- if (!resolvedApiKey && modelAuth) {
1488
+ if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1332
1489
  try {
1333
1490
  resolvedApiKey = resolveApiKeyFromAuthResult(
1334
1491
  await modelAuth.resolveApiKeyForProvider({
1335
1492
  provider: providerId,
1336
- cfg: api.config,
1493
+ cfg: modelAuthConfig,
1337
1494
  ...(authProfileId ? { profileId: authProfileId } : {}),
1338
1495
  }),
1339
1496
  );
1340
1497
  } catch (err) {
1341
- console.error(
1342
- `[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
1343
- err instanceof Error ? err.message : err,
1344
- );
1498
+ log.warn(`[lcm] modelAuth.resolveApiKeyForProvider FAILED: ${describeLogError(err)}`);
1345
1499
  }
1346
1500
  }
1347
1501
  if (!resolvedApiKey) {
@@ -1426,7 +1580,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1426
1580
  ...requestMetadata,
1427
1581
  };
1428
1582
  } catch (err) {
1429
- console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
1583
+ log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
1430
1584
  const authError = detectProviderAuthError(err);
1431
1585
  return {
1432
1586
  content: [],
@@ -1536,15 +1690,66 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1536
1690
  }
1537
1691
  },
1538
1692
  agentLaneSubagent: "subagent",
1539
- log: {
1540
- info: (msg) => console.error(msg),
1541
- warn: (msg) => console.error(msg),
1542
- error: (msg) => console.error(msg),
1543
- debug: (msg) => api.logger.debug?.(msg),
1544
- },
1693
+ log,
1545
1694
  };
1546
1695
  }
1547
1696
 
1697
+ /**
1698
+ * Wire event handlers, context engines, tools, and commands to the
1699
+ * OpenClaw plugin API using shared init closures.
1700
+ */
1701
+ function wirePluginHandlers(
1702
+ api: OpenClawPluginApi,
1703
+ deps: LcmDependencies,
1704
+ shared: SharedLcmInit,
1705
+ ): void {
1706
+ api.on("before_reset", async (event, ctx) => {
1707
+ await (await shared.waitForEngine()).handleBeforeReset({
1708
+ reason: event.reason,
1709
+ sessionId: ctx.sessionId,
1710
+ sessionKey: ctx.sessionKey,
1711
+ });
1712
+ });
1713
+ api.on("before_prompt_build", () => ({
1714
+ prependSystemContext: LOSSLESS_RECALL_POLICY_PROMPT,
1715
+ }));
1716
+ api.on("session_end", async (event) => {
1717
+ const lifecycleEvent = event as SessionEndLifecycleEvent;
1718
+ await (await shared.waitForEngine()).handleSessionEnd({
1719
+ reason: lifecycleEvent.reason,
1720
+ sessionId: lifecycleEvent.sessionId,
1721
+ sessionKey: lifecycleEvent.sessionKey,
1722
+ nextSessionId: lifecycleEvent.nextSessionId,
1723
+ nextSessionKey: lifecycleEvent.nextSessionKey,
1724
+ });
1725
+ });
1726
+
1727
+ api.registerContextEngine("lossless-claw", () => shared.getCachedEngine() ?? shared.waitForEngine());
1728
+ api.registerContextEngine("default", () => shared.getCachedEngine() ?? shared.waitForEngine());
1729
+
1730
+ api.registerTool((ctx) =>
1731
+ createLcmGrepTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
1732
+ );
1733
+ api.registerTool((ctx) =>
1734
+ createLcmDescribeTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
1735
+ );
1736
+ api.registerTool((ctx) =>
1737
+ createLcmExpandTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
1738
+ );
1739
+ api.registerTool((ctx) =>
1740
+ createLcmExpandQueryTool({
1741
+ deps,
1742
+ getLcm: shared.waitForEngine,
1743
+ sessionKey: ctx.sessionKey,
1744
+ requesterSessionKey: ctx.sessionKey,
1745
+ }),
1746
+ );
1747
+
1748
+ api.registerCommand(
1749
+ createLcmCommand({ db: shared.waitForDatabase, config: deps.config, deps }),
1750
+ );
1751
+ }
1752
+
1548
1753
  const lcmPlugin = {
1549
1754
  id: "lossless-claw",
1550
1755
  name: "Lossless Context Management",
@@ -1563,82 +1768,192 @@ const lcmPlugin = {
1563
1768
 
1564
1769
  register(api: OpenClawPluginApi) {
1565
1770
  const deps = createLcmDependencies(api);
1566
- const database = createLcmDatabaseConnection(deps.config.databasePath);
1567
- const lcm = new LcmContextEngine(deps, database);
1568
-
1569
- api.on("before_reset", async (event, ctx) => {
1570
- await lcm.handleBeforeReset({
1571
- reason: event.reason,
1572
- sessionId: ctx.sessionId,
1573
- sessionKey: ctx.sessionKey,
1771
+ const dbPath = deps.config.databasePath;
1772
+ const normalizedDbPath = normalizePath(dbPath);
1773
+
1774
+ // ── Singleton check ─────────────────────────────────────────────
1775
+ // OpenClaw v2026.4.5+ calls register() per-agent-context (main,
1776
+ // subagents, cron lanes). Reuse the existing connection and engine
1777
+ // when the same DB path is already initialized.
1778
+ const existingInit = getSharedInit(normalizedDbPath);
1779
+ if (existingInit && !existingInit.stopped) {
1780
+ wirePluginHandlers(api, deps, existingInit);
1781
+ return;
1782
+ }
1783
+
1784
+ // ── Eager-first DB init with deferred fallback on lock ──────────
1785
+ let database: DatabaseSync | null = null;
1786
+ let lcm: LcmContextEngine | null = null;
1787
+ let initPromise: Promise<LcmContextEngine> | null = null;
1788
+ let initError: Error | null = null;
1789
+ let resolveDeferredInit: ((engine: LcmContextEngine) => void) | null = null;
1790
+ let rejectDeferredInit: ((error: Error) => void) | null = null;
1791
+ let stopped = false;
1792
+
1793
+ /** Normalize unknown failures into stable Error instances. */
1794
+ function toInitError(error: unknown): Error {
1795
+ return error instanceof Error ? error : new Error(String(error));
1796
+ }
1797
+
1798
+ /** Build a live DB+engine pair and roll back the DB handle if engine init fails. */
1799
+ function initializeEngine(): LcmContextEngine {
1800
+ const nextDatabase = createLcmDatabaseConnection(dbPath);
1801
+ try {
1802
+ const nextEngine = new LcmContextEngine(deps, nextDatabase);
1803
+ database = nextDatabase;
1804
+ lcm = nextEngine;
1805
+ initError = null;
1806
+ return nextEngine;
1807
+ } catch (error) {
1808
+ closeLcmConnection(nextDatabase);
1809
+ throw error;
1810
+ }
1811
+ }
1812
+
1813
+ /** Keep one shared deferred init promise so early callers all await the same retry. */
1814
+ function ensureDeferredInitPromise(): Promise<LcmContextEngine> {
1815
+ if (initPromise) {
1816
+ return initPromise;
1817
+ }
1818
+
1819
+ initPromise = new Promise<LcmContextEngine>((resolve, reject) => {
1820
+ resolveDeferredInit = resolve;
1821
+ rejectDeferredInit = reject;
1574
1822
  });
1575
- });
1576
- api.on("before_prompt_build", () => ({
1577
- prependSystemContext: LOSSLESS_RECALL_POLICY_PROMPT,
1578
- }));
1579
- api.on("session_end", async (event) => {
1580
- const lifecycleEvent = event as SessionEndLifecycleEvent;
1581
- await lcm.handleSessionEnd({
1582
- reason: lifecycleEvent.reason,
1583
- sessionId: lifecycleEvent.sessionId,
1584
- sessionKey: lifecycleEvent.sessionKey,
1585
- nextSessionId: lifecycleEvent.nextSessionId,
1586
- nextSessionKey: lifecycleEvent.nextSessionKey,
1823
+ initPromise.catch(() => {});
1824
+ return initPromise;
1825
+ }
1826
+
1827
+ /** Resolve the shared deferred init promise exactly once. */
1828
+ function resolveDeferredEngine(nextEngine: LcmContextEngine): void {
1829
+ const resolve = resolveDeferredInit;
1830
+ resolveDeferredInit = null;
1831
+ rejectDeferredInit = null;
1832
+ resolve?.(nextEngine);
1833
+ }
1834
+
1835
+ /** Reject the shared deferred init promise exactly once and retain the root cause. */
1836
+ function rejectDeferredEngine(error: Error): void {
1837
+ initError = error;
1838
+ const reject = rejectDeferredInit;
1839
+ resolveDeferredInit = null;
1840
+ rejectDeferredInit = null;
1841
+ reject?.(error);
1842
+ }
1843
+
1844
+ /** Return the initialized engine, waiting for deferred startup when the DB is lock-contended. */
1845
+ async function waitForEngine(): Promise<LcmContextEngine> {
1846
+ if (stopped) {
1847
+ throw new Error("[lcm] Database connection closed after gateway_stop");
1848
+ }
1849
+ if (initError) {
1850
+ throw initError;
1851
+ }
1852
+ if (lcm) {
1853
+ return lcm;
1854
+ }
1855
+ if (initPromise) {
1856
+ return initPromise;
1857
+ }
1858
+
1859
+ try {
1860
+ const nextEngine = initializeEngine();
1861
+ initPromise = Promise.resolve(nextEngine);
1862
+ return nextEngine;
1863
+ } catch (error) {
1864
+ const normalized = toInitError(error);
1865
+ if (!/database is locked/i.test(normalized.message)) {
1866
+ initError = normalized;
1867
+ throw normalized;
1868
+ }
1869
+
1870
+ deps.log.warn("[lcm] DB locked during eager init, deferring to gateway_start");
1871
+ return ensureDeferredInitPromise();
1872
+ }
1873
+ }
1874
+
1875
+ /** Return the initialized DB handle, sharing the same wait/error semantics as the engine. */
1876
+ async function waitForDatabase(): Promise<DatabaseSync> {
1877
+ await waitForEngine();
1878
+ if (!database) {
1879
+ throw initError ?? new Error("[lcm] Database initialization finished without a handle");
1880
+ }
1881
+ return database;
1882
+ }
1883
+
1884
+ try {
1885
+ const nextEngine = initializeEngine();
1886
+ initPromise = Promise.resolve(nextEngine);
1887
+ } catch (error) {
1888
+ const normalized = toInitError(error);
1889
+ if (!/database is locked/i.test(normalized.message)) {
1890
+ initError = normalized;
1891
+ throw normalized;
1892
+ }
1893
+
1894
+ deps.log.warn("[lcm] DB locked during eager init, deferring to gateway_start");
1895
+ ensureDeferredInitPromise();
1896
+ api.on("gateway_start", async () => {
1897
+ if (stopped || lcm || initError) {
1898
+ return;
1899
+ }
1900
+ try {
1901
+ const nextEngine = initializeEngine();
1902
+ initPromise = Promise.resolve(nextEngine);
1903
+ resolveDeferredEngine(nextEngine);
1904
+ } catch (retryError) {
1905
+ const normalizedRetryError = toInitError(retryError);
1906
+ rejectDeferredEngine(normalizedRetryError);
1907
+ deps.log.error(`[lcm] Deferred DB init failed: ${normalizedRetryError.message}`);
1908
+ }
1587
1909
  });
1910
+ }
1911
+
1912
+ const shared: SharedLcmInit = {
1913
+ stopped: false,
1914
+ getCachedEngine: () => lcm,
1915
+ waitForEngine,
1916
+ waitForDatabase,
1917
+ };
1918
+ setSharedInit(normalizedDbPath, shared);
1919
+
1920
+ api.on("gateway_stop", async () => {
1921
+ stopped = true;
1922
+ shared.stopped = true;
1923
+ if (!lcm && !database) {
1924
+ rejectDeferredEngine(new Error("[lcm] Database connection closed after gateway_stop"));
1925
+ }
1926
+ if (database) {
1927
+ closeLcmConnection(database);
1928
+ database = null;
1929
+ }
1930
+ lcm = null;
1931
+ removeSharedInit(normalizedDbPath);
1588
1932
  });
1589
- api.registerContextEngine("lossless-claw", () => lcm);
1590
- api.registerContextEngine("default", () => lcm);
1591
- api.registerTool((ctx) =>
1592
- createLcmGrepTool({
1593
- deps,
1594
- lcm,
1595
- sessionKey: ctx.sessionKey,
1596
- }),
1597
- );
1598
- api.registerTool((ctx) =>
1599
- createLcmDescribeTool({
1600
- deps,
1601
- lcm,
1602
- sessionKey: ctx.sessionKey,
1603
- }),
1604
- );
1605
- api.registerTool((ctx) =>
1606
- createLcmExpandTool({
1607
- deps,
1608
- lcm,
1609
- sessionKey: ctx.sessionKey,
1610
- }),
1611
- );
1612
- api.registerTool((ctx) =>
1613
- createLcmExpandQueryTool({
1614
- deps,
1615
- lcm,
1616
- sessionKey: ctx.sessionKey,
1617
- requesterSessionKey: ctx.sessionKey,
1618
- }),
1619
- );
1620
- api.registerCommand(
1621
- createLcmCommand({
1622
- db: database,
1623
- config: deps.config,
1624
- deps,
1625
- }),
1626
- );
1933
+
1934
+ wirePluginHandlers(api, deps, shared);
1627
1935
 
1628
1936
  logStartupBannerOnce({
1629
1937
  key: "plugin-loaded",
1630
- log: (message) => console.error(message),
1938
+ log: (message) => deps.log.info(message),
1631
1939
  message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
1632
1940
  });
1633
1941
  logStartupBannerOnce({
1634
1942
  key: "compaction-model",
1635
- log: (message) => console.error(message),
1943
+ log: (message) => deps.log.info(message),
1636
1944
  message: buildCompactionModelLog({
1637
1945
  config: deps.config,
1638
1946
  openClawConfig: api.config,
1639
1947
  defaultProvider: process.env.OPENCLAW_PROVIDER?.trim() ?? "",
1640
1948
  }),
1641
1949
  });
1950
+ if (deps.config.fallbackProviders.length > 0) {
1951
+ logStartupBannerOnce({
1952
+ key: "fallback-providers",
1953
+ log: (message) => deps.log.info(message),
1954
+ message: `[lcm] Fallback providers: ${deps.config.fallbackProviders.map((fp) => `${fp.provider}/${fp.model}`).join(", ")}`,
1955
+ });
1956
+ }
1642
1957
  },
1643
1958
  };
1644
1959