@martian-engineering/lossless-claw 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,12 +26,17 @@ Nothing is lost. Raw messages stay in the database. Summaries link back to their
26
26
 
27
27
  ### Install the plugin
28
28
 
29
+ **From npm** (recommended):
30
+
31
+ ```bash
32
+ npm install @martian-engineering/lossless-claw
33
+ ```
34
+
35
+ **From source** (for development):
36
+
29
37
  ```bash
30
- # Clone the repo
31
38
  git clone https://github.com/Martian-Engineering/lossless-claw.git
32
39
  cd lossless-claw
33
-
34
- # Install dependencies
35
40
  npm install
36
41
  ```
37
42
 
@@ -39,6 +44,21 @@ npm install
39
44
 
40
45
  Add the plugin to your OpenClaw config (`~/.openclaw/openclaw.json`):
41
46
 
47
+ ```json
48
+ {
49
+ "plugins": {
50
+ "paths": [
51
+ "node_modules/@martian-engineering/lossless-claw"
52
+ ],
53
+ "slots": {
54
+ "contextEngine": "lossless-claw"
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ If installed from source, use the absolute path to the cloned repo instead:
61
+
42
62
  ```json
43
63
  {
44
64
  "plugins": {
@@ -2,12 +2,16 @@
2
2
 
3
3
  ## Quick start
4
4
 
5
- Add LCM to your OpenClaw config:
5
+ Install the plugin and add it to your OpenClaw config:
6
+
7
+ ```bash
8
+ npm install @martian-engineering/lossless-claw
9
+ ```
6
10
 
7
11
  ```json
8
12
  {
9
13
  "plugins": {
10
- "paths": ["/path/to/lossless-claw"],
14
+ "paths": ["node_modules/@martian-engineering/lossless-claw"],
11
15
  "slots": {
12
16
  "contextEngine": "lossless-claw"
13
17
  }
@@ -15,6 +19,8 @@ Add LCM to your OpenClaw config:
15
19
  }
16
20
  ```
17
21
 
22
+ If installed from source, use the absolute path to the repo instead of `node_modules/...`.
23
+
18
24
  Set recommended environment variables:
19
25
 
20
26
  ```bash
package/index.ts CHANGED
@@ -39,8 +39,29 @@ function normalizeAgentId(agentId: string | undefined): string {
39
39
  return normalized.length > 0 ? normalized : "main";
40
40
  }
41
41
 
42
+ type PluginEnvSnapshot = {
43
+ lcmSummaryModel: string;
44
+ lcmSummaryProvider: string;
45
+ openclawProvider: string;
46
+ agentDir: string;
47
+ home: string;
48
+ };
49
+
50
+ type ReadEnvFn = (key: string) => string | undefined;
51
+
52
+ /** Capture plugin env values once during initialization. */
53
+ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
54
+ return {
55
+ lcmSummaryModel: env.LCM_SUMMARY_MODEL?.trim() ?? "",
56
+ lcmSummaryProvider: env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
57
+ openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
58
+ agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
59
+ home: env.HOME?.trim() ?? "",
60
+ };
61
+ }
62
+
42
63
  /** Resolve common provider API keys from environment. */
43
- function resolveApiKey(provider: string): string | undefined {
64
+ function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
44
65
  const keyMap: Record<string, string[]> = {
45
66
  openai: ["OPENAI_API_KEY"],
46
67
  anthropic: ["ANTHROPIC_API_KEY"],
@@ -59,7 +80,7 @@ function resolveApiKey(provider: string): string | undefined {
59
80
  keys.push(normalizedProviderEnv);
60
81
 
61
82
  for (const key of keys) {
62
- const value = process.env[key]?.trim();
83
+ const value = readEnv(key)?.trim();
63
84
  if (value) {
64
85
  return value;
65
86
  }
@@ -255,19 +276,19 @@ function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore |
255
276
  }
256
277
 
257
278
  /** Determine candidate auth store paths ordered by precedence. */
258
- function resolveAuthStorePaths(agentDir?: string): string[] {
279
+ function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
259
280
  const paths: string[] = [];
260
- const directAgentDir = agentDir?.trim();
281
+ const directAgentDir = params.agentDir?.trim();
261
282
  if (directAgentDir) {
262
283
  paths.push(join(directAgentDir, "auth-profiles.json"));
263
284
  }
264
285
 
265
- const envAgentDir = process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
286
+ const envAgentDir = params.envSnapshot.agentDir;
266
287
  if (envAgentDir) {
267
288
  paths.push(join(envAgentDir, "auth-profiles.json"));
268
289
  }
269
290
 
270
- const home = process.env.HOME?.trim();
291
+ const home = params.envSnapshot.home;
271
292
  if (home) {
272
293
  paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
273
294
  }
@@ -334,8 +355,12 @@ async function resolveApiKeyFromAuthProfiles(params: {
334
355
  agentDir?: string;
335
356
  runtimeConfig?: unknown;
336
357
  piAiModule: PiAiModule;
358
+ envSnapshot: PluginEnvSnapshot;
337
359
  }): Promise<string | undefined> {
338
- const storesWithPaths = resolveAuthStorePaths(params.agentDir)
360
+ const storesWithPaths = resolveAuthStorePaths({
361
+ agentDir: params.agentDir,
362
+ envSnapshot: params.envSnapshot,
363
+ })
339
364
  .map((path) => {
340
365
  try {
341
366
  const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
@@ -526,7 +551,9 @@ function readLatestAssistantReply(messages: unknown[]): string | undefined {
526
551
 
527
552
  /** Construct LCM dependencies from plugin API/runtime surfaces. */
528
553
  function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
529
- const config = resolveLcmConfig(process.env);
554
+ const envSnapshot = snapshotPluginEnv();
555
+ const readEnv: ReadEnvFn = (key) => process.env[key];
556
+ const config = resolveLcmConfig();
530
557
 
531
558
  return {
532
559
  config,
@@ -602,7 +629,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
602
629
  maxTokens: 8_000,
603
630
  };
604
631
 
605
- let resolvedApiKey = apiKey?.trim() || resolveApiKey(providerId);
632
+ let resolvedApiKey = apiKey?.trim() || resolveApiKey(providerId, readEnv);
606
633
  if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
607
634
  resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
608
635
  }
@@ -613,6 +640,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
613
640
  agentDir,
614
641
  runtimeConfig,
615
642
  piAiModule: mod,
643
+ envSnapshot,
616
644
  });
617
645
  }
618
646
 
@@ -673,7 +701,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
673
701
  }
674
702
  },
675
703
  resolveModel: (modelRef, providerHint) => {
676
- const raw = (modelRef ?? process.env.LCM_SUMMARY_MODEL ?? "").trim();
704
+ const raw = (modelRef ?? envSnapshot.lcmSummaryModel).trim();
677
705
  if (!raw) {
678
706
  throw new Error("No model configured for LCM summarization.");
679
707
  }
@@ -688,15 +716,15 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
688
716
 
689
717
  const provider = (
690
718
  providerHint?.trim() ||
691
- process.env.LCM_SUMMARY_PROVIDER ||
692
- process.env.OPENCLAW_PROVIDER ||
719
+ envSnapshot.lcmSummaryProvider ||
720
+ envSnapshot.openclawProvider ||
693
721
  "openai"
694
722
  ).trim();
695
723
  return { provider, model: raw };
696
724
  },
697
- getApiKey: (provider) => resolveApiKey(provider),
725
+ getApiKey: (provider) => resolveApiKey(provider, readEnv),
698
726
  requireApiKey: (provider) => {
699
- const key = resolveApiKey(provider);
727
+ const key = resolveApiKey(provider, readEnv);
700
728
  if (!key) {
701
729
  throw new Error(`Missing API key for provider '${provider}'.`);
702
730
  }
@@ -756,7 +784,7 @@ const lcmPlugin = {
756
784
  ? (value as Record<string, unknown>)
757
785
  : {};
758
786
  const enabled = typeof raw.enabled === "boolean" ? raw.enabled : undefined;
759
- const config = resolveLcmConfig(process.env);
787
+ const config = resolveLcmConfig();
760
788
  if (enabled !== undefined) {
761
789
  config.enabled = enabled;
762
790
  }
@@ -0,0 +1,62 @@
1
+ {
2
+ "id": "lossless-claw",
3
+ "uiHints": {
4
+ "contextThreshold": {
5
+ "label": "Context Threshold",
6
+ "help": "Fraction of context window that triggers compaction (0.0–1.0)"
7
+ },
8
+ "incrementalMaxDepth": {
9
+ "label": "Incremental Max Depth",
10
+ "help": "How deep incremental compaction goes (0 = leaf only)"
11
+ },
12
+ "freshTailCount": {
13
+ "label": "Fresh Tail Count",
14
+ "help": "Number of recent messages protected from compaction"
15
+ },
16
+ "dbPath": {
17
+ "label": "Database Path",
18
+ "help": "Path to LCM SQLite database (default: ~/.openclaw/lcm.db)"
19
+ }
20
+ },
21
+ "configSchema": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "properties": {
25
+ "enabled": {
26
+ "type": "boolean"
27
+ },
28
+ "contextThreshold": {
29
+ "type": "number",
30
+ "minimum": 0,
31
+ "maximum": 1
32
+ },
33
+ "incrementalMaxDepth": {
34
+ "type": "integer",
35
+ "minimum": 0
36
+ },
37
+ "freshTailCount": {
38
+ "type": "integer",
39
+ "minimum": 1
40
+ },
41
+ "leafMinFanout": {
42
+ "type": "integer",
43
+ "minimum": 2
44
+ },
45
+ "condensedMinFanout": {
46
+ "type": "integer",
47
+ "minimum": 2
48
+ },
49
+ "condensedMinFanoutHard": {
50
+ "type": "integer",
51
+ "minimum": 2
52
+ },
53
+ "dbPath": {
54
+ "type": "string"
55
+ },
56
+ "largeFileThresholdTokens": {
57
+ "type": "integer",
58
+ "minimum": 1000
59
+ }
60
+ }
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "index.ts",
20
20
  "src/**/*.ts",
21
+ "openclaw.plugin.json",
21
22
  "docs/",
22
23
  "README.md",
23
24
  "LICENSE"
package/src/db/config.ts CHANGED
@@ -15,6 +15,10 @@ export type LcmConfig = {
15
15
  condensedTargetTokens: number;
16
16
  maxExpandTokens: number;
17
17
  largeFileTokenThreshold: number;
18
+ /** Provider override for large-file text summarization. */
19
+ largeFileSummaryProvider: string;
20
+ /** Model override for large-file text summarization. */
21
+ largeFileSummaryModel: string;
18
22
  autocompactDisabled: boolean;
19
23
  /** IANA timezone for timestamps in summaries (from TZ env or system default) */
20
24
  timezone: string;
@@ -37,6 +41,8 @@ export function resolveLcmConfig(env: NodeJS.ProcessEnv = process.env): LcmConfi
37
41
  condensedTargetTokens: parseInt(env.LCM_CONDENSED_TARGET_TOKENS ?? "2000", 10),
38
42
  maxExpandTokens: parseInt(env.LCM_MAX_EXPAND_TOKENS ?? "4000", 10),
39
43
  largeFileTokenThreshold: parseInt(env.LCM_LARGE_FILE_TOKEN_THRESHOLD ?? "25000", 10),
44
+ largeFileSummaryProvider: env.LCM_LARGE_FILE_SUMMARY_PROVIDER?.trim() ?? "",
45
+ largeFileSummaryModel: env.LCM_LARGE_FILE_SUMMARY_MODEL?.trim() ?? "",
40
46
  autocompactDisabled: env.LCM_AUTOCOMPACT_DISABLED === "true",
41
47
  timezone: env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
42
48
  pruneHeartbeatOk: env.LCM_PRUNE_HEARTBEAT_OK === "true",
package/src/engine.ts CHANGED
@@ -647,9 +647,7 @@ export class LcmContextEngine implements ContextEngine {
647
647
  customInstructions?: string;
648
648
  }): Promise<(text: string, aggressive?: boolean) => Promise<string>> {
649
649
  const lp = params.legacyParams ?? {};
650
- console.error(`[lcm] resolveSummarize called, legacyParams keys: ${Object.keys(lp).join(",")}, has summarize fn: ${typeof lp.summarize === "function"}`);
651
650
  if (typeof lp.summarize === "function") {
652
- console.error(`[lcm] resolveSummarize: using legacy summarize function`);
653
651
  return lp.summarize as (text: string, aggressive?: boolean) => Promise<string>;
654
652
  }
655
653
  try {
@@ -659,7 +657,6 @@ export class LcmContextEngine implements ContextEngine {
659
657
  customInstructions: params.customInstructions,
660
658
  });
661
659
  if (runtimeSummarizer) {
662
- console.error(`[lcm] resolveSummarize: got runtime summarizer`);
663
660
  return runtimeSummarizer;
664
661
  }
665
662
  console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
@@ -684,8 +681,8 @@ export class LcmContextEngine implements ContextEngine {
684
681
  }
685
682
  this.largeFileTextSummarizerResolved = true;
686
683
 
687
- const provider = process.env.LCM_LARGE_FILE_SUMMARY_PROVIDER?.trim() ?? "";
688
- const model = process.env.LCM_LARGE_FILE_SUMMARY_MODEL?.trim() ?? "";
684
+ const provider = this.deps.config.largeFileSummaryProvider;
685
+ const model = this.deps.config.largeFileSummaryModel;
689
686
  if (!provider || !model) {
690
687
  return undefined;
691
688
  }
package/src/summarize.ts CHANGED
@@ -338,12 +338,10 @@ export async function createLcmSummarizeFromLegacyParams(params: {
338
338
  const modelHint =
339
339
  typeof params.legacyParams.model === "string" ? params.legacyParams.model.trim() : "";
340
340
  const modelRef = modelHint || undefined;
341
- console.error(`[lcm] createLcmSummarize: providerHint="${providerHint}", modelHint="${modelHint}", modelRef="${modelRef}"`);
342
341
 
343
342
  let resolved: { provider: string; model: string };
344
343
  try {
345
344
  resolved = params.deps.resolveModel(modelRef, providerHint || undefined);
346
- console.error(`[lcm] createLcmSummarize: resolved model=${resolved.model}, provider=${resolved.provider}`);
347
345
  } catch (err) {
348
346
  console.error(`[lcm] createLcmSummarize: resolveModel FAILED:`, err instanceof Error ? err.message : err);
349
347
  return undefined;