@parad0x_labs/openclaw-context-capsule 0.1.1 → 1.6.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.
- package/README.md +52 -27
- package/SKILL.md +125 -0
- package/dist/compression.d.ts +67 -0
- package/dist/compression.js +1142 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +290 -0
- package/dist/platform.d.ts +14 -0
- package/dist/platform.js +46 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +22 -13
- package/src/index.ts +0 -224
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-capsule ContextEngine plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Compresses older session history before it reaches the LLM, while keeping a
|
|
5
|
+
* recent verbatim tail for coherence. Older turns become a bounded extractive
|
|
6
|
+
* capsule containing durable facts, decisions, tasks, errors, paths, and links.
|
|
7
|
+
*
|
|
8
|
+
* Self-contained:
|
|
9
|
+
* The compression core is vendored inline (./compression.ts). There is no
|
|
10
|
+
* external runtime dependency. The plugin makes no network requests, no file
|
|
11
|
+
* system access, and no on-chain calls. Everything runs locally.
|
|
12
|
+
*
|
|
13
|
+
* Data handling:
|
|
14
|
+
* Text content is passed through an inline vault-scan gate before reaching the
|
|
15
|
+
* compression core OR the model, including short sessions, verbatim tails, and
|
|
16
|
+
* compression error fallbacks. No matched values are logged; only category
|
|
17
|
+
* counts are emitted. Redaction is best-effort pattern matching, not a formal
|
|
18
|
+
* privacy guarantee.
|
|
19
|
+
*/
|
|
20
|
+
declare const _default: any;
|
|
21
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-capsule ContextEngine plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Compresses older session history before it reaches the LLM, while keeping a
|
|
5
|
+
* recent verbatim tail for coherence. Older turns become a bounded extractive
|
|
6
|
+
* capsule containing durable facts, decisions, tasks, errors, paths, and links.
|
|
7
|
+
*
|
|
8
|
+
* Self-contained:
|
|
9
|
+
* The compression core is vendored inline (./compression.ts). There is no
|
|
10
|
+
* external runtime dependency. The plugin makes no network requests, no file
|
|
11
|
+
* system access, and no on-chain calls. Everything runs locally.
|
|
12
|
+
*
|
|
13
|
+
* Data handling:
|
|
14
|
+
* Text content is passed through an inline vault-scan gate before reaching the
|
|
15
|
+
* compression core OR the model, including short sessions, verbatim tails, and
|
|
16
|
+
* compression error fallbacks. No matched values are logged; only category
|
|
17
|
+
* counts are emitted. Redaction is best-effort pattern matching, not a formal
|
|
18
|
+
* privacy guarantee.
|
|
19
|
+
*/
|
|
20
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
21
|
+
import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core";
|
|
22
|
+
import { compressContext, injectCapsule } from "./compression.js";
|
|
23
|
+
const VAULT_PATTERNS = [
|
|
24
|
+
{
|
|
25
|
+
key: "pem_key",
|
|
26
|
+
re: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/gi,
|
|
27
|
+
placeholder: "[REDACTED_PRIVATE_KEY]",
|
|
28
|
+
},
|
|
29
|
+
{ key: "anthropic", re: /sk-ant-[A-Za-z0-9\-_]{20,}/g, placeholder: "[REDACTED_ANTHROPIC_KEY]" },
|
|
30
|
+
{ key: "openai_project", re: /sk-proj-[A-Za-z0-9_\-]{40,}/g, placeholder: "[REDACTED_OPENAI_PROJECT_KEY]" },
|
|
31
|
+
{ key: "openai", re: /sk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}/g, placeholder: "[REDACTED_OPENAI_KEY]" },
|
|
32
|
+
{ key: "generic_sk", re: /\bsk-[A-Za-z0-9]{20,}\b/g, placeholder: "[REDACTED_SK_KEY]" },
|
|
33
|
+
{ key: "github", re: /gh[pousr]_[A-Za-z0-9_]{36,}/g, placeholder: "[REDACTED_GITHUB_TOKEN]" },
|
|
34
|
+
{ key: "slack", re: /xox[bpras]-[A-Za-z0-9\-]{10,}/g, placeholder: "[REDACTED_SLACK_TOKEN]" },
|
|
35
|
+
{ key: "aws", re: /AKIA[0-9A-Z]{16}/g, placeholder: "[REDACTED_AWS_KEY]" },
|
|
36
|
+
{ key: "stripe", re: /(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{24,}/g, placeholder: "[REDACTED_STRIPE_KEY]" },
|
|
37
|
+
{ key: "jwt", re: /eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}/g, placeholder: "[REDACTED_JWT]" },
|
|
38
|
+
{ key: "bearer", re: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, placeholder: "[REDACTED_BEARER]" },
|
|
39
|
+
{
|
|
40
|
+
key: "credential",
|
|
41
|
+
re: /(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|auth[_-]?token)\s*[=:]\s*["']?([A-Za-z0-9/+=\-_.]{8,})["']?/gi,
|
|
42
|
+
placeholder: "[REDACTED_SECRET]",
|
|
43
|
+
},
|
|
44
|
+
{ key: "card", re: /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2})[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b/g, placeholder: "[REDACTED_CC]" },
|
|
45
|
+
];
|
|
46
|
+
const DEFAULT_MIN_MESSAGES = 20;
|
|
47
|
+
const DEFAULT_KEEP_RECENT = 10;
|
|
48
|
+
// Tuned for fidelity over raw token-cut: ~1400 tokens lands near the knee of the
|
|
49
|
+
// recall/compression curve (~5x reduction at ~70%+ key-signal recall on real
|
|
50
|
+
// sessions) instead of the old 700 (~7.5x but ~43% recall). A high compression
|
|
51
|
+
// ratio is worthless if the decisions, errors, and refs don't survive.
|
|
52
|
+
const DEFAULT_MAX_CAPSULE_TOKENS = 1400;
|
|
53
|
+
const DEFAULT_CAPSULE_TOKEN_RATIO = 0.14;
|
|
54
|
+
const DEFAULT_MIN_COMPRESS_TOKENS = 900;
|
|
55
|
+
const MIN_TAIL_MESSAGES = 2;
|
|
56
|
+
function vaultGuard(text, label) {
|
|
57
|
+
let result = text;
|
|
58
|
+
const hits = {};
|
|
59
|
+
for (const { key, re, placeholder } of VAULT_PATTERNS) {
|
|
60
|
+
re.lastIndex = 0;
|
|
61
|
+
const matches = result.match(re);
|
|
62
|
+
if (matches?.length) {
|
|
63
|
+
hits[key] = (hits[key] ?? 0) + matches.length;
|
|
64
|
+
re.lastIndex = 0;
|
|
65
|
+
result = result.replace(re, placeholder);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const total = Object.values(hits).reduce((a, b) => a + b, 0);
|
|
69
|
+
if (total > 0) {
|
|
70
|
+
const summary = Object.entries(hits)
|
|
71
|
+
.map(([k, n]) => `${k}x${n}`)
|
|
72
|
+
.join(", ");
|
|
73
|
+
console.warn(`[context-capsule vault] ${label}: redacted ${total} (${summary})`);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function numberFromConfig(value, fallback, min, max) {
|
|
78
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
79
|
+
if (!Number.isFinite(n))
|
|
80
|
+
return fallback;
|
|
81
|
+
return Math.max(min, Math.min(max, n));
|
|
82
|
+
}
|
|
83
|
+
function integerFromConfig(value, fallback, min, max) {
|
|
84
|
+
return Math.floor(numberFromConfig(value, fallback, min, max));
|
|
85
|
+
}
|
|
86
|
+
function getOwnRecord(value, key) {
|
|
87
|
+
if (!value || typeof value !== "object")
|
|
88
|
+
return undefined;
|
|
89
|
+
return value[key];
|
|
90
|
+
}
|
|
91
|
+
function readPluginConfig(ctx) {
|
|
92
|
+
const config = getOwnRecord(getOwnRecord(getOwnRecord(ctx, "config"), "plugins"), "entries");
|
|
93
|
+
const raw = getOwnRecord(config, "context-capsule");
|
|
94
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
95
|
+
return {
|
|
96
|
+
minMessages: integerFromConfig(record.minMessages, DEFAULT_MIN_MESSAGES, 1, 10000),
|
|
97
|
+
keepRecentMessages: integerFromConfig(record.keepRecentMessages, DEFAULT_KEEP_RECENT, 1, 200),
|
|
98
|
+
maxCapsuleTokens: integerFromConfig(record.maxCapsuleTokens, DEFAULT_MAX_CAPSULE_TOKENS, 120, 4000),
|
|
99
|
+
capsuleTokenRatio: numberFromConfig(record.capsuleTokenRatio, DEFAULT_CAPSULE_TOKEN_RATIO, 0.01, 0.5),
|
|
100
|
+
minCompressTokens: integerFromConfig(record.minCompressTokens, DEFAULT_MIN_COMPRESS_TOKENS, 0, 1000000),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function normalizeRole(role) {
|
|
104
|
+
return role === "toolResult" ? "tool" : typeof role === "string" ? role : "unknown";
|
|
105
|
+
}
|
|
106
|
+
function contentToText(content) {
|
|
107
|
+
if (typeof content === "string")
|
|
108
|
+
return content;
|
|
109
|
+
if (!Array.isArray(content))
|
|
110
|
+
return "";
|
|
111
|
+
return content
|
|
112
|
+
.map((block) => {
|
|
113
|
+
if (!block || typeof block !== "object")
|
|
114
|
+
return "";
|
|
115
|
+
const b = block;
|
|
116
|
+
if (b.type === "text" && typeof b.text === "string")
|
|
117
|
+
return b.text;
|
|
118
|
+
if (b.type === "toolResult" || b.type === "tool_result")
|
|
119
|
+
return contentToText(b.content);
|
|
120
|
+
return "";
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function normalizeMessages(messages) {
|
|
126
|
+
return messages.map((msg) => ({
|
|
127
|
+
role: normalizeRole(msg.role),
|
|
128
|
+
content: contentToText(msg.content),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
function redactContent(content, label) {
|
|
132
|
+
if (typeof content === "string") {
|
|
133
|
+
const clean = vaultGuard(content, label);
|
|
134
|
+
return { content: clean, changed: clean !== content };
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(content))
|
|
137
|
+
return { content, changed: false };
|
|
138
|
+
let changed = false;
|
|
139
|
+
const next = content.map((block, index) => {
|
|
140
|
+
if (!block || typeof block !== "object")
|
|
141
|
+
return block;
|
|
142
|
+
const b = block;
|
|
143
|
+
if (typeof b.text === "string") {
|
|
144
|
+
const clean = vaultGuard(b.text, `${label}.text[${index}]`);
|
|
145
|
+
if (clean !== b.text) {
|
|
146
|
+
changed = true;
|
|
147
|
+
return { ...b, text: clean };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if ("content" in b) {
|
|
151
|
+
const nested = redactContent(b.content, `${label}.content[${index}]`);
|
|
152
|
+
if (nested.changed) {
|
|
153
|
+
changed = true;
|
|
154
|
+
return { ...b, content: nested.content };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return block;
|
|
158
|
+
});
|
|
159
|
+
return { content: next, changed };
|
|
160
|
+
}
|
|
161
|
+
function redactMessage(msg, index) {
|
|
162
|
+
if (!("content" in msg))
|
|
163
|
+
return msg;
|
|
164
|
+
const role = normalizeRole(msg.role);
|
|
165
|
+
const redacted = redactContent(msg.content, `msg[${index}:${role}]`);
|
|
166
|
+
return redacted.changed ? { ...msg, content: redacted.content } : msg;
|
|
167
|
+
}
|
|
168
|
+
/** Rough token estimate: ~4 chars per token, including text blocks. */
|
|
169
|
+
function estimateTokens(messages) {
|
|
170
|
+
return normalizeMessages(messages).reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0);
|
|
171
|
+
}
|
|
172
|
+
function resolveCapsuleTokenBudget(cfg, tokenBudget) {
|
|
173
|
+
if (!Number.isFinite(tokenBudget) || !tokenBudget || tokenBudget <= 0) {
|
|
174
|
+
return cfg.maxCapsuleTokens;
|
|
175
|
+
}
|
|
176
|
+
return Math.max(120, Math.min(cfg.maxCapsuleTokens, Math.floor(tokenBudget * cfg.capsuleTokenRatio)));
|
|
177
|
+
}
|
|
178
|
+
function resolveKeepRecentCount(params) {
|
|
179
|
+
const { messages, cfg, tokenBudget } = params;
|
|
180
|
+
let keep = Math.min(cfg.keepRecentMessages, messages.length);
|
|
181
|
+
if (!Number.isFinite(tokenBudget) || !tokenBudget || tokenBudget <= 0)
|
|
182
|
+
return keep;
|
|
183
|
+
const tailBudget = Math.max(600, Math.floor(tokenBudget * 0.35));
|
|
184
|
+
while (keep > MIN_TAIL_MESSAGES && estimateTokens(messages.slice(-keep)) > tailBudget) {
|
|
185
|
+
keep -= 1;
|
|
186
|
+
}
|
|
187
|
+
return keep;
|
|
188
|
+
}
|
|
189
|
+
class ContextCapsuleEngine {
|
|
190
|
+
info = {
|
|
191
|
+
id: "context-capsule",
|
|
192
|
+
name: "Context Capsule",
|
|
193
|
+
version: "1.6.0",
|
|
194
|
+
ownsCompaction: false,
|
|
195
|
+
turnMaintenanceMode: "background",
|
|
196
|
+
};
|
|
197
|
+
cfg;
|
|
198
|
+
constructor(cfg) {
|
|
199
|
+
this.cfg = cfg;
|
|
200
|
+
}
|
|
201
|
+
async ingest(_params) {
|
|
202
|
+
return { ingested: true };
|
|
203
|
+
}
|
|
204
|
+
async assemble(params) {
|
|
205
|
+
const scannedMessages = params.messages.map((msg, index) => redactMessage(msg, index));
|
|
206
|
+
const totalTokens = estimateTokens(scannedMessages);
|
|
207
|
+
if (scannedMessages.length < this.cfg.minMessages ||
|
|
208
|
+
totalTokens < this.cfg.minCompressTokens ||
|
|
209
|
+
scannedMessages.length <= MIN_TAIL_MESSAGES) {
|
|
210
|
+
return {
|
|
211
|
+
messages: scannedMessages,
|
|
212
|
+
estimatedTokens: totalTokens,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const keepRecent = resolveKeepRecentCount({
|
|
216
|
+
messages: scannedMessages,
|
|
217
|
+
cfg: this.cfg,
|
|
218
|
+
tokenBudget: params.tokenBudget,
|
|
219
|
+
});
|
|
220
|
+
const tail = scannedMessages.slice(-keepRecent);
|
|
221
|
+
const older = scannedMessages.slice(0, -keepRecent);
|
|
222
|
+
if (older.length === 0) {
|
|
223
|
+
return {
|
|
224
|
+
messages: scannedMessages,
|
|
225
|
+
estimatedTokens: totalTokens,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const capsuleTokens = resolveCapsuleTokenBudget(this.cfg, params.tokenBudget);
|
|
229
|
+
const normalized = normalizeMessages(older).filter((msg) => msg.content.trim().length > 0);
|
|
230
|
+
if (normalized.length === 0) {
|
|
231
|
+
return {
|
|
232
|
+
messages: tail,
|
|
233
|
+
estimatedTokens: estimateTokens(tail),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
let summaryText;
|
|
237
|
+
try {
|
|
238
|
+
const capsule = compressContext(normalized, {
|
|
239
|
+
sessionId: params.sessionId,
|
|
240
|
+
maxOutputTokens: capsuleTokens,
|
|
241
|
+
});
|
|
242
|
+
summaryText = injectCapsule(capsule, { maxOutputTokens: capsuleTokens });
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.warn("[context-capsule] Compression failed; falling back to vault-scanned messages.", err instanceof Error ? err.message : String(err));
|
|
246
|
+
return {
|
|
247
|
+
messages: scannedMessages,
|
|
248
|
+
estimatedTokens: totalTokens,
|
|
249
|
+
promptAuthority: "preassembly_may_overflow",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// Deliver the capsule via systemPromptAddition — the ONLY supported channel
|
|
253
|
+
// that reaches the model. OpenClaw's provider adapters (Anthropic, OpenAI)
|
|
254
|
+
// emit only user/assistant/toolResult messages and SILENTLY DROP any other
|
|
255
|
+
// role, so a role:"system" message placed in `messages` would never reach the
|
|
256
|
+
// model. `messages` therefore carries only the verbatim recent tail (valid
|
|
257
|
+
// roles); the compressed older history rides in systemPromptAddition, which
|
|
258
|
+
// OpenClaw merges into the system prompt.
|
|
259
|
+
const systemPromptAddition = `[Context Capsule — compressed older conversation history]\n${summaryText}\n\n` +
|
|
260
|
+
"The block above is a lossy extractive capsule of earlier turns (the recent " +
|
|
261
|
+
"messages below it remain verbatim). Use it for continuity; ask the user to " +
|
|
262
|
+
"restate exact wording when precision matters. Anything marked superseded/~~ " +
|
|
263
|
+
"was abandoned — do not act on it.";
|
|
264
|
+
return {
|
|
265
|
+
messages: tail,
|
|
266
|
+
estimatedTokens: estimateTokens(tail) + Math.ceil(systemPromptAddition.length / 4),
|
|
267
|
+
systemPromptAddition,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async compact(params) {
|
|
271
|
+
// Transcript compaction (shrinking the stored session when it nears the model
|
|
272
|
+
// context) is delegated to OpenClaw's native runtime bridge — the SAME path
|
|
273
|
+
// the built-in legacy engine uses. A stub that merely returns
|
|
274
|
+
// {compacted:false} is treated as a compaction FAILURE by the CLI/gateway
|
|
275
|
+
// (it throws "transcript compaction failed"), which breaks long sessions.
|
|
276
|
+
return await delegateCompactionToRuntime(params);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
export default definePluginEntry({
|
|
280
|
+
id: "context-capsule",
|
|
281
|
+
name: "Context Capsule",
|
|
282
|
+
description: "Context engine that compresses older agent session history into a bounded, " +
|
|
283
|
+
"extractive capsule before the model call, keeping recent messages verbatim. " +
|
|
284
|
+
"It preserves durable facts, tasks, decisions, errors, files, commands, and " +
|
|
285
|
+
"links while cutting prompt tokens on long-running sessions. Compression and " +
|
|
286
|
+
"best-effort secret redaction run locally with no network or file-system access.",
|
|
287
|
+
register(api) {
|
|
288
|
+
api.registerContextEngine("context-capsule", (ctx) => new ContextCapsuleEngine(readPluginConfig(ctx)));
|
|
289
|
+
},
|
|
290
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** SHA-256 of a UTF-8 string (or raw bytes) -> 32 bytes. */
|
|
2
|
+
export declare function sha256Bytes(data: string | Uint8Array): Uint8Array;
|
|
3
|
+
/** SHA-256 of a UTF-8 string -> lowercase hex. */
|
|
4
|
+
export declare function sha256Hex(data: string): string;
|
|
5
|
+
/** SHA-256 of (a ‖ b) raw bytes — the merkle internal-node hash. */
|
|
6
|
+
export declare function sha256Concat(a: Uint8Array, b: Uint8Array): Uint8Array;
|
|
7
|
+
/** Deflate (zlib, level 9) a UTF-8 string -> base64 + raw byte length. */
|
|
8
|
+
export declare function deflate(text: string): {
|
|
9
|
+
base64: string;
|
|
10
|
+
bytes: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function utf8ByteLength(text: string): number;
|
|
13
|
+
export declare function zeroBytes(n: number): Uint8Array;
|
|
14
|
+
export declare function bytesToHex(bytes: Uint8Array): string;
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform crypto/compression primitives for the context-capsule core.
|
|
3
|
+
*
|
|
4
|
+
* This is the DEFAULT (Node) implementation — `node:crypto` + `node:zlib`, the
|
|
5
|
+
* exact behavior the published OpenClaw plugin has always had (zero runtime
|
|
6
|
+
* deps). The compression core imports these names instead of the Node builtins
|
|
7
|
+
* directly so the SAME source can run in a browser: a browser build aliases this
|
|
8
|
+
* module to `platform.browser.ts` (pure-JS @noble/hashes + pako). The two
|
|
9
|
+
* backends agree on everything that matters — see test/platform-parity.test.mjs:
|
|
10
|
+
* • SHA-256 is byte-identical, so a capsule's merkleRoot, capsuleId, and the
|
|
11
|
+
* injected memory the model sees are byte-identical across backends.
|
|
12
|
+
* • deflate is interoperable/round-trip-identical (each inflates the other's
|
|
13
|
+
* output to the exact original); compressed BYTES may differ on highly
|
|
14
|
+
* repetitive input — which never affects integrity, since the merkle root is
|
|
15
|
+
* built over sha256 leaves, not the compressed blob.
|
|
16
|
+
* No source line of compression.ts cares which backend is active.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { deflateSync } from "node:zlib";
|
|
20
|
+
/** SHA-256 of a UTF-8 string (or raw bytes) -> 32 bytes. */
|
|
21
|
+
export function sha256Bytes(data) {
|
|
22
|
+
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
23
|
+
return new Uint8Array(createHash("sha256").update(buf).digest());
|
|
24
|
+
}
|
|
25
|
+
/** SHA-256 of a UTF-8 string -> lowercase hex. */
|
|
26
|
+
export function sha256Hex(data) {
|
|
27
|
+
return createHash("sha256").update(Buffer.from(data, "utf8")).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
/** SHA-256 of (a ‖ b) raw bytes — the merkle internal-node hash. */
|
|
30
|
+
export function sha256Concat(a, b) {
|
|
31
|
+
return new Uint8Array(createHash("sha256").update(Buffer.from(a)).update(Buffer.from(b)).digest());
|
|
32
|
+
}
|
|
33
|
+
/** Deflate (zlib, level 9) a UTF-8 string -> base64 + raw byte length. */
|
|
34
|
+
export function deflate(text) {
|
|
35
|
+
const out = deflateSync(Buffer.from(text, "utf8"), { level: 9 });
|
|
36
|
+
return { base64: out.toString("base64"), bytes: out.length };
|
|
37
|
+
}
|
|
38
|
+
export function utf8ByteLength(text) {
|
|
39
|
+
return Buffer.byteLength(text, "utf8");
|
|
40
|
+
}
|
|
41
|
+
export function zeroBytes(n) {
|
|
42
|
+
return new Uint8Array(n);
|
|
43
|
+
}
|
|
44
|
+
export function bytesToHex(bytes) {
|
|
45
|
+
return Buffer.from(bytes).toString("hex");
|
|
46
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -17,6 +17,23 @@
|
|
|
17
17
|
"type": "integer",
|
|
18
18
|
"minimum": 1,
|
|
19
19
|
"description": "Number of most-recent messages kept verbatim after compression. Default: 10."
|
|
20
|
+
},
|
|
21
|
+
"maxCapsuleTokens": {
|
|
22
|
+
"type": "integer",
|
|
23
|
+
"minimum": 120,
|
|
24
|
+
"maximum": 4000,
|
|
25
|
+
"description": "Maximum tokens reserved for the injected extractive capsule. Default: 1400 (tuned for fidelity — near the knee of the recall/compression curve)."
|
|
26
|
+
},
|
|
27
|
+
"capsuleTokenRatio": {
|
|
28
|
+
"type": "number",
|
|
29
|
+
"minimum": 0.01,
|
|
30
|
+
"maximum": 0.5,
|
|
31
|
+
"description": "When the host provides a model token budget, cap the capsule at this fraction of that budget. Default: 0.14."
|
|
32
|
+
},
|
|
33
|
+
"minCompressTokens": {
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"minimum": 0,
|
|
36
|
+
"description": "Minimum estimated transcript tokens before compression is applied. Default: 900."
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parad0x_labs/openclaw-context-capsule",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Self-contained OpenClaw context engine that compresses older agent session history into a bounded extractive capsule, preserving decisions, tasks, errors, files, links, and durable facts while keeping recent messages verbatim.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
7
|
"files": [
|
|
8
|
-
"
|
|
8
|
+
"dist/",
|
|
9
9
|
"openclaw.plugin.json",
|
|
10
|
-
"README.md"
|
|
10
|
+
"README.md",
|
|
11
|
+
"SKILL.md"
|
|
11
12
|
],
|
|
12
13
|
"keywords": [
|
|
13
14
|
"openclaw",
|
|
@@ -20,17 +21,24 @@
|
|
|
20
21
|
],
|
|
21
22
|
"openclaw": {
|
|
22
23
|
"extensions": [
|
|
23
|
-
"./
|
|
24
|
+
"./dist/index.js"
|
|
24
25
|
],
|
|
25
|
-
"compat": {
|
|
26
|
-
"pluginApi": "1.x"
|
|
27
|
-
},
|
|
28
26
|
"build": {
|
|
29
|
-
"openclawVersion": ">=
|
|
27
|
+
"openclawVersion": ">=2026.6.0"
|
|
30
28
|
}
|
|
31
29
|
},
|
|
32
|
-
"
|
|
33
|
-
"@
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@noble/hashes": "^2.2.0",
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"pako": "^2.1.0",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
39
|
+
"prepack": "npm run build",
|
|
40
|
+
"prepublishOnly": "npm run build && npm test",
|
|
41
|
+
"test": "npm run build && node test/compression-smoke.mjs && node test/quality.test.mjs && node test/supersession-bench.mjs && node test/hardening-bench.mjs && node test/secret-bench.mjs && node test/platform-parity.test.mjs"
|
|
34
42
|
},
|
|
35
43
|
"engines": {
|
|
36
44
|
"node": ">=22"
|
|
@@ -38,6 +46,7 @@
|
|
|
38
46
|
"license": "MIT",
|
|
39
47
|
"repository": {
|
|
40
48
|
"type": "git",
|
|
41
|
-
"url": "https://github.com/Parad0x-Labs/
|
|
49
|
+
"url": "https://github.com/Parad0x-Labs/openclaw-skills",
|
|
50
|
+
"directory": "skills/context-capsule"
|
|
42
51
|
}
|
|
43
52
|
}
|
package/src/index.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* context-capsule ContextEngine plugin for OpenClaw.
|
|
3
|
-
*
|
|
4
|
-
* Compresses session history before it reaches the LLM, achieving ~99% token
|
|
5
|
-
* reduction while keeping a verbatim tail of recent messages for coherence.
|
|
6
|
-
* Sessions under the minMessages threshold pass through unchanged.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
10
|
-
import type {
|
|
11
|
-
AssembleResult,
|
|
12
|
-
CompactResult,
|
|
13
|
-
ContextEngine,
|
|
14
|
-
ContextEngineInfo,
|
|
15
|
-
IngestResult,
|
|
16
|
-
} from "openclaw/plugin-sdk/context-engine";
|
|
17
|
-
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
18
|
-
// @ts-expect-error — external package, types may not be present at build time
|
|
19
|
-
import { compressContext, injectCapsule } from "@parad0x_labs/context-capsule";
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Configuration defaults
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
const DEFAULT_MIN_MESSAGES = 20;
|
|
26
|
-
const DEFAULT_KEEP_RECENT = 10;
|
|
27
|
-
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Helpers
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
type SimpleMessage = { role: string; content: string };
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Convert an OpenClaw AgentMessage to the plain {role, content} shape expected
|
|
36
|
-
* by @parad0x_labs/context-capsule.
|
|
37
|
-
*
|
|
38
|
-
* Handles:
|
|
39
|
-
* - "toolResult" role → "tool"
|
|
40
|
-
* - Content that is already a string → used as-is
|
|
41
|
-
* - Content that is an array of content blocks → joined to a single string
|
|
42
|
-
*/
|
|
43
|
-
function normalizeMessages(messages: AgentMessage[]): SimpleMessage[] {
|
|
44
|
-
return messages.map((msg) => {
|
|
45
|
-
const role = msg.role === "toolResult" ? "tool" : (msg.role as string);
|
|
46
|
-
|
|
47
|
-
let content: string;
|
|
48
|
-
if (!("content" in msg) || (msg as { content: unknown }).content == null) {
|
|
49
|
-
content = "";
|
|
50
|
-
} else if (typeof (msg as { content: unknown }).content === "string") {
|
|
51
|
-
content = (msg as { content: string }).content;
|
|
52
|
-
} else if (Array.isArray((msg as { content: unknown[] }).content)) {
|
|
53
|
-
const blocks = (msg as { content: unknown[] }).content;
|
|
54
|
-
content = blocks
|
|
55
|
-
.map((block) => {
|
|
56
|
-
if (!block || typeof block !== "object") return "";
|
|
57
|
-
const b = block as Record<string, unknown>;
|
|
58
|
-
if (b.type === "text" && typeof b.text === "string") return b.text;
|
|
59
|
-
if (b.type === "toolResult" || b.type === "tool_result") {
|
|
60
|
-
const inner = b.content;
|
|
61
|
-
if (typeof inner === "string") return inner;
|
|
62
|
-
if (Array.isArray(inner)) {
|
|
63
|
-
return (inner as unknown[])
|
|
64
|
-
.map((ib) => {
|
|
65
|
-
if (!ib || typeof ib !== "object") return "";
|
|
66
|
-
const ibr = ib as Record<string, unknown>;
|
|
67
|
-
return ibr.type === "text" && typeof ibr.text === "string" ? ibr.text : "";
|
|
68
|
-
})
|
|
69
|
-
.filter(Boolean)
|
|
70
|
-
.join("\n");
|
|
71
|
-
}
|
|
72
|
-
return "";
|
|
73
|
-
}
|
|
74
|
-
return "";
|
|
75
|
-
})
|
|
76
|
-
.filter(Boolean)
|
|
77
|
-
.join("\n");
|
|
78
|
-
} else {
|
|
79
|
-
content = "";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return { role, content };
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Rough token estimate: ~4 chars per token */
|
|
87
|
-
function estimateTokens(messages: AgentMessage[]): number {
|
|
88
|
-
return messages.reduce((sum, m) => {
|
|
89
|
-
const c =
|
|
90
|
-
"content" in m && typeof (m as { content: unknown }).content === "string"
|
|
91
|
-
? ((m as { content: string }).content).length
|
|
92
|
-
: 0;
|
|
93
|
-
return sum + Math.ceil(c / 4);
|
|
94
|
-
}, 0);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// ContextEngine implementation
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
type CapsuleConfig = {
|
|
102
|
-
minMessages: number;
|
|
103
|
-
keepRecentMessages: number;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
class ContextCapsuleEngine implements ContextEngine {
|
|
107
|
-
readonly info: ContextEngineInfo = {
|
|
108
|
-
id: "context-capsule",
|
|
109
|
-
name: "Context Capsule",
|
|
110
|
-
version: "0.1.0",
|
|
111
|
-
ownsCompaction: false,
|
|
112
|
-
turnMaintenanceMode: "background",
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
private readonly cfg: CapsuleConfig;
|
|
116
|
-
|
|
117
|
-
constructor(cfg: Partial<CapsuleConfig> = {}) {
|
|
118
|
-
this.cfg = {
|
|
119
|
-
minMessages: cfg.minMessages ?? DEFAULT_MIN_MESSAGES,
|
|
120
|
-
keepRecentMessages: cfg.keepRecentMessages ?? DEFAULT_KEEP_RECENT,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Required: ingest — accept each message as the transcript grows
|
|
125
|
-
async ingest(_params: {
|
|
126
|
-
sessionId: string;
|
|
127
|
-
sessionKey?: string;
|
|
128
|
-
message: AgentMessage;
|
|
129
|
-
isHeartbeat?: boolean;
|
|
130
|
-
}): Promise<IngestResult> {
|
|
131
|
-
return { ingested: true };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Required: assemble — build the context window for the next model call
|
|
135
|
-
async assemble(params: {
|
|
136
|
-
sessionId: string;
|
|
137
|
-
sessionKey?: string;
|
|
138
|
-
messages: AgentMessage[];
|
|
139
|
-
tokenBudget?: number;
|
|
140
|
-
availableTools?: Set<string>;
|
|
141
|
-
model?: string;
|
|
142
|
-
prompt?: string;
|
|
143
|
-
}): Promise<AssembleResult> {
|
|
144
|
-
const { messages } = params;
|
|
145
|
-
|
|
146
|
-
// Short sessions: pass through unchanged
|
|
147
|
-
if (messages.length < this.cfg.minMessages) {
|
|
148
|
-
return {
|
|
149
|
-
messages,
|
|
150
|
-
estimatedTokens: estimateTokens(messages),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Compress the older history, keep the tail verbatim
|
|
155
|
-
const tail = messages.slice(-this.cfg.keepRecentMessages);
|
|
156
|
-
const older = messages.slice(0, -this.cfg.keepRecentMessages);
|
|
157
|
-
const normalized = normalizeMessages(older);
|
|
158
|
-
|
|
159
|
-
let summaryText: string;
|
|
160
|
-
try {
|
|
161
|
-
const capsule = await compressContext(normalized);
|
|
162
|
-
const injected = await injectCapsule(capsule);
|
|
163
|
-
summaryText = typeof injected === "string" ? injected : JSON.stringify(injected);
|
|
164
|
-
} catch {
|
|
165
|
-
// Fallback: skip compression, return original messages
|
|
166
|
-
return {
|
|
167
|
-
messages,
|
|
168
|
-
estimatedTokens: estimateTokens(messages),
|
|
169
|
-
promptAuthority: "preassembly_may_overflow",
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Prepend capsule as a system context message
|
|
174
|
-
const capsuleSystemMessage = {
|
|
175
|
-
role: "system",
|
|
176
|
-
content: `[Context Capsule — compressed history]\n${summaryText}`,
|
|
177
|
-
} as unknown as AgentMessage;
|
|
178
|
-
|
|
179
|
-
const assembled = [capsuleSystemMessage, ...tail];
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
messages: assembled,
|
|
183
|
-
estimatedTokens: estimateTokens(assembled),
|
|
184
|
-
systemPromptAddition:
|
|
185
|
-
"Earlier conversation history has been compressed into the context capsule above.",
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Required: compact — delegate to runtime (engine does not own compaction)
|
|
190
|
-
async compact(_params: {
|
|
191
|
-
sessionId: string;
|
|
192
|
-
sessionKey?: string;
|
|
193
|
-
sessionFile: string;
|
|
194
|
-
tokenBudget?: number;
|
|
195
|
-
force?: boolean;
|
|
196
|
-
currentTokenCount?: number;
|
|
197
|
-
compactionTarget?: "budget" | "threshold";
|
|
198
|
-
customInstructions?: string;
|
|
199
|
-
runtimeContext?: unknown;
|
|
200
|
-
abortSignal?: AbortSignal;
|
|
201
|
-
}): Promise<CompactResult> {
|
|
202
|
-
return {
|
|
203
|
-
ok: true,
|
|
204
|
-
compacted: false,
|
|
205
|
-
reason: "delegated-to-runtime",
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ---------------------------------------------------------------------------
|
|
211
|
-
// Plugin registration
|
|
212
|
-
// ---------------------------------------------------------------------------
|
|
213
|
-
|
|
214
|
-
export default definePluginEntry({
|
|
215
|
-
id: "context-capsule",
|
|
216
|
-
name: "Context Capsule",
|
|
217
|
-
description:
|
|
218
|
-
"99.3% token reduction on agent sessions via @parad0x_labs/context-capsule. " +
|
|
219
|
-
"Works with Ollama, LM Studio, GPT-4, Mistral, and Claude. " +
|
|
220
|
-
"Public benchmark with recovery-score gate in CI.",
|
|
221
|
-
register(api) {
|
|
222
|
-
api.registerContextEngine("context-capsule", (_ctx) => new ContextCapsuleEngine());
|
|
223
|
-
},
|
|
224
|
-
});
|