@martian-engineering/lossless-claw 0.6.3 → 0.8.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.
Files changed (38) hide show
  1. package/README.md +26 -6
  2. package/docs/agent-tools.md +16 -5
  3. package/docs/configuration.md +223 -214
  4. package/openclaw.plugin.json +123 -0
  5. package/package.json +1 -1
  6. package/skills/lossless-claw/SKILL.md +3 -2
  7. package/skills/lossless-claw/references/architecture.md +12 -0
  8. package/skills/lossless-claw/references/config.md +135 -3
  9. package/skills/lossless-claw/references/diagnostics.md +13 -0
  10. package/src/assembler.ts +17 -5
  11. package/src/compaction.ts +161 -53
  12. package/src/db/config.ts +102 -4
  13. package/src/db/connection.ts +35 -7
  14. package/src/db/features.ts +24 -5
  15. package/src/db/migration.ts +257 -78
  16. package/src/engine.ts +1007 -110
  17. package/src/estimate-tokens.ts +80 -0
  18. package/src/lcm-log.ts +37 -0
  19. package/src/plugin/index.ts +493 -101
  20. package/src/plugin/lcm-command.ts +288 -7
  21. package/src/plugin/lcm-doctor-apply.ts +1 -3
  22. package/src/plugin/lcm-doctor-cleaners.ts +655 -0
  23. package/src/plugin/shared-init.ts +59 -0
  24. package/src/prune.ts +391 -0
  25. package/src/retrieval.ts +8 -9
  26. package/src/startup-banner-log.ts +1 -0
  27. package/src/store/compaction-telemetry-store.ts +156 -0
  28. package/src/store/conversation-store.ts +6 -1
  29. package/src/store/fts5-sanitize.ts +25 -4
  30. package/src/store/full-text-sort.ts +21 -0
  31. package/src/store/index.ts +8 -0
  32. package/src/store/summary-store.ts +21 -14
  33. package/src/summarize.ts +55 -34
  34. package/src/tools/lcm-describe-tool.ts +9 -4
  35. package/src/tools/lcm-expand-query-tool.ts +609 -200
  36. package/src/tools/lcm-expand-tool.ts +9 -4
  37. package/src/tools/lcm-grep-tool.ts +22 -8
  38. package/src/types.ts +1 -0
@@ -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,11 +131,19 @@ 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";
110
144
  const MODEL_AUTH_MERGE_COMMIT = "4790e40";
111
145
  const MODEL_AUTH_REQUIRED_RELEASE = "the first OpenClaw release after 2026.3.8";
146
+ const PROVIDER_API_RESOLUTION_ERROR_PREFIX = "[lcm] unable to resolve API family for provider ";
112
147
  const AUTH_ERROR_TEXT_PATTERN =
113
148
  /\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i;
114
149
  const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const;
@@ -136,12 +171,33 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
136
171
  "Recall order for compacted conversation history:",
137
172
  "1. `lcm_grep` — search by regex or full-text across messages and summaries",
138
173
  "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)",
174
+ "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)",
175
+ "",
176
+ "**`lcm_grep` routing guidance:**",
177
+ '- Prefer `mode: "full_text"` for keyword or topical recall; keep `mode: "regex"` for literal patterns.',
178
+ '- Full-text queries use FTS5 semantics, and FTS5 defaults to AND matching, so extra terms make matching stricter rather than broader.',
179
+ '- Prefer 1-3 distinctive full-text terms or one quoted phrase. Do not pad queries with synonyms or extra keywords.',
180
+ '- Wrap exact multi-word phrases in quotes, for example `"error handling"`.',
181
+ '- Keep the default `sort: "recency"` for "what just happened?" lookups.',
182
+ '- Use `sort: "relevance"` when hunting for the best older match on a topic.',
183
+ '- Use `sort: "hybrid"` when relevance matters but newer context should still get a boost.',
140
184
  "",
141
185
  "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
142
186
  "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
143
187
  "- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
188
+ "- `query` uses the same FTS5 full-text search path as `lcm_grep`, so the same query-construction rules apply.",
189
+ "- `query` is for matching candidate summaries; `prompt` is the natural-language question or task to answer after expansion.",
190
+ "- FTS5 defaults to AND matching, so more query terms narrow results instead of broadening them.",
191
+ "- For `query`, use 1-3 distinctive terms or a quoted phrase. Do not stuff synonyms or extra keywords into it.",
192
+ "**Scope selection rule:**",
193
+ "- Start with the current conversation scope.",
194
+ "- If the in-context summaries already look relevant to the user's question, prefer `lcm_grep` or `lcm_expand_query` without `allConversations`.",
195
+ "- Use `allConversations: true` only when the current summaries do not appear sufficient, the question seems outside the current conversation, or the user is explicitly asking about work across sessions.",
196
+ "- For global discovery, prefer `lcm_grep(..., allConversations: true)` first.",
197
+ "- If global matches are found and the user needs one synthesized answer, use `lcm_expand_query(..., allConversations: true)`; this is bounded synthesis, not exhaustive expansion.",
198
+ "- If you already know the exact target conversation, prefer explicit `conversationId` instead of `allConversations`.",
144
199
  "- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
200
+ "- Keep raw summary IDs out of normal user-facing prose unless the user explicitly asks for sources or IDs.",
145
201
  "",
146
202
  "These precedence rules apply only to compacted conversation history. Lossless-claw does not supersede memory tools globally.",
147
203
  "",
@@ -162,6 +218,27 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
162
218
  };
163
219
  }
164
220
 
221
+ /** Coerce a plugin-config-like value into a plain object when possible. */
222
+ function toPluginConfig(value: unknown): Record<string, unknown> | undefined {
223
+ return value && typeof value === "object" && !Array.isArray(value)
224
+ ? (value as Record<string, unknown>)
225
+ : undefined;
226
+ }
227
+
228
+ /** Resolve plugin config from direct runtime injection or the root OpenClaw config fallback. */
229
+ function resolvePluginConfig(api: OpenClawPluginApi): Record<string, unknown> | undefined {
230
+ const directPluginConfig = toPluginConfig(api.pluginConfig);
231
+ if (directPluginConfig && Object.keys(directPluginConfig).length > 0) {
232
+ return directPluginConfig;
233
+ }
234
+
235
+ const rootConfig = toPluginConfig(api.config);
236
+ const plugins = toPluginConfig(rootConfig?.plugins);
237
+ const entries = toPluginConfig(plugins?.entries);
238
+ const pluginEntry = toPluginConfig(entries?.["lossless-claw"]);
239
+ return toPluginConfig(pluginEntry?.config);
240
+ }
241
+
165
242
  function truncateErrorMessage(message: string, maxChars = 240): string {
166
243
  return message.length <= maxChars ? message : `${message.slice(0, maxChars)}...`;
167
244
  }
@@ -459,7 +536,7 @@ function normalizeProviderId(provider: string): string {
459
536
  }
460
537
 
461
538
  /** Resolve known provider API defaults when model lookup misses. */
462
- function inferApiFromProvider(provider: string): string {
539
+ function inferApiFromProvider(provider: string): string | undefined {
463
540
  const normalized = normalizeProviderId(provider);
464
541
  const map: Record<string, string> = {
465
542
  anthropic: "anthropic-messages",
@@ -472,7 +549,7 @@ function inferApiFromProvider(provider: string): string {
472
549
  "google-vertex": "google-vertex",
473
550
  "amazon-bedrock": "bedrock-converse-stream",
474
551
  };
475
- return map[normalized] ?? "openai-responses";
552
+ return map[normalized];
476
553
  }
477
554
 
478
555
  /** Codex Responses rejects `temperature`; omit it for that API family. */
@@ -562,12 +639,18 @@ function buildModelAuthLookupModel(params: {
562
639
  provider: string;
563
640
  model: string;
564
641
  api?: string;
642
+ contextWindow?: number;
565
643
  }): RuntimeModelAuthModel {
644
+ const contextWindow =
645
+ typeof params.contextWindow === "number" && Number.isFinite(params.contextWindow) && params.contextWindow > 0
646
+ ? params.contextWindow
647
+ : 1_000_000;
648
+
566
649
  return {
567
650
  id: params.model,
568
651
  name: params.model,
569
652
  provider: params.provider,
570
- api: params.api?.trim() || inferApiFromProvider(params.provider),
653
+ api: params.api?.trim() || inferApiFromProvider(params.provider) || "",
571
654
  reasoning: false,
572
655
  input: ["text"],
573
656
  cost: {
@@ -576,7 +659,7 @@ function buildModelAuthLookupModel(params: {
576
659
  cacheRead: 0,
577
660
  cacheWrite: 0,
578
661
  },
579
- contextWindow: 200_000,
662
+ contextWindow,
580
663
  maxTokens: 8_000,
581
664
  };
582
665
  }
@@ -587,6 +670,78 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
587
670
  return apiKey ? apiKey : undefined;
588
671
  }
589
672
 
673
+ /** Normalize a runtime auth override base URL when present. */
674
+ function resolveBaseUrlFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
675
+ const baseUrl = auth?.baseUrl?.trim();
676
+ return baseUrl ? baseUrl : undefined;
677
+ }
678
+
679
+ /** Normalize raw runtime auth headers into plain string headers. */
680
+ function resolveRuntimeAuthHeaders(
681
+ request: RuntimeModelRequestTransportOverrides | undefined,
682
+ ): Record<string, string> | undefined {
683
+ if (!request) {
684
+ return undefined;
685
+ }
686
+
687
+ const headers: Record<string, string> = {};
688
+ if (isRecord(request.headers)) {
689
+ for (const [key, value] of Object.entries(request.headers)) {
690
+ if (typeof value !== "string") {
691
+ continue;
692
+ }
693
+ const headerName = key.trim();
694
+ const headerValue = value.trim();
695
+ if (headerName && headerValue) {
696
+ headers[headerName] = headerValue;
697
+ }
698
+ }
699
+ }
700
+
701
+ const auth = request.auth;
702
+ if (auth?.mode === "authorization-bearer") {
703
+ const token = auth.token.trim();
704
+ if (token) {
705
+ for (const key of Object.keys(headers)) {
706
+ if (key.toLowerCase() === "authorization") {
707
+ delete headers[key];
708
+ }
709
+ }
710
+ headers.Authorization = `Bearer ${token}`;
711
+ }
712
+ } else if (auth?.mode === "header") {
713
+ const headerName = auth.headerName.trim();
714
+ const value = auth.value.trim();
715
+ if (headerName && value) {
716
+ const normalizedHeader = headerName.toLowerCase();
717
+ for (const key of Object.keys(headers)) {
718
+ if (
719
+ key.toLowerCase() === normalizedHeader ||
720
+ (normalizedHeader !== "authorization" && key.toLowerCase() === "authorization")
721
+ ) {
722
+ delete headers[key];
723
+ }
724
+ }
725
+ headers[headerName] = `${auth.prefix?.trim() ?? ""}${value}`;
726
+ }
727
+ }
728
+
729
+ return Object.keys(headers).length > 0 ? headers : undefined;
730
+ }
731
+
732
+ /** Attach OpenClaw transport overrides to a model for runtimes that inspect the shared symbol. */
733
+ function attachRuntimeAuthRequestTransport<TModel extends object>(
734
+ model: TModel,
735
+ request: RuntimeModelRequestTransportOverrides | undefined,
736
+ ): TModel {
737
+ if (!request) {
738
+ return model;
739
+ }
740
+ const next = { ...model } as TModel & Record<symbol, unknown>;
741
+ next[Symbol.for("openclaw.modelProviderRequestTransport")] = request;
742
+ return next;
743
+ }
744
+
590
745
  function buildLegacyAuthFallbackWarning(): string {
591
746
  return [
592
747
  "[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
@@ -1097,11 +1252,9 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1097
1252
  envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
1098
1253
  const modelAuth = getRuntimeModelAuth(api);
1099
1254
  const readEnv: ReadEnvFn = (key) => process.env[key];
1100
- const pluginConfig =
1101
- api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
1102
- ? api.pluginConfig
1103
- : undefined;
1255
+ const pluginConfig = resolvePluginConfig(api);
1104
1256
  const config = resolveLcmConfig(process.env, pluginConfig);
1257
+ const log = createLcmLogger(api);
1105
1258
 
1106
1259
  // Read model overrides from plugin config
1107
1260
  if (pluginConfig) {
@@ -1116,7 +1269,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1116
1269
  }
1117
1270
 
1118
1271
  if (!modelAuth) {
1119
- api.logger.warn(buildLegacyAuthFallbackWarning());
1272
+ log.warn(buildLegacyAuthFallbackWarning());
1120
1273
  }
1121
1274
 
1122
1275
  /** Resolve the best config object to hand to runtime.modelAuth for this lookup. */
@@ -1145,7 +1298,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1145
1298
  try {
1146
1299
  const modelAuthKey = resolveApiKeyFromAuthResult(
1147
1300
  await modelAuth.getApiKeyForModel({
1148
- model: buildModelAuthLookupModel({ provider, model }),
1301
+ model: buildModelAuthLookupModel({ provider, model, contextWindow: 1_000_000 }),
1149
1302
  cfg: modelAuthConfig,
1150
1303
  ...(options?.profileId ? { profileId: options.profileId } : {}),
1151
1304
  ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
@@ -1194,6 +1347,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1194
1347
  authProfileId,
1195
1348
  agentDir,
1196
1349
  runtimeConfig,
1350
+ skipModelAuth,
1197
1351
  messages,
1198
1352
  system,
1199
1353
  maxTokens,
@@ -1213,6 +1367,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1213
1367
  if (!providerId || !modelId) {
1214
1368
  return { content: [] };
1215
1369
  }
1370
+ const workspaceDir = agentDir?.trim() || api.resolvePath(".");
1216
1371
 
1217
1372
  // When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
1218
1373
  // passes legacyParams without config), fall back to the plugin API so
@@ -1229,6 +1384,9 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1229
1384
  const knownModel =
1230
1385
  typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
1231
1386
  const fallbackApi =
1387
+ (isRecord(knownModel) && typeof knownModel.api === "string" && knownModel.api.trim()
1388
+ ? knownModel.api.trim()
1389
+ : undefined) ||
1232
1390
  providerApi?.trim() ||
1233
1391
  resolveProviderApiFromRuntimeConfig(effectiveRuntimeConfig, providerId) ||
1234
1392
  (() => {
@@ -1243,6 +1401,12 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1243
1401
  return first.api.trim();
1244
1402
  })() ||
1245
1403
  inferApiFromProvider(providerId);
1404
+ if (!fallbackApi) {
1405
+ throw new Error(
1406
+ `[lcm] unable to resolve API family for provider ${providerId}; set models.providers.${providerId}.api explicitly instead of falling back implicitly.`,
1407
+ );
1408
+ }
1409
+ const modelAuthConfig = resolveModelAuthConfig(effectiveRuntimeConfig);
1246
1410
 
1247
1411
  // Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
1248
1412
  // Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
@@ -1259,7 +1423,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1259
1423
  return isRecord(cfg) ? cfg : {};
1260
1424
  })();
1261
1425
 
1262
- const resolvedModel =
1426
+ let resolvedModel =
1263
1427
  isRecord(knownModel) &&
1264
1428
  typeof knownModel.api === "string" &&
1265
1429
  typeof knownModel.provider === "string" &&
@@ -1268,18 +1432,29 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1268
1432
  ...knownModel,
1269
1433
  id: knownModel.id,
1270
1434
  provider: knownModel.provider,
1271
- api: knownModel.api,
1272
- // Merge baseUrl/headers from provider config if not already on the model.
1435
+ api:
1436
+ typeof providerLevelConfig.api === "string" && providerLevelConfig.api.trim()
1437
+ ? providerLevelConfig.api.trim()
1438
+ : knownModel.api,
1439
+ // Provider config must be able to override built-in transport defaults.
1440
+ // Otherwise built-in providers like `openai` keep their catalog baseUrl
1441
+ // (`https://api.openai.com/v1`) even when OpenClaw runtime config points
1442
+ // that provider id at a custom proxy.
1273
1443
  // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
1274
1444
  // baseUrl is undefined.
1275
1445
  baseUrl:
1276
- typeof knownModel.baseUrl === "string"
1277
- ? knownModel.baseUrl
1278
- : typeof providerLevelConfig.baseUrl === "string"
1279
- ? providerLevelConfig.baseUrl
1446
+ typeof providerLevelConfig.baseUrl === "string"
1447
+ ? providerLevelConfig.baseUrl
1448
+ : typeof knownModel.baseUrl === "string"
1449
+ ? knownModel.baseUrl
1280
1450
  : "",
1281
- ...(knownModel.headers == null && isRecord(providerLevelConfig.headers)
1282
- ? { headers: providerLevelConfig.headers }
1451
+ ...(isRecord(providerLevelConfig.headers)
1452
+ ? {
1453
+ headers: {
1454
+ ...(isRecord(knownModel.headers) ? knownModel.headers : {}),
1455
+ ...providerLevelConfig.headers,
1456
+ },
1457
+ }
1283
1458
  : {}),
1284
1459
  }
1285
1460
  : {
@@ -1295,7 +1470,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1295
1470
  cacheRead: 0,
1296
1471
  cacheWrite: 0,
1297
1472
  },
1298
- contextWindow: 200_000,
1473
+ contextWindow: 1_000_000,
1299
1474
  maxTokens: 8_000,
1300
1475
  // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
1301
1476
  // baseUrl is undefined.
@@ -1307,8 +1482,51 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1307
1482
  : {}),
1308
1483
  };
1309
1484
 
1485
+ let runtimeAuth: RuntimeModelAuthResult | undefined;
1486
+ if (modelAuth && skipModelAuth !== true && typeof modelAuth.getRuntimeAuthForModel === "function") {
1487
+ try {
1488
+ runtimeAuth = await modelAuth.getRuntimeAuthForModel({
1489
+ model: buildModelAuthLookupModel({
1490
+ provider: providerId,
1491
+ model: modelId,
1492
+ api: resolvedModel.api,
1493
+ contextWindow: resolvedModel.contextWindow,
1494
+ }),
1495
+ cfg: modelAuthConfig,
1496
+ ...(authProfileId ? { profileId: authProfileId } : {}),
1497
+ workspaceDir,
1498
+ });
1499
+ } catch (err) {
1500
+ console.error(
1501
+ `[lcm] modelAuth.getRuntimeAuthForModel FAILED:`,
1502
+ err instanceof Error ? err.message : err,
1503
+ );
1504
+ }
1505
+ }
1506
+
1507
+ const runtimeAuthBaseUrl = resolveBaseUrlFromAuthResult(runtimeAuth);
1508
+ const runtimeAuthHeaders = resolveRuntimeAuthHeaders(runtimeAuth?.request);
1509
+ resolvedModel = attachRuntimeAuthRequestTransport(
1510
+ {
1511
+ ...resolvedModel,
1512
+ ...(runtimeAuthBaseUrl ? { baseUrl: runtimeAuthBaseUrl } : {}),
1513
+ ...(runtimeAuthHeaders
1514
+ ? {
1515
+ headers: {
1516
+ ...(isRecord(resolvedModel.headers) ? resolvedModel.headers : {}),
1517
+ ...runtimeAuthHeaders,
1518
+ },
1519
+ }
1520
+ : {}),
1521
+ },
1522
+ runtimeAuth?.request,
1523
+ );
1524
+
1310
1525
  let resolvedApiKey = apiKey?.trim();
1311
- if (!resolvedApiKey && modelAuth) {
1526
+ if (!resolvedApiKey) {
1527
+ resolvedApiKey = resolveApiKeyFromAuthResult(runtimeAuth);
1528
+ }
1529
+ if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1312
1530
  try {
1313
1531
  resolvedApiKey = resolveApiKeyFromAuthResult(
1314
1532
  await modelAuth.getApiKeyForModel({
@@ -1316,32 +1534,27 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1316
1534
  provider: providerId,
1317
1535
  model: modelId,
1318
1536
  api: resolvedModel.api,
1537
+ contextWindow: resolvedModel.contextWindow,
1319
1538
  }),
1320
- cfg: api.config,
1539
+ cfg: modelAuthConfig,
1321
1540
  ...(authProfileId ? { profileId: authProfileId } : {}),
1322
1541
  }),
1323
1542
  );
1324
1543
  } catch (err) {
1325
- console.error(
1326
- `[lcm] modelAuth.getApiKeyForModel FAILED:`,
1327
- err instanceof Error ? err.message : err,
1328
- );
1544
+ log.warn(`[lcm] modelAuth.getApiKeyForModel FAILED: ${describeLogError(err)}`);
1329
1545
  }
1330
1546
  }
1331
- if (!resolvedApiKey && modelAuth) {
1547
+ if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1332
1548
  try {
1333
1549
  resolvedApiKey = resolveApiKeyFromAuthResult(
1334
1550
  await modelAuth.resolveApiKeyForProvider({
1335
1551
  provider: providerId,
1336
- cfg: api.config,
1552
+ cfg: modelAuthConfig,
1337
1553
  ...(authProfileId ? { profileId: authProfileId } : {}),
1338
1554
  }),
1339
1555
  );
1340
1556
  } catch (err) {
1341
- console.error(
1342
- `[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
1343
- err instanceof Error ? err.message : err,
1344
- );
1557
+ log.warn(`[lcm] modelAuth.resolveApiKeyForProvider FAILED: ${describeLogError(err)}`);
1345
1558
  }
1346
1559
  }
1347
1560
  if (!resolvedApiKey) {
@@ -1426,11 +1639,21 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1426
1639
  ...requestMetadata,
1427
1640
  };
1428
1641
  } catch (err) {
1429
- console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
1642
+ log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
1430
1643
  const authError = detectProviderAuthError(err);
1644
+ const configError =
1645
+ !authError &&
1646
+ err instanceof Error &&
1647
+ err.message.startsWith(PROVIDER_API_RESOLUTION_ERROR_PREFIX)
1648
+ ? {
1649
+ kind: "provider_config",
1650
+ message: err.message,
1651
+ }
1652
+ : undefined;
1431
1653
  return {
1432
1654
  content: [],
1433
1655
  ...(authError ? { error: authError } : {}),
1656
+ ...(configError ? { error: configError } : {}),
1434
1657
  };
1435
1658
  }
1436
1659
  },
@@ -1536,15 +1759,66 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1536
1759
  }
1537
1760
  },
1538
1761
  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
- },
1762
+ log,
1545
1763
  };
1546
1764
  }
1547
1765
 
1766
+ /**
1767
+ * Wire event handlers, context engines, tools, and commands to the
1768
+ * OpenClaw plugin API using shared init closures.
1769
+ */
1770
+ function wirePluginHandlers(
1771
+ api: OpenClawPluginApi,
1772
+ deps: LcmDependencies,
1773
+ shared: SharedLcmInit,
1774
+ ): void {
1775
+ api.on("before_reset", async (event, ctx) => {
1776
+ await (await shared.waitForEngine()).handleBeforeReset({
1777
+ reason: event.reason,
1778
+ sessionId: ctx.sessionId,
1779
+ sessionKey: ctx.sessionKey,
1780
+ });
1781
+ });
1782
+ api.on("before_prompt_build", () => ({
1783
+ prependSystemContext: LOSSLESS_RECALL_POLICY_PROMPT,
1784
+ }));
1785
+ api.on("session_end", async (event) => {
1786
+ const lifecycleEvent = event as SessionEndLifecycleEvent;
1787
+ await (await shared.waitForEngine()).handleSessionEnd({
1788
+ reason: lifecycleEvent.reason,
1789
+ sessionId: lifecycleEvent.sessionId,
1790
+ sessionKey: lifecycleEvent.sessionKey,
1791
+ nextSessionId: lifecycleEvent.nextSessionId,
1792
+ nextSessionKey: lifecycleEvent.nextSessionKey,
1793
+ });
1794
+ });
1795
+
1796
+ api.registerContextEngine("lossless-claw", () => shared.getCachedEngine() ?? shared.waitForEngine());
1797
+ api.registerContextEngine("default", () => shared.getCachedEngine() ?? shared.waitForEngine());
1798
+
1799
+ api.registerTool((ctx) =>
1800
+ createLcmGrepTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
1801
+ );
1802
+ api.registerTool((ctx) =>
1803
+ createLcmDescribeTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
1804
+ );
1805
+ api.registerTool((ctx) =>
1806
+ createLcmExpandTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
1807
+ );
1808
+ api.registerTool((ctx) =>
1809
+ createLcmExpandQueryTool({
1810
+ deps,
1811
+ getLcm: shared.waitForEngine,
1812
+ sessionKey: ctx.sessionKey,
1813
+ requesterSessionKey: ctx.sessionKey,
1814
+ }),
1815
+ );
1816
+
1817
+ api.registerCommand(
1818
+ createLcmCommand({ db: shared.waitForDatabase, config: deps.config, deps }),
1819
+ );
1820
+ }
1821
+
1548
1822
  const lcmPlugin = {
1549
1823
  id: "lossless-claw",
1550
1824
  name: "Lossless Context Management",
@@ -1563,82 +1837,200 @@ const lcmPlugin = {
1563
1837
 
1564
1838
  register(api: OpenClawPluginApi) {
1565
1839
  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,
1840
+ const dbPath = deps.config.databasePath;
1841
+ const normalizedDbPath = normalizePath(dbPath);
1842
+
1843
+ // ── Singleton check ─────────────────────────────────────────────
1844
+ // OpenClaw v2026.4.5+ calls register() per-agent-context (main,
1845
+ // subagents, cron lanes). Reuse the existing connection and engine
1846
+ // when the same DB path is already initialized.
1847
+ const existingInit = getSharedInit(normalizedDbPath);
1848
+ if (existingInit && !existingInit.stopped) {
1849
+ deps.log.info(`[lcm] Reusing shared engine init for db=${normalizedDbPath}`);
1850
+ wirePluginHandlers(api, deps, existingInit);
1851
+ return;
1852
+ }
1853
+
1854
+ // ── Eager-first DB init with deferred fallback on lock ──────────
1855
+ let database: DatabaseSync | null = null;
1856
+ let lcm: LcmContextEngine | null = null;
1857
+ let initPromise: Promise<LcmContextEngine> | null = null;
1858
+ let initError: Error | null = null;
1859
+ let resolveDeferredInit: ((engine: LcmContextEngine) => void) | null = null;
1860
+ let rejectDeferredInit: ((error: Error) => void) | null = null;
1861
+ let stopped = false;
1862
+
1863
+ /** Normalize unknown failures into stable Error instances. */
1864
+ function toInitError(error: unknown): Error {
1865
+ return error instanceof Error ? error : new Error(String(error));
1866
+ }
1867
+
1868
+ /** Build a live DB+engine pair and roll back the DB handle if engine init fails. */
1869
+ function initializeEngine(): LcmContextEngine {
1870
+ const startedAt = Date.now();
1871
+ const nextDatabase = createLcmDatabaseConnection(dbPath);
1872
+ try {
1873
+ const nextEngine = new LcmContextEngine(deps, nextDatabase);
1874
+ database = nextDatabase;
1875
+ lcm = nextEngine;
1876
+ initError = null;
1877
+ deps.log.info(
1878
+ `[lcm] Engine initialized for db=${normalizedDbPath} duration=${Date.now() - startedAt}ms`,
1879
+ );
1880
+ return nextEngine;
1881
+ } catch (error) {
1882
+ closeLcmConnection(nextDatabase);
1883
+ deps.log.info(
1884
+ `[lcm] Engine init failed for db=${normalizedDbPath} duration=${Date.now() - startedAt}ms error=${toInitError(error).message}`,
1885
+ );
1886
+ throw error;
1887
+ }
1888
+ }
1889
+
1890
+ /** Keep one shared deferred init promise so early callers all await the same retry. */
1891
+ function ensureDeferredInitPromise(): Promise<LcmContextEngine> {
1892
+ if (initPromise) {
1893
+ return initPromise;
1894
+ }
1895
+
1896
+ initPromise = new Promise<LcmContextEngine>((resolve, reject) => {
1897
+ resolveDeferredInit = resolve;
1898
+ rejectDeferredInit = reject;
1574
1899
  });
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,
1900
+ initPromise.catch(() => {});
1901
+ return initPromise;
1902
+ }
1903
+
1904
+ /** Resolve the shared deferred init promise exactly once. */
1905
+ function resolveDeferredEngine(nextEngine: LcmContextEngine): void {
1906
+ const resolve = resolveDeferredInit;
1907
+ resolveDeferredInit = null;
1908
+ rejectDeferredInit = null;
1909
+ resolve?.(nextEngine);
1910
+ }
1911
+
1912
+ /** Reject the shared deferred init promise exactly once and retain the root cause. */
1913
+ function rejectDeferredEngine(error: Error): void {
1914
+ initError = error;
1915
+ const reject = rejectDeferredInit;
1916
+ resolveDeferredInit = null;
1917
+ rejectDeferredInit = null;
1918
+ reject?.(error);
1919
+ }
1920
+
1921
+ /** Return the initialized engine, waiting for deferred startup when the DB is lock-contended. */
1922
+ async function waitForEngine(): Promise<LcmContextEngine> {
1923
+ if (stopped) {
1924
+ throw new Error("[lcm] Database connection closed after gateway_stop");
1925
+ }
1926
+ if (initError) {
1927
+ throw initError;
1928
+ }
1929
+ if (lcm) {
1930
+ return lcm;
1931
+ }
1932
+ if (initPromise) {
1933
+ return initPromise;
1934
+ }
1935
+
1936
+ try {
1937
+ const nextEngine = initializeEngine();
1938
+ initPromise = Promise.resolve(nextEngine);
1939
+ return nextEngine;
1940
+ } catch (error) {
1941
+ const normalized = toInitError(error);
1942
+ if (!/database is locked/i.test(normalized.message)) {
1943
+ initError = normalized;
1944
+ throw normalized;
1945
+ }
1946
+
1947
+ deps.log.warn("[lcm] DB locked during eager init, deferring to gateway_start");
1948
+ return ensureDeferredInitPromise();
1949
+ }
1950
+ }
1951
+
1952
+ /** Return the initialized DB handle, sharing the same wait/error semantics as the engine. */
1953
+ async function waitForDatabase(): Promise<DatabaseSync> {
1954
+ await waitForEngine();
1955
+ if (!database) {
1956
+ throw initError ?? new Error("[lcm] Database initialization finished without a handle");
1957
+ }
1958
+ return database;
1959
+ }
1960
+
1961
+ try {
1962
+ const nextEngine = initializeEngine();
1963
+ initPromise = Promise.resolve(nextEngine);
1964
+ } catch (error) {
1965
+ const normalized = toInitError(error);
1966
+ if (!/database is locked/i.test(normalized.message)) {
1967
+ initError = normalized;
1968
+ throw normalized;
1969
+ }
1970
+
1971
+ deps.log.warn("[lcm] DB locked during eager init, deferring to gateway_start");
1972
+ ensureDeferredInitPromise();
1973
+ api.on("gateway_start", async () => {
1974
+ if (stopped || lcm || initError) {
1975
+ return;
1976
+ }
1977
+ try {
1978
+ const nextEngine = initializeEngine();
1979
+ initPromise = Promise.resolve(nextEngine);
1980
+ resolveDeferredEngine(nextEngine);
1981
+ } catch (retryError) {
1982
+ const normalizedRetryError = toInitError(retryError);
1983
+ rejectDeferredEngine(normalizedRetryError);
1984
+ deps.log.error(`[lcm] Deferred DB init failed: ${normalizedRetryError.message}`);
1985
+ }
1587
1986
  });
1987
+ }
1988
+
1989
+ const shared: SharedLcmInit = {
1990
+ stopped: false,
1991
+ getCachedEngine: () => lcm,
1992
+ waitForEngine,
1993
+ waitForDatabase,
1994
+ };
1995
+ setSharedInit(normalizedDbPath, shared);
1996
+
1997
+ api.on("gateway_stop", async () => {
1998
+ stopped = true;
1999
+ shared.stopped = true;
2000
+ if (!lcm && !database) {
2001
+ rejectDeferredEngine(new Error("[lcm] Database connection closed after gateway_stop"));
2002
+ }
2003
+ if (database) {
2004
+ closeLcmConnection(database);
2005
+ database = null;
2006
+ }
2007
+ lcm = null;
2008
+ removeSharedInit(normalizedDbPath);
1588
2009
  });
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
- );
2010
+
2011
+ wirePluginHandlers(api, deps, shared);
1627
2012
 
1628
2013
  logStartupBannerOnce({
1629
2014
  key: "plugin-loaded",
1630
- log: (message) => console.error(message),
2015
+ log: (message) => deps.log.info(message),
1631
2016
  message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
1632
2017
  });
1633
2018
  logStartupBannerOnce({
1634
2019
  key: "compaction-model",
1635
- log: (message) => console.error(message),
2020
+ log: (message) => deps.log.info(message),
1636
2021
  message: buildCompactionModelLog({
1637
2022
  config: deps.config,
1638
2023
  openClawConfig: api.config,
1639
2024
  defaultProvider: process.env.OPENCLAW_PROVIDER?.trim() ?? "",
1640
2025
  }),
1641
2026
  });
2027
+ if (deps.config.fallbackProviders.length > 0) {
2028
+ logStartupBannerOnce({
2029
+ key: "fallback-providers",
2030
+ log: (message) => deps.log.info(message),
2031
+ message: `[lcm] Fallback providers: ${deps.config.fallbackProviders.map((fp) => `${fp.provider}/${fp.model}`).join(", ")}`,
2032
+ });
2033
+ }
1642
2034
  },
1643
2035
  };
1644
2036