entroly-openclaw 1.0.53 → 1.0.57

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
@@ -3,26 +3,41 @@
3
3
  Entroly provides budget-aware, auditable context assembly for OpenClaw while
4
4
  leaving OpenClaw's persisted transcript untouched.
5
5
 
6
+ **Every OpenClaw provider benefits from the same Entroly context engine.**
7
+ OpenClaw continues to own provider selection, authentication, model failover,
8
+ and wire formats. Entroly operates once on OpenClaw's normalized messages
9
+ before each model run, whether the route is OpenAI, Anthropic, Gemini,
10
+ Nemotron, OpenRouter, Ollama, or a private/custom provider.
11
+
6
12
  Unlike uniform history summarization, Entroly scores older messages against the
7
13
  current request, reserves a bounded part of the context budget for matching
8
14
  evidence, and keeps evidence messages verbatim when they fit. Lower-value
9
15
  history is compressed around those evidence pins. The receipt records every
10
- score, matched query term, allocation, and transformation.
16
+ score, query-term match count, allocation, and transformation without storing
17
+ the request text or matched terms.
11
18
 
12
- ## Install
19
+ ## Install from ClawHub
13
20
 
14
- After the package is published:
21
+ Install the Python engine, then install the plugin from OpenClaw's official
22
+ ClawHub registry:
15
23
 
16
24
  ```bash
17
- pip install entroly
18
- openclaw plugins install entroly-openclaw
25
+ pip install "entroly>=1.0.57"
26
+ openclaw plugins install clawhub:entroly-openclaw
27
+ openclaw plugins enable entroly
28
+ ```
29
+
30
+ The npm-only fallback remains available as:
31
+
32
+ ```bash
33
+ openclaw plugins install npm:entroly-openclaw
19
34
  openclaw plugins enable entroly
20
35
  ```
21
36
 
22
37
  From an Entroly source checkout:
23
38
 
24
39
  ```bash
25
- pip install entroly
40
+ pip install "entroly>=1.0.57"
26
41
  openclaw plugins install ./integrations/openclaw
27
42
  openclaw plugins enable entroly
28
43
  ```
@@ -49,7 +64,27 @@ openclaw plugins doctor
49
64
  After the first agent turn, run `/entroly-context` in any connected channel to
50
65
  see the estimated before/after context size, reduction, warnings, and receipt.
51
66
  Run `/entroly-context doctor` to verify the configured Python executable and
52
- local JSONL bridge before inviting users onto the Gateway.
67
+ local JSONL bridge before inviting users onto the Gateway. The plugin requires
68
+ the Entroly 1.0.57 bridge v2 protocol; doctor reports an actionable upgrade
69
+ instead of accepting an older, incompatible Python installation.
70
+
71
+ OpenClaw's resolved prompt token budget is authoritative. Entroly never guesses
72
+ a context window from the provider name. For a custom model whose limit
73
+ OpenClaw cannot resolve, configure that model's context window in OpenClaw or
74
+ set an explicit operator-approved fallback:
75
+
76
+ ```json5
77
+ {
78
+ plugins: {
79
+ entries: {
80
+ entroly: { config: { fallbackTokenBudget: 32768 } }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Without either budget, Entroly returns the exact original context with an
87
+ actionable warning instead of risking a provider overflow.
53
88
 
54
89
  ## Reproduce the evidence-pinning control
55
90
 
@@ -63,16 +98,51 @@ uniform budget compression at the same estimated token budget. See the
63
98
  no model calls and does not claim downstream task accuracy.
64
99
 
65
100
  Receipts are written under `<workspace>/.entroly/receipts/openclaw/` unless
66
- `receiptDir` is configured. They record per-message hashes and decisions,
67
- estimated tokens, reduction, warnings, and whether context changed. The
68
- original content remains recoverable from OpenClaw's unchanged transcript.
101
+ `receiptDir` is configured. They record keyed per-message digests and decisions,
102
+ estimated tokens, reduction, warnings, and whether context changed. Query text
103
+ and matched terms are not persisted; only lengths and counts remain. Each
104
+ append-only proposal has a cryptographic nonce and immutable payload digest. It
105
+ is first marked `proposed`, then atomically marked `accepted` only after the
106
+ plugin validates the returned message structure and reveals its precommitted
107
+ 256-bit challenge. Entroly stores only a host-commit digest plus an HMAC
108
+ signature from a private key outside the workspace receipt; it never persists
109
+ the challenge secret. Acceptance proves plugin validation, not provider
110
+ delivery. Content digests are HMAC-keyed as well, preventing offline guesses of
111
+ low-entropy user prompts from a receipt alone.
112
+ The original content remains recoverable from OpenClaw's unchanged transcript.
69
113
  Entroly makes no remote calls in this path.
70
114
 
115
+ The signing key is initialized atomically outside the workspace receipt store
116
+ under the user state directory (`%LOCALAPPDATA%/Entroly` on Windows or
117
+ `$XDG_STATE_HOME/entroly` on Unix). Controlled deployments may set
118
+ `ENTROLY_OPENCLAW_RECEIPT_KEY_FILE` to an absolute protected path. Entroly
119
+ quarantines an interrupted empty initialization artifact; a non-empty corrupt
120
+ key fails open with its exact recovery path instead of silently rotating and
121
+ invalidating earlier receipt signatures. `/entroly-context doctor` checks this
122
+ key lifecycle as part of bridge readiness.
123
+
124
+ The receipt directory defaults to private `0700` permissions and receipt files
125
+ to `0600` on Unix; Windows uses the workspace ACL. Entroly never auto-deletes
126
+ audit history. The default append-only quota is 512 files or 64 MiB, whichever
127
+ comes first. At the limit, assembly fails open with an actionable message;
128
+ archive receipts or raise `receiptMaxFiles` / `receiptMaxBytes` explicitly.
129
+
71
130
  ## Safety contract
72
131
 
73
132
  - System and developer messages are never modified.
74
- - Structured message content is never modified.
133
+ - Signed text, thinking/reasoning, images, tool calls, tool-result metadata,
134
+ opaque provider signatures, and unknown content blocks are never modified.
135
+ - Only unsigned text fields in older normalized messages are compressible;
136
+ provider/model/usage metadata cannot change selection or allocation.
75
137
  - Recent messages are preserved verbatim.
76
138
  - Bridge errors return the exact original message list.
139
+ - A malformed host message envelope is surfaced as an error; it is never
140
+ coerced into an empty prompt.
141
+ - If the minimum safe normalized context cannot fit the host budget, Entroly
142
+ returns the exact original context and requests OpenClaw recovery.
77
143
  - Entroly does not rewrite the OpenClaw transcript or claim persistent
78
144
  compaction.
145
+ - `/compact` and provider-overflow recovery delegate to OpenClaw's native
146
+ compaction runtime, preserving its provider-aware retry behavior.
147
+ - Active OpenClaw memory guidance remains present when Entroly is selected as
148
+ the context engine.
package/bridge-client.js CHANGED
@@ -1,17 +1,36 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import readline from "node:readline";
3
3
 
4
+ export const ENTROLY_BRIDGE_SCHEMA = "entroly.openclaw.bridge.v2";
5
+
6
+ export function validateBridgeHealth(result) {
7
+ if (
8
+ !result ||
9
+ result.ok !== true ||
10
+ result.schema_version !== ENTROLY_BRIDGE_SCHEMA ||
11
+ result.provider_independent !== true ||
12
+ result.receipt_commit_protocol !== "two_phase"
13
+ ) {
14
+ throw new Error(
15
+ "Incompatible Entroly Python bridge; install entroly>=1.0.57 with `python -m pip install -U entroly`",
16
+ );
17
+ }
18
+ return result;
19
+ }
20
+
4
21
  export class EntrolyBridgeClient {
5
22
  constructor({
6
23
  pythonCommand = "python",
7
24
  timeoutMs = 5000,
8
25
  logger = console,
9
26
  spawnProcess = spawn,
27
+ environment = process.env,
10
28
  } = {}) {
11
29
  this.pythonCommand = pythonCommand;
12
30
  this.timeoutMs = timeoutMs;
13
31
  this.logger = logger;
14
32
  this.spawnProcess = spawnProcess;
33
+ this.environment = environment;
15
34
  this.nextId = 1;
16
35
  this.pending = new Map();
17
36
  this.process = undefined;
@@ -24,7 +43,11 @@ export class EntrolyBridgeClient {
24
43
  const child = this.spawnProcess(
25
44
  this.pythonCommand,
26
45
  ["-m", "entroly.openclaw_bridge", "--jsonl"],
27
- { stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
46
+ {
47
+ stdio: ["pipe", "pipe", "pipe"],
48
+ windowsHide: true,
49
+ env: this.environment,
50
+ },
28
51
  );
29
52
  this.process = child;
30
53
  const lines = readline.createInterface({ input: child.stdout });
@@ -61,8 +84,15 @@ export class EntrolyBridgeClient {
61
84
  });
62
85
  }
63
86
 
64
- health() {
65
- return this.request({ operation: "health" });
87
+ async health({ workspaceDir, receiptDir, writeReceipts = true } = {}) {
88
+ return validateBridgeHealth(
89
+ await this.request({
90
+ operation: "health",
91
+ workspace_dir: workspaceDir,
92
+ receipt_dir: receiptDir,
93
+ write_receipt: writeReceipts,
94
+ }),
95
+ );
66
96
  }
67
97
 
68
98
  #onLine(line) {
package/engine.js CHANGED
@@ -1,4 +1,269 @@
1
- const DEFAULT_TOKEN_BUDGET = 50_000;
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import { ENTROLY_BRIDGE_SCHEMA } from "./bridge-client.js";
4
+
5
+ const PROVIDER_MODE = "openclaw_managed";
6
+
7
+ function positiveInteger(value) {
8
+ return typeof value === "number" && Number.isInteger(value) && value > 0
9
+ ? value
10
+ : undefined;
11
+ }
12
+
13
+ function boundedString(value, limit = 256) {
14
+ return typeof value === "string" && value.trim()
15
+ ? value.trim().slice(0, limit)
16
+ : null;
17
+ }
18
+
19
+ function safeDiagnostic(value, limit = 400) {
20
+ let diagnostic = String(value?.message ?? value ?? "unknown error")
21
+ .replace(/\s+/g, " ")
22
+ .trim();
23
+ diagnostic = diagnostic
24
+ .replace(
25
+ /\bAuthorization\s*:\s*(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi,
26
+ "Authorization: [REDACTED]",
27
+ )
28
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
29
+ .replace(
30
+ /\b(api[_-]?key|authorization|password|secret|token)\b\s*[:=]\s*["']?[^\s,;"']+/gi,
31
+ "$1=[REDACTED]",
32
+ )
33
+ .replace(/\b(?:sk|gh[opusr])[-_][A-Za-z0-9_-]{8,}\b/gi, "[REDACTED]");
34
+ return diagnostic.slice(0, limit) || "unknown error";
35
+ }
36
+
37
+ function statusSnapshot(status) {
38
+ const snapshot = { ok: status?.ok === true };
39
+ const numericFields = [
40
+ "estimated_tokens",
41
+ "source_tokens",
42
+ "tokens_saved",
43
+ "evidence_pinned",
44
+ "evidence_pin_blocked",
45
+ ];
46
+ const booleanFields = ["changed", "provider_independent"];
47
+ const stringFields = [
48
+ "schema_version",
49
+ "receipt_id",
50
+ "provider_mode",
51
+ "budget_source",
52
+ "model",
53
+ "provider_hint",
54
+ "assembly_strategy",
55
+ "error",
56
+ ];
57
+ for (const field of numericFields) {
58
+ if (typeof status?.[field] === "number" && Number.isFinite(status[field])) {
59
+ snapshot[field] = status[field];
60
+ }
61
+ }
62
+ for (const field of booleanFields) {
63
+ if (typeof status?.[field] === "boolean") snapshot[field] = status[field];
64
+ }
65
+ for (const field of stringFields) {
66
+ if (status?.[field] === undefined || status?.[field] === null) continue;
67
+ const value =
68
+ field === "error"
69
+ ? safeDiagnostic(status?.[field])
70
+ : boundedString(status?.[field], field === "receipt_id" ? 160 : 256);
71
+ if (value !== null) snapshot[field] = value;
72
+ }
73
+ if (Array.isArray(status?.warnings)) {
74
+ snapshot.warnings = status.warnings
75
+ .slice(0, 8)
76
+ .map((warning) => safeDiagnostic(warning));
77
+ }
78
+ return snapshot;
79
+ }
80
+
81
+ function validateReceiptCommit(result, proposal) {
82
+ if (
83
+ !result ||
84
+ result.ok !== true ||
85
+ result.schema_version !== ENTROLY_BRIDGE_SCHEMA ||
86
+ result.committed !== true ||
87
+ result.receipt_id !== proposal.receiptId ||
88
+ result.proposal_id !== proposal.proposalId ||
89
+ result.proposal_sha256 !== proposal.proposalSha256 ||
90
+ result.receipt_path !== proposal.receiptPath ||
91
+ result.acceptance_commit_sha256 !== proposal.acceptanceCommitSha256
92
+ ) {
93
+ throw new Error("Entroly bridge did not acknowledge the validated receipt");
94
+ }
95
+ }
96
+
97
+ function sha256(value) {
98
+ return createHash("sha256").update(value, "utf8").digest("hex");
99
+ }
100
+
101
+ function resolveAssemblyRuntime({ tokenBudget, model, runtimeSettings, fallbackTokenBudget }) {
102
+ const explicitBudget = positiveInteger(tokenBudget);
103
+ const runtimeBudget = positiveInteger(runtimeSettings?.limits?.promptTokenBudget);
104
+ const fallbackCandidate = positiveInteger(fallbackTokenBudget);
105
+ const configuredFallback =
106
+ fallbackCandidate !== undefined && fallbackCandidate >= 1024
107
+ ? fallbackCandidate
108
+ : undefined;
109
+ const requestedModel = boundedString(runtimeSettings?.model?.requested);
110
+ const resolvedModel =
111
+ boundedString(runtimeSettings?.model?.resolved) ?? requestedModel ?? boundedString(model);
112
+ const runtimeMetadata = {
113
+ schema_version: runtimeSettings?.schemaVersion === 1 ? 1 : null,
114
+ runtime: {
115
+ host: boundedString(runtimeSettings?.runtime?.host, 64),
116
+ mode: boundedString(runtimeSettings?.runtime?.mode, 64),
117
+ harness_id: boundedString(runtimeSettings?.runtime?.harnessId, 128),
118
+ runtime_id: boundedString(runtimeSettings?.runtime?.runtimeId, 128),
119
+ },
120
+ model: {
121
+ requested: requestedModel ?? boundedString(model),
122
+ resolved: resolvedModel,
123
+ provider: boundedString(runtimeSettings?.model?.provider, 128),
124
+ family: boundedString(runtimeSettings?.model?.family, 128),
125
+ },
126
+ limits: {
127
+ prompt_token_budget: runtimeBudget ?? null,
128
+ max_output_tokens: positiveInteger(runtimeSettings?.limits?.maxOutputTokens) ?? null,
129
+ },
130
+ };
131
+
132
+ if (explicitBudget !== undefined) {
133
+ return {
134
+ tokenBudget: explicitBudget,
135
+ budgetSource: "openclaw_token_budget",
136
+ model: resolvedModel,
137
+ runtimeMetadata,
138
+ };
139
+ }
140
+ if (runtimeBudget !== undefined) {
141
+ return {
142
+ tokenBudget: runtimeBudget,
143
+ budgetSource: "openclaw_runtime_settings",
144
+ model: resolvedModel,
145
+ runtimeMetadata,
146
+ };
147
+ }
148
+ if (configuredFallback !== undefined) {
149
+ return {
150
+ tokenBudget: configuredFallback,
151
+ budgetSource: "operator_fallback",
152
+ model: resolvedModel,
153
+ runtimeMetadata,
154
+ };
155
+ }
156
+ return {
157
+ tokenBudget: undefined,
158
+ budgetSource: "missing",
159
+ model: resolvedModel,
160
+ runtimeMetadata,
161
+ };
162
+ }
163
+
164
+ function canonicalJson(value) {
165
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
166
+ if (value && typeof value === "object") {
167
+ return `{${Object.keys(value)
168
+ .sort()
169
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
170
+ .join(",")}}`;
171
+ }
172
+ return JSON.stringify(value);
173
+ }
174
+
175
+ function isCompressibleTextBlock(block) {
176
+ return Boolean(
177
+ block &&
178
+ typeof block === "object" &&
179
+ !Array.isArray(block) &&
180
+ block.type === "text" &&
181
+ typeof block.text === "string" &&
182
+ Object.keys(block).every((key) => key === "type" || key === "text"),
183
+ );
184
+ }
185
+
186
+ function validateAssemblyResult(result, sourceMessages, preserveLastN) {
187
+ if (
188
+ !result ||
189
+ result.ok !== true ||
190
+ result.schema_version !== ENTROLY_BRIDGE_SCHEMA
191
+ ) {
192
+ throw new Error("Entroly bridge did not return a successful assembly result");
193
+ }
194
+ if (
195
+ !Array.isArray(result.messages) ||
196
+ result.messages.length !== sourceMessages.length ||
197
+ !result.messages.every(
198
+ (message) => message && typeof message === "object" && !Array.isArray(message),
199
+ )
200
+ ) {
201
+ throw new Error("Entroly bridge returned an invalid or incomplete message list");
202
+ }
203
+ if (
204
+ typeof result.estimated_tokens !== "number" ||
205
+ !Number.isFinite(result.estimated_tokens) ||
206
+ result.estimated_tokens < 0
207
+ ) {
208
+ throw new Error("Entroly bridge returned an invalid token estimate");
209
+ }
210
+
211
+ const recentStart = Math.max(0, sourceMessages.length - preserveLastN);
212
+ for (let index = 0; index < sourceMessages.length; index += 1) {
213
+ const source = sourceMessages[index];
214
+ const assembled = result.messages[index];
215
+ if (source.role !== assembled.role) {
216
+ throw new Error(`Entroly bridge changed message role at index ${index}`);
217
+ }
218
+
219
+ const { content: sourceContent, ...sourceMetadata } = source;
220
+ const { content: assembledContent, ...assembledMetadata } = assembled;
221
+ if (canonicalJson(sourceMetadata) !== canonicalJson(assembledMetadata)) {
222
+ throw new Error(`Entroly bridge changed message metadata at index ${index}`);
223
+ }
224
+
225
+ const exactMessage =
226
+ source.role === "system" || source.role === "developer" || index >= recentStart;
227
+ if (exactMessage) {
228
+ if (canonicalJson(sourceContent) !== canonicalJson(assembledContent)) {
229
+ throw new Error(`Entroly bridge changed protected message at index ${index}`);
230
+ }
231
+ continue;
232
+ }
233
+
234
+ if (typeof sourceContent === "string") {
235
+ if (typeof assembledContent !== "string") {
236
+ throw new Error(`Entroly bridge changed text content shape at index ${index}`);
237
+ }
238
+ continue;
239
+ }
240
+ if (!Array.isArray(sourceContent) || !Array.isArray(assembledContent)) {
241
+ if (canonicalJson(sourceContent) !== canonicalJson(assembledContent)) {
242
+ throw new Error(`Entroly bridge changed opaque content at index ${index}`);
243
+ }
244
+ continue;
245
+ }
246
+ if (sourceContent.length !== assembledContent.length) {
247
+ throw new Error(`Entroly bridge changed content block count at index ${index}`);
248
+ }
249
+ for (let blockIndex = 0; blockIndex < sourceContent.length; blockIndex += 1) {
250
+ const sourceBlock = sourceContent[blockIndex];
251
+ const assembledBlock = assembledContent[blockIndex];
252
+ if (isCompressibleTextBlock(sourceBlock)) {
253
+ if (!isCompressibleTextBlock(assembledBlock)) {
254
+ throw new Error(
255
+ `Entroly bridge changed text block shape at index ${index}:${blockIndex}`,
256
+ );
257
+ }
258
+ } else if (canonicalJson(sourceBlock) !== canonicalJson(assembledBlock)) {
259
+ throw new Error(
260
+ `Entroly bridge changed opaque content block at index ${index}:${blockIndex}`,
261
+ );
262
+ }
263
+ }
264
+ }
265
+ return result;
266
+ }
2
267
 
3
268
  function estimateTokens(messages) {
4
269
  return Math.ceil(JSON.stringify(messages).length / 4);
@@ -9,7 +274,11 @@ export function formatEntrolyStatus(status) {
9
274
  return "Entroly: no context assembly has completed for this session yet.";
10
275
  }
11
276
  if (!status.ok) {
12
- return `Entroly: last assembly failed open to the original context.\nReason: ${status.error}`;
277
+ return [
278
+ "Entroly: last assembly failed open to the original context.",
279
+ "Provider routing remains OpenClaw-managed.",
280
+ `Reason: ${safeDiagnostic(status.error)}`,
281
+ ].join("\n");
13
282
  }
14
283
  const source = status.source_tokens ?? 0;
15
284
  const assembled = status.estimated_tokens ?? source;
@@ -17,6 +286,8 @@ export function formatEntrolyStatus(status) {
17
286
  const reduction = source > 0 ? ((saved / source) * 100).toFixed(1) : "0.0";
18
287
  const lines = [
19
288
  "Entroly protected the last context assembly",
289
+ "Provider routing: OpenClaw-managed (Entroly is provider-independent)",
290
+ `Budget source: ${status.budget_source ?? "unknown"}`,
20
291
  `Strategy: ${status.assembly_strategy ?? "budgeted_context"}`,
21
292
  `Evidence pinned verbatim: ${status.evidence_pinned ?? 0} message(s)`,
22
293
  `Evidence pins blocked by firewall: ${status.evidence_pin_blocked ?? 0}`,
@@ -24,8 +295,10 @@ export function formatEntrolyStatus(status) {
24
295
  `Estimated reduction: ${reduction}% (${saved.toLocaleString()} tokens)`,
25
296
  `Changed: ${status.changed ? "yes" : "no"}`,
26
297
  ];
27
- if (status.receipt_id) lines.push(`Receipt: ${status.receipt_id}`);
28
- if (status.warnings?.length) lines.push(`Warnings: ${status.warnings.join(" | ")}`);
298
+ if (status.receipt_id) lines.push(`Receipt: ${boundedString(status.receipt_id, 160)}`);
299
+ if (status.warnings?.length) {
300
+ lines.push(`Warnings: ${status.warnings.map((warning) => safeDiagnostic(warning)).join(" | ")}`);
301
+ }
29
302
  return lines.join("\n");
30
303
  }
31
304
 
@@ -34,11 +307,12 @@ export function formatEntrolyDoctor({ ok, error, pythonCommand = "python" }) {
34
307
  return [
35
308
  "Entroly doctor: ready",
36
309
  `Python command: ${pythonCommand}`,
37
- "Bridge: responsive",
310
+ "Bridge: compatible (v2, two-phase receipts)",
38
311
  "Local-only context assembly: available",
312
+ "Provider-neutral bridge: ready; routing and authentication remain OpenClaw-managed",
39
313
  ].join("\n");
40
314
  }
41
- const reason = String(error?.message ?? error ?? "unknown error").replace(/\s+/g, " ");
315
+ const reason = safeDiagnostic(error);
42
316
  return [
43
317
  "Entroly doctor: not ready",
44
318
  `Python command: ${pythonCommand}`,
@@ -50,16 +324,42 @@ export function formatEntrolyDoctor({ ok, error, pythonCommand = "python" }) {
50
324
 
51
325
  export function createEntrolyContextEngine({
52
326
  bridge,
327
+ delegateCompaction,
328
+ buildMemoryPrompt = () => undefined,
53
329
  config = {},
54
330
  logger = console,
55
331
  statusBySession = new Map(),
332
+ maxStatusSessions = 512,
56
333
  }) {
334
+ if (typeof delegateCompaction !== "function") {
335
+ throw new TypeError("Entroly requires OpenClaw's compaction delegate");
336
+ }
337
+ const statusLimit = positiveInteger(maxStatusSessions) ?? 512;
338
+ const storeStatus = (sessionId, status) => {
339
+ statusBySession.delete(sessionId);
340
+ statusBySession.set(sessionId, statusSnapshot(status));
341
+ while (statusBySession.size > statusLimit) {
342
+ statusBySession.delete(statusBySession.keys().next().value);
343
+ }
344
+ };
57
345
 
58
346
  return {
59
347
  info: {
60
348
  id: "entroly",
61
349
  name: "Entroly Context Engine",
62
350
  ownsCompaction: false,
351
+ hostRequirements: {
352
+ "agent-run": {
353
+ requiredCapabilities: ["assemble-before-prompt"],
354
+ unsupportedMessage:
355
+ "Entroly requires a native OpenClaw host that applies assembled context before each model call.",
356
+ },
357
+ "manual-compact": {
358
+ requiredCapabilities: ["compact"],
359
+ unsupportedMessage:
360
+ "Entroly delegates transcript compaction to the native OpenClaw runtime.",
361
+ },
362
+ },
63
363
  },
64
364
 
65
365
  async ingest() {
@@ -72,61 +372,150 @@ export function createEntrolyContextEngine({
72
372
 
73
373
  async assemble({
74
374
  sessionId,
375
+ sessionKey,
75
376
  messages,
76
377
  tokenBudget,
378
+ availableTools,
379
+ citationsMode,
77
380
  model,
78
381
  prompt,
79
382
  runtimeSettings,
80
383
  }) {
81
- const sourceMessages = Array.isArray(messages) ? messages : [];
82
- const effectiveBudget =
83
- tokenBudget ?? runtimeSettings?.limits?.promptTokenBudget ?? DEFAULT_TOKEN_BUDGET;
384
+ if (!Array.isArray(messages)) {
385
+ const error = new TypeError(
386
+ "OpenClaw did not supply context messages as an array; Entroly refused to assemble an empty prompt",
387
+ );
388
+ logger.error?.(`entroly: ${safeDiagnostic(error)}`);
389
+ storeStatus(sessionId, { ok: false, error: error.message });
390
+ throw error;
391
+ }
392
+ const sourceMessages = messages;
393
+ const assemblyRuntime = resolveAssemblyRuntime({
394
+ tokenBudget,
395
+ model,
396
+ runtimeSettings,
397
+ fallbackTokenBudget: config.fallbackTokenBudget,
398
+ });
399
+ const preserveLastN = positiveInteger(config.preserveLastN) ?? 4;
400
+ let systemPromptAddition;
84
401
  try {
85
- const result = await bridge.request({
86
- operation: "assemble",
87
- session_id: sessionId,
88
- messages: sourceMessages,
89
- token_budget: effectiveBudget,
90
- model,
91
- prompt,
92
- workspace_dir: config.workspaceDir,
93
- preserve_last_n: config.preserveLastN ?? 4,
94
- receipt_dir: config.receiptDir,
95
- write_receipt: config.writeReceipts !== false,
96
- distill: config.distill !== false,
97
- evidence_pinning: config.evidencePinning !== false,
402
+ systemPromptAddition = buildMemoryPrompt({
403
+ availableTools: availableTools ?? new Set(),
404
+ citationsMode,
405
+ agentSessionKey: sessionKey,
98
406
  });
99
- statusBySession.set(sessionId, result);
100
- return {
101
- messages: result.messages,
102
- estimatedTokens: result.estimated_tokens,
103
- promptAuthority: "assembled",
104
- };
105
407
  } catch (error) {
106
408
  logger.warn?.(
107
- `entroly: assembly failed; passing exact original context: ${error.message}`,
409
+ `entroly: OpenClaw memory guidance could not be added: ${safeDiagnostic(error)}`,
108
410
  );
109
- statusBySession.set(sessionId, {
411
+ }
412
+ const failOpen = (error) => {
413
+ const reason = safeDiagnostic(error);
414
+ logger.warn?.(`entroly: assembly failed; passing exact original context: ${reason}`);
415
+ storeStatus(sessionId, {
110
416
  ok: false,
111
- error: error.message,
417
+ error: reason,
112
418
  estimated_tokens: estimateTokens(sourceMessages),
419
+ provider_mode: PROVIDER_MODE,
420
+ provider_independent: true,
421
+ budget_source: assemblyRuntime.budgetSource,
113
422
  });
114
423
  return {
115
424
  messages: sourceMessages,
116
425
  estimatedTokens: estimateTokens(sourceMessages),
117
426
  promptAuthority: "preassembly_may_overflow",
427
+ systemPromptAddition,
118
428
  };
429
+ };
430
+ if (assemblyRuntime.tokenBudget === undefined) {
431
+ return failOpen(
432
+ new Error(
433
+ "OpenClaw did not provide a positive prompt token budget; configure the model context window or plugins.entries.entroly.config.fallbackTokenBudget",
434
+ ),
435
+ );
436
+ }
437
+ try {
438
+ const receiptCommitToken =
439
+ config.writeReceipts === false ? null : randomBytes(32).toString("hex");
440
+ const result = validateAssemblyResult(
441
+ await bridge.request({
442
+ operation: "assemble",
443
+ session_id: sessionId,
444
+ messages: sourceMessages,
445
+ token_budget: assemblyRuntime.tokenBudget,
446
+ budget_source: assemblyRuntime.budgetSource,
447
+ model: assemblyRuntime.model,
448
+ openclaw_runtime: assemblyRuntime.runtimeMetadata,
449
+ prompt,
450
+ workspace_dir: config.workspaceDir,
451
+ preserve_last_n: preserveLastN,
452
+ receipt_dir: config.receiptDir,
453
+ write_receipt: config.writeReceipts !== false,
454
+ receipt_commit_challenge_sha256:
455
+ receiptCommitToken === null ? undefined : sha256(receiptCommitToken),
456
+ receipt_max_files: positiveInteger(config.receiptMaxFiles),
457
+ receipt_max_bytes: positiveInteger(config.receiptMaxBytes),
458
+ distill: config.distill !== false,
459
+ evidence_pinning: config.evidencePinning !== false,
460
+ }),
461
+ sourceMessages,
462
+ preserveLastN,
463
+ );
464
+ if (result.receipt_commit_required === true) {
465
+ const proposal = {
466
+ receiptId: boundedString(result.receipt_id, 160),
467
+ proposalId: boundedString(result.proposal_id, 160),
468
+ proposalSha256: boundedString(result.proposal_sha256, 64),
469
+ receiptPath: boundedString(result.receipt_path, 1024),
470
+ acceptanceCommitSha256:
471
+ receiptCommitToken === null
472
+ ? null
473
+ : sha256(
474
+ `entroly.openclaw.accept.v1:${result.proposal_sha256}:${receiptCommitToken}`,
475
+ ),
476
+ };
477
+ if (
478
+ !/^ocr_[0-9a-f]{20}$/.test(proposal.receiptId ?? "") ||
479
+ !/^ocp_[0-9a-f]{32}$/.test(proposal.proposalId ?? "") ||
480
+ !/^[0-9a-f]{64}$/.test(proposal.proposalSha256 ?? "") ||
481
+ !proposal.receiptPath
482
+ ) {
483
+ throw new Error("Entroly bridge returned an invalid receipt proposal");
484
+ }
485
+ validateReceiptCommit(
486
+ await bridge.request({
487
+ operation: "commit_receipt",
488
+ receipt_id: proposal.receiptId,
489
+ proposal_id: proposal.proposalId,
490
+ proposal_sha256: proposal.proposalSha256,
491
+ receipt_path: proposal.receiptPath,
492
+ receipt_commit_token: receiptCommitToken,
493
+ workspace_dir: config.workspaceDir,
494
+ }),
495
+ proposal,
496
+ );
497
+ } else if (result.receipt_path) {
498
+ throw new Error(
499
+ "Entroly bridge wrote a receipt without the required acceptance handshake",
500
+ );
501
+ }
502
+ storeStatus(sessionId, result);
503
+ return {
504
+ messages: result.messages,
505
+ estimatedTokens: result.estimated_tokens,
506
+ promptAuthority:
507
+ result.estimated_tokens > assemblyRuntime.tokenBudget
508
+ ? "preassembly_may_overflow"
509
+ : "assembled",
510
+ systemPromptAddition,
511
+ };
512
+ } catch (error) {
513
+ return failOpen(error);
119
514
  }
120
515
  },
121
516
 
122
- async compact({ currentTokenCount }) {
123
- return {
124
- ok: true,
125
- compacted: false,
126
- reason:
127
- "Entroly applies reversible per-turn context assembly and does not rewrite the OpenClaw transcript.",
128
- result: { tokensBefore: currentTokenCount ?? 0 },
129
- };
517
+ async compact(params) {
518
+ return await delegateCompaction(params);
130
519
  },
131
520
 
132
521
  getStatus(sessionId) {
package/index.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import {
3
+ buildMemorySystemPromptAddition,
4
+ delegateCompactionToRuntime,
5
+ } from "openclaw/plugin-sdk/core";
2
6
  import { EntrolyBridgeClient } from "./bridge-client.js";
3
7
  import {
4
8
  createEntrolyContextEngine,
@@ -11,20 +15,24 @@ export default definePluginEntry({
11
15
  name: "Entroly Context Engine",
12
16
  register(api) {
13
17
  const config = api.pluginConfig ?? {};
18
+ let latestWorkspaceDir;
14
19
  const bridge = new EntrolyBridgeClient({
15
20
  pythonCommand: config.pythonCommand ?? "python",
16
21
  timeoutMs: config.timeoutMs ?? 5000,
17
22
  logger: api.logger,
18
23
  });
19
24
  const statusBySession = new Map();
20
- api.registerContextEngine("entroly", (factoryContext) =>
21
- createEntrolyContextEngine({
25
+ api.registerContextEngine("entroly", (factoryContext) => {
26
+ latestWorkspaceDir = factoryContext.workspaceDir;
27
+ return createEntrolyContextEngine({
22
28
  bridge,
29
+ delegateCompaction: delegateCompactionToRuntime,
30
+ buildMemoryPrompt: buildMemorySystemPromptAddition,
23
31
  config: { ...config, workspaceDir: factoryContext.workspaceDir },
24
32
  logger: api.logger,
25
33
  statusBySession,
26
- }),
27
- );
34
+ });
35
+ });
28
36
  api.registerCommand({
29
37
  name: "entroly-context",
30
38
  description: "Show Entroly context savings or run `doctor`.",
@@ -32,7 +40,11 @@ export default definePluginEntry({
32
40
  handler: async (ctx) => {
33
41
  if (ctx.args?.trim().toLowerCase() === "doctor") {
34
42
  try {
35
- await bridge.health();
43
+ await bridge.health({
44
+ workspaceDir: latestWorkspaceDir,
45
+ receiptDir: config.receiptDir,
46
+ writeReceipts: config.writeReceipts !== false,
47
+ });
36
48
  return {
37
49
  text: formatEntrolyDoctor({
38
50
  ok: true,
@@ -4,7 +4,8 @@
4
4
  "onStartup": true
5
5
  },
6
6
  "name": "Entroly Context Engine",
7
- "description": "Selects and compresses context under budget while preserving protected messages and writing local audit receipts.",
7
+ "description": "Provider-independent context assembly for every OpenClaw model route, with protected blocks, recovery delegation, and local audit receipts.",
8
+ "icon": "https://raw.githubusercontent.com/juyterman1000/entroly/main/docs/assets/entroly_wordmark.svg",
8
9
  "configSchema": {
9
10
  "type": "object",
10
11
  "additionalProperties": false,
@@ -26,10 +27,27 @@
26
27
  "maximum": 30000,
27
28
  "default": 5000
28
29
  },
30
+ "fallbackTokenBudget": {
31
+ "type": "integer",
32
+ "minimum": 1024,
33
+ "maximum": 4000000
34
+ },
29
35
  "receiptDir": {
30
36
  "type": "string",
31
37
  "minLength": 1
32
38
  },
39
+ "receiptMaxFiles": {
40
+ "type": "integer",
41
+ "minimum": 8,
42
+ "maximum": 10000,
43
+ "default": 512
44
+ },
45
+ "receiptMaxBytes": {
46
+ "type": "integer",
47
+ "minimum": 1048576,
48
+ "maximum": 1073741824,
49
+ "default": 67108864
50
+ },
33
51
  "writeReceipts": {
34
52
  "type": "boolean",
35
53
  "default": true
@@ -57,10 +75,22 @@
57
75
  "label": "Bridge Timeout (ms)",
58
76
  "help": "Maximum time for one Entroly context assembly request."
59
77
  },
78
+ "fallbackTokenBudget": {
79
+ "label": "Fallback Prompt Token Budget",
80
+ "help": "Optional operator-approved budget used only when OpenClaw cannot resolve a finite model prompt budget."
81
+ },
60
82
  "receiptDir": {
61
83
  "label": "Receipt Directory",
62
84
  "help": "Optional local directory for Entroly OpenClaw receipts."
63
85
  },
86
+ "receiptMaxFiles": {
87
+ "label": "Receipt File Limit",
88
+ "help": "Fail safely instead of writing when this append-only receipt count is reached."
89
+ },
90
+ "receiptMaxBytes": {
91
+ "label": "Receipt Byte Limit",
92
+ "help": "Fail safely instead of allowing the receipt directory to exceed this byte quota."
93
+ },
64
94
  "writeReceipts": {
65
95
  "label": "Write Local Receipts",
66
96
  "help": "Record context hashes, estimated savings, warnings, and decision metadata locally."
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "entroly-openclaw",
3
- "version": "1.0.53",
4
- "description": "Auditable, budget-aware Entroly context engine for OpenClaw",
3
+ "version": "1.0.57",
4
+ "description": "Provider-independent, auditable context optimization for every OpenClaw model route",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
+ "homepage": "https://github.com/juyterman1000/entroly#entroly-for-openclaw",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/juyterman1000/entroly.git",
11
+ "directory": "integrations/openclaw"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/juyterman1000/entroly/issues"
15
+ },
7
16
  "engines": {
8
17
  "node": ">=22.19"
9
18
  },
@@ -32,12 +41,17 @@
32
41
  "./index.js"
33
42
  ],
34
43
  "install": {
44
+ "clawhubSpec": "clawhub:entroly-openclaw",
35
45
  "npmSpec": "entroly-openclaw",
36
46
  "defaultChoice": "npm",
37
47
  "minHostVersion": ">=2026.6.11"
38
48
  },
39
49
  "compat": {
40
- "pluginApi": ">=2026.6.11"
50
+ "pluginApi": ">=2026.6.11",
51
+ "minGatewayVersion": ">=2026.6.11"
52
+ },
53
+ "build": {
54
+ "openclawVersion": "2026.6.11"
41
55
  },
42
56
  "release": {
43
57
  "publishToClawHub": true,