@martian-engineering/lossless-claw 0.2.7 → 0.2.8

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 (2) hide show
  1. package/index.ts +448 -26
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * DAG-based conversation summarization with incremental compaction,
5
5
  * full-text search, and sub-agent expansion.
6
6
  */
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
8
9
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
10
  import { resolveLcmConfig } from "./src/db/config.js";
10
11
  import { LcmContextEngine } from "./src/engine.js";
@@ -45,8 +46,12 @@ type PluginEnvSnapshot = {
45
46
  pluginSummaryProvider: string;
46
47
  openclawProvider: string;
47
48
  openclawDefaultModel: string;
49
+ agentDir: string;
50
+ home: string;
48
51
  };
49
52
 
53
+ type ReadEnvFn = (key: string) => string | undefined;
54
+
50
55
  type CompleteSimpleOptions = {
51
56
  apiKey?: string;
52
57
  maxTokens: number;
@@ -90,6 +95,10 @@ type RuntimeModelAuth = {
90
95
  }) => Promise<RuntimeModelAuthResult | undefined>;
91
96
  };
92
97
 
98
+ const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
99
+ const MODEL_AUTH_MERGE_COMMIT = "4790e40";
100
+ const MODEL_AUTH_REQUIRED_RELEASE = "the first OpenClaw release after 2026.3.8";
101
+
93
102
  /** Capture plugin env values once during initialization. */
94
103
  function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
95
104
  return {
@@ -99,6 +108,8 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
99
108
  pluginSummaryProvider: "",
100
109
  openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
101
110
  openclawDefaultModel: "",
111
+ agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
112
+ home: env.HOME?.trim() ?? "",
102
113
  };
103
114
  }
104
115
 
@@ -117,6 +128,58 @@ function readDefaultModelFromConfig(config: unknown): string {
117
128
  return typeof primary === "string" ? primary.trim() : "";
118
129
  }
119
130
 
131
+ /** Resolve common provider API keys from environment. */
132
+ function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
133
+ const keyMap: Record<string, string[]> = {
134
+ openai: ["OPENAI_API_KEY"],
135
+ anthropic: ["ANTHROPIC_API_KEY"],
136
+ google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
137
+ groq: ["GROQ_API_KEY"],
138
+ xai: ["XAI_API_KEY"],
139
+ mistral: ["MISTRAL_API_KEY"],
140
+ together: ["TOGETHER_API_KEY"],
141
+ openrouter: ["OPENROUTER_API_KEY"],
142
+ "github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
143
+ };
144
+
145
+ const providerKey = provider.trim().toLowerCase();
146
+ const keys = keyMap[providerKey] ?? [];
147
+ const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
148
+ keys.push(normalizedProviderEnv);
149
+
150
+ for (const key of keys) {
151
+ const value = readEnv(key)?.trim();
152
+ if (value) {
153
+ return value;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ type AuthProfileCredential =
160
+ | { type: "api_key"; provider: string; key?: string; email?: string }
161
+ | { type: "token"; provider: string; token?: string; expires?: number; email?: string }
162
+ | ({
163
+ type: "oauth";
164
+ provider: string;
165
+ access?: string;
166
+ refresh?: string;
167
+ expires?: number;
168
+ email?: string;
169
+ } & Record<string, unknown>);
170
+
171
+ type AuthProfileStore = {
172
+ profiles: Record<string, AuthProfileCredential>;
173
+ order?: Record<string, string[]>;
174
+ };
175
+
176
+ type PiAiOAuthCredentials = {
177
+ refresh: string;
178
+ access: string;
179
+ expires: number;
180
+ [key: string]: unknown;
181
+ };
182
+
120
183
  type PiAiModule = {
121
184
  completeSimple?: (
122
185
  model: {
@@ -148,6 +211,11 @@ type PiAiModule = {
148
211
  ) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
149
212
  getModel?: (provider: string, modelId: string) => unknown;
150
213
  getModels?: (provider: string) => unknown[];
214
+ getEnvApiKey?: (provider: string) => string | undefined;
215
+ getOAuthApiKey?: (
216
+ providerId: string,
217
+ credentials: Record<string, PiAiOAuthCredentials>,
218
+ ) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
151
219
  };
152
220
 
153
221
  /** Narrow unknown values to plain objects. */
@@ -251,14 +319,11 @@ function resolveProviderApiFromRuntimeConfig(
251
319
  return typeof api === "string" && api.trim() ? api.trim() : undefined;
252
320
  }
253
321
 
254
- /** Resolve runtime.modelAuth from plugin runtime, even before plugin-sdk typings land locally. */
255
- function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth {
322
+ /** Resolve runtime.modelAuth from plugin runtime when available. */
323
+ function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth | undefined {
256
324
  const runtime = api.runtime as OpenClawPluginApi["runtime"] & {
257
325
  modelAuth?: RuntimeModelAuth;
258
326
  };
259
- if (!runtime.modelAuth) {
260
- throw new Error("OpenClaw runtime.modelAuth is required by lossless-claw.");
261
- }
262
327
  return runtime.modelAuth;
263
328
  }
264
329
 
@@ -292,6 +357,294 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
292
357
  return apiKey ? apiKey : undefined;
293
358
  }
294
359
 
360
+ function buildLegacyAuthFallbackWarning(): string {
361
+ return [
362
+ "[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
363
+ `Stock lossless-claw 0.2.7 expects OpenClaw plugin runtime support from PR #41090 (${MODEL_AUTH_PR_URL}).`,
364
+ `OpenClaw 2026.3.8 and 2026.3.8-beta.1 do not include merge commit ${MODEL_AUTH_MERGE_COMMIT};`,
365
+ `${MODEL_AUTH_REQUIRED_RELEASE} is required for stock lossless-claw 0.2.7 without this fallback patch.`,
366
+ ].join(" ");
367
+ }
368
+
369
+ /** Parse auth-profiles JSON into a minimal store shape. */
370
+ function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
371
+ try {
372
+ const parsed = JSON.parse(raw) as unknown;
373
+ if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
374
+ return undefined;
375
+ }
376
+
377
+ const profiles: Record<string, AuthProfileCredential> = {};
378
+ for (const [profileId, value] of Object.entries(parsed.profiles)) {
379
+ if (!isRecord(value)) {
380
+ continue;
381
+ }
382
+ const type = value.type;
383
+ const provider = typeof value.provider === "string" ? value.provider.trim() : "";
384
+ if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
385
+ continue;
386
+ }
387
+ profiles[profileId] = value as AuthProfileCredential;
388
+ }
389
+
390
+ const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
391
+ const order: Record<string, string[]> | undefined = rawOrder
392
+ ? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
393
+ if (!Array.isArray(value)) {
394
+ return acc;
395
+ }
396
+ const ids = value
397
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
398
+ .filter(Boolean);
399
+ if (ids.length > 0) {
400
+ acc[provider] = ids;
401
+ }
402
+ return acc;
403
+ }, {})
404
+ : undefined;
405
+
406
+ return {
407
+ profiles,
408
+ ...(order && Object.keys(order).length > 0 ? { order } : {}),
409
+ };
410
+ } catch {
411
+ return undefined;
412
+ }
413
+ }
414
+
415
+ /** Merge auth stores, letting later stores override earlier profiles/order. */
416
+ function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
417
+ if (stores.length === 0) {
418
+ return undefined;
419
+ }
420
+ const merged: AuthProfileStore = { profiles: {} };
421
+ for (const store of stores) {
422
+ merged.profiles = { ...merged.profiles, ...store.profiles };
423
+ if (store.order) {
424
+ merged.order = { ...(merged.order ?? {}), ...store.order };
425
+ }
426
+ }
427
+ return merged;
428
+ }
429
+
430
+ /** Determine candidate auth store paths ordered by precedence. */
431
+ function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
432
+ const paths: string[] = [];
433
+ const directAgentDir = params.agentDir?.trim();
434
+ if (directAgentDir) {
435
+ paths.push(join(directAgentDir, "auth-profiles.json"));
436
+ }
437
+
438
+ const envAgentDir = params.envSnapshot.agentDir;
439
+ if (envAgentDir) {
440
+ paths.push(join(envAgentDir, "auth-profiles.json"));
441
+ }
442
+
443
+ const home = params.envSnapshot.home;
444
+ if (home) {
445
+ paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
446
+ }
447
+
448
+ return [...new Set(paths)];
449
+ }
450
+
451
+ /** Build profile selection order for provider auth lookup. */
452
+ function resolveAuthProfileCandidates(params: {
453
+ provider: string;
454
+ store: AuthProfileStore;
455
+ authProfileId?: string;
456
+ runtimeConfig?: unknown;
457
+ }): string[] {
458
+ const candidates: string[] = [];
459
+ const normalizedProvider = normalizeProviderId(params.provider);
460
+ const push = (value: string | undefined) => {
461
+ const profileId = value?.trim();
462
+ if (!profileId) {
463
+ return;
464
+ }
465
+ if (!candidates.includes(profileId)) {
466
+ candidates.push(profileId);
467
+ }
468
+ };
469
+
470
+ push(params.authProfileId);
471
+
472
+ const storeOrder = findProviderConfigValue(params.store.order, params.provider);
473
+ for (const profileId of storeOrder ?? []) {
474
+ push(profileId);
475
+ }
476
+
477
+ if (isRecord(params.runtimeConfig)) {
478
+ const auth = params.runtimeConfig.auth;
479
+ if (isRecord(auth)) {
480
+ const order = findProviderConfigValue(
481
+ isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
482
+ params.provider,
483
+ );
484
+ if (Array.isArray(order)) {
485
+ for (const profileId of order) {
486
+ if (typeof profileId === "string") {
487
+ push(profileId);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ for (const [profileId, credential] of Object.entries(params.store.profiles)) {
495
+ if (normalizeProviderId(credential.provider) === normalizedProvider) {
496
+ push(profileId);
497
+ }
498
+ }
499
+
500
+ return candidates;
501
+ }
502
+
503
+ /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
504
+ async function resolveApiKeyFromAuthProfiles(params: {
505
+ provider: string;
506
+ authProfileId?: string;
507
+ agentDir?: string;
508
+ runtimeConfig?: unknown;
509
+ piAiModule: PiAiModule;
510
+ envSnapshot: PluginEnvSnapshot;
511
+ }): Promise<string | undefined> {
512
+ const storesWithPaths = resolveAuthStorePaths({
513
+ agentDir: params.agentDir,
514
+ envSnapshot: params.envSnapshot,
515
+ })
516
+ .map((path) => {
517
+ try {
518
+ const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
519
+ return parsed ? { path, store: parsed } : undefined;
520
+ } catch {
521
+ return undefined;
522
+ }
523
+ })
524
+ .filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
525
+ if (storesWithPaths.length === 0) {
526
+ return undefined;
527
+ }
528
+
529
+ const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
530
+ if (!mergedStore) {
531
+ return undefined;
532
+ }
533
+
534
+ const candidates = resolveAuthProfileCandidates({
535
+ provider: params.provider,
536
+ store: mergedStore,
537
+ authProfileId: params.authProfileId,
538
+ runtimeConfig: params.runtimeConfig,
539
+ });
540
+ if (candidates.length === 0) {
541
+ return undefined;
542
+ }
543
+
544
+ const persistPath =
545
+ params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
546
+
547
+ for (const profileId of candidates) {
548
+ const credential = mergedStore.profiles[profileId];
549
+ if (!credential) {
550
+ continue;
551
+ }
552
+ if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
553
+ continue;
554
+ }
555
+
556
+ if (credential.type === "api_key") {
557
+ const key = credential.key?.trim();
558
+ if (key) {
559
+ return key;
560
+ }
561
+ continue;
562
+ }
563
+
564
+ if (credential.type === "token") {
565
+ const token = credential.token?.trim();
566
+ if (!token) {
567
+ continue;
568
+ }
569
+ const expires = credential.expires;
570
+ if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
571
+ continue;
572
+ }
573
+ return token;
574
+ }
575
+
576
+ const access = credential.access?.trim();
577
+ const expires = credential.expires;
578
+ const isExpired =
579
+ typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
580
+
581
+ if (!isExpired && access) {
582
+ if (
583
+ (credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
584
+ typeof credential.projectId === "string" &&
585
+ credential.projectId.trim()
586
+ ) {
587
+ return JSON.stringify({
588
+ token: access,
589
+ projectId: credential.projectId.trim(),
590
+ });
591
+ }
592
+ return access;
593
+ }
594
+
595
+ if (typeof params.piAiModule.getOAuthApiKey !== "function") {
596
+ continue;
597
+ }
598
+
599
+ try {
600
+ const oauthCredential = {
601
+ access: credential.access ?? "",
602
+ refresh: credential.refresh ?? "",
603
+ expires: typeof credential.expires === "number" ? credential.expires : 0,
604
+ ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
605
+ ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
606
+ };
607
+ const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
608
+ [params.provider]: oauthCredential,
609
+ });
610
+ if (!refreshed?.apiKey) {
611
+ continue;
612
+ }
613
+ mergedStore.profiles[profileId] = {
614
+ ...credential,
615
+ ...refreshed.newCredentials,
616
+ type: "oauth",
617
+ };
618
+ if (persistPath) {
619
+ try {
620
+ writeFileSync(
621
+ persistPath,
622
+ JSON.stringify(
623
+ {
624
+ version: 1,
625
+ profiles: mergedStore.profiles,
626
+ ...(mergedStore.order ? { order: mergedStore.order } : {}),
627
+ },
628
+ null,
629
+ 2,
630
+ ),
631
+ "utf8",
632
+ );
633
+ } catch {
634
+ // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
635
+ }
636
+ }
637
+ return refreshed.apiKey;
638
+ } catch {
639
+ if (access) {
640
+ return access;
641
+ }
642
+ }
643
+ }
644
+
645
+ return undefined;
646
+ }
647
+
295
648
  /** Build a minimal but useful sub-agent prompt. */
296
649
  function buildSubagentSystemPrompt(params: {
297
650
  depth: number;
@@ -353,6 +706,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
353
706
  const envSnapshot = snapshotPluginEnv();
354
707
  envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
355
708
  const modelAuth = getRuntimeModelAuth(api);
709
+ const readEnv: ReadEnvFn = (key) => process.env[key];
356
710
  const pluginConfig =
357
711
  api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
358
712
  ? api.pluginConfig
@@ -371,6 +725,10 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
371
725
  }
372
726
  }
373
727
 
728
+ if (!modelAuth) {
729
+ api.logger.warn(buildLegacyAuthFallbackWarning());
730
+ }
731
+
374
732
  return {
375
733
  config,
376
734
  complete: async ({
@@ -448,7 +806,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
448
806
  };
449
807
 
450
808
  let resolvedApiKey = apiKey?.trim();
451
- if (!resolvedApiKey) {
809
+ if (!resolvedApiKey && modelAuth) {
452
810
  try {
453
811
  resolvedApiKey = resolveApiKeyFromAuthResult(
454
812
  await modelAuth.resolveApiKeyForProvider({
@@ -464,6 +822,22 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
464
822
  );
465
823
  }
466
824
  }
825
+ if (!resolvedApiKey && !modelAuth) {
826
+ resolvedApiKey = resolveApiKey(providerId, readEnv);
827
+ }
828
+ if (!resolvedApiKey && !modelAuth && typeof mod.getEnvApiKey === "function") {
829
+ resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
830
+ }
831
+ if (!resolvedApiKey && !modelAuth) {
832
+ resolvedApiKey = await resolveApiKeyFromAuthProfiles({
833
+ provider: providerId,
834
+ authProfileId,
835
+ agentDir,
836
+ runtimeConfig,
837
+ piAiModule: mod,
838
+ envSnapshot,
839
+ });
840
+ }
467
841
 
468
842
  const completeOptions = buildCompleteSimpleOptions({
469
843
  api: resolvedModel.api,
@@ -587,28 +961,76 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
587
961
  return { provider, model: raw };
588
962
  },
589
963
  getApiKey: async (provider, model, options) => {
590
- try {
591
- return resolveApiKeyFromAuthResult(
592
- await modelAuth.getApiKeyForModel({
593
- model: buildModelAuthLookupModel({ provider, model }),
594
- cfg: api.config,
595
- ...(options?.profileId ? { profileId: options.profileId } : {}),
596
- ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
597
- }),
598
- );
599
- } catch {
600
- return undefined;
964
+ if (modelAuth) {
965
+ try {
966
+ const modelAuthKey = resolveApiKeyFromAuthResult(
967
+ await modelAuth.getApiKeyForModel({
968
+ model: buildModelAuthLookupModel({ provider, model }),
969
+ cfg: api.config,
970
+ ...(options?.profileId ? { profileId: options.profileId } : {}),
971
+ ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
972
+ }),
973
+ );
974
+ if (modelAuthKey) {
975
+ return modelAuthKey;
976
+ }
977
+ } catch {
978
+ // Fall through to auth-profile lookup for older OpenClaw runtimes.
979
+ }
980
+ }
981
+
982
+ const envKey = resolveApiKey(provider, readEnv);
983
+ if (envKey) {
984
+ return envKey;
601
985
  }
986
+
987
+ const piAiModuleId = "@mariozechner/pi-ai";
988
+ const mod = (await import(piAiModuleId)) as PiAiModule;
989
+ return resolveApiKeyFromAuthProfiles({
990
+ provider,
991
+ authProfileId: options?.profileId,
992
+ agentDir: api.resolvePath("."),
993
+ runtimeConfig: api.config,
994
+ piAiModule: mod,
995
+ envSnapshot,
996
+ });
602
997
  },
603
998
  requireApiKey: async (provider, model, options) => {
604
- const key = await resolveApiKeyFromAuthResult(
605
- await modelAuth.getApiKeyForModel({
606
- model: buildModelAuthLookupModel({ provider, model }),
607
- cfg: api.config,
608
- ...(options?.profileId ? { profileId: options.profileId } : {}),
609
- ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
610
- }),
611
- );
999
+ const key = await (async () => {
1000
+ if (modelAuth) {
1001
+ try {
1002
+ const modelAuthKey = resolveApiKeyFromAuthResult(
1003
+ await modelAuth.getApiKeyForModel({
1004
+ model: buildModelAuthLookupModel({ provider, model }),
1005
+ cfg: api.config,
1006
+ ...(options?.profileId ? { profileId: options.profileId } : {}),
1007
+ ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
1008
+ }),
1009
+ );
1010
+ if (modelAuthKey) {
1011
+ return modelAuthKey;
1012
+ }
1013
+ } catch {
1014
+ // Fall through to auth-profile lookup for older OpenClaw runtimes.
1015
+ }
1016
+ }
1017
+
1018
+ const envKey = resolveApiKey(provider, readEnv);
1019
+ if (envKey) {
1020
+ return envKey;
1021
+ }
1022
+
1023
+ const piAiModuleId = "@mariozechner/pi-ai";
1024
+ const mod = (await import(piAiModuleId)) as PiAiModule;
1025
+ return resolveApiKeyFromAuthProfiles({
1026
+ provider,
1027
+ authProfileId: options?.profileId,
1028
+ agentDir: api.resolvePath("."),
1029
+ runtimeConfig: api.config,
1030
+ piAiModule: mod,
1031
+ envSnapshot,
1032
+ });
1033
+ })();
612
1034
  if (!key) {
613
1035
  throw new Error(`Missing API key for provider '${provider}' (model '${model}').`);
614
1036
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
5
5
  "type": "module",
6
6
  "main": "index.ts",