@martian-engineering/lossless-claw 0.9.4 → 0.10.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.
@@ -0,0 +1,231 @@
1
+ const PLUGIN_ID = "lossless-claw";
2
+ const ENTRY_PATH = ["plugins", "entries", PLUGIN_ID];
3
+ const CONFIG_PATH = [...ENTRY_PATH, "config"];
4
+
5
+ function isRecord(value) {
6
+ return !!value && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+
9
+ function readString(value) {
10
+ return typeof value === "string" && value.trim() ? value.trim() : "";
11
+ }
12
+
13
+ function readEntry(cfg) {
14
+ const plugins = isRecord(cfg) ? cfg.plugins : undefined;
15
+ const entries = isRecord(plugins) ? plugins.entries : undefined;
16
+ const entry = isRecord(entries) ? entries[PLUGIN_ID] : undefined;
17
+ return isRecord(entry) ? entry : undefined;
18
+ }
19
+
20
+ function readConfig(cfg) {
21
+ const config = readEntry(cfg)?.config;
22
+ return isRecord(config) ? config : undefined;
23
+ }
24
+
25
+ function readLlmPolicy(cfg) {
26
+ const llm = readEntry(cfg)?.llm;
27
+ if (!isRecord(llm)) {
28
+ return {
29
+ allowModelOverride: false,
30
+ allowedModels: [],
31
+ };
32
+ }
33
+ return {
34
+ allowModelOverride: llm.allowModelOverride === true,
35
+ allowedModels: Array.isArray(llm.allowedModels) ? llm.allowedModels : [],
36
+ };
37
+ }
38
+
39
+ function toModelRef(provider, model) {
40
+ const modelId = readString(model);
41
+ if (!modelId) {
42
+ return undefined;
43
+ }
44
+ const slash = modelId.indexOf("/");
45
+ if (slash > 0 && slash < modelId.length - 1) {
46
+ const directProvider = modelId.slice(0, slash).trim();
47
+ const directModel = modelId.slice(slash + 1).trim();
48
+ return directProvider && directModel ? `${directProvider}/${directModel}` : undefined;
49
+ }
50
+ const providerId = readString(provider);
51
+ return providerId ? `${providerId}/${modelId}` : undefined;
52
+ }
53
+
54
+ /** Collect configured Lossless summary model refs that doctor can safely allowlist. */
55
+ function collectLosslessRuntimeLlmModelRefs(cfg) {
56
+ const config = readConfig(cfg);
57
+ if (!config) {
58
+ return { modelRefs: [], skipped: [] };
59
+ }
60
+
61
+ const modelRefs = [];
62
+ const skipped = [];
63
+ const addConfiguredModel = (field, model, provider, configPath) => {
64
+ const modelId = readString(model);
65
+ if (!modelId) {
66
+ return;
67
+ }
68
+ const modelRef = toModelRef(provider, modelId);
69
+ if (modelRef) {
70
+ modelRefs.push({ field, modelRef, configPath });
71
+ return;
72
+ }
73
+ skipped.push({
74
+ field,
75
+ configPath,
76
+ reason: `${field} is a bare model without a provider; use provider/model or set the matching provider field so doctor can update plugins.entries.${PLUGIN_ID}.llm.allowedModels.`,
77
+ });
78
+ };
79
+
80
+ addConfiguredModel(
81
+ "summaryModel",
82
+ config.summaryModel,
83
+ config.summaryProvider,
84
+ [...CONFIG_PATH, "summaryModel"].join("."),
85
+ );
86
+ addConfiguredModel(
87
+ "largeFileSummaryModel",
88
+ config.largeFileSummaryModel,
89
+ config.largeFileSummaryProvider,
90
+ [...CONFIG_PATH, "largeFileSummaryModel"].join("."),
91
+ );
92
+
93
+ if (Array.isArray(config.fallbackProviders)) {
94
+ for (const [index, fallback] of config.fallbackProviders.entries()) {
95
+ if (!isRecord(fallback)) {
96
+ skipped.push({
97
+ field: "fallbackProviders",
98
+ configPath: `${[...CONFIG_PATH, "fallbackProviders"].join(".")}[${index}]`,
99
+ reason:
100
+ "fallbackProviders entries must be objects with provider and model before doctor can update llm.allowedModels.",
101
+ });
102
+ continue;
103
+ }
104
+ const modelRef = toModelRef(fallback.provider, fallback.model);
105
+ if (modelRef) {
106
+ modelRefs.push({
107
+ field: "fallbackProviders",
108
+ modelRef,
109
+ configPath: `${[...CONFIG_PATH, "fallbackProviders"].join(".")}[${index}]`,
110
+ });
111
+ } else if (readString(fallback.model) || readString(fallback.provider)) {
112
+ skipped.push({
113
+ field: "fallbackProviders",
114
+ configPath: `${[...CONFIG_PATH, "fallbackProviders"].join(".")}[${index}]`,
115
+ reason:
116
+ "fallbackProviders entries need both provider and model before doctor can update llm.allowedModels.",
117
+ });
118
+ }
119
+ }
120
+ }
121
+
122
+ const seen = new Set();
123
+ return {
124
+ modelRefs: modelRefs.filter((entry) => {
125
+ const key = `${entry.field}:${entry.modelRef}`;
126
+ if (seen.has(key)) {
127
+ return false;
128
+ }
129
+ seen.add(key);
130
+ return true;
131
+ }),
132
+ skipped,
133
+ };
134
+ }
135
+
136
+ function collectMissingPolicyEntries(cfg) {
137
+ const { modelRefs, skipped } = collectLosslessRuntimeLlmModelRefs(cfg);
138
+ const policy = readLlmPolicy(cfg);
139
+ const allowedStrings = new Set(policy.allowedModels.filter((entry) => typeof entry === "string"));
140
+ const missingRefs = modelRefs.filter((entry) => !allowedStrings.has(entry.modelRef));
141
+ return {
142
+ modelRefs,
143
+ skipped,
144
+ missingRefs,
145
+ missingAllowModelOverride: modelRefs.length > 0 && policy.allowModelOverride !== true,
146
+ };
147
+ }
148
+
149
+ function hasIssueForField(cfg, field) {
150
+ const issues = collectMissingPolicyEntries(cfg);
151
+ return (
152
+ issues.missingAllowModelOverride ||
153
+ issues.missingRefs.some((entry) => entry.field === field) ||
154
+ issues.skipped.some((entry) => entry.field === field)
155
+ );
156
+ }
157
+
158
+ /** Doctor warning rules for Lossless runtime LLM model override policy. */
159
+ export const legacyConfigRules = [
160
+ {
161
+ path: [...CONFIG_PATH, "summaryModel"],
162
+ message:
163
+ 'Lossless summaryModel uses api.runtime.llm.complete model overrides. Configure plugins.entries.lossless-claw.llm.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
164
+ match: (_value, root) => hasIssueForField(root, "summaryModel"),
165
+ },
166
+ {
167
+ path: [...CONFIG_PATH, "largeFileSummaryModel"],
168
+ message:
169
+ 'Lossless largeFileSummaryModel uses api.runtime.llm.complete model overrides. Configure plugins.entries.lossless-claw.llm.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
170
+ match: (_value, root) => hasIssueForField(root, "largeFileSummaryModel"),
171
+ },
172
+ {
173
+ path: [...CONFIG_PATH, "fallbackProviders"],
174
+ message:
175
+ 'Lossless fallbackProviders use api.runtime.llm.complete model overrides. Configure plugins.entries.lossless-claw.llm.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
176
+ match: (_value, root) => hasIssueForField(root, "fallbackProviders"),
177
+ },
178
+ ];
179
+
180
+ function cloneRootWithLosslessLlm(cfg) {
181
+ const root = isRecord(cfg) ? { ...cfg } : {};
182
+ const plugins = isRecord(root.plugins) ? { ...root.plugins } : {};
183
+ const entries = isRecord(plugins.entries) ? { ...plugins.entries } : {};
184
+ const entry = isRecord(entries[PLUGIN_ID]) ? { ...entries[PLUGIN_ID] } : {};
185
+ const llm = isRecord(entry.llm) ? { ...entry.llm } : {};
186
+
187
+ root.plugins = plugins;
188
+ plugins.entries = entries;
189
+ entries[PLUGIN_ID] = entry;
190
+ entry.llm = llm;
191
+
192
+ return { root, llm };
193
+ }
194
+
195
+ /** Add the minimal plugin runtime LLM policy needed for configured Lossless summary models. */
196
+ export function normalizeCompatibilityConfig({ cfg }) {
197
+ const issues = collectMissingPolicyEntries(cfg);
198
+ if (issues.modelRefs.length === 0) {
199
+ return { config: cfg, changes: [] };
200
+ }
201
+
202
+ const { root, llm } = cloneRootWithLosslessLlm(cfg);
203
+ const changes = [];
204
+
205
+ if (llm.allowModelOverride !== true) {
206
+ llm.allowModelOverride = true;
207
+ changes.push("Set plugins.entries.lossless-claw.llm.allowModelOverride = true for configured Lossless summary model overrides.");
208
+ }
209
+
210
+ const currentAllowed = Array.isArray(llm.allowedModels) ? [...llm.allowedModels] : [];
211
+ const allowedStrings = new Set(currentAllowed.filter((entry) => typeof entry === "string"));
212
+ const added = [];
213
+ for (const { modelRef } of issues.modelRefs) {
214
+ if (!allowedStrings.has(modelRef)) {
215
+ currentAllowed.push(modelRef);
216
+ allowedStrings.add(modelRef);
217
+ added.push(modelRef);
218
+ }
219
+ }
220
+
221
+ if (added.length > 0 || !Array.isArray(llm.allowedModels)) {
222
+ llm.allowedModels = currentAllowed;
223
+ changes.push(
224
+ `Added plugins.entries.lossless-claw.llm.allowedModels entries for configured Lossless summary models: ${added.join(", ")}`,
225
+ );
226
+ }
227
+
228
+ return { config: root, changes };
229
+ }
230
+
231
+ export { collectLosslessRuntimeLlmModelRefs };
@@ -24,9 +24,13 @@
24
24
  "label": "Context Threshold",
25
25
  "help": "Fraction of context window that triggers compaction (0.0–1.0)"
26
26
  },
27
+ "sweepMaxDepth": {
28
+ "label": "Sweep Max Depth",
29
+ "help": "Preferred maximum condensation source depth during routine full sweeps (0 = leaf only, -1 = unlimited). Pressure sweeps may go deeper when summarized context remains above target."
30
+ },
27
31
  "incrementalMaxDepth": {
28
- "label": "Incremental Max Depth",
29
- "help": "How deep incremental compaction goes (0 = leaf only, -1 = unlimited)"
32
+ "label": "Incremental Max Depth (Deprecated)",
33
+ "help": "Deprecated alias for sweepMaxDepth. Kept for existing configs."
30
34
  },
31
35
  "freshTailCount": {
32
36
  "label": "Fresh Tail Count",
@@ -40,9 +44,17 @@
40
44
  "label": "Prompt-Aware Eviction",
41
45
  "help": "When enabled, budget-constrained assembly keeps older context by prompt relevance instead of pure chronology. This can improve recall under tight budgets, but it can also reduce provider prompt-cache hit rates because the preserved prefix changes as prompts change."
42
46
  },
47
+ "stubLargeToolPayloads": {
48
+ "label": "Stub Large Tool Payloads",
49
+ "help": "When enabled, evictable tool-result rows whose payload was externalized via lcm-blob-migrate are replaced with a [LCM Tool Output: file_xxx | …] reference at assemble time. Fresh tail is never stubbed. The agent recovers the original output via lcm_describe(id=file_xxx, expandFile=true). Default off; requires running scripts/lcm-blob-migrate.mjs first to populate messages.large_content."
50
+ },
43
51
  "leafChunkTokens": {
44
52
  "label": "Leaf Chunk Tokens",
45
- "help": "Maximum source tokens per leaf compaction chunk before summarization"
53
+ "help": "Maximum source tokens per leaf compaction chunk before summarization (default 20000)"
54
+ },
55
+ "summaryPrefixTargetTokens": {
56
+ "label": "Summary Prefix Target Tokens",
57
+ "help": "Optional target for summarized-prefix tokens after a full sweep. When omitted, Lossless derives a pressure target from contextThreshold and leafChunkTokens."
46
58
  },
47
59
  "bootstrapMaxTokens": {
48
60
  "label": "Bootstrap Max Tokens",
@@ -114,7 +126,7 @@
114
126
  },
115
127
  "summaryModel": {
116
128
  "label": "Summary Model",
117
- "help": "Model override for LCM summarization (e.g., 'gpt-5.4' to reuse the session provider, or 'openai-resp/gpt-5.4' for a full cross-provider ref)"
129
+ "help": "Runtime LLM model override for LCM summarization (e.g., 'gpt-5.4' with summaryProvider, or 'openai-resp/gpt-5.4'); explicit overrides require plugins.entries.lossless-claw.llm policy"
118
130
  },
119
131
  "summaryProvider": {
120
132
  "label": "Summary Provider",
@@ -122,7 +134,7 @@
122
134
  },
123
135
  "largeFileSummaryModel": {
124
136
  "label": "Large File Summary Model",
125
- "help": "Model override for large-file summarization"
137
+ "help": "Runtime LLM model override for large-file summarization; explicit overrides require plugins.entries.lossless-claw.llm policy"
126
138
  },
127
139
  "largeFileSummaryProvider": {
128
140
  "label": "Large File Summary Provider",
@@ -165,40 +177,40 @@
165
177
  "help": "Cooldown before the summarization circuit breaker auto-resets"
166
178
  },
167
179
  "cacheAwareCompaction.enabled": {
168
- "label": "Cache-Aware Compaction",
169
- "help": "When enabled, hot prompt cache defers incremental compaction while cold cache allows bounded catch-up passes"
180
+ "label": "Cache-Aware Compaction (Deprecated)",
181
+ "help": "Deprecated compatibility setting. Automatic compaction is now threshold-only and does not use prompt-cache hot/cold state."
170
182
  },
171
183
  "cacheAwareCompaction.cacheTTLSeconds": {
172
- "label": "Short Cache TTL (seconds)",
173
- "help": "Treat short-lived prompt-cache entries as hot for this many seconds before deferred compaction may rewrite the prompt"
184
+ "label": "Short Cache TTL (Deprecated)",
185
+ "help": "Deprecated compatibility setting. Threshold debt no longer waits for cache TTL."
174
186
  },
175
187
  "cacheAwareCompaction.maxColdCacheCatchupPasses": {
176
- "label": "Cold Cache Catch-Up Passes",
177
- "help": "Maximum incremental leaf passes allowed in one maintenance cycle when prompt cache is cold"
188
+ "label": "Cold Cache Catch-Up (Deprecated)",
189
+ "help": "Deprecated compatibility setting. Automatic cold-cache catch-up passes were removed."
178
190
  },
179
191
  "cacheAwareCompaction.hotCachePressureFactor": {
180
- "label": "Hot Cache Pressure Factor",
181
- "help": "Multiplier applied to the hot-cache leaf trigger before raw-history pressure overrides cache preservation"
192
+ "label": "Hot Cache Pressure (Deprecated)",
193
+ "help": "Deprecated compatibility setting. Hot-cache raw-history pressure no longer drives automatic compaction."
182
194
  },
183
195
  "cacheAwareCompaction.hotCacheBudgetHeadroomRatio": {
184
- "label": "Hot Cache Budget Headroom",
185
- "help": "Fraction of the real token budget that must remain free before hot-cache incremental compaction is skipped entirely"
196
+ "label": "Hot Cache Headroom (Deprecated)",
197
+ "help": "Deprecated compatibility setting. Hot-cache budget headroom no longer defers threshold compaction."
186
198
  },
187
199
  "cacheAwareCompaction.coldCacheObservationThreshold": {
188
- "label": "Cold Cache Observation Threshold",
189
- "help": "Consecutive cold observations required before non-explicit cache misses are treated as truly cold"
200
+ "label": "Cold Cache Observations (Deprecated)",
201
+ "help": "Deprecated compatibility setting. Cold-cache streaks are diagnostic only."
190
202
  },
191
203
  "cacheAwareCompaction.criticalBudgetPressureRatio": {
192
- "label": "Critical Budget Pressure Ratio",
193
- "help": "Fraction of token budget at which deferred compaction fires regardless of prompt-cache state. Defaults to 0.70 — set to 1 to disable the override and let cache-aware throttling fully control deferral."
204
+ "label": "Critical Budget Pressure (Deprecated)",
205
+ "help": "Deprecated compatibility setting. contextThreshold is now the only automatic compaction threshold."
194
206
  },
195
207
  "dynamicLeafChunkTokens.enabled": {
196
- "label": "Dynamic Leaf Chunk Tokens",
197
- "help": "When enabled, incremental compaction uses a larger working leaf chunk in busy sessions and keeps the static floor in quieter sessions"
208
+ "label": "Dynamic Leaf Chunks (Deprecated)",
209
+ "help": "Deprecated compatibility setting. Automatic compaction now uses leafChunkTokens directly."
198
210
  },
199
211
  "dynamicLeafChunkTokens.max": {
200
- "label": "Dynamic Leaf Chunk Max",
201
- "help": "Maximum working leaf chunk target for dynamic incremental compaction. The static leafChunkTokens value remains the floor."
212
+ "label": "Dynamic Leaf Chunk Max (Deprecated)",
213
+ "help": "Deprecated compatibility setting. Automatic compaction now uses leafChunkTokens directly."
202
214
  },
203
215
  "timezone": {
204
216
  "label": "Timezone",
@@ -220,6 +232,10 @@
220
232
  "label": "Auto-Rotate Session Files",
221
233
  "help": "Automatically rotate oversized LCM-managed session JSONL files after startup and runtime checks"
222
234
  },
235
+ "autoRotateSessionFiles.createBackups": {
236
+ "label": "Auto-Rotate Backups",
237
+ "help": "Create the rolling rotate-latest SQLite backup before automatic session-file rotation; disabled by default"
238
+ },
223
239
  "autoRotateSessionFiles.sizeBytes": {
224
240
  "label": "Auto-Rotate Size Bytes",
225
241
  "help": "Session JSONL byte threshold for automatic rotation (default: 2097152)"
@@ -234,7 +250,7 @@
234
250
  },
235
251
  "fallbackProviders": {
236
252
  "label": "Fallback Providers",
237
- "help": "Explicit fallback provider/model pairs for compaction summarization (e.g., [{\"provider\": \"anthropic\", \"model\": \"claude-haiku-4-5\"}])"
253
+ "help": "Explicit runtime LLM fallback provider/model pairs for compaction summarization; entries require plugins.entries.lossless-claw.llm policy"
238
254
  }
239
255
  },
240
256
  "configSchema": {
@@ -249,6 +265,10 @@
249
265
  "minimum": 0,
250
266
  "maximum": 1
251
267
  },
268
+ "sweepMaxDepth": {
269
+ "type": "integer",
270
+ "minimum": -1
271
+ },
252
272
  "incrementalMaxDepth": {
253
273
  "type": "integer",
254
274
  "minimum": -1
@@ -264,10 +284,17 @@
264
284
  "promptAwareEviction": {
265
285
  "type": "boolean"
266
286
  },
287
+ "stubLargeToolPayloads": {
288
+ "type": "boolean"
289
+ },
267
290
  "leafChunkTokens": {
268
291
  "type": "integer",
269
292
  "minimum": 1
270
293
  },
294
+ "summaryPrefixTargetTokens": {
295
+ "type": "integer",
296
+ "minimum": 1
297
+ },
271
298
  "bootstrapMaxTokens": {
272
299
  "type": "integer",
273
300
  "minimum": 1
@@ -445,6 +472,9 @@
445
472
  "enabled": {
446
473
  "type": "boolean"
447
474
  },
475
+ "createBackups": {
476
+ "type": "boolean"
477
+ },
448
478
  "sizeBytes": {
449
479
  "type": "integer",
450
480
  "minimum": 1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.9.4",
4
- "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
3
+ "version": "0.10.0",
4
+ "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with threshold compaction",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "license": "MIT",
@@ -16,7 +16,7 @@
16
16
  "dag"
17
17
  ],
18
18
  "scripts": {
19
- "build": "esbuild index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --external:openclaw --external:\"@mariozechner/*\" --minify-whitespace",
19
+ "build": "esbuild index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --external:openclaw --external:\"@earendil-works/*\" --minify-whitespace",
20
20
  "changeset": "changeset",
21
21
  "release:verify": "npm run build && npm test && npm pack --dry-run",
22
22
  "test": "vitest run --dir test",
@@ -24,6 +24,8 @@
24
24
  },
25
25
  "files": [
26
26
  "dist/",
27
+ "doctor-contract-api.d.ts",
28
+ "doctor-contract-api.js",
27
29
  "skills/",
28
30
  "openclaw.plugin.json",
29
31
  "docs/",
@@ -31,35 +33,21 @@
31
33
  "LICENSE"
32
34
  ],
33
35
  "dependencies": {
36
+ "@earendil-works/pi-agent-core": ">=0.74 <1",
37
+ "@earendil-works/pi-ai": ">=0.74 <1",
38
+ "@earendil-works/pi-coding-agent": ">=0.74 <1",
34
39
  "@sinclair/typebox": "0.34.48"
35
40
  },
36
41
  "devDependencies": {
37
42
  "@changesets/changelog-github": "^0.6.0",
38
43
  "@changesets/cli": "^2.30.0",
39
- "@mariozechner/pi-agent-core": "0.66.1",
40
- "@mariozechner/pi-ai": "0.66.1",
41
- "@mariozechner/pi-coding-agent": "0.66.1",
42
44
  "esbuild": "^0.28.0",
43
45
  "typescript": "^5.7.0",
44
46
  "vitest": "^3.0.0"
45
47
  },
46
48
  "peerDependencies": {
47
- "@mariozechner/pi-agent-core": ">=0.66 <1",
48
- "@mariozechner/pi-ai": ">=0.66 <1",
49
- "@mariozechner/pi-coding-agent": ">=0.66 <1",
50
49
  "openclaw": ">=2026.2.17 <2026.6.0"
51
50
  },
52
- "peerDependenciesMeta": {
53
- "@mariozechner/pi-agent-core": {
54
- "optional": true
55
- },
56
- "@mariozechner/pi-ai": {
57
- "optional": true
58
- },
59
- "@mariozechner/pi-coding-agent": {
60
- "optional": true
61
- }
62
- },
63
51
  "publishConfig": {
64
52
  "access": "public"
65
53
  },