@martian-engineering/lossless-claw 0.2.6 → 0.2.7

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 CHANGED
@@ -4,8 +4,7 @@
4
4
  * DAG-based conversation summarization with incremental compaction,
5
5
  * full-text search, and sub-agent expansion.
6
6
  */
7
- import { readFileSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
7
+ import { readFileSync } from "node:fs";
9
8
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
9
  import { resolveLcmConfig } from "./src/db/config.js";
11
10
  import { LcmContextEngine } from "./src/engine.js";
@@ -46,12 +45,8 @@ type PluginEnvSnapshot = {
46
45
  pluginSummaryProvider: string;
47
46
  openclawProvider: string;
48
47
  openclawDefaultModel: string;
49
- agentDir: string;
50
- home: string;
51
48
  };
52
49
 
53
- type ReadEnvFn = (key: string) => string | undefined;
54
-
55
50
  type CompleteSimpleOptions = {
56
51
  apiKey?: string;
57
52
  maxTokens: number;
@@ -59,6 +54,42 @@ type CompleteSimpleOptions = {
59
54
  reasoning?: string;
60
55
  };
61
56
 
57
+ type RuntimeModelAuthResult = {
58
+ apiKey?: string;
59
+ };
60
+
61
+ type RuntimeModelAuthModel = {
62
+ id: string;
63
+ provider: string;
64
+ api: string;
65
+ name?: string;
66
+ reasoning?: boolean;
67
+ input?: string[];
68
+ cost?: {
69
+ input: number;
70
+ output: number;
71
+ cacheRead: number;
72
+ cacheWrite: number;
73
+ };
74
+ contextWindow?: number;
75
+ maxTokens?: number;
76
+ };
77
+
78
+ type RuntimeModelAuth = {
79
+ getApiKeyForModel: (params: {
80
+ model: RuntimeModelAuthModel;
81
+ cfg?: OpenClawPluginApi["config"];
82
+ profileId?: string;
83
+ preferredProfile?: string;
84
+ }) => Promise<RuntimeModelAuthResult | undefined>;
85
+ resolveApiKeyForProvider: (params: {
86
+ provider: string;
87
+ cfg?: OpenClawPluginApi["config"];
88
+ profileId?: string;
89
+ preferredProfile?: string;
90
+ }) => Promise<RuntimeModelAuthResult | undefined>;
91
+ };
92
+
62
93
  /** Capture plugin env values once during initialization. */
63
94
  function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
64
95
  return {
@@ -68,8 +99,6 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
68
99
  pluginSummaryProvider: "",
69
100
  openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
70
101
  openclawDefaultModel: "",
71
- agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
72
- home: env.HOME?.trim() ?? "",
73
102
  };
74
103
  }
75
104
 
@@ -88,58 +117,6 @@ function readDefaultModelFromConfig(config: unknown): string {
88
117
  return typeof primary === "string" ? primary.trim() : "";
89
118
  }
90
119
 
91
- /** Resolve common provider API keys from environment. */
92
- function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
93
- const keyMap: Record<string, string[]> = {
94
- openai: ["OPENAI_API_KEY"],
95
- anthropic: ["ANTHROPIC_API_KEY"],
96
- google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
97
- groq: ["GROQ_API_KEY"],
98
- xai: ["XAI_API_KEY"],
99
- mistral: ["MISTRAL_API_KEY"],
100
- together: ["TOGETHER_API_KEY"],
101
- openrouter: ["OPENROUTER_API_KEY"],
102
- "github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
103
- };
104
-
105
- const providerKey = provider.trim().toLowerCase();
106
- const keys = keyMap[providerKey] ?? [];
107
- const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
108
- keys.push(normalizedProviderEnv);
109
-
110
- for (const key of keys) {
111
- const value = readEnv(key)?.trim();
112
- if (value) {
113
- return value;
114
- }
115
- }
116
- return undefined;
117
- }
118
-
119
- type AuthProfileCredential =
120
- | { type: "api_key"; provider: string; key?: string; email?: string }
121
- | { type: "token"; provider: string; token?: string; expires?: number; email?: string }
122
- | ({
123
- type: "oauth";
124
- provider: string;
125
- access?: string;
126
- refresh?: string;
127
- expires?: number;
128
- email?: string;
129
- } & Record<string, unknown>);
130
-
131
- type AuthProfileStore = {
132
- profiles: Record<string, AuthProfileCredential>;
133
- order?: Record<string, string[]>;
134
- };
135
-
136
- type PiAiOAuthCredentials = {
137
- refresh: string;
138
- access: string;
139
- expires: number;
140
- [key: string]: unknown;
141
- };
142
-
143
120
  type PiAiModule = {
144
121
  completeSimple?: (
145
122
  model: {
@@ -171,11 +148,6 @@ type PiAiModule = {
171
148
  ) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
172
149
  getModel?: (provider: string, modelId: string) => unknown;
173
150
  getModels?: (provider: string) => unknown[];
174
- getEnvApiKey?: (provider: string) => string | undefined;
175
- getOAuthApiKey?: (
176
- providerId: string,
177
- credentials: Record<string, PiAiOAuthCredentials>,
178
- ) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
179
151
  };
180
152
 
181
153
  /** Narrow unknown values to plain objects. */
@@ -279,283 +251,45 @@ function resolveProviderApiFromRuntimeConfig(
279
251
  return typeof api === "string" && api.trim() ? api.trim() : undefined;
280
252
  }
281
253
 
282
- /** Parse auth-profiles JSON into a minimal store shape. */
283
- function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
284
- try {
285
- const parsed = JSON.parse(raw) as unknown;
286
- if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
287
- return undefined;
288
- }
289
-
290
- const profiles: Record<string, AuthProfileCredential> = {};
291
- for (const [profileId, value] of Object.entries(parsed.profiles)) {
292
- if (!isRecord(value)) {
293
- continue;
294
- }
295
- const type = value.type;
296
- const provider = typeof value.provider === "string" ? value.provider.trim() : "";
297
- if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
298
- continue;
299
- }
300
- profiles[profileId] = value as AuthProfileCredential;
301
- }
302
-
303
- const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
304
- const order: Record<string, string[]> | undefined = rawOrder
305
- ? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
306
- if (!Array.isArray(value)) {
307
- return acc;
308
- }
309
- const ids = value
310
- .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
311
- .filter(Boolean);
312
- if (ids.length > 0) {
313
- acc[provider] = ids;
314
- }
315
- return acc;
316
- }, {})
317
- : undefined;
318
-
319
- return {
320
- profiles,
321
- ...(order && Object.keys(order).length > 0 ? { order } : {}),
322
- };
323
- } catch {
324
- return undefined;
325
- }
326
- }
327
-
328
- /** Merge auth stores, letting later stores override earlier profiles/order. */
329
- function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
330
- if (stores.length === 0) {
331
- return undefined;
332
- }
333
- const merged: AuthProfileStore = { profiles: {} };
334
- for (const store of stores) {
335
- merged.profiles = { ...merged.profiles, ...store.profiles };
336
- if (store.order) {
337
- merged.order = { ...(merged.order ?? {}), ...store.order };
338
- }
339
- }
340
- return merged;
341
- }
342
-
343
- /** Determine candidate auth store paths ordered by precedence. */
344
- function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
345
- const paths: string[] = [];
346
- const directAgentDir = params.agentDir?.trim();
347
- if (directAgentDir) {
348
- paths.push(join(directAgentDir, "auth-profiles.json"));
349
- }
350
-
351
- const envAgentDir = params.envSnapshot.agentDir;
352
- if (envAgentDir) {
353
- paths.push(join(envAgentDir, "auth-profiles.json"));
354
- }
355
-
356
- const home = params.envSnapshot.home;
357
- if (home) {
358
- paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
359
- }
360
-
361
- return [...new Set(paths)];
362
- }
363
-
364
- /** Build profile selection order for provider auth lookup. */
365
- function resolveAuthProfileCandidates(params: {
366
- provider: string;
367
- store: AuthProfileStore;
368
- authProfileId?: string;
369
- runtimeConfig?: unknown;
370
- }): string[] {
371
- const candidates: string[] = [];
372
- const normalizedProvider = normalizeProviderId(params.provider);
373
- const push = (value: string | undefined) => {
374
- const profileId = value?.trim();
375
- if (!profileId) {
376
- return;
377
- }
378
- if (!candidates.includes(profileId)) {
379
- candidates.push(profileId);
380
- }
254
+ /** Resolve runtime.modelAuth from plugin runtime, even before plugin-sdk typings land locally. */
255
+ function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth {
256
+ const runtime = api.runtime as OpenClawPluginApi["runtime"] & {
257
+ modelAuth?: RuntimeModelAuth;
381
258
  };
382
-
383
- push(params.authProfileId);
384
-
385
- const storeOrder = findProviderConfigValue(params.store.order, params.provider);
386
- for (const profileId of storeOrder ?? []) {
387
- push(profileId);
388
- }
389
-
390
- if (isRecord(params.runtimeConfig)) {
391
- const auth = params.runtimeConfig.auth;
392
- if (isRecord(auth)) {
393
- const order = findProviderConfigValue(
394
- isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
395
- params.provider,
396
- );
397
- if (Array.isArray(order)) {
398
- for (const profileId of order) {
399
- if (typeof profileId === "string") {
400
- push(profileId);
401
- }
402
- }
403
- }
404
- }
405
- }
406
-
407
- for (const [profileId, credential] of Object.entries(params.store.profiles)) {
408
- if (normalizeProviderId(credential.provider) === normalizedProvider) {
409
- push(profileId);
410
- }
259
+ if (!runtime.modelAuth) {
260
+ throw new Error("OpenClaw runtime.modelAuth is required by lossless-claw.");
411
261
  }
412
-
413
- return candidates;
262
+ return runtime.modelAuth;
414
263
  }
415
264
 
416
- /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
417
- async function resolveApiKeyFromAuthProfiles(params: {
265
+ /** Build the minimal model shape required by runtime.modelAuth.getApiKeyForModel(). */
266
+ function buildModelAuthLookupModel(params: {
418
267
  provider: string;
419
- authProfileId?: string;
420
- agentDir?: string;
421
- runtimeConfig?: unknown;
422
- piAiModule: PiAiModule;
423
- envSnapshot: PluginEnvSnapshot;
424
- }): Promise<string | undefined> {
425
- const storesWithPaths = resolveAuthStorePaths({
426
- agentDir: params.agentDir,
427
- envSnapshot: params.envSnapshot,
428
- })
429
- .map((path) => {
430
- try {
431
- const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
432
- return parsed ? { path, store: parsed } : undefined;
433
- } catch {
434
- return undefined;
435
- }
436
- })
437
- .filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
438
- if (storesWithPaths.length === 0) {
439
- return undefined;
440
- }
441
-
442
- const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
443
- if (!mergedStore) {
444
- return undefined;
445
- }
446
-
447
- const candidates = resolveAuthProfileCandidates({
268
+ model: string;
269
+ api?: string;
270
+ }): RuntimeModelAuthModel {
271
+ return {
272
+ id: params.model,
273
+ name: params.model,
448
274
  provider: params.provider,
449
- store: mergedStore,
450
- authProfileId: params.authProfileId,
451
- runtimeConfig: params.runtimeConfig,
452
- });
453
- if (candidates.length === 0) {
454
- return undefined;
455
- }
456
-
457
- const persistPath =
458
- params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
459
-
460
- for (const profileId of candidates) {
461
- const credential = mergedStore.profiles[profileId];
462
- if (!credential) {
463
- continue;
464
- }
465
- if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
466
- continue;
467
- }
468
-
469
- if (credential.type === "api_key") {
470
- const key = credential.key?.trim();
471
- if (key) {
472
- return key;
473
- }
474
- continue;
475
- }
476
-
477
- if (credential.type === "token") {
478
- const token = credential.token?.trim();
479
- if (!token) {
480
- continue;
481
- }
482
- const expires = credential.expires;
483
- if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
484
- continue;
485
- }
486
- return token;
487
- }
488
-
489
- const access = credential.access?.trim();
490
- const expires = credential.expires;
491
- const isExpired =
492
- typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
493
-
494
- if (!isExpired && access) {
495
- if (
496
- (credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
497
- typeof credential.projectId === "string" &&
498
- credential.projectId.trim()
499
- ) {
500
- return JSON.stringify({
501
- token: access,
502
- projectId: credential.projectId.trim(),
503
- });
504
- }
505
- return access;
506
- }
507
-
508
- if (typeof params.piAiModule.getOAuthApiKey !== "function") {
509
- continue;
510
- }
511
-
512
- try {
513
- const oauthCredential = {
514
- access: credential.access ?? "",
515
- refresh: credential.refresh ?? "",
516
- expires: typeof credential.expires === "number" ? credential.expires : 0,
517
- ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
518
- ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
519
- };
520
- const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
521
- [params.provider]: oauthCredential,
522
- });
523
- if (!refreshed?.apiKey) {
524
- continue;
525
- }
526
- mergedStore.profiles[profileId] = {
527
- ...credential,
528
- ...refreshed.newCredentials,
529
- type: "oauth",
530
- };
531
- if (persistPath) {
532
- try {
533
- writeFileSync(
534
- persistPath,
535
- JSON.stringify(
536
- {
537
- version: 1,
538
- profiles: mergedStore.profiles,
539
- ...(mergedStore.order ? { order: mergedStore.order } : {}),
540
- },
541
- null,
542
- 2,
543
- ),
544
- "utf8",
545
- );
546
- } catch {
547
- // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
548
- }
549
- }
550
- return refreshed.apiKey;
551
- } catch {
552
- if (access) {
553
- return access;
554
- }
555
- }
556
- }
275
+ api: params.api?.trim() || inferApiFromProvider(params.provider),
276
+ reasoning: false,
277
+ input: ["text"],
278
+ cost: {
279
+ input: 0,
280
+ output: 0,
281
+ cacheRead: 0,
282
+ cacheWrite: 0,
283
+ },
284
+ contextWindow: 200_000,
285
+ maxTokens: 8_000,
286
+ };
287
+ }
557
288
 
558
- return undefined;
289
+ /** Normalize an auth result down to the API key that pi-ai expects. */
290
+ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
291
+ const apiKey = auth?.apiKey?.trim();
292
+ return apiKey ? apiKey : undefined;
559
293
  }
560
294
 
561
295
  /** Build a minimal but useful sub-agent prompt. */
@@ -618,7 +352,7 @@ function readLatestAssistantReply(messages: unknown[]): string | undefined {
618
352
  function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
619
353
  const envSnapshot = snapshotPluginEnv();
620
354
  envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
621
- const readEnv: ReadEnvFn = (key) => process.env[key];
355
+ const modelAuth = getRuntimeModelAuth(api);
622
356
  const pluginConfig =
623
357
  api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
624
358
  ? api.pluginConfig
@@ -713,19 +447,22 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
713
447
  maxTokens: 8_000,
714
448
  };
715
449
 
716
- let resolvedApiKey = apiKey?.trim() || resolveApiKey(providerId, readEnv);
717
- if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
718
- resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
719
- }
450
+ let resolvedApiKey = apiKey?.trim();
720
451
  if (!resolvedApiKey) {
721
- resolvedApiKey = await resolveApiKeyFromAuthProfiles({
722
- provider: providerId,
723
- authProfileId,
724
- agentDir,
725
- runtimeConfig,
726
- piAiModule: mod,
727
- envSnapshot,
728
- });
452
+ try {
453
+ resolvedApiKey = resolveApiKeyFromAuthResult(
454
+ await modelAuth.resolveApiKeyForProvider({
455
+ provider: providerId,
456
+ cfg: api.config,
457
+ ...(authProfileId ? { profileId: authProfileId } : {}),
458
+ }),
459
+ );
460
+ } catch (err) {
461
+ console.error(
462
+ `[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
463
+ err instanceof Error ? err.message : err,
464
+ );
465
+ }
729
466
  }
730
467
 
731
468
  const completeOptions = buildCompleteSimpleOptions({
@@ -849,11 +586,31 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
849
586
  ).trim();
850
587
  return { provider, model: raw };
851
588
  },
852
- getApiKey: (provider) => resolveApiKey(provider, readEnv),
853
- requireApiKey: (provider) => {
854
- const key = resolveApiKey(provider, readEnv);
589
+ 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;
601
+ }
602
+ },
603
+ 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
+ );
855
612
  if (!key) {
856
- throw new Error(`Missing API key for provider '${provider}'.`);
613
+ throw new Error(`Missing API key for provider '${provider}' (model '${model}').`);
857
614
  }
858
615
  return key;
859
616
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",
@@ -23,6 +23,9 @@
23
23
  "README.md",
24
24
  "LICENSE"
25
25
  ],
26
+ "scripts": {
27
+ "test": "vitest run --dir test"
28
+ },
26
29
  "dependencies": {
27
30
  "@mariozechner/pi-agent-core": "*",
28
31
  "@mariozechner/pi-ai": "*",
package/src/summarize.ts CHANGED
@@ -672,8 +672,6 @@ export async function createLcmSummarizeFromLegacyParams(params: {
672
672
  : undefined;
673
673
  const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
674
674
 
675
- const apiKey = params.deps.getApiKey(provider, model);
676
-
677
675
  const condensedTargetTokens =
678
676
  Number.isFinite(params.deps.config.condensedTargetTokens) &&
679
677
  params.deps.config.condensedTargetTokens > 0
@@ -691,6 +689,9 @@ export async function createLcmSummarizeFromLegacyParams(params: {
691
689
 
692
690
  const mode: SummaryMode = aggressive ? "aggressive" : "normal";
693
691
  const isCondensed = options?.isCondensed === true;
692
+ const apiKey = await params.deps.getApiKey(provider, model, {
693
+ profileId: authProfileId,
694
+ });
694
695
  const targetTokens = resolveTargetTokens({
695
696
  inputTokens: estimateTokens(text),
696
697
  mode,
package/src/types.ts CHANGED
@@ -58,8 +58,22 @@ export type ResolveModelFn = (modelRef?: string, providerHint?: string) => {
58
58
  /**
59
59
  * API key resolution function.
60
60
  */
61
- export type GetApiKeyFn = (provider: string, model: string) => string | undefined;
62
- export type RequireApiKeyFn = (provider: string, model: string) => string;
61
+ export type ApiKeyLookupOptions = {
62
+ profileId?: string;
63
+ preferredProfile?: string;
64
+ };
65
+
66
+ export type GetApiKeyFn = (
67
+ provider: string,
68
+ model: string,
69
+ options?: ApiKeyLookupOptions,
70
+ ) => Promise<string | undefined>;
71
+
72
+ export type RequireApiKeyFn = (
73
+ provider: string,
74
+ model: string,
75
+ options?: ApiKeyLookupOptions,
76
+ ) => Promise<string>;
63
77
 
64
78
  /**
65
79
  * Session key utilities.