@martian-engineering/lossless-claw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,808 @@
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 { 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 type { LcmDependencies } from "./src/types.js";
17
+
18
+ /** Parse `agent:<agentId>:<suffix...>` session keys. */
19
+ function parseAgentSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
20
+ const value = sessionKey.trim();
21
+ if (!value.startsWith("agent:")) {
22
+ return null;
23
+ }
24
+ const parts = value.split(":");
25
+ if (parts.length < 3) {
26
+ return null;
27
+ }
28
+ const agentId = parts[1]?.trim();
29
+ const suffix = parts.slice(2).join(":").trim();
30
+ if (!agentId || !suffix) {
31
+ return null;
32
+ }
33
+ return { agentId, suffix };
34
+ }
35
+
36
+ /** Return a stable normalized agent id. */
37
+ function normalizeAgentId(agentId: string | undefined): string {
38
+ const normalized = (agentId ?? "").trim();
39
+ return normalized.length > 0 ? normalized : "main";
40
+ }
41
+
42
+ /** Resolve common provider API keys from environment. */
43
+ function resolveApiKey(provider: string): string | undefined {
44
+ const keyMap: Record<string, string[]> = {
45
+ openai: ["OPENAI_API_KEY"],
46
+ anthropic: ["ANTHROPIC_API_KEY"],
47
+ google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
48
+ groq: ["GROQ_API_KEY"],
49
+ xai: ["XAI_API_KEY"],
50
+ mistral: ["MISTRAL_API_KEY"],
51
+ together: ["TOGETHER_API_KEY"],
52
+ openrouter: ["OPENROUTER_API_KEY"],
53
+ "github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
54
+ };
55
+
56
+ const providerKey = provider.trim().toLowerCase();
57
+ const keys = keyMap[providerKey] ?? [];
58
+ const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
59
+ keys.push(normalizedProviderEnv);
60
+
61
+ for (const key of keys) {
62
+ const value = process.env[key]?.trim();
63
+ if (value) {
64
+ return value;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ type AuthProfileCredential =
71
+ | { type: "api_key"; provider: string; key?: string; email?: string }
72
+ | { type: "token"; provider: string; token?: string; expires?: number; email?: string }
73
+ | ({
74
+ type: "oauth";
75
+ provider: string;
76
+ access?: string;
77
+ refresh?: string;
78
+ expires?: number;
79
+ email?: string;
80
+ } & Record<string, unknown>);
81
+
82
+ type AuthProfileStore = {
83
+ profiles: Record<string, AuthProfileCredential>;
84
+ order?: Record<string, string[]>;
85
+ };
86
+
87
+ type PiAiOAuthCredentials = {
88
+ refresh: string;
89
+ access: string;
90
+ expires: number;
91
+ [key: string]: unknown;
92
+ };
93
+
94
+ type PiAiModule = {
95
+ completeSimple?: (
96
+ model: {
97
+ id: string;
98
+ provider: string;
99
+ api: string;
100
+ name?: string;
101
+ reasoning?: boolean;
102
+ input?: string[];
103
+ cost?: {
104
+ input: number;
105
+ output: number;
106
+ cacheRead: number;
107
+ cacheWrite: number;
108
+ };
109
+ contextWindow?: number;
110
+ maxTokens?: number;
111
+ },
112
+ request: { messages: Array<{ role: string; content: unknown; timestamp?: number }> },
113
+ options: {
114
+ apiKey?: string;
115
+ maxTokens: number;
116
+ temperature?: number;
117
+ },
118
+ ) => Promise<{ content?: Array<{ type: string; text?: string }> }>;
119
+ getModel?: (provider: string, modelId: string) => unknown;
120
+ getModels?: (provider: string) => unknown[];
121
+ getEnvApiKey?: (provider: string) => string | undefined;
122
+ getOAuthApiKey?: (
123
+ providerId: string,
124
+ credentials: Record<string, PiAiOAuthCredentials>,
125
+ ) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
126
+ };
127
+
128
+ /** Narrow unknown values to plain objects. */
129
+ function isRecord(value: unknown): value is Record<string, unknown> {
130
+ return !!value && typeof value === "object" && !Array.isArray(value);
131
+ }
132
+
133
+ /** Normalize provider ids for case-insensitive matching. */
134
+ function normalizeProviderId(provider: string): string {
135
+ return provider.trim().toLowerCase();
136
+ }
137
+
138
+ /** Resolve known provider API defaults when model lookup misses. */
139
+ function inferApiFromProvider(provider: string): string {
140
+ const normalized = normalizeProviderId(provider);
141
+ const map: Record<string, string> = {
142
+ anthropic: "anthropic-messages",
143
+ openai: "openai-responses",
144
+ "openai-codex": "openai-codex-responses",
145
+ "github-copilot": "openai-codex-responses",
146
+ google: "google-generative-ai",
147
+ "google-gemini-cli": "google-gemini-cli",
148
+ "google-antigravity": "google-gemini-cli",
149
+ "google-vertex": "google-vertex",
150
+ "amazon-bedrock": "bedrock-converse-stream",
151
+ };
152
+ return map[normalized] ?? "openai-responses";
153
+ }
154
+
155
+ /** Select provider-specific config values with case-insensitive provider keys. */
156
+ function findProviderConfigValue<T>(
157
+ map: Record<string, T> | undefined,
158
+ provider: string,
159
+ ): T | undefined {
160
+ if (!map) {
161
+ return undefined;
162
+ }
163
+ if (map[provider] !== undefined) {
164
+ return map[provider];
165
+ }
166
+ const normalizedProvider = normalizeProviderId(provider);
167
+ for (const [key, value] of Object.entries(map)) {
168
+ if (normalizeProviderId(key) === normalizedProvider) {
169
+ return value;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /** Resolve provider API from runtime config if available. */
176
+ function resolveProviderApiFromRuntimeConfig(
177
+ runtimeConfig: unknown,
178
+ provider: string,
179
+ ): string | undefined {
180
+ if (!isRecord(runtimeConfig)) {
181
+ return undefined;
182
+ }
183
+ const providers = (runtimeConfig as { models?: { providers?: Record<string, unknown> } }).models
184
+ ?.providers;
185
+ if (!providers || !isRecord(providers)) {
186
+ return undefined;
187
+ }
188
+ const value = findProviderConfigValue(providers, provider);
189
+ if (!isRecord(value)) {
190
+ return undefined;
191
+ }
192
+ const api = value.api;
193
+ return typeof api === "string" && api.trim() ? api.trim() : undefined;
194
+ }
195
+
196
+ /** Parse auth-profiles JSON into a minimal store shape. */
197
+ function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
198
+ try {
199
+ const parsed = JSON.parse(raw) as unknown;
200
+ if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
201
+ return undefined;
202
+ }
203
+
204
+ const profiles: Record<string, AuthProfileCredential> = {};
205
+ for (const [profileId, value] of Object.entries(parsed.profiles)) {
206
+ if (!isRecord(value)) {
207
+ continue;
208
+ }
209
+ const type = value.type;
210
+ const provider = typeof value.provider === "string" ? value.provider.trim() : "";
211
+ if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
212
+ continue;
213
+ }
214
+ profiles[profileId] = value as AuthProfileCredential;
215
+ }
216
+
217
+ const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
218
+ const order: Record<string, string[]> | undefined = rawOrder
219
+ ? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
220
+ if (!Array.isArray(value)) {
221
+ return acc;
222
+ }
223
+ const ids = value
224
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
225
+ .filter(Boolean);
226
+ if (ids.length > 0) {
227
+ acc[provider] = ids;
228
+ }
229
+ return acc;
230
+ }, {})
231
+ : undefined;
232
+
233
+ return {
234
+ profiles,
235
+ ...(order && Object.keys(order).length > 0 ? { order } : {}),
236
+ };
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+
242
+ /** Merge auth stores, letting later stores override earlier profiles/order. */
243
+ function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
244
+ if (stores.length === 0) {
245
+ return undefined;
246
+ }
247
+ const merged: AuthProfileStore = { profiles: {} };
248
+ for (const store of stores) {
249
+ merged.profiles = { ...merged.profiles, ...store.profiles };
250
+ if (store.order) {
251
+ merged.order = { ...(merged.order ?? {}), ...store.order };
252
+ }
253
+ }
254
+ return merged;
255
+ }
256
+
257
+ /** Determine candidate auth store paths ordered by precedence. */
258
+ function resolveAuthStorePaths(agentDir?: string): string[] {
259
+ const paths: string[] = [];
260
+ const directAgentDir = agentDir?.trim();
261
+ if (directAgentDir) {
262
+ paths.push(join(directAgentDir, "auth-profiles.json"));
263
+ }
264
+
265
+ const envAgentDir = process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
266
+ if (envAgentDir) {
267
+ paths.push(join(envAgentDir, "auth-profiles.json"));
268
+ }
269
+
270
+ const home = process.env.HOME?.trim();
271
+ if (home) {
272
+ paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
273
+ }
274
+
275
+ return [...new Set(paths)];
276
+ }
277
+
278
+ /** Build profile selection order for provider auth lookup. */
279
+ function resolveAuthProfileCandidates(params: {
280
+ provider: string;
281
+ store: AuthProfileStore;
282
+ authProfileId?: string;
283
+ runtimeConfig?: unknown;
284
+ }): string[] {
285
+ const candidates: string[] = [];
286
+ const normalizedProvider = normalizeProviderId(params.provider);
287
+ const push = (value: string | undefined) => {
288
+ const profileId = value?.trim();
289
+ if (!profileId) {
290
+ return;
291
+ }
292
+ if (!candidates.includes(profileId)) {
293
+ candidates.push(profileId);
294
+ }
295
+ };
296
+
297
+ push(params.authProfileId);
298
+
299
+ const storeOrder = findProviderConfigValue(params.store.order, params.provider);
300
+ for (const profileId of storeOrder ?? []) {
301
+ push(profileId);
302
+ }
303
+
304
+ if (isRecord(params.runtimeConfig)) {
305
+ const auth = params.runtimeConfig.auth;
306
+ if (isRecord(auth)) {
307
+ const order = findProviderConfigValue(
308
+ isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
309
+ params.provider,
310
+ );
311
+ if (Array.isArray(order)) {
312
+ for (const profileId of order) {
313
+ if (typeof profileId === "string") {
314
+ push(profileId);
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ for (const [profileId, credential] of Object.entries(params.store.profiles)) {
322
+ if (normalizeProviderId(credential.provider) === normalizedProvider) {
323
+ push(profileId);
324
+ }
325
+ }
326
+
327
+ return candidates;
328
+ }
329
+
330
+ /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
331
+ async function resolveApiKeyFromAuthProfiles(params: {
332
+ provider: string;
333
+ authProfileId?: string;
334
+ agentDir?: string;
335
+ runtimeConfig?: unknown;
336
+ piAiModule: PiAiModule;
337
+ }): Promise<string | undefined> {
338
+ const storesWithPaths = resolveAuthStorePaths(params.agentDir)
339
+ .map((path) => {
340
+ try {
341
+ const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
342
+ return parsed ? { path, store: parsed } : undefined;
343
+ } catch {
344
+ return undefined;
345
+ }
346
+ })
347
+ .filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
348
+ if (storesWithPaths.length === 0) {
349
+ return undefined;
350
+ }
351
+
352
+ const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
353
+ if (!mergedStore) {
354
+ return undefined;
355
+ }
356
+
357
+ const candidates = resolveAuthProfileCandidates({
358
+ provider: params.provider,
359
+ store: mergedStore,
360
+ authProfileId: params.authProfileId,
361
+ runtimeConfig: params.runtimeConfig,
362
+ });
363
+ if (candidates.length === 0) {
364
+ return undefined;
365
+ }
366
+
367
+ const persistPath =
368
+ params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
369
+
370
+ for (const profileId of candidates) {
371
+ const credential = mergedStore.profiles[profileId];
372
+ if (!credential) {
373
+ continue;
374
+ }
375
+ if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
376
+ continue;
377
+ }
378
+
379
+ if (credential.type === "api_key") {
380
+ const key = credential.key?.trim();
381
+ if (key) {
382
+ return key;
383
+ }
384
+ continue;
385
+ }
386
+
387
+ if (credential.type === "token") {
388
+ const token = credential.token?.trim();
389
+ if (!token) {
390
+ continue;
391
+ }
392
+ const expires = credential.expires;
393
+ if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
394
+ continue;
395
+ }
396
+ return token;
397
+ }
398
+
399
+ const access = credential.access?.trim();
400
+ const expires = credential.expires;
401
+ const isExpired =
402
+ typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
403
+
404
+ if (!isExpired && access) {
405
+ if (
406
+ (credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
407
+ typeof credential.projectId === "string" &&
408
+ credential.projectId.trim()
409
+ ) {
410
+ return JSON.stringify({
411
+ token: access,
412
+ projectId: credential.projectId.trim(),
413
+ });
414
+ }
415
+ return access;
416
+ }
417
+
418
+ if (typeof params.piAiModule.getOAuthApiKey !== "function") {
419
+ continue;
420
+ }
421
+
422
+ try {
423
+ const oauthCredential = {
424
+ access: credential.access ?? "",
425
+ refresh: credential.refresh ?? "",
426
+ expires: typeof credential.expires === "number" ? credential.expires : 0,
427
+ ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
428
+ ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
429
+ };
430
+ const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
431
+ [params.provider]: oauthCredential,
432
+ });
433
+ if (!refreshed?.apiKey) {
434
+ continue;
435
+ }
436
+ mergedStore.profiles[profileId] = {
437
+ ...credential,
438
+ ...refreshed.newCredentials,
439
+ type: "oauth",
440
+ };
441
+ if (persistPath) {
442
+ try {
443
+ writeFileSync(
444
+ persistPath,
445
+ JSON.stringify(
446
+ {
447
+ version: 1,
448
+ profiles: mergedStore.profiles,
449
+ ...(mergedStore.order ? { order: mergedStore.order } : {}),
450
+ },
451
+ null,
452
+ 2,
453
+ ),
454
+ "utf8",
455
+ );
456
+ } catch {
457
+ // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
458
+ }
459
+ }
460
+ return refreshed.apiKey;
461
+ } catch {
462
+ if (access) {
463
+ return access;
464
+ }
465
+ }
466
+ }
467
+
468
+ return undefined;
469
+ }
470
+
471
+ /** Build a minimal but useful sub-agent prompt. */
472
+ function buildSubagentSystemPrompt(params: {
473
+ depth: number;
474
+ maxDepth: number;
475
+ taskSummary?: string;
476
+ }): string {
477
+ const task = params.taskSummary?.trim() || "Perform delegated LCM expansion work.";
478
+ return [
479
+ "You are a delegated sub-agent for LCM expansion.",
480
+ `Depth: ${params.depth}/${params.maxDepth}`,
481
+ "Return concise, factual results only.",
482
+ task,
483
+ ].join("\n");
484
+ }
485
+
486
+ /** Extract latest assistant text from session message snapshots. */
487
+ function readLatestAssistantReply(messages: unknown[]): string | undefined {
488
+ for (let i = messages.length - 1; i >= 0; i--) {
489
+ const item = messages[i];
490
+ if (!item || typeof item !== "object") {
491
+ continue;
492
+ }
493
+ const record = item as { role?: unknown; content?: unknown };
494
+ if (record.role !== "assistant") {
495
+ continue;
496
+ }
497
+
498
+ if (typeof record.content === "string") {
499
+ const trimmed = record.content.trim();
500
+ if (trimmed) {
501
+ return trimmed;
502
+ }
503
+ continue;
504
+ }
505
+
506
+ if (!Array.isArray(record.content)) {
507
+ continue;
508
+ }
509
+
510
+ const text = record.content
511
+ .filter((entry): entry is { type?: unknown; text?: unknown } => {
512
+ return !!entry && typeof entry === "object";
513
+ })
514
+ .map((entry) => (entry.type === "text" && typeof entry.text === "string" ? entry.text : ""))
515
+ .filter(Boolean)
516
+ .join("\n")
517
+ .trim();
518
+
519
+ if (text) {
520
+ return text;
521
+ }
522
+ }
523
+
524
+ return undefined;
525
+ }
526
+
527
+ /** Construct LCM dependencies from plugin API/runtime surfaces. */
528
+ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
529
+ const config = resolveLcmConfig(process.env);
530
+
531
+ return {
532
+ config,
533
+ complete: async ({
534
+ provider,
535
+ model,
536
+ apiKey,
537
+ providerApi,
538
+ authProfileId,
539
+ agentDir,
540
+ runtimeConfig,
541
+ messages,
542
+ maxTokens,
543
+ temperature,
544
+ }) => {
545
+ try {
546
+ const piAiModuleId = "@mariozechner/pi-ai";
547
+ const mod = (await import(piAiModuleId)) as PiAiModule;
548
+
549
+ if (typeof mod.completeSimple !== "function") {
550
+ return { content: [] };
551
+ }
552
+
553
+ const providerId = (provider ?? "").trim();
554
+ const modelId = model.trim();
555
+ if (!providerId || !modelId) {
556
+ return { content: [] };
557
+ }
558
+
559
+ const knownModel =
560
+ typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
561
+ const fallbackApi =
562
+ providerApi?.trim() ||
563
+ resolveProviderApiFromRuntimeConfig(runtimeConfig, providerId) ||
564
+ (() => {
565
+ if (typeof mod.getModels !== "function") {
566
+ return undefined;
567
+ }
568
+ const models = mod.getModels(providerId);
569
+ const first = Array.isArray(models) ? models[0] : undefined;
570
+ if (!isRecord(first) || typeof first.api !== "string" || !first.api.trim()) {
571
+ return undefined;
572
+ }
573
+ return first.api.trim();
574
+ })() ||
575
+ inferApiFromProvider(providerId);
576
+
577
+ const resolvedModel =
578
+ isRecord(knownModel) &&
579
+ typeof knownModel.api === "string" &&
580
+ typeof knownModel.provider === "string" &&
581
+ typeof knownModel.id === "string"
582
+ ? {
583
+ ...knownModel,
584
+ id: knownModel.id,
585
+ provider: knownModel.provider,
586
+ api: knownModel.api,
587
+ }
588
+ : {
589
+ id: modelId,
590
+ name: modelId,
591
+ provider: providerId,
592
+ api: fallbackApi,
593
+ reasoning: false,
594
+ input: ["text"],
595
+ cost: {
596
+ input: 0,
597
+ output: 0,
598
+ cacheRead: 0,
599
+ cacheWrite: 0,
600
+ },
601
+ contextWindow: 200_000,
602
+ maxTokens: 8_000,
603
+ };
604
+
605
+ let resolvedApiKey = apiKey?.trim() || resolveApiKey(providerId);
606
+ if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
607
+ resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
608
+ }
609
+ if (!resolvedApiKey) {
610
+ resolvedApiKey = await resolveApiKeyFromAuthProfiles({
611
+ provider: providerId,
612
+ authProfileId,
613
+ agentDir,
614
+ runtimeConfig,
615
+ piAiModule: mod,
616
+ });
617
+ }
618
+
619
+ const result = await mod.completeSimple(
620
+ resolvedModel,
621
+ {
622
+ messages: messages.map((message) => ({
623
+ role: message.role,
624
+ content: message.content,
625
+ timestamp: Date.now(),
626
+ })),
627
+ },
628
+ {
629
+ apiKey: resolvedApiKey,
630
+ maxTokens,
631
+ temperature,
632
+ },
633
+ );
634
+
635
+ return {
636
+ content: Array.isArray(result?.content) ? result.content : [],
637
+ };
638
+ } catch (err) {
639
+ console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
640
+ return { content: [] };
641
+ }
642
+ },
643
+ callGateway: async (params) => {
644
+ const sub = api.runtime.subagent;
645
+ switch (params.method) {
646
+ case "agent":
647
+ return sub.run({
648
+ sessionKey: String(params.params?.sessionKey ?? ""),
649
+ message: String(params.params?.message ?? ""),
650
+ extraSystemPrompt: params.params?.extraSystemPrompt as string | undefined,
651
+ lane: params.params?.lane as string | undefined,
652
+ deliver: (params.params?.deliver as boolean) ?? false,
653
+ idempotencyKey: params.params?.idempotencyKey as string | undefined,
654
+ });
655
+ case "agent.wait":
656
+ return sub.waitForRun({
657
+ runId: String(params.params?.runId ?? ""),
658
+ timeoutMs: (params.params?.timeoutMs as number) ?? params.timeoutMs,
659
+ });
660
+ case "sessions.get":
661
+ return sub.getSession({
662
+ sessionKey: String(params.params?.key ?? ""),
663
+ limit: params.params?.limit as number | undefined,
664
+ });
665
+ case "sessions.delete":
666
+ await sub.deleteSession({
667
+ sessionKey: String(params.params?.key ?? ""),
668
+ deleteTranscript: (params.params?.deleteTranscript as boolean) ?? true,
669
+ });
670
+ return {};
671
+ default:
672
+ throw new Error(`Unsupported gateway method in LCM plugin: ${params.method}`);
673
+ }
674
+ },
675
+ resolveModel: (modelRef, providerHint) => {
676
+ const raw = (modelRef ?? process.env.LCM_SUMMARY_MODEL ?? "").trim();
677
+ if (!raw) {
678
+ throw new Error("No model configured for LCM summarization.");
679
+ }
680
+
681
+ if (raw.includes("/")) {
682
+ const [provider, ...rest] = raw.split("/");
683
+ const model = rest.join("/").trim();
684
+ if (provider && model) {
685
+ return { provider: provider.trim(), model };
686
+ }
687
+ }
688
+
689
+ const provider = (
690
+ providerHint?.trim() ||
691
+ process.env.LCM_SUMMARY_PROVIDER ||
692
+ process.env.OPENCLAW_PROVIDER ||
693
+ "openai"
694
+ ).trim();
695
+ return { provider, model: raw };
696
+ },
697
+ getApiKey: (provider) => resolveApiKey(provider),
698
+ requireApiKey: (provider) => {
699
+ const key = resolveApiKey(provider);
700
+ if (!key) {
701
+ throw new Error(`Missing API key for provider '${provider}'.`);
702
+ }
703
+ return key;
704
+ },
705
+ parseAgentSessionKey,
706
+ isSubagentSessionKey: (sessionKey) => {
707
+ const parsed = parseAgentSessionKey(sessionKey);
708
+ return !!parsed && parsed.suffix.startsWith("subagent:");
709
+ },
710
+ normalizeAgentId,
711
+ buildSubagentSystemPrompt,
712
+ readLatestAssistantReply,
713
+ resolveAgentDir: () => api.resolvePath("."),
714
+ resolveSessionIdFromSessionKey: async (sessionKey) => {
715
+ const key = sessionKey.trim();
716
+ if (!key) {
717
+ return undefined;
718
+ }
719
+
720
+ try {
721
+ const cfg = api.runtime.config.loadConfig();
722
+ const parsed = parseAgentSessionKey(key);
723
+ const agentId = normalizeAgentId(parsed?.agentId);
724
+ const storePath = api.runtime.channel.session.resolveStorePath(cfg.session?.store, {
725
+ agentId,
726
+ });
727
+ const raw = readFileSync(storePath, "utf8");
728
+ const store = JSON.parse(raw) as Record<string, { sessionId?: string } | undefined>;
729
+ const sessionId = store[key]?.sessionId;
730
+ return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
731
+ } catch {
732
+ return undefined;
733
+ }
734
+ },
735
+ agentLaneSubagent: "subagent",
736
+ log: {
737
+ info: (msg) => api.logger.info(msg),
738
+ warn: (msg) => api.logger.warn(msg),
739
+ error: (msg) => api.logger.error(msg),
740
+ debug: (msg) => api.logger.debug?.(msg),
741
+ },
742
+ };
743
+ }
744
+
745
+ const lcmPlugin = {
746
+ id: "lossless-claw",
747
+ name: "Lossless Context Management",
748
+ description:
749
+ "DAG-based conversation summarization with incremental compaction, full-text search, and sub-agent expansion",
750
+
751
+ configSchema: {
752
+ parse(value: unknown) {
753
+ // Merge plugin config with env vars — env vars take precedence for backward compat
754
+ const raw =
755
+ value && typeof value === "object" && !Array.isArray(value)
756
+ ? (value as Record<string, unknown>)
757
+ : {};
758
+ const enabled = typeof raw.enabled === "boolean" ? raw.enabled : undefined;
759
+ const config = resolveLcmConfig(process.env);
760
+ if (enabled !== undefined) {
761
+ config.enabled = enabled;
762
+ }
763
+ return config;
764
+ },
765
+ },
766
+
767
+ register(api: OpenClawPluginApi) {
768
+ const deps = createLcmDependencies(api);
769
+ const lcm = new LcmContextEngine(deps);
770
+
771
+ api.registerContextEngine("lossless-claw", () => lcm);
772
+ api.registerTool((ctx) =>
773
+ createLcmGrepTool({
774
+ deps,
775
+ lcm,
776
+ sessionKey: ctx.sessionKey,
777
+ }),
778
+ );
779
+ api.registerTool((ctx) =>
780
+ createLcmDescribeTool({
781
+ deps,
782
+ lcm,
783
+ sessionKey: ctx.sessionKey,
784
+ }),
785
+ );
786
+ api.registerTool((ctx) =>
787
+ createLcmExpandTool({
788
+ deps,
789
+ lcm,
790
+ sessionKey: ctx.sessionKey,
791
+ }),
792
+ );
793
+ api.registerTool((ctx) =>
794
+ createLcmExpandQueryTool({
795
+ deps,
796
+ lcm,
797
+ sessionKey: ctx.sessionKey,
798
+ requesterSessionKey: ctx.sessionKey,
799
+ }),
800
+ );
801
+
802
+ api.logger.info(
803
+ `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
804
+ );
805
+ },
806
+ };
807
+
808
+ export default lcmPlugin;