@martian-engineering/lossless-claw 0.6.2 → 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,37 @@ 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;
94
+ };
95
+
96
+ type SessionEndLifecycleEvent = {
97
+ sessionId?: string;
98
+ sessionKey?: string;
99
+ reason?: string;
100
+ nextSessionId?: string;
101
+ nextSessionKey?: string;
67
102
  };
68
103
 
69
104
  type RuntimeModelAuthModel = {
@@ -96,6 +131,13 @@ type RuntimeModelAuth = {
96
131
  profileId?: string;
97
132
  preferredProfile?: string;
98
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>;
99
141
  };
100
142
 
101
143
  const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
@@ -128,12 +170,20 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
128
170
  "Recall order for compacted conversation history:",
129
171
  "1. `lcm_grep` — search by regex or full-text across messages and summaries",
130
172
  "2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
131
- "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.',
132
181
  "",
133
182
  "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
134
183
  "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
135
184
  "- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
136
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.",
137
187
  "",
138
188
  "These precedence rules apply only to compacted conversation history. Lossless-claw does not supersede memory tools globally.",
139
189
  "",
@@ -579,6 +629,78 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
579
629
  return apiKey ? apiKey : undefined;
580
630
  }
581
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
+
582
704
  function buildLegacyAuthFallbackWarning(): string {
583
705
  return [
584
706
  "[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
@@ -1094,6 +1216,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1094
1216
  ? api.pluginConfig
1095
1217
  : undefined;
1096
1218
  const config = resolveLcmConfig(process.env, pluginConfig);
1219
+ const log = createLcmLogger(api);
1097
1220
 
1098
1221
  // Read model overrides from plugin config
1099
1222
  if (pluginConfig) {
@@ -1108,7 +1231,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1108
1231
  }
1109
1232
 
1110
1233
  if (!modelAuth) {
1111
- api.logger.warn(buildLegacyAuthFallbackWarning());
1234
+ log.warn(buildLegacyAuthFallbackWarning());
1112
1235
  }
1113
1236
 
1114
1237
  /** Resolve the best config object to hand to runtime.modelAuth for this lookup. */
@@ -1186,6 +1309,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1186
1309
  authProfileId,
1187
1310
  agentDir,
1188
1311
  runtimeConfig,
1312
+ skipModelAuth,
1189
1313
  messages,
1190
1314
  system,
1191
1315
  maxTokens,
@@ -1205,6 +1329,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1205
1329
  if (!providerId || !modelId) {
1206
1330
  return { content: [] };
1207
1331
  }
1332
+ const workspaceDir = agentDir?.trim() || api.resolvePath(".");
1208
1333
 
1209
1334
  // When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
1210
1335
  // passes legacyParams without config), fall back to the plugin API so
@@ -1235,6 +1360,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1235
1360
  return first.api.trim();
1236
1361
  })() ||
1237
1362
  inferApiFromProvider(providerId);
1363
+ const modelAuthConfig = resolveModelAuthConfig(effectiveRuntimeConfig);
1238
1364
 
1239
1365
  // Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
1240
1366
  // Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
@@ -1251,7 +1377,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1251
1377
  return isRecord(cfg) ? cfg : {};
1252
1378
  })();
1253
1379
 
1254
- const resolvedModel =
1380
+ let resolvedModel =
1255
1381
  isRecord(knownModel) &&
1256
1382
  typeof knownModel.api === "string" &&
1257
1383
  typeof knownModel.provider === "string" &&
@@ -1299,8 +1425,50 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1299
1425
  : {}),
1300
1426
  };
1301
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
+
1302
1467
  let resolvedApiKey = apiKey?.trim();
1303
- if (!resolvedApiKey && modelAuth) {
1468
+ if (!resolvedApiKey) {
1469
+ resolvedApiKey = resolveApiKeyFromAuthResult(runtimeAuth);
1470
+ }
1471
+ if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1304
1472
  try {
1305
1473
  resolvedApiKey = resolveApiKeyFromAuthResult(
1306
1474
  await modelAuth.getApiKeyForModel({
@@ -1309,31 +1477,25 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1309
1477
  model: modelId,
1310
1478
  api: resolvedModel.api,
1311
1479
  }),
1312
- cfg: api.config,
1480
+ cfg: modelAuthConfig,
1313
1481
  ...(authProfileId ? { profileId: authProfileId } : {}),
1314
1482
  }),
1315
1483
  );
1316
1484
  } catch (err) {
1317
- console.error(
1318
- `[lcm] modelAuth.getApiKeyForModel FAILED:`,
1319
- err instanceof Error ? err.message : err,
1320
- );
1485
+ log.warn(`[lcm] modelAuth.getApiKeyForModel FAILED: ${describeLogError(err)}`);
1321
1486
  }
1322
1487
  }
1323
- if (!resolvedApiKey && modelAuth) {
1488
+ if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1324
1489
  try {
1325
1490
  resolvedApiKey = resolveApiKeyFromAuthResult(
1326
1491
  await modelAuth.resolveApiKeyForProvider({
1327
1492
  provider: providerId,
1328
- cfg: api.config,
1493
+ cfg: modelAuthConfig,
1329
1494
  ...(authProfileId ? { profileId: authProfileId } : {}),
1330
1495
  }),
1331
1496
  );
1332
1497
  } catch (err) {
1333
- console.error(
1334
- `[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
1335
- err instanceof Error ? err.message : err,
1336
- );
1498
+ log.warn(`[lcm] modelAuth.resolveApiKeyForProvider FAILED: ${describeLogError(err)}`);
1337
1499
  }
1338
1500
  }
1339
1501
  if (!resolvedApiKey) {
@@ -1418,7 +1580,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1418
1580
  ...requestMetadata,
1419
1581
  };
1420
1582
  } catch (err) {
1421
- console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
1583
+ log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
1422
1584
  const authError = detectProviderAuthError(err);
1423
1585
  return {
1424
1586
  content: [],
@@ -1528,15 +1690,66 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1528
1690
  }
1529
1691
  },
1530
1692
  agentLaneSubagent: "subagent",
1531
- log: {
1532
- info: (msg) => api.logger.info(msg),
1533
- warn: (msg) => api.logger.warn(msg),
1534
- error: (msg) => api.logger.error(msg),
1535
- debug: (msg) => api.logger.debug?.(msg),
1536
- },
1693
+ log,
1537
1694
  };
1538
1695
  }
1539
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
+
1540
1753
  const lcmPlugin = {
1541
1754
  id: "lossless-claw",
1542
1755
  name: "Lossless Context Management",
@@ -1555,72 +1768,192 @@ const lcmPlugin = {
1555
1768
 
1556
1769
  register(api: OpenClawPluginApi) {
1557
1770
  const deps = createLcmDependencies(api);
1558
- const database = createLcmDatabaseConnection(deps.config.databasePath);
1559
- const lcm = new LcmContextEngine(deps, database);
1560
-
1561
- api.on("before_reset", async (event, ctx) => {
1562
- await lcm.handleBeforeReset({
1563
- reason: event.reason,
1564
- sessionId: ctx.sessionId,
1565
- 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;
1822
+ });
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
+ }
1566
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);
1567
1932
  });
1568
- api.on("before_prompt_build", () => ({
1569
- prependSystemContext: LOSSLESS_RECALL_POLICY_PROMPT,
1570
- }));
1571
- api.registerContextEngine("lossless-claw", () => lcm);
1572
- api.registerContextEngine("default", () => lcm);
1573
- api.registerTool((ctx) =>
1574
- createLcmGrepTool({
1575
- deps,
1576
- lcm,
1577
- sessionKey: ctx.sessionKey,
1578
- }),
1579
- );
1580
- api.registerTool((ctx) =>
1581
- createLcmDescribeTool({
1582
- deps,
1583
- lcm,
1584
- sessionKey: ctx.sessionKey,
1585
- }),
1586
- );
1587
- api.registerTool((ctx) =>
1588
- createLcmExpandTool({
1589
- deps,
1590
- lcm,
1591
- sessionKey: ctx.sessionKey,
1592
- }),
1593
- );
1594
- api.registerTool((ctx) =>
1595
- createLcmExpandQueryTool({
1596
- deps,
1597
- lcm,
1598
- sessionKey: ctx.sessionKey,
1599
- requesterSessionKey: ctx.sessionKey,
1600
- }),
1601
- );
1602
- api.registerCommand(
1603
- createLcmCommand({
1604
- db: database,
1605
- config: deps.config,
1606
- deps,
1607
- }),
1608
- );
1933
+
1934
+ wirePluginHandlers(api, deps, shared);
1609
1935
 
1610
1936
  logStartupBannerOnce({
1611
1937
  key: "plugin-loaded",
1612
- log: (message) => api.logger.info(message),
1938
+ log: (message) => deps.log.info(message),
1613
1939
  message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
1614
1940
  });
1615
1941
  logStartupBannerOnce({
1616
1942
  key: "compaction-model",
1617
- log: (message) => api.logger.info(message),
1943
+ log: (message) => deps.log.info(message),
1618
1944
  message: buildCompactionModelLog({
1619
1945
  config: deps.config,
1620
1946
  openClawConfig: api.config,
1621
1947
  defaultProvider: process.env.OPENCLAW_PROVIDER?.trim() ?? "",
1622
1948
  }),
1623
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
+ }
1624
1957
  },
1625
1958
  };
1626
1959