@jonathangu/openclawbrain 0.3.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/bin/openclawbrain.js +15 -0
  4. package/docs/END_STATE.md +244 -0
  5. package/docs/EVIDENCE.md +128 -0
  6. package/docs/RELEASE_CONTRACT.md +91 -0
  7. package/docs/agent-tools.md +106 -0
  8. package/docs/architecture.md +224 -0
  9. package/docs/configuration.md +178 -0
  10. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/status.json +87 -0
  11. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/summary.md +16 -0
  12. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/trace.json +273 -0
  13. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/validation-report.json +652 -0
  14. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/channels-status.txt +31 -0
  15. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/config-snapshot.json +66 -0
  16. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/doctor.json +14 -0
  17. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-probe.txt +34 -0
  18. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-status.txt +41 -0
  19. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/logs.txt +428 -0
  20. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status-all.txt +60 -0
  21. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status.json +223 -0
  22. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/summary.md +13 -0
  23. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/trace.json +4 -0
  24. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/validation-report.json +334 -0
  25. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/channels-status.txt +25 -0
  26. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/config-snapshot.json +91 -0
  27. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/doctor.json +14 -0
  28. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-probe.txt +36 -0
  29. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-status.txt +44 -0
  30. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/logs.txt +428 -0
  31. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-doctor.json +10 -0
  32. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-sdk-probe.json +11 -0
  33. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-setup-only.json +12 -0
  34. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/summary.md +30 -0
  35. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/validation-report.json +72 -0
  36. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status-all.txt +63 -0
  37. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status.json +200 -0
  38. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/summary.md +13 -0
  39. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/trace.json +4 -0
  40. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/validation-report.json +311 -0
  41. package/docs/evidence/README.md +16 -0
  42. package/docs/fts5.md +161 -0
  43. package/docs/tui.md +506 -0
  44. package/index.ts +1372 -0
  45. package/openclaw.plugin.json +136 -0
  46. package/package.json +66 -0
  47. package/src/assembler.ts +804 -0
  48. package/src/brain-cli.ts +316 -0
  49. package/src/brain-core/decay.ts +35 -0
  50. package/src/brain-core/episode.ts +82 -0
  51. package/src/brain-core/graph.ts +321 -0
  52. package/src/brain-core/health.ts +116 -0
  53. package/src/brain-core/mutator.ts +281 -0
  54. package/src/brain-core/pack.ts +117 -0
  55. package/src/brain-core/policy.ts +153 -0
  56. package/src/brain-core/replay.ts +1 -0
  57. package/src/brain-core/teacher.ts +105 -0
  58. package/src/brain-core/trace.ts +40 -0
  59. package/src/brain-core/traverse.ts +230 -0
  60. package/src/brain-core/types.ts +405 -0
  61. package/src/brain-core/update.ts +123 -0
  62. package/src/brain-harvest/human.ts +46 -0
  63. package/src/brain-harvest/scanner.ts +98 -0
  64. package/src/brain-harvest/self.ts +147 -0
  65. package/src/brain-runtime/assembler-extension.ts +230 -0
  66. package/src/brain-runtime/evidence-detectors.ts +68 -0
  67. package/src/brain-runtime/graph-io.ts +72 -0
  68. package/src/brain-runtime/harvester-extension.ts +98 -0
  69. package/src/brain-runtime/service.ts +659 -0
  70. package/src/brain-runtime/tools.ts +109 -0
  71. package/src/brain-runtime/worker-state.ts +106 -0
  72. package/src/brain-runtime/worker-supervisor.ts +169 -0
  73. package/src/brain-store/embedding.ts +179 -0
  74. package/src/brain-store/init.ts +347 -0
  75. package/src/brain-store/migrations.ts +188 -0
  76. package/src/brain-store/store.ts +816 -0
  77. package/src/brain-worker/child-runner.ts +321 -0
  78. package/src/brain-worker/jobs.ts +12 -0
  79. package/src/brain-worker/mutation-job.ts +5 -0
  80. package/src/brain-worker/promotion-job.ts +5 -0
  81. package/src/brain-worker/protocol.ts +79 -0
  82. package/src/brain-worker/teacher-job.ts +5 -0
  83. package/src/brain-worker/update-job.ts +5 -0
  84. package/src/brain-worker/worker.ts +422 -0
  85. package/src/compaction.ts +1332 -0
  86. package/src/db/config.ts +265 -0
  87. package/src/db/connection.ts +72 -0
  88. package/src/db/features.ts +42 -0
  89. package/src/db/migration.ts +561 -0
  90. package/src/engine.ts +1995 -0
  91. package/src/expansion-auth.ts +351 -0
  92. package/src/expansion-policy.ts +303 -0
  93. package/src/expansion.ts +383 -0
  94. package/src/integrity.ts +600 -0
  95. package/src/large-files.ts +527 -0
  96. package/src/openclaw-bridge.ts +22 -0
  97. package/src/retrieval.ts +357 -0
  98. package/src/store/conversation-store.ts +748 -0
  99. package/src/store/fts5-sanitize.ts +29 -0
  100. package/src/store/full-text-fallback.ts +74 -0
  101. package/src/store/index.ts +29 -0
  102. package/src/store/summary-store.ts +918 -0
  103. package/src/summarize.ts +847 -0
  104. package/src/tools/common.ts +53 -0
  105. package/src/tools/lcm-conversation-scope.ts +76 -0
  106. package/src/tools/lcm-describe-tool.ts +234 -0
  107. package/src/tools/lcm-expand-query-tool.ts +594 -0
  108. package/src/tools/lcm-expand-tool.delegation.ts +556 -0
  109. package/src/tools/lcm-expand-tool.ts +448 -0
  110. package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
  111. package/src/tools/lcm-grep-tool.ts +200 -0
  112. package/src/transcript-repair.ts +301 -0
  113. package/src/types.ts +149 -0
package/index.ts ADDED
@@ -0,0 +1,1372 @@
1
+ /**
2
+ * @jonathangu/openclawbrain — OpenClawBrain v2 for OpenClaw
3
+ *
4
+ * Lossless transcript memory plus the learned routing/runtime layer:
5
+ * LCM compaction/recall, brain assembly decisions, and `brain_*` tools.
6
+ */
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
+ import { resolveLcmConfig } from "./src/db/config.js";
11
+ import { LcmContextEngine } from "./src/engine.js";
12
+ import { createLcmDescribeTool } from "./src/tools/lcm-describe-tool.js";
13
+ import { createLcmExpandQueryTool } from "./src/tools/lcm-expand-query-tool.js";
14
+ import { createLcmExpandTool } from "./src/tools/lcm-expand-tool.js";
15
+ import { createLcmGrepTool } from "./src/tools/lcm-grep-tool.js";
16
+ import {
17
+ createBrainTeachTool,
18
+ createBrainStatusTool,
19
+ createBrainTraceTool,
20
+ } from "./src/brain-runtime/tools.js";
21
+ import type { LcmDependencies } from "./src/types.js";
22
+
23
+ /** Parse `agent:<agentId>:<suffix...>` session keys. */
24
+ function parseAgentSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
25
+ const value = sessionKey.trim();
26
+ if (!value.startsWith("agent:")) {
27
+ return null;
28
+ }
29
+ const parts = value.split(":");
30
+ if (parts.length < 3) {
31
+ return null;
32
+ }
33
+ const agentId = parts[1]?.trim();
34
+ const suffix = parts.slice(2).join(":").trim();
35
+ if (!agentId || !suffix) {
36
+ return null;
37
+ }
38
+ return { agentId, suffix };
39
+ }
40
+
41
+ /** Return a stable normalized agent id. */
42
+ function normalizeAgentId(agentId: string | undefined): string {
43
+ const normalized = (agentId ?? "").trim();
44
+ return normalized.length > 0 ? normalized : "main";
45
+ }
46
+
47
+ type PluginEnvSnapshot = {
48
+ lcmSummaryModel: string;
49
+ lcmSummaryProvider: string;
50
+ pluginSummaryModel: string;
51
+ pluginSummaryProvider: string;
52
+ openclawProvider: string;
53
+ openclawDefaultModel: string;
54
+ agentDir: string;
55
+ home: string;
56
+ };
57
+
58
+ type ReadEnvFn = (key: string) => string | undefined;
59
+
60
+ type CompleteSimpleOptions = {
61
+ apiKey?: string;
62
+ maxTokens: number;
63
+ temperature?: number;
64
+ reasoning?: string;
65
+ };
66
+
67
+ type RuntimeModelAuthResult = {
68
+ apiKey?: string;
69
+ };
70
+
71
+ type RuntimeModelAuthModel = {
72
+ id: string;
73
+ provider: string;
74
+ api: string;
75
+ name?: string;
76
+ reasoning?: boolean;
77
+ input?: string[];
78
+ cost?: {
79
+ input: number;
80
+ output: number;
81
+ cacheRead: number;
82
+ cacheWrite: number;
83
+ };
84
+ contextWindow?: number;
85
+ maxTokens?: number;
86
+ };
87
+
88
+ type RuntimeModelAuth = {
89
+ getApiKeyForModel: (params: {
90
+ model: RuntimeModelAuthModel;
91
+ cfg?: OpenClawPluginApi["config"];
92
+ profileId?: string;
93
+ preferredProfile?: string;
94
+ }) => Promise<RuntimeModelAuthResult | undefined>;
95
+ resolveApiKeyForProvider: (params: {
96
+ provider: string;
97
+ cfg?: OpenClawPluginApi["config"];
98
+ profileId?: string;
99
+ preferredProfile?: string;
100
+ }) => Promise<RuntimeModelAuthResult | undefined>;
101
+ };
102
+
103
+ const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
104
+ const MODEL_AUTH_MERGE_COMMIT = "4790e40";
105
+ const MODEL_AUTH_REQUIRED_RELEASE = "the first OpenClaw release after 2026.3.8";
106
+
107
+ /** Capture plugin env values once during initialization. */
108
+ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
109
+ return {
110
+ lcmSummaryModel: env.LCM_SUMMARY_MODEL?.trim() ?? "",
111
+ lcmSummaryProvider: env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
112
+ pluginSummaryModel: "",
113
+ pluginSummaryProvider: "",
114
+ openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
115
+ openclawDefaultModel: "",
116
+ agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
117
+ home: env.HOME?.trim() ?? "",
118
+ };
119
+ }
120
+
121
+ /** Read OpenClaw's configured default model from the validated runtime config. */
122
+ function readDefaultModelFromConfig(config: unknown): string {
123
+ if (!config || typeof config !== "object") {
124
+ return "";
125
+ }
126
+
127
+ const model = (config as { agents?: { defaults?: { model?: unknown } } }).agents?.defaults?.model;
128
+ if (typeof model === "string") {
129
+ return model.trim();
130
+ }
131
+
132
+ const primary = (model as { primary?: unknown } | undefined)?.primary;
133
+ return typeof primary === "string" ? primary.trim() : "";
134
+ }
135
+
136
+ /** Resolve common provider API keys from environment. */
137
+ function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
138
+ const keyMap: Record<string, string[]> = {
139
+ openai: ["OPENAI_API_KEY"],
140
+ anthropic: ["ANTHROPIC_API_KEY"],
141
+ google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
142
+ groq: ["GROQ_API_KEY"],
143
+ xai: ["XAI_API_KEY"],
144
+ mistral: ["MISTRAL_API_KEY"],
145
+ together: ["TOGETHER_API_KEY"],
146
+ openrouter: ["OPENROUTER_API_KEY"],
147
+ "github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
148
+ };
149
+
150
+ const providerKey = provider.trim().toLowerCase();
151
+ const keys = keyMap[providerKey] ?? [];
152
+ const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
153
+ keys.push(normalizedProviderEnv);
154
+
155
+ for (const key of keys) {
156
+ const value = readEnv(key)?.trim();
157
+ if (value) {
158
+ return value;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ /** A SecretRef pointing to a value inside secrets.json via a nested path. */
165
+ type SecretRef = {
166
+ source?: string;
167
+ provider?: string;
168
+ id: string;
169
+ };
170
+
171
+ type SecretProviderConfig = {
172
+ source?: string;
173
+ path?: string;
174
+ mode?: string;
175
+ };
176
+
177
+ type AuthProfileCredential =
178
+ | { type: "api_key"; provider: string; key?: string; keyRef?: SecretRef; email?: string }
179
+ | { type: "token"; provider: string; token?: string; tokenRef?: SecretRef; expires?: number; email?: string }
180
+ | ({
181
+ type: "oauth";
182
+ provider: string;
183
+ access?: string;
184
+ refresh?: string;
185
+ expires?: number;
186
+ email?: string;
187
+ } & Record<string, unknown>);
188
+
189
+ type AuthProfileStore = {
190
+ profiles: Record<string, AuthProfileCredential>;
191
+ order?: Record<string, string[]>;
192
+ };
193
+
194
+ type PiAiOAuthCredentials = {
195
+ refresh: string;
196
+ access: string;
197
+ expires: number;
198
+ [key: string]: unknown;
199
+ };
200
+
201
+ type PiAiModule = {
202
+ completeSimple?: (
203
+ model: {
204
+ id: string;
205
+ provider: string;
206
+ api: string;
207
+ name?: string;
208
+ reasoning?: boolean;
209
+ input?: string[];
210
+ cost?: {
211
+ input: number;
212
+ output: number;
213
+ cacheRead: number;
214
+ cacheWrite: number;
215
+ };
216
+ contextWindow?: number;
217
+ maxTokens?: number;
218
+ },
219
+ request: {
220
+ systemPrompt?: string;
221
+ messages: Array<{ role: string; content: unknown; timestamp?: number }>;
222
+ },
223
+ options: {
224
+ apiKey?: string;
225
+ maxTokens: number;
226
+ temperature?: number;
227
+ reasoning?: string;
228
+ },
229
+ ) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
230
+ getModel?: (provider: string, modelId: string) => unknown;
231
+ getModels?: (provider: string) => unknown[];
232
+ getEnvApiKey?: (provider: string) => string | undefined;
233
+ getOAuthApiKey?: (
234
+ providerId: string,
235
+ credentials: Record<string, PiAiOAuthCredentials>,
236
+ ) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
237
+ };
238
+
239
+ /** Narrow unknown values to plain objects. */
240
+ function isRecord(value: unknown): value is Record<string, unknown> {
241
+ return !!value && typeof value === "object" && !Array.isArray(value);
242
+ }
243
+
244
+ /** Normalize provider ids for case-insensitive matching. */
245
+ function normalizeProviderId(provider: string): string {
246
+ return provider.trim().toLowerCase();
247
+ }
248
+
249
+ /** Resolve known provider API defaults when model lookup misses. */
250
+ function inferApiFromProvider(provider: string): string {
251
+ const normalized = normalizeProviderId(provider);
252
+ const map: Record<string, string> = {
253
+ anthropic: "anthropic-messages",
254
+ openai: "openai-responses",
255
+ "openai-codex": "openai-codex-responses",
256
+ "github-copilot": "openai-codex-responses",
257
+ google: "google-generative-ai",
258
+ "google-gemini-cli": "google-gemini-cli",
259
+ "google-antigravity": "google-gemini-cli",
260
+ "google-vertex": "google-vertex",
261
+ "amazon-bedrock": "bedrock-converse-stream",
262
+ };
263
+ return map[normalized] ?? "openai-responses";
264
+ }
265
+
266
+ /** Codex Responses rejects `temperature`; omit it for that API family. */
267
+ export function shouldOmitTemperatureForApi(api: string | undefined): boolean {
268
+ return (api ?? "").trim().toLowerCase() === "openai-codex-responses";
269
+ }
270
+
271
+ /** Build provider-aware options for pi-ai completeSimple. */
272
+ export function buildCompleteSimpleOptions(params: {
273
+ api: string | undefined;
274
+ apiKey: string | undefined;
275
+ maxTokens: number;
276
+ temperature: number | undefined;
277
+ reasoning: string | undefined;
278
+ }): CompleteSimpleOptions {
279
+ const options: CompleteSimpleOptions = {
280
+ apiKey: params.apiKey,
281
+ maxTokens: params.maxTokens,
282
+ };
283
+
284
+ if (
285
+ typeof params.temperature === "number" &&
286
+ Number.isFinite(params.temperature) &&
287
+ !shouldOmitTemperatureForApi(params.api)
288
+ ) {
289
+ options.temperature = params.temperature;
290
+ }
291
+
292
+ if (typeof params.reasoning === "string" && params.reasoning.trim()) {
293
+ options.reasoning = params.reasoning.trim();
294
+ }
295
+
296
+ return options;
297
+ }
298
+
299
+ /** Select provider-specific config values with case-insensitive provider keys. */
300
+ function findProviderConfigValue<T>(
301
+ map: Record<string, T> | undefined,
302
+ provider: string,
303
+ ): T | undefined {
304
+ if (!map) {
305
+ return undefined;
306
+ }
307
+ if (map[provider] !== undefined) {
308
+ return map[provider];
309
+ }
310
+ const normalizedProvider = normalizeProviderId(provider);
311
+ for (const [key, value] of Object.entries(map)) {
312
+ if (normalizeProviderId(key) === normalizedProvider) {
313
+ return value;
314
+ }
315
+ }
316
+ return undefined;
317
+ }
318
+
319
+ /** Resolve provider API from runtime config if available. */
320
+ function resolveProviderApiFromRuntimeConfig(
321
+ runtimeConfig: unknown,
322
+ provider: string,
323
+ ): string | undefined {
324
+ if (!isRecord(runtimeConfig)) {
325
+ return undefined;
326
+ }
327
+ const providers = (runtimeConfig as { models?: { providers?: Record<string, unknown> } }).models
328
+ ?.providers;
329
+ if (!providers || !isRecord(providers)) {
330
+ return undefined;
331
+ }
332
+ const value = findProviderConfigValue(providers, provider);
333
+ if (!isRecord(value)) {
334
+ return undefined;
335
+ }
336
+ const api = value.api;
337
+ return typeof api === "string" && api.trim() ? api.trim() : undefined;
338
+ }
339
+
340
+ /** Resolve runtime.modelAuth from plugin runtime when available. */
341
+ function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth | undefined {
342
+ const runtime = api.runtime as OpenClawPluginApi["runtime"] & {
343
+ modelAuth?: RuntimeModelAuth;
344
+ };
345
+ return runtime.modelAuth;
346
+ }
347
+
348
+ /** Build the minimal model shape required by runtime.modelAuth.getApiKeyForModel(). */
349
+ function buildModelAuthLookupModel(params: {
350
+ provider: string;
351
+ model: string;
352
+ api?: string;
353
+ }): RuntimeModelAuthModel {
354
+ return {
355
+ id: params.model,
356
+ name: params.model,
357
+ provider: params.provider,
358
+ api: params.api?.trim() || inferApiFromProvider(params.provider),
359
+ reasoning: false,
360
+ input: ["text"],
361
+ cost: {
362
+ input: 0,
363
+ output: 0,
364
+ cacheRead: 0,
365
+ cacheWrite: 0,
366
+ },
367
+ contextWindow: 200_000,
368
+ maxTokens: 8_000,
369
+ };
370
+ }
371
+
372
+ /** Normalize an auth result down to the API key that pi-ai expects. */
373
+ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
374
+ const apiKey = auth?.apiKey?.trim();
375
+ return apiKey ? apiKey : undefined;
376
+ }
377
+
378
+ function buildLegacyAuthFallbackWarning(): string {
379
+ return [
380
+ "[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
381
+ `Stock lossless-claw 0.2.7 expects OpenClaw plugin runtime support from PR #41090 (${MODEL_AUTH_PR_URL}).`,
382
+ `OpenClaw 2026.3.8 and 2026.3.8-beta.1 do not include merge commit ${MODEL_AUTH_MERGE_COMMIT};`,
383
+ `${MODEL_AUTH_REQUIRED_RELEASE} is required for stock lossless-claw 0.2.7 without this fallback patch.`,
384
+ ].join(" ");
385
+ }
386
+
387
+ /** Parse auth-profiles JSON into a minimal store shape. */
388
+ function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
389
+ try {
390
+ const parsed = JSON.parse(raw) as unknown;
391
+ if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
392
+ return undefined;
393
+ }
394
+
395
+ const profiles: Record<string, AuthProfileCredential> = {};
396
+ for (const [profileId, value] of Object.entries(parsed.profiles)) {
397
+ if (!isRecord(value)) {
398
+ continue;
399
+ }
400
+ const type = value.type;
401
+ const provider = typeof value.provider === "string" ? value.provider.trim() : "";
402
+ if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
403
+ continue;
404
+ }
405
+ profiles[profileId] = value as AuthProfileCredential;
406
+ }
407
+
408
+ const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
409
+ const order: Record<string, string[]> | undefined = rawOrder
410
+ ? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
411
+ if (!Array.isArray(value)) {
412
+ return acc;
413
+ }
414
+ const ids = value
415
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
416
+ .filter(Boolean);
417
+ if (ids.length > 0) {
418
+ acc[provider] = ids;
419
+ }
420
+ return acc;
421
+ }, {})
422
+ : undefined;
423
+
424
+ return {
425
+ profiles,
426
+ ...(order && Object.keys(order).length > 0 ? { order } : {}),
427
+ };
428
+ } catch {
429
+ return undefined;
430
+ }
431
+ }
432
+
433
+ /** Merge auth stores, letting later stores override earlier profiles/order. */
434
+ function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
435
+ if (stores.length === 0) {
436
+ return undefined;
437
+ }
438
+ const merged: AuthProfileStore = { profiles: {} };
439
+ for (const store of stores) {
440
+ merged.profiles = { ...merged.profiles, ...store.profiles };
441
+ if (store.order) {
442
+ merged.order = { ...(merged.order ?? {}), ...store.order };
443
+ }
444
+ }
445
+ return merged;
446
+ }
447
+
448
+ /** Determine candidate auth store paths ordered by precedence. */
449
+ function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
450
+ const paths: string[] = [];
451
+ const directAgentDir = params.agentDir?.trim();
452
+ if (directAgentDir) {
453
+ paths.push(join(directAgentDir, "auth-profiles.json"));
454
+ }
455
+
456
+ const envAgentDir = params.envSnapshot.agentDir;
457
+ if (envAgentDir) {
458
+ paths.push(join(envAgentDir, "auth-profiles.json"));
459
+ }
460
+
461
+ const home = params.envSnapshot.home;
462
+ if (home) {
463
+ paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
464
+ }
465
+
466
+ return [...new Set(paths)];
467
+ }
468
+
469
+ /** Build profile selection order for provider auth lookup. */
470
+ function resolveAuthProfileCandidates(params: {
471
+ provider: string;
472
+ store: AuthProfileStore;
473
+ authProfileId?: string;
474
+ runtimeConfig?: unknown;
475
+ }): string[] {
476
+ const candidates: string[] = [];
477
+ const normalizedProvider = normalizeProviderId(params.provider);
478
+ const push = (value: string | undefined) => {
479
+ const profileId = value?.trim();
480
+ if (!profileId) {
481
+ return;
482
+ }
483
+ if (!candidates.includes(profileId)) {
484
+ candidates.push(profileId);
485
+ }
486
+ };
487
+
488
+ push(params.authProfileId);
489
+
490
+ const storeOrder = findProviderConfigValue(params.store.order, params.provider);
491
+ for (const profileId of storeOrder ?? []) {
492
+ push(profileId);
493
+ }
494
+
495
+ if (isRecord(params.runtimeConfig)) {
496
+ const auth = params.runtimeConfig.auth;
497
+ if (isRecord(auth)) {
498
+ const order = findProviderConfigValue(
499
+ isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
500
+ params.provider,
501
+ );
502
+ if (Array.isArray(order)) {
503
+ for (const profileId of order) {
504
+ if (typeof profileId === "string") {
505
+ push(profileId);
506
+ }
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ for (const [profileId, credential] of Object.entries(params.store.profiles)) {
513
+ if (normalizeProviderId(credential.provider) === normalizedProvider) {
514
+ push(profileId);
515
+ }
516
+ }
517
+
518
+ return candidates;
519
+ }
520
+
521
+ /**
522
+ * Resolve a SecretRef (tokenRef/keyRef) to a credential string.
523
+ *
524
+ * OpenClaw's auth-profiles support a level of indirection: instead of storing
525
+ * the raw API key or token inline, a credential can reference it via a
526
+ * SecretRef. Two resolution strategies are supported:
527
+ *
528
+ * 1. `source: "env"` — read the value from an environment variable whose
529
+ * name is `ref.id` (e.g. `{ source: "env", id: "ANTHROPIC_API_KEY" }`).
530
+ *
531
+ * 2. File-based — resolve against a configured `secrets.providers.<provider>`
532
+ * file provider when available. JSON-mode providers walk slash-delimited
533
+ * paths, while singleValue providers use the sentinel id `value`.
534
+ *
535
+ * 3. Legacy fallback — when no file provider config is available, fall back to
536
+ * `~/.openclaw/secrets.json` for backward compatibility.
537
+ */
538
+ function resolveSecretRef(params: {
539
+ ref: SecretRef | undefined;
540
+ home: string;
541
+ config?: unknown;
542
+ }): string | undefined {
543
+ const ref = params.ref;
544
+ if (!ref?.id) return undefined;
545
+
546
+ // source: env — read directly from environment variable
547
+ if (ref.source === "env") {
548
+ const val = process.env[ref.id]?.trim();
549
+ return val || undefined;
550
+ }
551
+
552
+ // File-based provider config — use configured file provider when present.
553
+ try {
554
+ const providers = isRecord(params.config)
555
+ ? (params.config as { secrets?: { providers?: Record<string, unknown> } }).secrets?.providers
556
+ : undefined;
557
+ const providerName = ref.provider?.trim() || "default";
558
+ const provider =
559
+ providers && isRecord(providers)
560
+ ? providers[providerName]
561
+ : undefined;
562
+ if (isRecord(provider) && provider.source === "file" && typeof provider.path === "string") {
563
+ const configuredPath = provider.path.trim();
564
+ const filePath =
565
+ configuredPath.startsWith("~/") && params.home
566
+ ? join(params.home, configuredPath.slice(2))
567
+ : configuredPath;
568
+ if (!filePath) {
569
+ return undefined;
570
+ }
571
+ const raw = readFileSync(filePath, "utf8");
572
+ if (provider.mode === "singleValue") {
573
+ if (ref.id.trim() !== "value") {
574
+ return undefined;
575
+ }
576
+ const value = raw.trim();
577
+ return value || undefined;
578
+ }
579
+
580
+ const secrets = JSON.parse(raw) as Record<string, unknown>;
581
+ const parts = ref.id.replace(/^\//, "").split("/");
582
+ let current: unknown = secrets;
583
+ for (const part of parts) {
584
+ if (!current || typeof current !== "object") return undefined;
585
+ current = (current as Record<string, unknown>)[part];
586
+ }
587
+ return typeof current === "string" && current.trim() ? current.trim() : undefined;
588
+ }
589
+ } catch {
590
+ // Fall through to the legacy secrets.json lookup below.
591
+ }
592
+
593
+ // Legacy file fallback (source: "file" or unset) — read from ~/.openclaw/secrets.json
594
+ try {
595
+ const secretsPath = join(params.home, ".openclaw", "secrets.json");
596
+ const raw = readFileSync(secretsPath, "utf8");
597
+ const secrets = JSON.parse(raw) as Record<string, unknown>;
598
+ const parts = ref.id.replace(/^\//, "").split("/");
599
+ let current: unknown = secrets;
600
+ for (const part of parts) {
601
+ if (!current || typeof current !== "object") return undefined;
602
+ current = (current as Record<string, unknown>)[part];
603
+ }
604
+ return typeof current === "string" && current.trim() ? current.trim() : undefined;
605
+ } catch {
606
+ return undefined;
607
+ }
608
+ }
609
+
610
+ /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
611
+ async function resolveApiKeyFromAuthProfiles(params: {
612
+ provider: string;
613
+ authProfileId?: string;
614
+ agentDir?: string;
615
+ runtimeConfig?: unknown;
616
+ appConfig?: unknown;
617
+ piAiModule: PiAiModule;
618
+ envSnapshot: PluginEnvSnapshot;
619
+ }): Promise<string | undefined> {
620
+ const storesWithPaths = resolveAuthStorePaths({
621
+ agentDir: params.agentDir,
622
+ envSnapshot: params.envSnapshot,
623
+ })
624
+ .map((path) => {
625
+ try {
626
+ const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
627
+ return parsed ? { path, store: parsed } : undefined;
628
+ } catch {
629
+ return undefined;
630
+ }
631
+ })
632
+ .filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
633
+ if (storesWithPaths.length === 0) {
634
+ return undefined;
635
+ }
636
+
637
+ const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
638
+ if (!mergedStore) {
639
+ return undefined;
640
+ }
641
+
642
+ const candidates = resolveAuthProfileCandidates({
643
+ provider: params.provider,
644
+ store: mergedStore,
645
+ authProfileId: params.authProfileId,
646
+ runtimeConfig: params.runtimeConfig,
647
+ });
648
+ if (candidates.length === 0) {
649
+ return undefined;
650
+ }
651
+
652
+ const persistPath =
653
+ params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
654
+ const secretConfig = (() => {
655
+ if (isRecord(params.runtimeConfig)) {
656
+ const runtimeProviders = (params.runtimeConfig as {
657
+ secrets?: { providers?: Record<string, unknown> };
658
+ }).secrets?.providers;
659
+ if (isRecord(runtimeProviders) && Object.keys(runtimeProviders).length > 0) {
660
+ return params.runtimeConfig;
661
+ }
662
+ }
663
+ return params.appConfig ?? params.runtimeConfig;
664
+ })();
665
+
666
+ for (const profileId of candidates) {
667
+ const credential = mergedStore.profiles[profileId];
668
+ if (!credential) {
669
+ continue;
670
+ }
671
+ if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
672
+ continue;
673
+ }
674
+
675
+ if (credential.type === "api_key") {
676
+ const key =
677
+ credential.key?.trim() ||
678
+ resolveSecretRef({
679
+ ref: credential.keyRef,
680
+ home: params.envSnapshot.home,
681
+ config: secretConfig,
682
+ });
683
+ if (key) {
684
+ return key;
685
+ }
686
+ continue;
687
+ }
688
+
689
+ if (credential.type === "token") {
690
+ const token =
691
+ credential.token?.trim() ||
692
+ resolveSecretRef({
693
+ ref: credential.tokenRef,
694
+ home: params.envSnapshot.home,
695
+ config: secretConfig,
696
+ });
697
+ if (!token) {
698
+ continue;
699
+ }
700
+ const expires = credential.expires;
701
+ if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
702
+ continue;
703
+ }
704
+ return token;
705
+ }
706
+
707
+ const access = credential.access?.trim();
708
+ const expires = credential.expires;
709
+ const isExpired =
710
+ typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
711
+
712
+ if (!isExpired && access) {
713
+ if (
714
+ (credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
715
+ typeof credential.projectId === "string" &&
716
+ credential.projectId.trim()
717
+ ) {
718
+ return JSON.stringify({
719
+ token: access,
720
+ projectId: credential.projectId.trim(),
721
+ });
722
+ }
723
+ return access;
724
+ }
725
+
726
+ if (typeof params.piAiModule.getOAuthApiKey !== "function") {
727
+ continue;
728
+ }
729
+
730
+ try {
731
+ const oauthCredential = {
732
+ access: credential.access ?? "",
733
+ refresh: credential.refresh ?? "",
734
+ expires: typeof credential.expires === "number" ? credential.expires : 0,
735
+ ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
736
+ ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
737
+ };
738
+ const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
739
+ [params.provider]: oauthCredential,
740
+ });
741
+ if (!refreshed?.apiKey) {
742
+ continue;
743
+ }
744
+ mergedStore.profiles[profileId] = {
745
+ ...credential,
746
+ ...refreshed.newCredentials,
747
+ type: "oauth",
748
+ };
749
+ if (persistPath) {
750
+ try {
751
+ writeFileSync(
752
+ persistPath,
753
+ JSON.stringify(
754
+ {
755
+ version: 1,
756
+ profiles: mergedStore.profiles,
757
+ ...(mergedStore.order ? { order: mergedStore.order } : {}),
758
+ },
759
+ null,
760
+ 2,
761
+ ),
762
+ "utf8",
763
+ );
764
+ } catch {
765
+ // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
766
+ }
767
+ }
768
+ return refreshed.apiKey;
769
+ } catch {
770
+ if (access) {
771
+ return access;
772
+ }
773
+ }
774
+ }
775
+
776
+ return undefined;
777
+ }
778
+
779
+ /** Build a minimal but useful sub-agent prompt. */
780
+ function buildSubagentSystemPrompt(params: {
781
+ depth: number;
782
+ maxDepth: number;
783
+ taskSummary?: string;
784
+ }): string {
785
+ const task = params.taskSummary?.trim() || "Perform delegated LCM expansion work.";
786
+ return [
787
+ "You are a delegated sub-agent for LCM expansion.",
788
+ `Depth: ${params.depth}/${params.maxDepth}`,
789
+ "Return concise, factual results only.",
790
+ task,
791
+ ].join("\n");
792
+ }
793
+
794
+ /** Extract latest assistant text from session message snapshots. */
795
+ function readLatestAssistantReply(messages: unknown[]): string | undefined {
796
+ for (let i = messages.length - 1; i >= 0; i--) {
797
+ const item = messages[i];
798
+ if (!item || typeof item !== "object") {
799
+ continue;
800
+ }
801
+ const record = item as { role?: unknown; content?: unknown };
802
+ if (record.role !== "assistant") {
803
+ continue;
804
+ }
805
+
806
+ if (typeof record.content === "string") {
807
+ const trimmed = record.content.trim();
808
+ if (trimmed) {
809
+ return trimmed;
810
+ }
811
+ continue;
812
+ }
813
+
814
+ if (!Array.isArray(record.content)) {
815
+ continue;
816
+ }
817
+
818
+ const text = record.content
819
+ .filter((entry): entry is { type?: unknown; text?: unknown } => {
820
+ return !!entry && typeof entry === "object";
821
+ })
822
+ .map((entry) => (entry.type === "text" && typeof entry.text === "string" ? entry.text : ""))
823
+ .filter(Boolean)
824
+ .join("\n")
825
+ .trim();
826
+
827
+ if (text) {
828
+ return text;
829
+ }
830
+ }
831
+
832
+ return undefined;
833
+ }
834
+
835
+ /** Construct LCM dependencies from plugin API/runtime surfaces. */
836
+ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
837
+ const envSnapshot = snapshotPluginEnv();
838
+ envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
839
+ const modelAuth = getRuntimeModelAuth(api);
840
+ const readEnv: ReadEnvFn = (key) => process.env[key];
841
+ const pluginConfig =
842
+ api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
843
+ ? api.pluginConfig
844
+ : undefined;
845
+ const config = resolveLcmConfig(process.env, pluginConfig);
846
+
847
+ // Read model overrides from plugin config
848
+ if (pluginConfig) {
849
+ const summaryModel = pluginConfig.summaryModel;
850
+ const summaryProvider = pluginConfig.summaryProvider;
851
+ if (typeof summaryModel === "string") {
852
+ envSnapshot.pluginSummaryModel = summaryModel.trim();
853
+ }
854
+ if (typeof summaryProvider === "string") {
855
+ envSnapshot.pluginSummaryProvider = summaryProvider.trim();
856
+ }
857
+ }
858
+
859
+ if (!modelAuth) {
860
+ api.logger.warn(buildLegacyAuthFallbackWarning());
861
+ }
862
+
863
+ return {
864
+ config,
865
+ complete: async ({
866
+ provider,
867
+ model,
868
+ apiKey,
869
+ providerApi,
870
+ authProfileId,
871
+ agentDir,
872
+ runtimeConfig,
873
+ messages,
874
+ system,
875
+ maxTokens,
876
+ temperature,
877
+ reasoning,
878
+ }) => {
879
+ try {
880
+ const piAiModuleId = "@mariozechner/pi-ai";
881
+ const mod = (await import(piAiModuleId)) as PiAiModule;
882
+
883
+ if (typeof mod.completeSimple !== "function") {
884
+ return { content: [] };
885
+ }
886
+
887
+ const providerId = (provider ?? "").trim();
888
+ const modelId = model.trim();
889
+ if (!providerId || !modelId) {
890
+ return { content: [] };
891
+ }
892
+
893
+ // When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
894
+ // passes legacyParams without config), fall back to the plugin API so
895
+ // provider-level baseUrl/headers/apiKey are always resolvable.
896
+ let effectiveRuntimeConfig = runtimeConfig;
897
+ if (!isRecord(effectiveRuntimeConfig)) {
898
+ try {
899
+ effectiveRuntimeConfig = api.runtime.config.loadConfig();
900
+ } catch {
901
+ // loadConfig may not be available in all contexts; leave undefined.
902
+ }
903
+ }
904
+
905
+ const knownModel =
906
+ typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
907
+ const fallbackApi =
908
+ providerApi?.trim() ||
909
+ resolveProviderApiFromRuntimeConfig(effectiveRuntimeConfig, providerId) ||
910
+ (() => {
911
+ if (typeof mod.getModels !== "function") {
912
+ return undefined;
913
+ }
914
+ const models = mod.getModels(providerId);
915
+ const first = Array.isArray(models) ? models[0] : undefined;
916
+ if (!isRecord(first) || typeof first.api !== "string" || !first.api.trim()) {
917
+ return undefined;
918
+ }
919
+ return first.api.trim();
920
+ })() ||
921
+ inferApiFromProvider(providerId);
922
+
923
+ // Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
924
+ // Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
925
+ // apiKey under models.providers.<provider> in openclaw.json. Without this
926
+ // lookup the resolved model object lacks baseUrl, which crashes pi-ai's
927
+ // detectCompat() ("Cannot read properties of undefined (reading 'includes')"),
928
+ // and the apiKey is unresolvable, causing 401 errors. See #19.
929
+ const providerLevelConfig: Record<string, unknown> = (() => {
930
+ if (!isRecord(effectiveRuntimeConfig)) return {};
931
+ const providers = (effectiveRuntimeConfig as { models?: { providers?: Record<string, unknown> } })
932
+ .models?.providers;
933
+ if (!providers) return {};
934
+ const cfg = findProviderConfigValue(providers, providerId);
935
+ return isRecord(cfg) ? cfg : {};
936
+ })();
937
+
938
+ const resolvedModel =
939
+ isRecord(knownModel) &&
940
+ typeof knownModel.api === "string" &&
941
+ typeof knownModel.provider === "string" &&
942
+ typeof knownModel.id === "string"
943
+ ? {
944
+ ...knownModel,
945
+ id: knownModel.id,
946
+ provider: knownModel.provider,
947
+ api: knownModel.api,
948
+ // Merge baseUrl/headers from provider config if not already on the model.
949
+ // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
950
+ // baseUrl is undefined.
951
+ baseUrl:
952
+ typeof knownModel.baseUrl === "string"
953
+ ? knownModel.baseUrl
954
+ : typeof providerLevelConfig.baseUrl === "string"
955
+ ? providerLevelConfig.baseUrl
956
+ : "",
957
+ ...(knownModel.headers == null && isRecord(providerLevelConfig.headers)
958
+ ? { headers: providerLevelConfig.headers }
959
+ : {}),
960
+ }
961
+ : {
962
+ id: modelId,
963
+ name: modelId,
964
+ provider: providerId,
965
+ api: fallbackApi,
966
+ reasoning: false,
967
+ input: ["text"],
968
+ cost: {
969
+ input: 0,
970
+ output: 0,
971
+ cacheRead: 0,
972
+ cacheWrite: 0,
973
+ },
974
+ contextWindow: 200_000,
975
+ maxTokens: 8_000,
976
+ // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
977
+ // baseUrl is undefined.
978
+ baseUrl: typeof providerLevelConfig.baseUrl === "string"
979
+ ? providerLevelConfig.baseUrl
980
+ : "",
981
+ ...(isRecord(providerLevelConfig.headers)
982
+ ? { headers: providerLevelConfig.headers }
983
+ : {}),
984
+ };
985
+
986
+ let resolvedApiKey = apiKey?.trim();
987
+ if (!resolvedApiKey && modelAuth) {
988
+ try {
989
+ resolvedApiKey = resolveApiKeyFromAuthResult(
990
+ await modelAuth.resolveApiKeyForProvider({
991
+ provider: providerId,
992
+ cfg: api.config,
993
+ ...(authProfileId ? { profileId: authProfileId } : {}),
994
+ }),
995
+ );
996
+ } catch (err) {
997
+ console.error(
998
+ `[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
999
+ err instanceof Error ? err.message : err,
1000
+ );
1001
+ }
1002
+ }
1003
+ if (!resolvedApiKey && !modelAuth) {
1004
+ resolvedApiKey = resolveApiKey(providerId, readEnv);
1005
+ }
1006
+ if (!resolvedApiKey && !modelAuth && typeof mod.getEnvApiKey === "function") {
1007
+ resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
1008
+ }
1009
+ if (!resolvedApiKey && !modelAuth) {
1010
+ resolvedApiKey = await resolveApiKeyFromAuthProfiles({
1011
+ provider: providerId,
1012
+ authProfileId,
1013
+ agentDir,
1014
+ appConfig: api.config,
1015
+ runtimeConfig: effectiveRuntimeConfig,
1016
+ piAiModule: mod,
1017
+ envSnapshot,
1018
+ });
1019
+ }
1020
+ // Fallback: read apiKey from models.providers config (e.g. proxy providers
1021
+ // with keys like "not-needed-for-cli-proxy").
1022
+ if (!resolvedApiKey && isRecord(effectiveRuntimeConfig)) {
1023
+ const providers = (effectiveRuntimeConfig as { models?: { providers?: Record<string, unknown> } })
1024
+ .models?.providers;
1025
+ if (providers) {
1026
+ const providerCfg = findProviderConfigValue(providers, providerId);
1027
+ if (isRecord(providerCfg) && typeof providerCfg.apiKey === "string") {
1028
+ const cfgKey = providerCfg.apiKey.trim();
1029
+ if (cfgKey) {
1030
+ resolvedApiKey = cfgKey;
1031
+ }
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ const completeOptions = buildCompleteSimpleOptions({
1037
+ api: resolvedModel.api,
1038
+ apiKey: resolvedApiKey,
1039
+ maxTokens,
1040
+ temperature,
1041
+ reasoning,
1042
+ });
1043
+
1044
+ const result = await mod.completeSimple(
1045
+ resolvedModel,
1046
+ {
1047
+ ...(typeof system === "string" && system.trim()
1048
+ ? { systemPrompt: system.trim() }
1049
+ : {}),
1050
+ messages: messages.map((message) => ({
1051
+ role: message.role,
1052
+ content: message.content,
1053
+ timestamp: Date.now(),
1054
+ })),
1055
+ },
1056
+ completeOptions,
1057
+ );
1058
+
1059
+ if (!isRecord(result)) {
1060
+ return {
1061
+ content: [],
1062
+ request_provider: providerId,
1063
+ request_model: modelId,
1064
+ request_api: resolvedModel.api,
1065
+ request_reasoning:
1066
+ typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
1067
+ request_has_system:
1068
+ typeof system === "string" && system.trim().length > 0 ? "true" : "false",
1069
+ request_temperature:
1070
+ typeof completeOptions.temperature === "number"
1071
+ ? String(completeOptions.temperature)
1072
+ : "(omitted)",
1073
+ request_temperature_sent:
1074
+ typeof completeOptions.temperature === "number" ? "true" : "false",
1075
+ };
1076
+ }
1077
+
1078
+ return {
1079
+ ...result,
1080
+ content: Array.isArray(result.content) ? result.content : [],
1081
+ request_provider: providerId,
1082
+ request_model: modelId,
1083
+ request_api: resolvedModel.api,
1084
+ request_reasoning:
1085
+ typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
1086
+ request_has_system: typeof system === "string" && system.trim().length > 0 ? "true" : "false",
1087
+ request_temperature:
1088
+ typeof completeOptions.temperature === "number"
1089
+ ? String(completeOptions.temperature)
1090
+ : "(omitted)",
1091
+ request_temperature_sent: typeof completeOptions.temperature === "number" ? "true" : "false",
1092
+ };
1093
+ } catch (err) {
1094
+ console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
1095
+ return { content: [] };
1096
+ }
1097
+ },
1098
+ callGateway: async (params) => {
1099
+ const sub = api.runtime.subagent;
1100
+ switch (params.method) {
1101
+ case "agent":
1102
+ return sub.run({
1103
+ sessionKey: String(params.params?.sessionKey ?? ""),
1104
+ message: String(params.params?.message ?? ""),
1105
+ extraSystemPrompt: params.params?.extraSystemPrompt as string | undefined,
1106
+ lane: params.params?.lane as string | undefined,
1107
+ deliver: (params.params?.deliver as boolean) ?? false,
1108
+ idempotencyKey: params.params?.idempotencyKey as string | undefined,
1109
+ });
1110
+ case "agent.wait":
1111
+ return sub.waitForRun({
1112
+ runId: String(params.params?.runId ?? ""),
1113
+ timeoutMs: (params.params?.timeoutMs as number) ?? params.timeoutMs,
1114
+ });
1115
+ case "sessions.get":
1116
+ return sub.getSession({
1117
+ sessionKey: String(params.params?.key ?? ""),
1118
+ limit: params.params?.limit as number | undefined,
1119
+ });
1120
+ case "sessions.delete":
1121
+ await sub.deleteSession({
1122
+ sessionKey: String(params.params?.key ?? ""),
1123
+ deleteTranscript: (params.params?.deleteTranscript as boolean) ?? true,
1124
+ });
1125
+ return {};
1126
+ default:
1127
+ throw new Error(`Unsupported gateway method in LCM plugin: ${params.method}`);
1128
+ }
1129
+ },
1130
+ resolveModel: (modelRef, providerHint) => {
1131
+ const raw =
1132
+ (modelRef?.trim() ||
1133
+ envSnapshot.pluginSummaryModel ||
1134
+ envSnapshot.lcmSummaryModel ||
1135
+ envSnapshot.openclawDefaultModel).trim();
1136
+ if (!raw) {
1137
+ throw new Error("No model configured for LCM summarization.");
1138
+ }
1139
+
1140
+ if (raw.includes("/")) {
1141
+ const [provider, ...rest] = raw.split("/");
1142
+ const model = rest.join("/").trim();
1143
+ if (provider && model) {
1144
+ return { provider: provider.trim(), model };
1145
+ }
1146
+ }
1147
+
1148
+ const provider = (
1149
+ providerHint?.trim() ||
1150
+ envSnapshot.pluginSummaryProvider ||
1151
+ envSnapshot.lcmSummaryProvider ||
1152
+ envSnapshot.openclawProvider ||
1153
+ "openai"
1154
+ ).trim();
1155
+ return { provider, model: raw };
1156
+ },
1157
+ getApiKey: async (provider, model, options) => {
1158
+ if (modelAuth) {
1159
+ try {
1160
+ const modelAuthKey = resolveApiKeyFromAuthResult(
1161
+ await modelAuth.getApiKeyForModel({
1162
+ model: buildModelAuthLookupModel({ provider, model }),
1163
+ cfg: api.config,
1164
+ ...(options?.profileId ? { profileId: options.profileId } : {}),
1165
+ ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
1166
+ }),
1167
+ );
1168
+ if (modelAuthKey) {
1169
+ return modelAuthKey;
1170
+ }
1171
+ } catch {
1172
+ // Fall through to auth-profile lookup for older OpenClaw runtimes.
1173
+ }
1174
+ }
1175
+
1176
+ const envKey = resolveApiKey(provider, readEnv);
1177
+ if (envKey) {
1178
+ return envKey;
1179
+ }
1180
+
1181
+ const piAiModuleId = "@mariozechner/pi-ai";
1182
+ const mod = (await import(piAiModuleId)) as PiAiModule;
1183
+ return resolveApiKeyFromAuthProfiles({
1184
+ provider,
1185
+ authProfileId: options?.profileId,
1186
+ agentDir: api.resolvePath("."),
1187
+ runtimeConfig: api.config,
1188
+ piAiModule: mod,
1189
+ envSnapshot,
1190
+ });
1191
+ },
1192
+ requireApiKey: async (provider, model, options) => {
1193
+ const key = await (async () => {
1194
+ if (modelAuth) {
1195
+ try {
1196
+ const modelAuthKey = resolveApiKeyFromAuthResult(
1197
+ await modelAuth.getApiKeyForModel({
1198
+ model: buildModelAuthLookupModel({ provider, model }),
1199
+ cfg: api.config,
1200
+ ...(options?.profileId ? { profileId: options.profileId } : {}),
1201
+ ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
1202
+ }),
1203
+ );
1204
+ if (modelAuthKey) {
1205
+ return modelAuthKey;
1206
+ }
1207
+ } catch {
1208
+ // Fall through to auth-profile lookup for older OpenClaw runtimes.
1209
+ }
1210
+ }
1211
+
1212
+ const envKey = resolveApiKey(provider, readEnv);
1213
+ if (envKey) {
1214
+ return envKey;
1215
+ }
1216
+
1217
+ const piAiModuleId = "@mariozechner/pi-ai";
1218
+ const mod = (await import(piAiModuleId)) as PiAiModule;
1219
+ return resolveApiKeyFromAuthProfiles({
1220
+ provider,
1221
+ authProfileId: options?.profileId,
1222
+ agentDir: api.resolvePath("."),
1223
+ runtimeConfig: api.config,
1224
+ piAiModule: mod,
1225
+ envSnapshot,
1226
+ });
1227
+ })();
1228
+ if (!key) {
1229
+ throw new Error(`Missing API key for provider '${provider}' (model '${model}').`);
1230
+ }
1231
+ return key;
1232
+ },
1233
+ parseAgentSessionKey,
1234
+ isSubagentSessionKey: (sessionKey) => {
1235
+ const parsed = parseAgentSessionKey(sessionKey);
1236
+ return !!parsed && parsed.suffix.startsWith("subagent:");
1237
+ },
1238
+ normalizeAgentId,
1239
+ buildSubagentSystemPrompt,
1240
+ readLatestAssistantReply,
1241
+ resolveAgentDir: () => api.resolvePath("."),
1242
+ resolveSessionIdFromSessionKey: async (sessionKey) => {
1243
+ const key = sessionKey.trim();
1244
+ if (!key) {
1245
+ return undefined;
1246
+ }
1247
+
1248
+ try {
1249
+ const cfg = api.runtime.config.loadConfig();
1250
+ const parsed = parseAgentSessionKey(key);
1251
+ const agentId = normalizeAgentId(parsed?.agentId);
1252
+ const storePath = api.runtime.channel.session.resolveStorePath(cfg.session?.store, {
1253
+ agentId,
1254
+ });
1255
+ const raw = readFileSync(storePath, "utf8");
1256
+ const store = JSON.parse(raw) as Record<string, { sessionId?: string } | undefined>;
1257
+ const sessionId = store[key]?.sessionId;
1258
+ return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
1259
+ } catch {
1260
+ return undefined;
1261
+ }
1262
+ },
1263
+ agentLaneSubagent: "subagent",
1264
+ log: {
1265
+ info: (msg) => api.logger.info(msg),
1266
+ warn: (msg) => api.logger.warn(msg),
1267
+ error: (msg) => api.logger.error(msg),
1268
+ debug: (msg) => api.logger.debug?.(msg),
1269
+ },
1270
+ };
1271
+ }
1272
+
1273
+ const lcmPlugin = {
1274
+ id: "openclawbrain",
1275
+ name: "OpenClawBrain",
1276
+ description:
1277
+ "Lossless transcript memory plus a pack-based learning layer for OpenClaw",
1278
+
1279
+ configSchema: {
1280
+ parse(value: unknown) {
1281
+ const raw =
1282
+ value && typeof value === "object" && !Array.isArray(value)
1283
+ ? (value as Record<string, unknown>)
1284
+ : {};
1285
+ return resolveLcmConfig(process.env, raw);
1286
+ },
1287
+ },
1288
+
1289
+ register(api: OpenClawPluginApi) {
1290
+ const deps = createLcmDependencies(api);
1291
+ const lcm = new LcmContextEngine(deps);
1292
+
1293
+ api.registerContextEngine("openclawbrain", () => lcm);
1294
+ api.registerTool((ctx) =>
1295
+ createLcmGrepTool({
1296
+ deps,
1297
+ lcm,
1298
+ sessionKey: ctx.sessionKey,
1299
+ }),
1300
+ );
1301
+ api.registerTool((ctx) =>
1302
+ createLcmDescribeTool({
1303
+ deps,
1304
+ lcm,
1305
+ sessionKey: ctx.sessionKey,
1306
+ }),
1307
+ );
1308
+ api.registerTool((ctx) =>
1309
+ createLcmExpandTool({
1310
+ deps,
1311
+ lcm,
1312
+ sessionKey: ctx.sessionKey,
1313
+ }),
1314
+ );
1315
+ api.registerTool((ctx) =>
1316
+ createLcmExpandQueryTool({
1317
+ deps,
1318
+ lcm,
1319
+ sessionKey: ctx.sessionKey,
1320
+ requesterSessionKey: ctx.sessionKey,
1321
+ }),
1322
+ );
1323
+
1324
+ api.registerTool((ctx) =>
1325
+ createBrainTeachTool({
1326
+ teach: async (instruction, kind, tags) => {
1327
+ const brain = lcm.getBrainService();
1328
+ if (!brain) {
1329
+ throw new Error("OpenClawBrain runtime is unavailable");
1330
+ }
1331
+ const conversationId = await lcm.getConversationIdForSessionKey(ctx.sessionKey);
1332
+ return brain.teach({ instruction, conversationId, kind, tags });
1333
+ },
1334
+ status: async () => lcm.getBrainService()?.status() ?? { enabled: false },
1335
+ getTrace: async (traceId?: string) =>
1336
+ ((await lcm.getBrainService()?.getTrace(traceId)) as unknown as Record<string, unknown> | null) ?? null,
1337
+ }),
1338
+ );
1339
+ api.registerTool(() =>
1340
+ createBrainStatusTool({
1341
+ teach: async () => ({ nodeId: "" }),
1342
+ status: async () => lcm.getBrainService()?.status() ?? { enabled: false },
1343
+ getTrace: async (traceId?: string) =>
1344
+ ((await lcm.getBrainService()?.getTrace(traceId)) as unknown as Record<string, unknown> | null) ?? null,
1345
+ }),
1346
+ );
1347
+ api.registerTool(() =>
1348
+ createBrainTraceTool({
1349
+ teach: async () => ({ nodeId: "" }),
1350
+ status: async () => lcm.getBrainService()?.status() ?? { enabled: false },
1351
+ getTrace: async (traceId?: string) =>
1352
+ ((await lcm.getBrainService()?.getTrace(traceId)) as unknown as Record<string, unknown> | null) ?? null,
1353
+ }),
1354
+ );
1355
+
1356
+ api.registerService({
1357
+ id: "brain-worker",
1358
+ start: () => {
1359
+ lcm.getBrainService()?.startWorker();
1360
+ },
1361
+ stop: () => {
1362
+ lcm.getBrainService()?.stopWorker();
1363
+ },
1364
+ });
1365
+
1366
+ api.logger.info(
1367
+ `[openclawbrain] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
1368
+ );
1369
+ },
1370
+ };
1371
+
1372
+ export default lcmPlugin;