entroly-openclaw 1.0.46

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 ADDED
@@ -0,0 +1,78 @@
1
+ # Entroly for OpenClaw
2
+
3
+ Entroly provides budget-aware, auditable context assembly for OpenClaw while
4
+ leaving OpenClaw's persisted transcript untouched.
5
+
6
+ Unlike uniform history summarization, Entroly scores older messages against the
7
+ current request, reserves a bounded part of the context budget for matching
8
+ evidence, and keeps evidence messages verbatim when they fit. Lower-value
9
+ history is compressed around those evidence pins. The receipt records every
10
+ score, matched query term, allocation, and transformation.
11
+
12
+ ## Install
13
+
14
+ After the package is published:
15
+
16
+ ```bash
17
+ pip install entroly
18
+ openclaw plugins install entroly-openclaw
19
+ openclaw plugins enable entroly
20
+ ```
21
+
22
+ From an Entroly source checkout:
23
+
24
+ ```bash
25
+ pip install entroly
26
+ openclaw plugins install ./integrations/openclaw
27
+ openclaw plugins enable entroly
28
+ ```
29
+
30
+ Select the engine in `~/.openclaw/openclaw.json`:
31
+
32
+ ```json5
33
+ {
34
+ plugins: {
35
+ slots: {
36
+ contextEngine: "entroly"
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ Restart the Gateway and verify the loaded plugin with:
43
+
44
+ ```bash
45
+ openclaw plugins inspect entroly --runtime --json
46
+ openclaw plugins doctor
47
+ ```
48
+
49
+ After the first agent turn, run `/entroly-context` in any connected channel to
50
+ see the estimated before/after context size, reduction, warnings, and receipt.
51
+ Run `/entroly-context doctor` to verify the configured Python executable and
52
+ local JSONL bridge before inviting users onto the Gateway.
53
+
54
+ ## Reproduce the evidence-pinning control
55
+
56
+ ```bash
57
+ python -m benchmarks.openclaw_evidence_pinning
58
+ ```
59
+
60
+ The committed synthetic workload compares query-aware evidence pinning with
61
+ uniform budget compression at the same estimated token budget. See the
62
+ [result JSON](../../benchmarks/results/openclaw_evidence_pinning.json). It uses
63
+ no model calls and does not claim downstream task accuracy.
64
+
65
+ 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.
69
+ Entroly makes no remote calls in this path.
70
+
71
+ ## Safety contract
72
+
73
+ - System and developer messages are never modified.
74
+ - Structured message content is never modified.
75
+ - Recent messages are preserved verbatim.
76
+ - Bridge errors return the exact original message list.
77
+ - Entroly does not rewrite the OpenClaw transcript or claim persistent
78
+ compaction.
@@ -0,0 +1,119 @@
1
+ import { spawn } from "node:child_process";
2
+ import readline from "node:readline";
3
+
4
+ export class EntrolyBridgeClient {
5
+ constructor({
6
+ pythonCommand = "python",
7
+ timeoutMs = 5000,
8
+ logger = console,
9
+ spawnProcess = spawn,
10
+ } = {}) {
11
+ this.pythonCommand = pythonCommand;
12
+ this.timeoutMs = timeoutMs;
13
+ this.logger = logger;
14
+ this.spawnProcess = spawnProcess;
15
+ this.nextId = 1;
16
+ this.pending = new Map();
17
+ this.process = undefined;
18
+ }
19
+
20
+ start() {
21
+ if (this.process && !this.process.killed) {
22
+ return this.process;
23
+ }
24
+ const child = this.spawnProcess(
25
+ this.pythonCommand,
26
+ ["-m", "entroly.openclaw_bridge", "--jsonl"],
27
+ { stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
28
+ );
29
+ this.process = child;
30
+ const lines = readline.createInterface({ input: child.stdout });
31
+ lines.on("line", (line) => this.#onLine(line));
32
+ child.stderr.on("data", (chunk) => {
33
+ const message = String(chunk).trim();
34
+ if (message) this.logger.warn?.(`entroly bridge: ${message}`);
35
+ });
36
+ child.on("error", (error) => this.#handleChildFailure(child, error));
37
+ child.on("exit", (code, signal) => {
38
+ this.#handleChildFailure(
39
+ child,
40
+ new Error(`Entroly bridge exited (${code ?? signal ?? "unknown"})`),
41
+ );
42
+ });
43
+ return child;
44
+ }
45
+
46
+ request(payload) {
47
+ const child = this.start();
48
+ const requestId = String(this.nextId++);
49
+ return new Promise((resolve, reject) => {
50
+ const timer = setTimeout(() => {
51
+ this.#terminate(
52
+ child,
53
+ new Error(`Entroly bridge timed out after ${this.timeoutMs}ms`),
54
+ );
55
+ }, this.timeoutMs);
56
+ this.pending.set(requestId, { resolve, reject, timer });
57
+ child.stdin.write(`${JSON.stringify({ ...payload, request_id: requestId })}\n`, (error) => {
58
+ if (!error) return;
59
+ this.#terminate(child, error);
60
+ });
61
+ });
62
+ }
63
+
64
+ health() {
65
+ return this.request({ operation: "health" });
66
+ }
67
+
68
+ #onLine(line) {
69
+ let response;
70
+ try {
71
+ response = JSON.parse(line);
72
+ } catch (error) {
73
+ this.logger.warn?.(`entroly bridge emitted invalid JSON: ${error.message}`);
74
+ return;
75
+ }
76
+ const pending = this.pending.get(String(response.request_id));
77
+ if (!pending) return;
78
+ clearTimeout(pending.timer);
79
+ this.pending.delete(String(response.request_id));
80
+ if (response.ok) pending.resolve(response);
81
+ else pending.reject(new Error(response.error || "Entroly bridge request failed"));
82
+ }
83
+
84
+ #failAll(error) {
85
+ for (const pending of this.pending.values()) {
86
+ clearTimeout(pending.timer);
87
+ pending.reject(error);
88
+ }
89
+ this.pending.clear();
90
+ }
91
+
92
+ #handleChildFailure(child, error) {
93
+ if (this.process !== child) return;
94
+ this.process = undefined;
95
+ this.#failAll(error);
96
+ }
97
+
98
+ #terminate(child, error) {
99
+ if (this.process !== child) return;
100
+ this.process = undefined;
101
+ this.#failAll(error);
102
+ child.stdin.destroy();
103
+ if (!child.killed) child.kill();
104
+ }
105
+
106
+ async dispose() {
107
+ const child = this.process;
108
+ this.process = undefined;
109
+ if (!child || child.killed) return;
110
+ this.#failAll(new Error("Entroly bridge disposed"));
111
+ child.stdin.end();
112
+ // Brief grace for the process to exit on stdin EOF before SIGTERM.
113
+ await new Promise((resolve) => {
114
+ const timer = setTimeout(() => resolve(), 200);
115
+ child.on("exit", () => { clearTimeout(timer); resolve(); });
116
+ });
117
+ if (!child.killed) child.kill();
118
+ }
119
+ }
package/engine.js ADDED
@@ -0,0 +1,140 @@
1
+ const DEFAULT_TOKEN_BUDGET = 50_000;
2
+
3
+ function estimateTokens(messages) {
4
+ return Math.ceil(JSON.stringify(messages).length / 4);
5
+ }
6
+
7
+ export function formatEntrolyStatus(status) {
8
+ if (!status) {
9
+ return "Entroly: no context assembly has completed for this session yet.";
10
+ }
11
+ if (!status.ok) {
12
+ return `Entroly: last assembly failed open to the original context.\nReason: ${status.error}`;
13
+ }
14
+ const source = status.source_tokens ?? 0;
15
+ const assembled = status.estimated_tokens ?? source;
16
+ const saved = status.tokens_saved ?? Math.max(0, source - assembled);
17
+ const reduction = source > 0 ? ((saved / source) * 100).toFixed(1) : "0.0";
18
+ const lines = [
19
+ "Entroly protected the last context assembly",
20
+ `Strategy: ${status.assembly_strategy ?? "budgeted_context"}`,
21
+ `Evidence pinned verbatim: ${status.evidence_pinned ?? 0} message(s)`,
22
+ `Evidence pins blocked by firewall: ${status.evidence_pin_blocked ?? 0}`,
23
+ `Estimated tokens: ${source.toLocaleString()} -> ${assembled.toLocaleString()}`,
24
+ `Estimated reduction: ${reduction}% (${saved.toLocaleString()} tokens)`,
25
+ `Changed: ${status.changed ? "yes" : "no"}`,
26
+ ];
27
+ if (status.receipt_id) lines.push(`Receipt: ${status.receipt_id}`);
28
+ if (status.warnings?.length) lines.push(`Warnings: ${status.warnings.join(" | ")}`);
29
+ return lines.join("\n");
30
+ }
31
+
32
+ export function formatEntrolyDoctor({ ok, error, pythonCommand = "python" }) {
33
+ if (ok) {
34
+ return [
35
+ "Entroly doctor: ready",
36
+ `Python command: ${pythonCommand}`,
37
+ "Bridge: responsive",
38
+ "Local-only context assembly: available",
39
+ ].join("\n");
40
+ }
41
+ const reason = String(error?.message ?? error ?? "unknown error").replace(/\s+/g, " ");
42
+ return [
43
+ "Entroly doctor: not ready",
44
+ `Python command: ${pythonCommand}`,
45
+ `Reason: ${reason}`,
46
+ "Fix: install Entroly into that Python environment with `python -m pip install -U entroly`,",
47
+ "or set plugins.entries.entroly.config.pythonCommand to the correct Python executable.",
48
+ ].join("\n");
49
+ }
50
+
51
+ export function createEntrolyContextEngine({
52
+ bridge,
53
+ config = {},
54
+ logger = console,
55
+ statusBySession = new Map(),
56
+ }) {
57
+
58
+ return {
59
+ info: {
60
+ id: "entroly",
61
+ name: "Entroly Context Engine",
62
+ ownsCompaction: false,
63
+ },
64
+
65
+ async ingest() {
66
+ return { ingested: false };
67
+ },
68
+
69
+ async ingestBatch() {
70
+ return { ingestedCount: 0 };
71
+ },
72
+
73
+ async assemble({
74
+ sessionId,
75
+ messages,
76
+ tokenBudget,
77
+ model,
78
+ prompt,
79
+ runtimeSettings,
80
+ }) {
81
+ const sourceMessages = Array.isArray(messages) ? messages : [];
82
+ const effectiveBudget =
83
+ tokenBudget ?? runtimeSettings?.limits?.promptTokenBudget ?? DEFAULT_TOKEN_BUDGET;
84
+ 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,
98
+ });
99
+ statusBySession.set(sessionId, result);
100
+ return {
101
+ messages: result.messages,
102
+ estimatedTokens: result.estimated_tokens,
103
+ promptAuthority: "assembled",
104
+ };
105
+ } catch (error) {
106
+ logger.warn?.(
107
+ `entroly: assembly failed; passing exact original context: ${error.message}`,
108
+ );
109
+ statusBySession.set(sessionId, {
110
+ ok: false,
111
+ error: error.message,
112
+ estimated_tokens: estimateTokens(sourceMessages),
113
+ });
114
+ return {
115
+ messages: sourceMessages,
116
+ estimatedTokens: estimateTokens(sourceMessages),
117
+ promptAuthority: "preassembly_may_overflow",
118
+ };
119
+ }
120
+ },
121
+
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
+ };
130
+ },
131
+
132
+ getStatus(sessionId) {
133
+ return statusBySession.get(sessionId);
134
+ },
135
+
136
+ async dispose() {
137
+ await bridge.dispose();
138
+ },
139
+ };
140
+ }
package/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { EntrolyBridgeClient } from "./bridge-client.js";
3
+ import {
4
+ createEntrolyContextEngine,
5
+ formatEntrolyDoctor,
6
+ formatEntrolyStatus,
7
+ } from "./engine.js";
8
+
9
+ export default definePluginEntry({
10
+ id: "entroly",
11
+ name: "Entroly Context Engine",
12
+ register(api) {
13
+ const config = api.pluginConfig ?? {};
14
+ const bridge = new EntrolyBridgeClient({
15
+ pythonCommand: config.pythonCommand ?? "python",
16
+ timeoutMs: config.timeoutMs ?? 5000,
17
+ logger: api.logger,
18
+ });
19
+ const statusBySession = new Map();
20
+ api.registerContextEngine("entroly", (factoryContext) =>
21
+ createEntrolyContextEngine({
22
+ bridge,
23
+ config: { ...config, workspaceDir: factoryContext.workspaceDir },
24
+ logger: api.logger,
25
+ statusBySession,
26
+ }),
27
+ );
28
+ api.registerCommand({
29
+ name: "entroly-context",
30
+ description: "Show Entroly context savings or run `doctor`.",
31
+ acceptsArgs: true,
32
+ handler: async (ctx) => {
33
+ if (ctx.args?.trim().toLowerCase() === "doctor") {
34
+ try {
35
+ await bridge.health();
36
+ return {
37
+ text: formatEntrolyDoctor({
38
+ ok: true,
39
+ pythonCommand: config.pythonCommand ?? "python",
40
+ }),
41
+ };
42
+ } catch (error) {
43
+ return {
44
+ text: formatEntrolyDoctor({
45
+ ok: false,
46
+ error,
47
+ pythonCommand: config.pythonCommand ?? "python",
48
+ }),
49
+ };
50
+ }
51
+ }
52
+ return {
53
+ text: formatEntrolyStatus(
54
+ ctx.sessionId ? statusBySession.get(ctx.sessionId) : undefined,
55
+ ),
56
+ };
57
+ },
58
+ });
59
+ },
60
+ });
@@ -0,0 +1,77 @@
1
+ {
2
+ "id": "entroly",
3
+ "activation": {
4
+ "onStartup": true
5
+ },
6
+ "name": "Entroly Context Engine",
7
+ "description": "Selects and compresses context under budget while preserving protected messages and writing local audit receipts.",
8
+ "configSchema": {
9
+ "type": "object",
10
+ "additionalProperties": false,
11
+ "properties": {
12
+ "pythonCommand": {
13
+ "type": "string",
14
+ "minLength": 1,
15
+ "default": "python"
16
+ },
17
+ "preserveLastN": {
18
+ "type": "integer",
19
+ "minimum": 1,
20
+ "maximum": 32,
21
+ "default": 4
22
+ },
23
+ "timeoutMs": {
24
+ "type": "integer",
25
+ "minimum": 100,
26
+ "maximum": 30000,
27
+ "default": 5000
28
+ },
29
+ "receiptDir": {
30
+ "type": "string",
31
+ "minLength": 1
32
+ },
33
+ "writeReceipts": {
34
+ "type": "boolean",
35
+ "default": true
36
+ },
37
+ "distill": {
38
+ "type": "boolean",
39
+ "default": true
40
+ },
41
+ "evidencePinning": {
42
+ "type": "boolean",
43
+ "default": true
44
+ }
45
+ }
46
+ },
47
+ "uiHints": {
48
+ "pythonCommand": {
49
+ "label": "Python Command",
50
+ "help": "Python executable where the entroly package is installed."
51
+ },
52
+ "preserveLastN": {
53
+ "label": "Recent Messages Preserved",
54
+ "help": "Number of recent messages kept byte-for-byte unchanged."
55
+ },
56
+ "timeoutMs": {
57
+ "label": "Bridge Timeout (ms)",
58
+ "help": "Maximum time for one Entroly context assembly request."
59
+ },
60
+ "receiptDir": {
61
+ "label": "Receipt Directory",
62
+ "help": "Optional local directory for Entroly OpenClaw receipts."
63
+ },
64
+ "writeReceipts": {
65
+ "label": "Write Local Receipts",
66
+ "help": "Record context hashes, estimated savings, warnings, and decision metadata locally."
67
+ },
68
+ "distill": {
69
+ "label": "Distill Older Assistant Messages",
70
+ "help": "Remove filler from compressible older assistant responses before budget compression."
71
+ },
72
+ "evidencePinning": {
73
+ "label": "Query-Aware Evidence Pinning",
74
+ "help": "Keep older messages that match the current request verbatim when they fit the bounded evidence reserve."
75
+ }
76
+ }
77
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "entroly-openclaw",
3
+ "version": "1.0.46",
4
+ "description": "Auditable, budget-aware Entroly context engine for OpenClaw",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22.19"
9
+ },
10
+ "files": [
11
+ "bridge-client.js",
12
+ "engine.js",
13
+ "index.js",
14
+ "openclaw.plugin.json",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/*.test.js",
19
+ "check": "node --check bridge-client.js && node --check engine.js && node --check index.js",
20
+ "pack:check": "npm pack --dry-run"
21
+ },
22
+ "peerDependencies": {
23
+ "openclaw": ">=2026.6.11"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "openclaw": {
27
+ "optional": true
28
+ }
29
+ },
30
+ "openclaw": {
31
+ "extensions": [
32
+ "./index.js"
33
+ ],
34
+ "install": {
35
+ "npmSpec": "entroly-openclaw",
36
+ "defaultChoice": "npm",
37
+ "minHostVersion": ">=2026.6.11"
38
+ },
39
+ "compat": {
40
+ "pluginApi": ">=2026.6.11"
41
+ },
42
+ "release": {
43
+ "publishToClawHub": true,
44
+ "publishToNpm": true,
45
+ "bundleRuntimeDependencies": false
46
+ }
47
+ }
48
+ }