@parad0x_labs/openclaw-context-capsule 1.4.0 → 1.7.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/CHANGELOG.md +21 -0
- package/README.md +55 -57
- package/SKILL.md +134 -0
- package/dist/compression.d.ts +67 -0
- package/dist/compression.js +1156 -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 +24 -11
- package/src/compression.ts +0 -176
- package/src/index.ts +0 -312
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.7.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,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parad0x_labs/openclaw-context-capsule",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Self-contained OpenClaw context engine that compresses
|
|
3
|
+
"version": "1.7.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",
|
|
12
|
+
"CHANGELOG.md"
|
|
11
13
|
],
|
|
12
14
|
"keywords": [
|
|
13
15
|
"openclaw",
|
|
@@ -20,21 +22,32 @@
|
|
|
20
22
|
],
|
|
21
23
|
"openclaw": {
|
|
22
24
|
"extensions": [
|
|
23
|
-
"./
|
|
25
|
+
"./dist/index.js"
|
|
24
26
|
],
|
|
25
|
-
"compat": {
|
|
26
|
-
"pluginApi": "1.x"
|
|
27
|
-
},
|
|
28
27
|
"build": {
|
|
29
|
-
"openclawVersion": ">=
|
|
28
|
+
"openclawVersion": ">=2026.6.0"
|
|
30
29
|
}
|
|
31
30
|
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@noble/hashes": "^2.2.0",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"pako": "^2.1.0",
|
|
35
|
+
"typescript": "^5.6.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.json",
|
|
39
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
40
|
+
"prepack": "npm run build",
|
|
41
|
+
"prepublishOnly": "npm run build && npm test",
|
|
42
|
+
"test": "npm run build && node test/compression-smoke.mjs && node test/quality.test.mjs && node test/value-survival.test.mjs && node test/supersession-bench.mjs && node test/hardening-bench.mjs && node test/secret-bench.mjs && node test/platform-parity.test.mjs"
|
|
43
|
+
},
|
|
32
44
|
"engines": {
|
|
33
45
|
"node": ">=22"
|
|
34
46
|
},
|
|
35
47
|
"license": "MIT",
|
|
36
48
|
"repository": {
|
|
37
49
|
"type": "git",
|
|
38
|
-
"url": "https://github.com/Parad0x-Labs/
|
|
50
|
+
"url": "https://github.com/Parad0x-Labs/openclaw-skills",
|
|
51
|
+
"directory": "skills/context-capsule"
|
|
39
52
|
}
|
|
40
53
|
}
|
package/src/compression.ts
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Self-contained compression core for the Context Capsule skill.
|
|
3
|
-
*
|
|
4
|
-
* This is a VENDORED, MINIMAL SUBSET of @parad0x_labs/context-capsule —
|
|
5
|
-
* only the two pure functions this plugin actually uses (compressContext +
|
|
6
|
-
* injectCapsule) and their local helpers. It is bundled directly in the skill
|
|
7
|
-
* so the published artifact is fully self-contained and auditable: there is no
|
|
8
|
-
* external runtime dependency, and nothing here performs network I/O, file
|
|
9
|
-
* access, dynamic imports, or on-chain anchoring.
|
|
10
|
-
*
|
|
11
|
-
* Dependencies: Node.js built-ins only — `node:zlib` (deflate) and
|
|
12
|
-
* `node:crypto` (SHA-256). No third-party packages.
|
|
13
|
-
*
|
|
14
|
-
* Provenance: faithfully copied from
|
|
15
|
-
* github.com/Parad0x-Labs/dna-x402/tree/main/packages/context-capsule (MIT)
|
|
16
|
-
* Excluded on purpose: searchCapsule, estimateSavings, intent tagging, and
|
|
17
|
-
* anchorCorrectionChain (the latter is the only code path in the upstream
|
|
18
|
-
* package that touches the network — it is intentionally NOT vendored here).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { createHash } from "node:crypto";
|
|
22
|
-
import { deflateSync } from "node:zlib";
|
|
23
|
-
|
|
24
|
-
// ── Types ───────────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export interface Message {
|
|
27
|
-
role: string;
|
|
28
|
-
content: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** A compressed, auditable snapshot of an agent session's message history. */
|
|
32
|
-
export interface ContextCapsule {
|
|
33
|
-
sessionId: string;
|
|
34
|
-
capsuleId: string;
|
|
35
|
-
originalTokenEstimate: number;
|
|
36
|
-
compressedBytes: number;
|
|
37
|
-
compressionRatio: string;
|
|
38
|
-
topics: string[];
|
|
39
|
-
merkleRoot: string;
|
|
40
|
-
createdAt: number;
|
|
41
|
-
compressedBase64: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface CompressOptions {
|
|
45
|
-
sessionId?: string;
|
|
46
|
-
maxOutputTokens?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
/** SHA-256 of a UTF-8 string → Buffer */
|
|
52
|
-
function sha256(data: string): Buffer {
|
|
53
|
-
return createHash("sha256").update(data, "utf8").digest();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Build a SHA-256 Merkle root over an ordered list of leaf buffers.
|
|
58
|
-
* Empty → 32-byte zero buffer. Single leaf → that leaf. Odd node duplicates.
|
|
59
|
-
*/
|
|
60
|
-
function buildMerkleRoot(leaves: Buffer[]): Buffer {
|
|
61
|
-
if (leaves.length === 0) return Buffer.alloc(32, 0);
|
|
62
|
-
if (leaves.length === 1) return leaves[0];
|
|
63
|
-
|
|
64
|
-
let level: Buffer[] = leaves;
|
|
65
|
-
while (level.length > 1) {
|
|
66
|
-
const next: Buffer[] = [];
|
|
67
|
-
for (let i = 0; i < level.length; i += 2) {
|
|
68
|
-
const left = level[i];
|
|
69
|
-
const right = i + 1 < level.length ? level[i + 1] : level[i]; // duplicate odd
|
|
70
|
-
next.push(createHash("sha256").update(left).update(right).digest());
|
|
71
|
-
}
|
|
72
|
-
level = next;
|
|
73
|
-
}
|
|
74
|
-
return level[0];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Extract up to `maxTopics` noun-phrase-like tokens from message text. */
|
|
78
|
-
function extractTopics(messages: Message[], maxTopics = 5): string[] {
|
|
79
|
-
const allText = messages.map((m) => m.content).join(" ");
|
|
80
|
-
|
|
81
|
-
const titlePhrases = allText.match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\b/g) ?? [];
|
|
82
|
-
const properNouns = allText.match(/\b[A-Z][a-zA-Z]{3,}\b/g) ?? [];
|
|
83
|
-
|
|
84
|
-
const stopWords = new Set([
|
|
85
|
-
"the", "and", "for", "that", "this", "with", "have", "from", "they", "will",
|
|
86
|
-
"been", "were", "what", "when", "where", "which", "while", "about", "above",
|
|
87
|
-
"after", "before", "between", "through", "during", "because", "should",
|
|
88
|
-
]);
|
|
89
|
-
const plainWords =
|
|
90
|
-
allText
|
|
91
|
-
.toLowerCase()
|
|
92
|
-
.match(/\b[a-z]{7,}\b/g)
|
|
93
|
-
?.filter((w) => !stopWords.has(w)) ?? [];
|
|
94
|
-
|
|
95
|
-
const seen = new Set<string>();
|
|
96
|
-
const topics: string[] = [];
|
|
97
|
-
for (const raw of [...titlePhrases, ...properNouns, ...plainWords]) {
|
|
98
|
-
const key = raw.toLowerCase();
|
|
99
|
-
if (!seen.has(key)) {
|
|
100
|
-
seen.add(key);
|
|
101
|
-
topics.push(raw);
|
|
102
|
-
if (topics.length >= maxTopics) break;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return topics;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Deterministic capsule ID: sha256(sessionId + createdAt + merkleRoot) */
|
|
109
|
-
function buildCapsuleId(sessionId: string, createdAt: number, merkleRoot: string): string {
|
|
110
|
-
return createHash("sha256")
|
|
111
|
-
.update(`${sessionId}:${createdAt}:${merkleRoot}`)
|
|
112
|
-
.digest("hex")
|
|
113
|
-
.slice(0, 32);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Estimate token count from character length (chars / 4 approximation) */
|
|
117
|
-
function estimateTokens(text: string): number {
|
|
118
|
-
return Math.ceil(text.length / 4);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── Public API (subset) ──────────────────────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Compress a session's message history into a ContextCapsule.
|
|
125
|
-
* Pure function — zlib deflate + SHA-256 only, no I/O.
|
|
126
|
-
*/
|
|
127
|
-
export function compressContext(messages: Message[], opts: CompressOptions = {}): ContextCapsule {
|
|
128
|
-
if (!messages || messages.length === 0) {
|
|
129
|
-
throw new Error("compressContext: messages must be a non-empty array");
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const sessionId = opts.sessionId ?? `session_${Date.now()}`;
|
|
133
|
-
const createdAt = Date.now();
|
|
134
|
-
|
|
135
|
-
const jsonl = messages.map((m) => JSON.stringify(m)).join("\n");
|
|
136
|
-
const compressed = deflateSync(Buffer.from(jsonl, "utf8"), { level: 9 });
|
|
137
|
-
const compressedBase64 = compressed.toString("base64");
|
|
138
|
-
|
|
139
|
-
const originalTokenEstimate = estimateTokens(jsonl);
|
|
140
|
-
const originalBytes = Buffer.byteLength(jsonl, "utf8");
|
|
141
|
-
const compressedBytes = compressed.length;
|
|
142
|
-
const compressionRatio = `${(originalBytes / compressedBytes).toFixed(1)}x`;
|
|
143
|
-
|
|
144
|
-
const topics = extractTopics(messages);
|
|
145
|
-
|
|
146
|
-
const leaves = messages.map((m) => sha256(JSON.stringify(m)));
|
|
147
|
-
const merkleRoot = buildMerkleRoot(leaves).toString("hex");
|
|
148
|
-
const capsuleId = buildCapsuleId(sessionId, createdAt, merkleRoot);
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
sessionId,
|
|
152
|
-
capsuleId,
|
|
153
|
-
originalTokenEstimate,
|
|
154
|
-
compressedBytes,
|
|
155
|
-
compressionRatio,
|
|
156
|
-
topics,
|
|
157
|
-
merkleRoot,
|
|
158
|
-
createdAt,
|
|
159
|
-
compressedBase64,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Generate a compact (~60–100 token) injection string that replaces full
|
|
165
|
-
* history in an LLM call. Pure string builder — no I/O.
|
|
166
|
-
*/
|
|
167
|
-
export function injectCapsule(capsule: ContextCapsule): string {
|
|
168
|
-
const topicList = capsule.topics.length > 0 ? capsule.topics.join(", ") : "general conversation";
|
|
169
|
-
const rootShort = capsule.merkleRoot.slice(0, 12);
|
|
170
|
-
return (
|
|
171
|
-
`[CONTEXT CAPSULE: session ${capsule.sessionId} compressed ${capsule.compressionRatio}. ` +
|
|
172
|
-
`Key topics: ${topicList}. ` +
|
|
173
|
-
`Merkle: ${rootShort}... ` +
|
|
174
|
-
`Full history available on request.]`
|
|
175
|
-
);
|
|
176
|
-
}
|