@martian-engineering/lossless-claw 0.8.0 → 0.8.2

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 (52) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +971 -0
  3. package/docs/configuration.md +15 -5
  4. package/openclaw.plugin.json +27 -3
  5. package/package.json +7 -6
  6. package/skills/lossless-claw/references/config.md +37 -0
  7. package/index.ts +0 -2
  8. package/src/assembler.ts +0 -1196
  9. package/src/compaction.ts +0 -1753
  10. package/src/db/config.ts +0 -345
  11. package/src/db/connection.ts +0 -151
  12. package/src/db/features.ts +0 -61
  13. package/src/db/migration.ts +0 -868
  14. package/src/engine.ts +0 -4486
  15. package/src/estimate-tokens.ts +0 -80
  16. package/src/expansion-auth.ts +0 -365
  17. package/src/expansion-policy.ts +0 -303
  18. package/src/expansion.ts +0 -383
  19. package/src/integrity.ts +0 -600
  20. package/src/large-files.ts +0 -546
  21. package/src/lcm-log.ts +0 -37
  22. package/src/openclaw-bridge.ts +0 -22
  23. package/src/plugin/index.ts +0 -2037
  24. package/src/plugin/lcm-command.ts +0 -1040
  25. package/src/plugin/lcm-doctor-apply.ts +0 -540
  26. package/src/plugin/lcm-doctor-cleaners.ts +0 -655
  27. package/src/plugin/lcm-doctor-shared.ts +0 -210
  28. package/src/plugin/shared-init.ts +0 -59
  29. package/src/prune.ts +0 -391
  30. package/src/retrieval.ts +0 -360
  31. package/src/session-patterns.ts +0 -23
  32. package/src/startup-banner-log.ts +0 -49
  33. package/src/store/compaction-telemetry-store.ts +0 -156
  34. package/src/store/conversation-store.ts +0 -929
  35. package/src/store/fts5-sanitize.ts +0 -50
  36. package/src/store/full-text-fallback.ts +0 -83
  37. package/src/store/full-text-sort.ts +0 -21
  38. package/src/store/index.ts +0 -39
  39. package/src/store/parse-utc-timestamp.ts +0 -25
  40. package/src/store/summary-store.ts +0 -1519
  41. package/src/summarize.ts +0 -1508
  42. package/src/tools/common.ts +0 -53
  43. package/src/tools/lcm-conversation-scope.ts +0 -127
  44. package/src/tools/lcm-describe-tool.ts +0 -245
  45. package/src/tools/lcm-expand-query-tool.ts +0 -1235
  46. package/src/tools/lcm-expand-tool.delegation.ts +0 -580
  47. package/src/tools/lcm-expand-tool.ts +0 -453
  48. package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
  49. package/src/tools/lcm-grep-tool.ts +0 -228
  50. package/src/transaction-mutex.ts +0 -136
  51. package/src/transcript-repair.ts +0 -301
  52. package/src/types.ts +0 -165
@@ -1,2037 +0,0 @@
1
- /**
2
- * @martian-engineering/lossless-claw — Lossless Context Management plugin for OpenClaw
3
- *
4
- * DAG-based conversation summarization with incremental compaction,
5
- * full-text search, and sub-agent expansion.
6
- */
7
- import { readFileSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import type { DatabaseSync } from "node:sqlite";
10
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
11
- import { resolveLcmConfig } from "../db/config.js";
12
- import { closeLcmConnection, createLcmDatabaseConnection, normalizePath } from "../db/connection.js";
13
- import { LcmContextEngine } from "../engine.js";
14
- import { createLcmLogger, describeLogError } from "../lcm-log.js";
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";
18
- import { createLcmDescribeTool } from "../tools/lcm-describe-tool.js";
19
- import { createLcmExpandQueryTool } from "../tools/lcm-expand-query-tool.js";
20
- import { createLcmExpandTool } from "../tools/lcm-expand-tool.js";
21
- import { createLcmGrepTool } from "../tools/lcm-grep-tool.js";
22
- import { createLcmCommand } from "./lcm-command.js";
23
- import type { LcmDependencies } from "../types.js";
24
-
25
- /** Parse `agent:<agentId>:<suffix...>` session keys. */
26
- function parseAgentSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
27
- const value = sessionKey.trim();
28
- if (!value.startsWith("agent:")) {
29
- return null;
30
- }
31
- const parts = value.split(":");
32
- if (parts.length < 3) {
33
- return null;
34
- }
35
- const agentId = parts[1]?.trim();
36
- const suffix = parts.slice(2).join(":").trim();
37
- if (!agentId || !suffix) {
38
- return null;
39
- }
40
- return { agentId, suffix };
41
- }
42
-
43
- /** Return a stable normalized agent id. */
44
- function normalizeAgentId(agentId: string | undefined): string {
45
- const normalized = (agentId ?? "").trim();
46
- return normalized.length > 0 ? normalized : "main";
47
- }
48
-
49
- type PluginEnvSnapshot = {
50
- lcmSummaryModel: string;
51
- lcmSummaryProvider: string;
52
- pluginSummaryModel: string;
53
- pluginSummaryProvider: string;
54
- openclawProvider: string;
55
- openclawDefaultModel: string;
56
- agentDir: string;
57
- home: string;
58
- };
59
-
60
- type ReadEnvFn = (key: string) => string | undefined;
61
-
62
- type CompleteSimpleOptions = {
63
- apiKey?: string;
64
- maxTokens: number;
65
- temperature?: number;
66
- reasoning?: string;
67
- };
68
-
69
- type RuntimeModelAuthResult = {
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;
102
- };
103
-
104
- type RuntimeModelAuthModel = {
105
- id: string;
106
- provider: string;
107
- api: string;
108
- name?: string;
109
- reasoning?: boolean;
110
- input?: string[];
111
- cost?: {
112
- input: number;
113
- output: number;
114
- cacheRead: number;
115
- cacheWrite: number;
116
- };
117
- contextWindow?: number;
118
- maxTokens?: number;
119
- };
120
-
121
- type RuntimeModelAuth = {
122
- getApiKeyForModel: (params: {
123
- model: RuntimeModelAuthModel;
124
- cfg?: OpenClawPluginApi["config"];
125
- profileId?: string;
126
- preferredProfile?: string;
127
- }) => Promise<RuntimeModelAuthResult | undefined>;
128
- resolveApiKeyForProvider: (params: {
129
- provider: string;
130
- cfg?: OpenClawPluginApi["config"];
131
- profileId?: string;
132
- preferredProfile?: string;
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>;
141
- };
142
-
143
- const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
144
- const MODEL_AUTH_MERGE_COMMIT = "4790e40";
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 ";
147
- const AUTH_ERROR_TEXT_PATTERN =
148
- /\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i;
149
- const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const;
150
- const AUTH_ERROR_NESTED_KEYS = ["error", "response", "cause", "details", "data", "body"] as const;
151
-
152
- type CompletionBridgeErrorInfo = {
153
- kind: "provider_auth";
154
- statusCode?: number;
155
- code?: string;
156
- message?: string;
157
- };
158
-
159
- const LOSSLESS_RECALL_POLICY_PROMPT = [
160
- "## Lossless Recall Policy",
161
- "",
162
- "The lossless-claw plugin is active.",
163
- "",
164
- "For compacted conversation history, these instructions supersede generic memory-recall guidance. Prefer lossless-claw recall tools first when answering questions about prior conversation content, decisions made in the conversation, or details that may have been compacted.",
165
- "",
166
- "**Conflict handling:** If newer evidence conflicts with an older summary or recollection, prefer the newer evidence. Do not trust a stale summary over fresher contradictory information.",
167
- "",
168
- "**Contradictions/uncertainty:** If facts seem contradictory or uncertain, verify with lossless-claw recall tools before answering instead of trusting the summary at face value.",
169
- "",
170
- "**Tool escalation:**",
171
- "Recall order for compacted conversation history:",
172
- "1. `lcm_grep` — search by regex or full-text across messages and summaries",
173
- "2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
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.',
184
- "",
185
- "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
186
- "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
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`.",
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.",
201
- "",
202
- "These precedence rules apply only to compacted conversation history. Lossless-claw does not supersede memory tools globally.",
203
- "",
204
- "If a summary conflicts with newer evidence, prefer the newer evidence. Do not guess exact commands, SHAs, paths, timestamps, config values, or causal claims from compacted summaries when expansion is needed.",
205
- ].join("\n");
206
-
207
- /** Capture plugin env values once during initialization. */
208
- function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
209
- return {
210
- lcmSummaryModel: env.LCM_SUMMARY_MODEL?.trim() ?? "",
211
- lcmSummaryProvider: env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
212
- pluginSummaryModel: "",
213
- pluginSummaryProvider: "",
214
- openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
215
- openclawDefaultModel: "",
216
- agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
217
- home: env.HOME?.trim() ?? "",
218
- };
219
- }
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
-
242
- function truncateErrorMessage(message: string, maxChars = 240): string {
243
- return message.length <= maxChars ? message : `${message.slice(0, maxChars)}...`;
244
- }
245
-
246
- function collectErrorText(value: unknown, out: string[], depth = 0): void {
247
- if (depth >= 4) {
248
- return;
249
- }
250
- if (typeof value === "string") {
251
- const trimmed = value.trim();
252
- if (trimmed) {
253
- out.push(trimmed);
254
- }
255
- return;
256
- }
257
- if (Array.isArray(value)) {
258
- for (const entry of value.slice(0, 8)) {
259
- collectErrorText(entry, out, depth + 1);
260
- }
261
- return;
262
- }
263
- if (!isRecord(value)) {
264
- return;
265
- }
266
-
267
- for (const entry of Object.values(value).slice(0, 12)) {
268
- collectErrorText(entry, out, depth + 1);
269
- }
270
- }
271
-
272
- function extractErrorStatusCode(value: unknown, depth = 0): number | undefined {
273
- if (depth >= 4 || !isRecord(value)) {
274
- return undefined;
275
- }
276
-
277
- for (const key of AUTH_ERROR_STATUS_KEYS) {
278
- const candidate = value[key];
279
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
280
- return Math.trunc(candidate);
281
- }
282
- if (typeof candidate === "string") {
283
- const parsed = Number.parseInt(candidate, 10);
284
- if (Number.isFinite(parsed)) {
285
- return parsed;
286
- }
287
- }
288
- }
289
-
290
- for (const key of AUTH_ERROR_NESTED_KEYS) {
291
- const nested = value[key];
292
- const statusCode = extractErrorStatusCode(nested, depth + 1);
293
- if (statusCode !== undefined) {
294
- return statusCode;
295
- }
296
- }
297
-
298
- return undefined;
299
- }
300
-
301
- function detectProviderAuthError(error: unknown): CompletionBridgeErrorInfo | undefined {
302
- const statusCode = extractErrorStatusCode(error);
303
- const textParts: string[] = [];
304
- collectErrorText(error, textParts);
305
- const normalizedMessage = textParts.join(" ").replace(/\s+/g, " ").trim();
306
-
307
- if (statusCode !== 401 && !AUTH_ERROR_TEXT_PATTERN.test(normalizedMessage)) {
308
- return undefined;
309
- }
310
-
311
- const directCode =
312
- isRecord(error) && typeof error.code === "string" && error.code.trim()
313
- ? error.code.trim()
314
- : isRecord(error) &&
315
- isRecord(error.error) &&
316
- typeof error.error.code === "string" &&
317
- error.error.code.trim()
318
- ? error.error.code.trim()
319
- : undefined;
320
-
321
- return {
322
- kind: "provider_auth",
323
- ...(statusCode !== undefined ? { statusCode } : {}),
324
- ...(directCode ? { code: directCode } : {}),
325
- ...(normalizedMessage ? { message: truncateErrorMessage(normalizedMessage) } : {}),
326
- };
327
- }
328
-
329
- /** Read OpenClaw's configured default model from the validated runtime config. */
330
- function readDefaultModelFromConfig(config: unknown): string {
331
- if (!config || typeof config !== "object") {
332
- return "";
333
- }
334
-
335
- const model = (config as { agents?: { defaults?: { model?: unknown } } }).agents?.defaults?.model;
336
- if (typeof model === "string") {
337
- return model.trim();
338
- }
339
-
340
- const primary = (model as { primary?: unknown } | undefined)?.primary;
341
- return typeof primary === "string" ? primary.trim() : "";
342
- }
343
-
344
- /** Read OpenClaw's configured compaction model from the validated runtime config. */
345
- function readCompactionModelFromConfig(config: unknown): string {
346
- if (!config || typeof config !== "object") {
347
- return "";
348
- }
349
-
350
- const compaction = (config as {
351
- agents?: {
352
- defaults?: {
353
- compaction?: {
354
- model?: unknown;
355
- };
356
- };
357
- };
358
- }).agents?.defaults?.compaction;
359
- const model = compaction?.model;
360
- if (typeof model === "string") {
361
- return model.trim();
362
- }
363
-
364
- const primary = (model as { primary?: unknown } | undefined)?.primary;
365
- return typeof primary === "string" ? primary.trim() : "";
366
- }
367
-
368
- /** Format a provider/model pair for logs. */
369
- function formatProviderModel(params: { provider: string; model: string }): string {
370
- return `${params.provider}/${params.model}`;
371
- }
372
-
373
- /** Build a startup log showing which compaction model LCM will use. */
374
- function buildCompactionModelLog(params: {
375
- config: LcmConfig;
376
- openClawConfig: unknown;
377
- defaultProvider: string;
378
- }): string {
379
- const envSummaryModel = process.env.LCM_SUMMARY_MODEL?.trim() ?? "";
380
- const envSummaryProvider = process.env.LCM_SUMMARY_PROVIDER?.trim() ?? "";
381
- const pluginSummaryModel = params.config.summaryModel.trim();
382
- const pluginSummaryProvider = params.config.summaryProvider.trim();
383
- const compactionModelRef = readCompactionModelFromConfig(params.openClawConfig);
384
- const defaultModelRef = readDefaultModelFromConfig(params.openClawConfig);
385
- const selected =
386
- envSummaryModel
387
- ? { raw: envSummaryModel, source: "override" as const }
388
- : pluginSummaryModel
389
- ? { raw: pluginSummaryModel, source: "override" as const }
390
- : compactionModelRef
391
- ? { raw: compactionModelRef, source: "override" as const }
392
- : defaultModelRef
393
- ? { raw: defaultModelRef, source: "default" as const }
394
- : undefined;
395
- const usingOverride =
396
- selected?.source === "override" || Boolean(envSummaryProvider || pluginSummaryProvider);
397
- const raw = selected?.raw.trim() ?? "";
398
- if (!raw) {
399
- return "[lcm] Compaction summarization model: (unconfigured)";
400
- }
401
-
402
- if (raw.includes("/")) {
403
- const [provider, ...rest] = raw.split("/");
404
- const model = rest.join("/").trim();
405
- if (provider && model) {
406
- return `[lcm] Compaction summarization model: ${formatProviderModel({
407
- provider: provider.trim(),
408
- model,
409
- })} (${usingOverride ? "override" : "default"})`;
410
- }
411
- }
412
-
413
- const provider = (
414
- envSummaryProvider ||
415
- pluginSummaryProvider ||
416
- params.defaultProvider ||
417
- "openai"
418
- ).trim();
419
- return `[lcm] Compaction summarization model: ${formatProviderModel({
420
- provider,
421
- model: raw,
422
- })} (${usingOverride ? "override" : "default"})`;
423
- }
424
-
425
- /** Resolve common provider API keys from environment. */
426
- function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
427
- const keyMap: Record<string, string[]> = {
428
- openai: ["OPENAI_API_KEY"],
429
- anthropic: ["ANTHROPIC_API_KEY"],
430
- google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
431
- groq: ["GROQ_API_KEY"],
432
- xai: ["XAI_API_KEY"],
433
- mistral: ["MISTRAL_API_KEY"],
434
- together: ["TOGETHER_API_KEY"],
435
- openrouter: ["OPENROUTER_API_KEY"],
436
- "github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
437
- };
438
-
439
- const providerKey = provider.trim().toLowerCase();
440
- const keys = keyMap[providerKey] ?? [];
441
- const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
442
- keys.push(normalizedProviderEnv);
443
-
444
- for (const key of keys) {
445
- const value = readEnv(key)?.trim();
446
- if (value) {
447
- return value;
448
- }
449
- }
450
- return undefined;
451
- }
452
-
453
- /** A SecretRef pointing to a value inside secrets.json via a nested path. */
454
- type SecretRef = {
455
- source?: string;
456
- provider?: string;
457
- id: string;
458
- };
459
-
460
- type SecretProviderConfig = {
461
- source?: string;
462
- path?: string;
463
- mode?: string;
464
- };
465
-
466
- type AuthProfileCredential =
467
- | { type: "api_key"; provider: string; key?: string; keyRef?: SecretRef; email?: string }
468
- | { type: "token"; provider: string; token?: string; tokenRef?: SecretRef; expires?: number; email?: string }
469
- | ({
470
- type: "oauth";
471
- provider: string;
472
- access?: string;
473
- refresh?: string;
474
- expires?: number;
475
- email?: string;
476
- } & Record<string, unknown>);
477
-
478
- type AuthProfileStore = {
479
- profiles: Record<string, AuthProfileCredential>;
480
- order?: Record<string, string[]>;
481
- };
482
-
483
- type PiAiOAuthCredentials = {
484
- refresh: string;
485
- access: string;
486
- expires: number;
487
- [key: string]: unknown;
488
- };
489
-
490
- type PiAiModule = {
491
- completeSimple?: (
492
- model: {
493
- id: string;
494
- provider: string;
495
- api: string;
496
- name?: string;
497
- reasoning?: boolean;
498
- input?: string[];
499
- cost?: {
500
- input: number;
501
- output: number;
502
- cacheRead: number;
503
- cacheWrite: number;
504
- };
505
- contextWindow?: number;
506
- maxTokens?: number;
507
- },
508
- request: {
509
- systemPrompt?: string;
510
- messages: Array<{ role: string; content: unknown; timestamp?: number }>;
511
- },
512
- options: {
513
- apiKey?: string;
514
- maxTokens: number;
515
- temperature?: number;
516
- reasoning?: string;
517
- },
518
- ) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
519
- getModel?: (provider: string, modelId: string) => unknown;
520
- getModels?: (provider: string) => unknown[];
521
- getEnvApiKey?: (provider: string) => string | undefined;
522
- getOAuthApiKey?: (
523
- providerId: string,
524
- credentials: Record<string, PiAiOAuthCredentials>,
525
- ) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
526
- };
527
-
528
- /** Narrow unknown values to plain objects. */
529
- function isRecord(value: unknown): value is Record<string, unknown> {
530
- return !!value && typeof value === "object" && !Array.isArray(value);
531
- }
532
-
533
- /** Normalize provider ids for case-insensitive matching. */
534
- function normalizeProviderId(provider: string): string {
535
- return provider.trim().toLowerCase();
536
- }
537
-
538
- /** Resolve known provider API defaults when model lookup misses. */
539
- function inferApiFromProvider(provider: string): string | undefined {
540
- const normalized = normalizeProviderId(provider);
541
- const map: Record<string, string> = {
542
- anthropic: "anthropic-messages",
543
- openai: "openai-responses",
544
- "openai-codex": "openai-codex-responses",
545
- "github-copilot": "openai-codex-responses",
546
- google: "google-generative-ai",
547
- "google-gemini-cli": "google-gemini-cli",
548
- "google-antigravity": "google-gemini-cli",
549
- "google-vertex": "google-vertex",
550
- "amazon-bedrock": "bedrock-converse-stream",
551
- };
552
- return map[normalized];
553
- }
554
-
555
- /** Codex Responses rejects `temperature`; omit it for that API family. */
556
- export function shouldOmitTemperatureForApi(api: string | undefined): boolean {
557
- return (api ?? "").trim().toLowerCase() === "openai-codex-responses";
558
- }
559
-
560
- /** Build provider-aware options for pi-ai completeSimple. */
561
- export function buildCompleteSimpleOptions(params: {
562
- api: string | undefined;
563
- apiKey: string | undefined;
564
- maxTokens: number;
565
- temperature: number | undefined;
566
- reasoning: string | undefined;
567
- }): CompleteSimpleOptions {
568
- const options: CompleteSimpleOptions = {
569
- apiKey: params.apiKey,
570
- maxTokens: params.maxTokens,
571
- };
572
-
573
- if (
574
- typeof params.temperature === "number" &&
575
- Number.isFinite(params.temperature) &&
576
- !shouldOmitTemperatureForApi(params.api)
577
- ) {
578
- options.temperature = params.temperature;
579
- }
580
-
581
- if (typeof params.reasoning === "string" && params.reasoning.trim()) {
582
- options.reasoning = params.reasoning.trim();
583
- }
584
-
585
- return options;
586
- }
587
-
588
- /** Select provider-specific config values with case-insensitive provider keys. */
589
- function findProviderConfigValue<T>(
590
- map: Record<string, T> | undefined,
591
- provider: string,
592
- ): T | undefined {
593
- if (!map) {
594
- return undefined;
595
- }
596
- if (map[provider] !== undefined) {
597
- return map[provider];
598
- }
599
- const normalizedProvider = normalizeProviderId(provider);
600
- for (const [key, value] of Object.entries(map)) {
601
- if (normalizeProviderId(key) === normalizedProvider) {
602
- return value;
603
- }
604
- }
605
- return undefined;
606
- }
607
-
608
- /** Resolve provider API from runtime config if available. */
609
- function resolveProviderApiFromRuntimeConfig(
610
- runtimeConfig: unknown,
611
- provider: string,
612
- ): string | undefined {
613
- if (!isRecord(runtimeConfig)) {
614
- return undefined;
615
- }
616
- const providers = (runtimeConfig as { models?: { providers?: Record<string, unknown> } }).models
617
- ?.providers;
618
- if (!providers || !isRecord(providers)) {
619
- return undefined;
620
- }
621
- const value = findProviderConfigValue(providers, provider);
622
- if (!isRecord(value)) {
623
- return undefined;
624
- }
625
- const api = value.api;
626
- return typeof api === "string" && api.trim() ? api.trim() : undefined;
627
- }
628
-
629
- /** Resolve runtime.modelAuth from plugin runtime when available. */
630
- function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth | undefined {
631
- const runtime = api.runtime as OpenClawPluginApi["runtime"] & {
632
- modelAuth?: RuntimeModelAuth;
633
- };
634
- return runtime.modelAuth;
635
- }
636
-
637
- /** Build the minimal model shape required by runtime.modelAuth.getApiKeyForModel(). */
638
- function buildModelAuthLookupModel(params: {
639
- provider: string;
640
- model: string;
641
- api?: string;
642
- contextWindow?: number;
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
-
649
- return {
650
- id: params.model,
651
- name: params.model,
652
- provider: params.provider,
653
- api: params.api?.trim() || inferApiFromProvider(params.provider) || "",
654
- reasoning: false,
655
- input: ["text"],
656
- cost: {
657
- input: 0,
658
- output: 0,
659
- cacheRead: 0,
660
- cacheWrite: 0,
661
- },
662
- contextWindow,
663
- maxTokens: 8_000,
664
- };
665
- }
666
-
667
- /** Normalize an auth result down to the API key that pi-ai expects. */
668
- function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
669
- const apiKey = auth?.apiKey?.trim();
670
- return apiKey ? apiKey : undefined;
671
- }
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
-
745
- function buildLegacyAuthFallbackWarning(): string {
746
- return [
747
- "[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
748
- `Stock lossless-claw 0.2.7 expects OpenClaw plugin runtime support from PR #41090 (${MODEL_AUTH_PR_URL}).`,
749
- `OpenClaw 2026.3.8 and 2026.3.8-beta.1 do not include merge commit ${MODEL_AUTH_MERGE_COMMIT};`,
750
- `${MODEL_AUTH_REQUIRED_RELEASE} is required for stock lossless-claw 0.2.7 without this fallback patch.`,
751
- ].join(" ");
752
- }
753
-
754
- /** Parse auth-profiles JSON into a minimal store shape. */
755
- function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
756
- try {
757
- const parsed = JSON.parse(raw) as unknown;
758
- if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
759
- return undefined;
760
- }
761
-
762
- const profiles: Record<string, AuthProfileCredential> = {};
763
- for (const [profileId, value] of Object.entries(parsed.profiles)) {
764
- if (!isRecord(value)) {
765
- continue;
766
- }
767
- const type = value.type;
768
- const provider = typeof value.provider === "string" ? value.provider.trim() : "";
769
- if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
770
- continue;
771
- }
772
- profiles[profileId] = value as AuthProfileCredential;
773
- }
774
-
775
- const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
776
- const order: Record<string, string[]> | undefined = rawOrder
777
- ? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
778
- if (!Array.isArray(value)) {
779
- return acc;
780
- }
781
- const ids = value
782
- .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
783
- .filter(Boolean);
784
- if (ids.length > 0) {
785
- acc[provider] = ids;
786
- }
787
- return acc;
788
- }, {})
789
- : undefined;
790
-
791
- return {
792
- profiles,
793
- ...(order && Object.keys(order).length > 0 ? { order } : {}),
794
- };
795
- } catch {
796
- return undefined;
797
- }
798
- }
799
-
800
- /** Merge auth stores, letting later stores override earlier profiles/order. */
801
- function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
802
- if (stores.length === 0) {
803
- return undefined;
804
- }
805
- const merged: AuthProfileStore = { profiles: {} };
806
- for (const store of stores) {
807
- merged.profiles = { ...merged.profiles, ...store.profiles };
808
- if (store.order) {
809
- merged.order = { ...(merged.order ?? {}), ...store.order };
810
- }
811
- }
812
- return merged;
813
- }
814
-
815
- /** Determine candidate auth store paths ordered by precedence. */
816
- function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
817
- const paths: string[] = [];
818
- const directAgentDir = params.agentDir?.trim();
819
- if (directAgentDir) {
820
- paths.push(join(directAgentDir, "auth-profiles.json"));
821
- }
822
-
823
- const envAgentDir = params.envSnapshot.agentDir;
824
- if (envAgentDir) {
825
- paths.push(join(envAgentDir, "auth-profiles.json"));
826
- }
827
-
828
- const home = params.envSnapshot.home;
829
- if (home) {
830
- paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
831
- }
832
-
833
- return [...new Set(paths)];
834
- }
835
-
836
- /** Build profile selection order for provider auth lookup. */
837
- function resolveAuthProfileCandidates(params: {
838
- provider: string;
839
- store: AuthProfileStore;
840
- authProfileId?: string;
841
- runtimeConfig?: unknown;
842
- }): string[] {
843
- const candidates: string[] = [];
844
- const normalizedProvider = normalizeProviderId(params.provider);
845
- const push = (value: string | undefined) => {
846
- const profileId = value?.trim();
847
- if (!profileId) {
848
- return;
849
- }
850
- if (!candidates.includes(profileId)) {
851
- candidates.push(profileId);
852
- }
853
- };
854
-
855
- push(params.authProfileId);
856
-
857
- const storeOrder = findProviderConfigValue(params.store.order, params.provider);
858
- for (const profileId of storeOrder ?? []) {
859
- push(profileId);
860
- }
861
-
862
- if (isRecord(params.runtimeConfig)) {
863
- const auth = params.runtimeConfig.auth;
864
- if (isRecord(auth)) {
865
- const order = findProviderConfigValue(
866
- isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
867
- params.provider,
868
- );
869
- if (Array.isArray(order)) {
870
- for (const profileId of order) {
871
- if (typeof profileId === "string") {
872
- push(profileId);
873
- }
874
- }
875
- }
876
- }
877
- }
878
-
879
- for (const [profileId, credential] of Object.entries(params.store.profiles)) {
880
- if (normalizeProviderId(credential.provider) === normalizedProvider) {
881
- push(profileId);
882
- }
883
- }
884
-
885
- return candidates;
886
- }
887
-
888
- /**
889
- * Resolve a SecretRef (tokenRef/keyRef) to a credential string.
890
- *
891
- * OpenClaw's auth-profiles support a level of indirection: instead of storing
892
- * the raw API key or token inline, a credential can reference it via a
893
- * SecretRef. Two resolution strategies are supported:
894
- *
895
- * 1. `source: "env"` — read the value from an environment variable whose
896
- * name is `ref.id` (e.g. `{ source: "env", id: "ANTHROPIC_API_KEY" }`).
897
- *
898
- * 2. File-based — resolve against a configured `secrets.providers.<provider>`
899
- * file provider when available. JSON-mode providers walk slash-delimited
900
- * paths, while singleValue providers use the sentinel id `value`.
901
- *
902
- * 3. Legacy fallback — when no file provider config is available, fall back to
903
- * `~/.openclaw/secrets.json` for backward compatibility.
904
- */
905
- function resolveSecretRef(params: {
906
- ref: SecretRef | undefined;
907
- home: string;
908
- config?: unknown;
909
- }): string | undefined {
910
- const ref = params.ref;
911
- if (!ref?.id) return undefined;
912
-
913
- // source: env — read directly from environment variable
914
- if (ref.source === "env") {
915
- const val = process.env[ref.id]?.trim();
916
- return val || undefined;
917
- }
918
-
919
- // File-based provider config — use configured file provider when present.
920
- try {
921
- const providers = isRecord(params.config)
922
- ? (params.config as { secrets?: { providers?: Record<string, unknown> } }).secrets?.providers
923
- : undefined;
924
- const providerName = ref.provider?.trim() || "default";
925
- const provider =
926
- providers && isRecord(providers)
927
- ? providers[providerName]
928
- : undefined;
929
- if (isRecord(provider) && provider.source === "file" && typeof provider.path === "string") {
930
- const configuredPath = provider.path.trim();
931
- const filePath =
932
- configuredPath.startsWith("~/") && params.home
933
- ? join(params.home, configuredPath.slice(2))
934
- : configuredPath;
935
- if (!filePath) {
936
- return undefined;
937
- }
938
- const raw = readFileSync(filePath, "utf8");
939
- if (provider.mode === "singleValue") {
940
- if (ref.id.trim() !== "value") {
941
- return undefined;
942
- }
943
- const value = raw.trim();
944
- return value || undefined;
945
- }
946
-
947
- const secrets = JSON.parse(raw) as Record<string, unknown>;
948
- const parts = ref.id.replace(/^\//, "").split("/");
949
- let current: unknown = secrets;
950
- for (const part of parts) {
951
- if (!current || typeof current !== "object") return undefined;
952
- current = (current as Record<string, unknown>)[part];
953
- }
954
- return typeof current === "string" && current.trim() ? current.trim() : undefined;
955
- }
956
- } catch {
957
- // Fall through to the legacy secrets.json lookup below.
958
- }
959
-
960
- // Legacy file fallback (source: "file" or unset) — read from ~/.openclaw/secrets.json
961
- try {
962
- const secretsPath = join(params.home, ".openclaw", "secrets.json");
963
- const raw = readFileSync(secretsPath, "utf8");
964
- const secrets = JSON.parse(raw) as Record<string, unknown>;
965
- const parts = ref.id.replace(/^\//, "").split("/");
966
- let current: unknown = secrets;
967
- for (const part of parts) {
968
- if (!current || typeof current !== "object") return undefined;
969
- current = (current as Record<string, unknown>)[part];
970
- }
971
- return typeof current === "string" && current.trim() ? current.trim() : undefined;
972
- } catch {
973
- return undefined;
974
- }
975
- }
976
-
977
- /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
978
- async function resolveApiKeyFromAuthProfiles(params: {
979
- provider: string;
980
- authProfileId?: string;
981
- agentDir?: string;
982
- runtimeConfig?: unknown;
983
- appConfig?: unknown;
984
- piAiModule: PiAiModule;
985
- envSnapshot: PluginEnvSnapshot;
986
- }): Promise<string | undefined> {
987
- const storesWithPaths = resolveAuthStorePaths({
988
- agentDir: params.agentDir,
989
- envSnapshot: params.envSnapshot,
990
- })
991
- .map((path) => {
992
- try {
993
- const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
994
- return parsed ? { path, store: parsed } : undefined;
995
- } catch {
996
- return undefined;
997
- }
998
- })
999
- .filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
1000
- if (storesWithPaths.length === 0) {
1001
- return undefined;
1002
- }
1003
-
1004
- const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
1005
- if (!mergedStore) {
1006
- return undefined;
1007
- }
1008
-
1009
- const candidates = resolveAuthProfileCandidates({
1010
- provider: params.provider,
1011
- store: mergedStore,
1012
- authProfileId: params.authProfileId,
1013
- runtimeConfig: params.runtimeConfig,
1014
- });
1015
- if (candidates.length === 0) {
1016
- return undefined;
1017
- }
1018
-
1019
- const persistPath =
1020
- params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
1021
- const secretConfig = (() => {
1022
- if (isRecord(params.runtimeConfig)) {
1023
- const runtimeProviders = (params.runtimeConfig as {
1024
- secrets?: { providers?: Record<string, unknown> };
1025
- }).secrets?.providers;
1026
- if (isRecord(runtimeProviders) && Object.keys(runtimeProviders).length > 0) {
1027
- return params.runtimeConfig;
1028
- }
1029
- }
1030
- return params.appConfig ?? params.runtimeConfig;
1031
- })();
1032
-
1033
- for (const profileId of candidates) {
1034
- const credential = mergedStore.profiles[profileId];
1035
- if (!credential) {
1036
- continue;
1037
- }
1038
- if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
1039
- continue;
1040
- }
1041
-
1042
- if (credential.type === "api_key") {
1043
- const key =
1044
- credential.key?.trim() ||
1045
- resolveSecretRef({
1046
- ref: credential.keyRef,
1047
- home: params.envSnapshot.home,
1048
- config: secretConfig,
1049
- });
1050
- if (key) {
1051
- return key;
1052
- }
1053
- continue;
1054
- }
1055
-
1056
- if (credential.type === "token") {
1057
- const token =
1058
- credential.token?.trim() ||
1059
- resolveSecretRef({
1060
- ref: credential.tokenRef,
1061
- home: params.envSnapshot.home,
1062
- config: secretConfig,
1063
- });
1064
- if (!token) {
1065
- continue;
1066
- }
1067
- const expires = credential.expires;
1068
- if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
1069
- continue;
1070
- }
1071
- return token;
1072
- }
1073
-
1074
- const access = credential.access?.trim();
1075
- const expires = credential.expires;
1076
- const isExpired =
1077
- typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
1078
- const shouldPreferOAuthHelper =
1079
- typeof params.piAiModule.getOAuthApiKey === "function" &&
1080
- normalizeProviderId(params.provider) === "openai-codex";
1081
-
1082
- if (shouldPreferOAuthHelper) {
1083
- try {
1084
- const oauthCredential = {
1085
- access: credential.access ?? "",
1086
- refresh: credential.refresh ?? "",
1087
- expires: typeof credential.expires === "number" ? credential.expires : 0,
1088
- ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
1089
- ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
1090
- };
1091
- const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
1092
- [params.provider]: oauthCredential,
1093
- });
1094
- if (refreshed?.apiKey) {
1095
- mergedStore.profiles[profileId] = {
1096
- ...credential,
1097
- ...refreshed.newCredentials,
1098
- type: "oauth",
1099
- };
1100
- if (persistPath) {
1101
- try {
1102
- writeFileSync(
1103
- persistPath,
1104
- JSON.stringify(
1105
- {
1106
- version: 1,
1107
- profiles: mergedStore.profiles,
1108
- ...(mergedStore.order ? { order: mergedStore.order } : {}),
1109
- },
1110
- null,
1111
- 2,
1112
- ),
1113
- "utf8",
1114
- );
1115
- } catch {
1116
- // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
1117
- }
1118
- }
1119
- return refreshed.apiKey;
1120
- }
1121
- } catch {
1122
- // Fall back to the cached access token below when helper resolution fails.
1123
- }
1124
- }
1125
-
1126
- if (!isExpired && access) {
1127
- if (
1128
- (credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
1129
- typeof credential.projectId === "string" &&
1130
- credential.projectId.trim()
1131
- ) {
1132
- return JSON.stringify({
1133
- token: access,
1134
- projectId: credential.projectId.trim(),
1135
- });
1136
- }
1137
- return access;
1138
- }
1139
-
1140
- if (typeof params.piAiModule.getOAuthApiKey !== "function") {
1141
- continue;
1142
- }
1143
-
1144
- try {
1145
- const oauthCredential = {
1146
- access: credential.access ?? "",
1147
- refresh: credential.refresh ?? "",
1148
- expires: typeof credential.expires === "number" ? credential.expires : 0,
1149
- ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
1150
- ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
1151
- };
1152
- const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
1153
- [params.provider]: oauthCredential,
1154
- });
1155
- if (!refreshed?.apiKey) {
1156
- continue;
1157
- }
1158
- mergedStore.profiles[profileId] = {
1159
- ...credential,
1160
- ...refreshed.newCredentials,
1161
- type: "oauth",
1162
- };
1163
- if (persistPath) {
1164
- try {
1165
- writeFileSync(
1166
- persistPath,
1167
- JSON.stringify(
1168
- {
1169
- version: 1,
1170
- profiles: mergedStore.profiles,
1171
- ...(mergedStore.order ? { order: mergedStore.order } : {}),
1172
- },
1173
- null,
1174
- 2,
1175
- ),
1176
- "utf8",
1177
- );
1178
- } catch {
1179
- // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
1180
- }
1181
- }
1182
- return refreshed.apiKey;
1183
- } catch {
1184
- if (access) {
1185
- return access;
1186
- }
1187
- }
1188
- }
1189
-
1190
- return undefined;
1191
- }
1192
-
1193
- /** Build a minimal but useful sub-agent prompt. */
1194
- function buildSubagentSystemPrompt(params: {
1195
- depth: number;
1196
- maxDepth: number;
1197
- taskSummary?: string;
1198
- }): string {
1199
- const task = params.taskSummary?.trim() || "Perform delegated LCM expansion work.";
1200
- return [
1201
- "You are a delegated sub-agent for LCM expansion.",
1202
- `Depth: ${params.depth}/${params.maxDepth}`,
1203
- "Return concise, factual results only.",
1204
- task,
1205
- ].join("\n");
1206
- }
1207
-
1208
- /** Extract latest assistant text from session message snapshots. */
1209
- function readLatestAssistantReply(messages: unknown[]): string | undefined {
1210
- for (let i = messages.length - 1; i >= 0; i--) {
1211
- const item = messages[i];
1212
- if (!item || typeof item !== "object") {
1213
- continue;
1214
- }
1215
- const record = item as { role?: unknown; content?: unknown };
1216
- if (record.role !== "assistant") {
1217
- continue;
1218
- }
1219
-
1220
- if (typeof record.content === "string") {
1221
- const trimmed = record.content.trim();
1222
- if (trimmed) {
1223
- return trimmed;
1224
- }
1225
- continue;
1226
- }
1227
-
1228
- if (!Array.isArray(record.content)) {
1229
- continue;
1230
- }
1231
-
1232
- const text = record.content
1233
- .filter((entry): entry is { type?: unknown; text?: unknown } => {
1234
- return !!entry && typeof entry === "object";
1235
- })
1236
- .map((entry) => (entry.type === "text" && typeof entry.text === "string" ? entry.text : ""))
1237
- .filter(Boolean)
1238
- .join("\n")
1239
- .trim();
1240
-
1241
- if (text) {
1242
- return text;
1243
- }
1244
- }
1245
-
1246
- return undefined;
1247
- }
1248
-
1249
- /** Construct LCM dependencies from plugin API/runtime surfaces. */
1250
- function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1251
- const envSnapshot = snapshotPluginEnv();
1252
- envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
1253
- const modelAuth = getRuntimeModelAuth(api);
1254
- const readEnv: ReadEnvFn = (key) => process.env[key];
1255
- const pluginConfig = resolvePluginConfig(api);
1256
- const config = resolveLcmConfig(process.env, pluginConfig);
1257
- const log = createLcmLogger(api);
1258
-
1259
- // Read model overrides from plugin config
1260
- if (pluginConfig) {
1261
- const summaryModel = pluginConfig.summaryModel;
1262
- const summaryProvider = pluginConfig.summaryProvider;
1263
- if (typeof summaryModel === "string") {
1264
- envSnapshot.pluginSummaryModel = summaryModel.trim();
1265
- }
1266
- if (typeof summaryProvider === "string") {
1267
- envSnapshot.pluginSummaryProvider = summaryProvider.trim();
1268
- }
1269
- }
1270
-
1271
- if (!modelAuth) {
1272
- log.warn(buildLegacyAuthFallbackWarning());
1273
- }
1274
-
1275
- /** Resolve the best config object to hand to runtime.modelAuth for this lookup. */
1276
- const resolveModelAuthConfig = (runtimeConfig: unknown): OpenClawPluginApi["config"] => {
1277
- if (runtimeConfig && typeof runtimeConfig === "object") {
1278
- return runtimeConfig as OpenClawPluginApi["config"];
1279
- }
1280
- return api.config;
1281
- };
1282
-
1283
- /** Resolve an API key without throwing so summarizer auth fallback can retry safely. */
1284
- const lookupApiKey = async (
1285
- provider: string,
1286
- model: string,
1287
- options?: {
1288
- profileId?: string;
1289
- preferredProfile?: string;
1290
- agentDir?: string;
1291
- runtimeConfig?: unknown;
1292
- skipModelAuth?: boolean;
1293
- },
1294
- ): Promise<string | undefined> => {
1295
- const modelAuthConfig = resolveModelAuthConfig(options?.runtimeConfig);
1296
-
1297
- if (modelAuth && options?.skipModelAuth !== true) {
1298
- try {
1299
- const modelAuthKey = resolveApiKeyFromAuthResult(
1300
- await modelAuth.getApiKeyForModel({
1301
- model: buildModelAuthLookupModel({ provider, model, contextWindow: 1_000_000 }),
1302
- cfg: modelAuthConfig,
1303
- ...(options?.profileId ? { profileId: options.profileId } : {}),
1304
- ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
1305
- }),
1306
- );
1307
- if (modelAuthKey) {
1308
- return modelAuthKey;
1309
- }
1310
- } catch {
1311
- // Fall through to env/auth-profile lookup for older or scope-limited runtimes.
1312
- }
1313
- }
1314
-
1315
- const envKey = resolveApiKey(provider, readEnv);
1316
- if (envKey) {
1317
- return envKey;
1318
- }
1319
-
1320
- const piAiModuleId = "@mariozechner/pi-ai";
1321
- const mod = (await import(piAiModuleId)) as PiAiModule;
1322
- return resolveApiKeyFromAuthProfiles({
1323
- provider,
1324
- authProfileId: options?.profileId,
1325
- agentDir: options?.agentDir ?? api.resolvePath("."),
1326
- runtimeConfig: options?.runtimeConfig,
1327
- appConfig: api.config,
1328
- piAiModule: mod,
1329
- envSnapshot,
1330
- });
1331
- };
1332
-
1333
- return {
1334
- config,
1335
- isRuntimeManagedAuthProvider: (provider: string, providerApi?: string) => {
1336
- const normalizedProvider = normalizeProviderId(provider);
1337
- if (normalizedProvider === "openai-codex" || normalizedProvider === "github-copilot") {
1338
- return true;
1339
- }
1340
- return shouldOmitTemperatureForApi(providerApi);
1341
- },
1342
- complete: async ({
1343
- provider,
1344
- model,
1345
- apiKey,
1346
- providerApi,
1347
- authProfileId,
1348
- agentDir,
1349
- runtimeConfig,
1350
- skipModelAuth,
1351
- messages,
1352
- system,
1353
- maxTokens,
1354
- temperature,
1355
- reasoning,
1356
- }) => {
1357
- try {
1358
- const piAiModuleId = "@mariozechner/pi-ai";
1359
- const mod = (await import(piAiModuleId)) as PiAiModule;
1360
-
1361
- if (typeof mod.completeSimple !== "function") {
1362
- return { content: [] };
1363
- }
1364
-
1365
- const providerId = (provider ?? "").trim();
1366
- const modelId = model.trim();
1367
- if (!providerId || !modelId) {
1368
- return { content: [] };
1369
- }
1370
- const workspaceDir = agentDir?.trim() || api.resolvePath(".");
1371
-
1372
- // When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
1373
- // passes legacyParams without config), fall back to the plugin API so
1374
- // provider-level baseUrl/headers/apiKey are always resolvable.
1375
- let effectiveRuntimeConfig = runtimeConfig;
1376
- if (!isRecord(effectiveRuntimeConfig)) {
1377
- try {
1378
- effectiveRuntimeConfig = api.runtime.config.loadConfig();
1379
- } catch {
1380
- // loadConfig may not be available in all contexts; leave undefined.
1381
- }
1382
- }
1383
-
1384
- const knownModel =
1385
- typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
1386
- const fallbackApi =
1387
- (isRecord(knownModel) && typeof knownModel.api === "string" && knownModel.api.trim()
1388
- ? knownModel.api.trim()
1389
- : undefined) ||
1390
- providerApi?.trim() ||
1391
- resolveProviderApiFromRuntimeConfig(effectiveRuntimeConfig, providerId) ||
1392
- (() => {
1393
- if (typeof mod.getModels !== "function") {
1394
- return undefined;
1395
- }
1396
- const models = mod.getModels(providerId);
1397
- const first = Array.isArray(models) ? models[0] : undefined;
1398
- if (!isRecord(first) || typeof first.api !== "string" || !first.api.trim()) {
1399
- return undefined;
1400
- }
1401
- return first.api.trim();
1402
- })() ||
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);
1410
-
1411
- // Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
1412
- // Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
1413
- // apiKey under models.providers.<provider> in openclaw.json. Without this
1414
- // lookup the resolved model object lacks baseUrl, which crashes pi-ai's
1415
- // detectCompat() ("Cannot read properties of undefined (reading 'includes')"),
1416
- // and the apiKey is unresolvable, causing 401 errors. See #19.
1417
- const providerLevelConfig: Record<string, unknown> = (() => {
1418
- if (!isRecord(effectiveRuntimeConfig)) return {};
1419
- const providers = (effectiveRuntimeConfig as { models?: { providers?: Record<string, unknown> } })
1420
- .models?.providers;
1421
- if (!providers) return {};
1422
- const cfg = findProviderConfigValue(providers, providerId);
1423
- return isRecord(cfg) ? cfg : {};
1424
- })();
1425
-
1426
- let resolvedModel =
1427
- isRecord(knownModel) &&
1428
- typeof knownModel.api === "string" &&
1429
- typeof knownModel.provider === "string" &&
1430
- typeof knownModel.id === "string"
1431
- ? {
1432
- ...knownModel,
1433
- id: knownModel.id,
1434
- provider: knownModel.provider,
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.
1443
- // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
1444
- // baseUrl is undefined.
1445
- baseUrl:
1446
- typeof providerLevelConfig.baseUrl === "string"
1447
- ? providerLevelConfig.baseUrl
1448
- : typeof knownModel.baseUrl === "string"
1449
- ? knownModel.baseUrl
1450
- : "",
1451
- ...(isRecord(providerLevelConfig.headers)
1452
- ? {
1453
- headers: {
1454
- ...(isRecord(knownModel.headers) ? knownModel.headers : {}),
1455
- ...providerLevelConfig.headers,
1456
- },
1457
- }
1458
- : {}),
1459
- }
1460
- : {
1461
- id: modelId,
1462
- name: modelId,
1463
- provider: providerId,
1464
- api: fallbackApi,
1465
- reasoning: false,
1466
- input: ["text"],
1467
- cost: {
1468
- input: 0,
1469
- output: 0,
1470
- cacheRead: 0,
1471
- cacheWrite: 0,
1472
- },
1473
- contextWindow: 1_000_000,
1474
- maxTokens: 8_000,
1475
- // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
1476
- // baseUrl is undefined.
1477
- baseUrl: typeof providerLevelConfig.baseUrl === "string"
1478
- ? providerLevelConfig.baseUrl
1479
- : "",
1480
- ...(isRecord(providerLevelConfig.headers)
1481
- ? { headers: providerLevelConfig.headers }
1482
- : {}),
1483
- };
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
-
1525
- let resolvedApiKey = apiKey?.trim();
1526
- if (!resolvedApiKey) {
1527
- resolvedApiKey = resolveApiKeyFromAuthResult(runtimeAuth);
1528
- }
1529
- if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1530
- try {
1531
- resolvedApiKey = resolveApiKeyFromAuthResult(
1532
- await modelAuth.getApiKeyForModel({
1533
- model: buildModelAuthLookupModel({
1534
- provider: providerId,
1535
- model: modelId,
1536
- api: resolvedModel.api,
1537
- contextWindow: resolvedModel.contextWindow,
1538
- }),
1539
- cfg: modelAuthConfig,
1540
- ...(authProfileId ? { profileId: authProfileId } : {}),
1541
- }),
1542
- );
1543
- } catch (err) {
1544
- log.warn(`[lcm] modelAuth.getApiKeyForModel FAILED: ${describeLogError(err)}`);
1545
- }
1546
- }
1547
- if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
1548
- try {
1549
- resolvedApiKey = resolveApiKeyFromAuthResult(
1550
- await modelAuth.resolveApiKeyForProvider({
1551
- provider: providerId,
1552
- cfg: modelAuthConfig,
1553
- ...(authProfileId ? { profileId: authProfileId } : {}),
1554
- }),
1555
- );
1556
- } catch (err) {
1557
- log.warn(`[lcm] modelAuth.resolveApiKeyForProvider FAILED: ${describeLogError(err)}`);
1558
- }
1559
- }
1560
- if (!resolvedApiKey) {
1561
- resolvedApiKey = resolveApiKey(providerId, readEnv);
1562
- }
1563
- if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
1564
- resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
1565
- }
1566
- if (!resolvedApiKey) {
1567
- resolvedApiKey = await resolveApiKeyFromAuthProfiles({
1568
- provider: providerId,
1569
- authProfileId,
1570
- agentDir,
1571
- appConfig: api.config,
1572
- runtimeConfig: effectiveRuntimeConfig,
1573
- piAiModule: mod,
1574
- envSnapshot,
1575
- });
1576
- }
1577
- // Fallback: read apiKey from models.providers config (e.g. proxy providers
1578
- // with keys like "not-needed-for-cli-proxy").
1579
- if (!resolvedApiKey && isRecord(effectiveRuntimeConfig)) {
1580
- const providers = (effectiveRuntimeConfig as { models?: { providers?: Record<string, unknown> } })
1581
- .models?.providers;
1582
- if (providers) {
1583
- const providerCfg = findProviderConfigValue(providers, providerId);
1584
- if (isRecord(providerCfg) && typeof providerCfg.apiKey === "string") {
1585
- const cfgKey = providerCfg.apiKey.trim();
1586
- if (cfgKey) {
1587
- resolvedApiKey = cfgKey;
1588
- }
1589
- }
1590
- }
1591
- }
1592
-
1593
- const completeOptions = buildCompleteSimpleOptions({
1594
- api: resolvedModel.api,
1595
- apiKey: resolvedApiKey,
1596
- maxTokens,
1597
- temperature,
1598
- reasoning,
1599
- });
1600
- const requestMetadata = {
1601
- request_provider: providerId,
1602
- request_model: modelId,
1603
- request_api: resolvedModel.api,
1604
- request_reasoning:
1605
- typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
1606
- request_has_system: typeof system === "string" && system.trim().length > 0 ? "true" : "false",
1607
- request_temperature:
1608
- typeof completeOptions.temperature === "number"
1609
- ? String(completeOptions.temperature)
1610
- : "(omitted)",
1611
- request_temperature_sent: typeof completeOptions.temperature === "number" ? "true" : "false",
1612
- };
1613
-
1614
- const result = await mod.completeSimple(
1615
- resolvedModel,
1616
- {
1617
- ...(typeof system === "string" && system.trim()
1618
- ? { systemPrompt: system.trim() }
1619
- : {}),
1620
- messages: messages.map((message) => ({
1621
- role: message.role,
1622
- content: message.content,
1623
- timestamp: Date.now(),
1624
- })),
1625
- },
1626
- completeOptions,
1627
- );
1628
-
1629
- if (!isRecord(result)) {
1630
- return {
1631
- content: [],
1632
- ...requestMetadata,
1633
- };
1634
- }
1635
-
1636
- return {
1637
- ...result,
1638
- content: Array.isArray(result.content) ? result.content : [],
1639
- ...requestMetadata,
1640
- };
1641
- } catch (err) {
1642
- log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
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;
1653
- return {
1654
- content: [],
1655
- ...(authError ? { error: authError } : {}),
1656
- ...(configError ? { error: configError } : {}),
1657
- };
1658
- }
1659
- },
1660
- callGateway: async (params) => {
1661
- const sub = api.runtime.subagent;
1662
- switch (params.method) {
1663
- case "agent":
1664
- return sub.run({
1665
- sessionKey: String(params.params?.sessionKey ?? ""),
1666
- message: String(params.params?.message ?? ""),
1667
- provider: params.params?.provider as string | undefined,
1668
- model: params.params?.model as string | undefined,
1669
- extraSystemPrompt: params.params?.extraSystemPrompt as string | undefined,
1670
- lane: params.params?.lane as string | undefined,
1671
- deliver: (params.params?.deliver as boolean) ?? false,
1672
- idempotencyKey: params.params?.idempotencyKey as string | undefined,
1673
- });
1674
- case "agent.wait":
1675
- return sub.waitForRun({
1676
- runId: String(params.params?.runId ?? ""),
1677
- timeoutMs: (params.params?.timeoutMs as number) ?? params.timeoutMs,
1678
- });
1679
- case "sessions.get":
1680
- return sub.getSession({
1681
- sessionKey: String(params.params?.key ?? ""),
1682
- limit: params.params?.limit as number | undefined,
1683
- });
1684
- case "sessions.delete":
1685
- await sub.deleteSession({
1686
- sessionKey: String(params.params?.key ?? ""),
1687
- deleteTranscript: (params.params?.deleteTranscript as boolean) ?? true,
1688
- });
1689
- return {};
1690
- default:
1691
- throw new Error(`Unsupported gateway method in LCM plugin: ${params.method}`);
1692
- }
1693
- },
1694
- resolveModel: (modelRef, providerHint) => {
1695
- const raw =
1696
- (envSnapshot.lcmSummaryModel ||
1697
- config.summaryModel ||
1698
- modelRef?.trim() ||
1699
- envSnapshot.openclawDefaultModel).trim();
1700
- if (!raw) {
1701
- throw new Error("No model configured for LCM summarization.");
1702
- }
1703
-
1704
- if (raw.includes("/")) {
1705
- const [provider, ...rest] = raw.split("/");
1706
- const model = rest.join("/").trim();
1707
- if (provider && model) {
1708
- return { provider: provider.trim(), model };
1709
- }
1710
- }
1711
-
1712
- const provider = (
1713
- providerHint?.trim() ||
1714
- envSnapshot.lcmSummaryProvider ||
1715
- config.summaryProvider ||
1716
- envSnapshot.openclawProvider ||
1717
- "openai"
1718
- ).trim();
1719
- return { provider, model: raw };
1720
- },
1721
- getApiKey: async (provider, model, options) => {
1722
- return lookupApiKey(provider, model, options);
1723
- },
1724
- requireApiKey: async (provider, model, options) => {
1725
- const key = await lookupApiKey(provider, model, options);
1726
- if (!key) {
1727
- throw new Error(`Missing API key for provider '${provider}' (model '${model}').`);
1728
- }
1729
- return key;
1730
- },
1731
- parseAgentSessionKey,
1732
- isSubagentSessionKey: (sessionKey) => {
1733
- const parsed = parseAgentSessionKey(sessionKey);
1734
- return !!parsed && parsed.suffix.startsWith("subagent:");
1735
- },
1736
- normalizeAgentId,
1737
- buildSubagentSystemPrompt,
1738
- readLatestAssistantReply,
1739
- resolveAgentDir: () => api.resolvePath("."),
1740
- resolveSessionIdFromSessionKey: async (sessionKey) => {
1741
- const key = sessionKey.trim();
1742
- if (!key) {
1743
- return undefined;
1744
- }
1745
-
1746
- try {
1747
- const cfg = api.runtime.config.loadConfig();
1748
- const parsed = parseAgentSessionKey(key);
1749
- const agentId = normalizeAgentId(parsed?.agentId);
1750
- const storePath = api.runtime.channel.session.resolveStorePath(cfg.session?.store, {
1751
- agentId,
1752
- });
1753
- const raw = readFileSync(storePath, "utf8");
1754
- const store = JSON.parse(raw) as Record<string, { sessionId?: string } | undefined>;
1755
- const sessionId = store[key]?.sessionId;
1756
- return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
1757
- } catch {
1758
- return undefined;
1759
- }
1760
- },
1761
- agentLaneSubagent: "subagent",
1762
- log,
1763
- };
1764
- }
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
-
1822
- const lcmPlugin = {
1823
- id: "lossless-claw",
1824
- name: "Lossless Context Management",
1825
- description:
1826
- "DAG-based conversation summarization with incremental compaction, full-text search, and sub-agent expansion",
1827
-
1828
- configSchema: {
1829
- parse(value: unknown) {
1830
- const raw =
1831
- value && typeof value === "object" && !Array.isArray(value)
1832
- ? (value as Record<string, unknown>)
1833
- : {};
1834
- return resolveLcmConfig(process.env, raw);
1835
- },
1836
- },
1837
-
1838
- register(api: OpenClawPluginApi) {
1839
- const deps = createLcmDependencies(api);
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;
1899
- });
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
- }
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);
2009
- });
2010
-
2011
- wirePluginHandlers(api, deps, shared);
2012
-
2013
- logStartupBannerOnce({
2014
- key: "plugin-loaded",
2015
- log: (message) => deps.log.info(message),
2016
- message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
2017
- });
2018
- logStartupBannerOnce({
2019
- key: "compaction-model",
2020
- log: (message) => deps.log.info(message),
2021
- message: buildCompactionModelLog({
2022
- config: deps.config,
2023
- openClawConfig: api.config,
2024
- defaultProvider: process.env.OPENCLAW_PROVIDER?.trim() ?? "",
2025
- }),
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
- }
2034
- },
2035
- };
2036
-
2037
- export default lcmPlugin;