@parad0x_labs/openclaw-context-capsule 1.4.0 → 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 +55 -57
- 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 +23 -11
- package/src/compression.ts +0 -176
- package/src/index.ts +0 -312
package/src/index.ts
DELETED
|
@@ -1,312 +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
|
-
* Self-contained (v1.4.0):
|
|
9
|
-
* The compression core is vendored inline (./compression.ts) — there is no
|
|
10
|
-
* external runtime dependency. The plugin makes no network requests, no
|
|
11
|
-
* file-system access, and no on-chain calls. Everything runs locally.
|
|
12
|
-
*
|
|
13
|
-
* Data handling:
|
|
14
|
-
* All message content is passed through an inline vault-scan gate before
|
|
15
|
-
* reaching the compression core OR the model on any code path, including
|
|
16
|
-
* short sessions, the verbatim tail, and compression error fallbacks.
|
|
17
|
-
* The gate strips API keys, tokens, credentials, PII, and card numbers,
|
|
18
|
-
* replacing them with typed placeholders. No matched values are logged —
|
|
19
|
-
* only category counts. On compression failure the plugin is fail-closed:
|
|
20
|
-
* vault-scanned (redacted) messages are returned with a visible console.warn.
|
|
21
|
-
* Redaction is best-effort pattern matching, not a guarantee — do not rely on
|
|
22
|
-
* it as sole protection for highly sensitive sessions.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
26
|
-
import type {
|
|
27
|
-
AssembleResult,
|
|
28
|
-
CompactResult,
|
|
29
|
-
ContextEngine,
|
|
30
|
-
ContextEngineInfo,
|
|
31
|
-
IngestResult,
|
|
32
|
-
} from "openclaw/plugin-sdk/context-engine";
|
|
33
|
-
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
34
|
-
// Self-contained vendored compression core — no external runtime dependency.
|
|
35
|
-
// Only zlib + SHA-256, no network/file I/O. See ./compression.ts for provenance.
|
|
36
|
-
import { compressContext, injectCapsule } from "./compression";
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Inline vault-scan gate (ported from tools/liquefy_redact.py)
|
|
40
|
-
// Strips secrets and PII before any text crosses the compression boundary.
|
|
41
|
-
// Never logs matched values — only category names and counts.
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
const VAULT_PATTERNS: Array<{ key: string; re: RegExp; placeholder: string }> = [
|
|
45
|
-
{ key: "pem_key", re: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/gi, placeholder: "[REDACTED_PRIVATE_KEY]" },
|
|
46
|
-
{ key: "anthropic", re: /sk-ant-[A-Za-z0-9\-_]{20,}/g, placeholder: "[REDACTED_ANTHROPIC_KEY]" },
|
|
47
|
-
{ key: "openai", re: /sk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}/g, placeholder: "[REDACTED_OPENAI_KEY]" },
|
|
48
|
-
{ key: "generic_sk", re: /\bsk-[A-Za-z0-9]{20,}\b/g, placeholder: "[REDACTED_SK_KEY]" },
|
|
49
|
-
{ key: "github", re: /gh[pousr]_[A-Za-z0-9_]{36,}/g, placeholder: "[REDACTED_GITHUB_TOKEN]" },
|
|
50
|
-
{ key: "slack", re: /xox[bpras]-[A-Za-z0-9\-]{10,}/g, placeholder: "[REDACTED_SLACK_TOKEN]" },
|
|
51
|
-
{ key: "aws", re: /AKIA[0-9A-Z]{16}/g, placeholder: "[REDACTED_AWS_KEY]" },
|
|
52
|
-
{ key: "stripe", re: /(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{24,}/g, placeholder: "[REDACTED_STRIPE_KEY]" },
|
|
53
|
-
{ key: "jwt", re: /eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}/g, placeholder: "[REDACTED_JWT]" },
|
|
54
|
-
{ key: "bearer", re: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, placeholder: "[REDACTED_BEARER]" },
|
|
55
|
-
{ key: "credential", re: /(?:password|passwd|secret|token|api[_\-]?key|access[_\-]?key|auth[_\-]?token)\s*[=:]\s*["']?([A-Za-z0-9/+=\-_.]{8,})["']?/gi, placeholder: "[REDACTED_SECRET]" },
|
|
56
|
-
{ 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]" },
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
function vaultGuard(text: string, label: string): string {
|
|
60
|
-
let result = text;
|
|
61
|
-
const hits: Record<string, number> = {};
|
|
62
|
-
for (const { key, re, placeholder } of VAULT_PATTERNS) {
|
|
63
|
-
re.lastIndex = 0;
|
|
64
|
-
const matches = result.match(re);
|
|
65
|
-
if (matches?.length) {
|
|
66
|
-
hits[key] = (hits[key] ?? 0) + matches.length;
|
|
67
|
-
re.lastIndex = 0;
|
|
68
|
-
result = result.replace(re, placeholder);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
const total = Object.values(hits).reduce((a, b) => a + b, 0);
|
|
72
|
-
if (total > 0) {
|
|
73
|
-
const summary = Object.entries(hits).map(([k, n]) => `${k}x${n}`).join(", ");
|
|
74
|
-
console.warn(`[context-capsule vault] ${label}: redacted ${total} — ${summary}`);
|
|
75
|
-
}
|
|
76
|
-
return result;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// Configuration defaults
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
const DEFAULT_MIN_MESSAGES = 20;
|
|
84
|
-
const DEFAULT_KEEP_RECENT = 10;
|
|
85
|
-
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// Helpers
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
type SimpleMessage = { role: string; content: string };
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Convert an OpenClaw AgentMessage to the plain {role, content} shape expected
|
|
94
|
-
* by @parad0x_labs/context-capsule.
|
|
95
|
-
*
|
|
96
|
-
* Handles:
|
|
97
|
-
* - "toolResult" role → "tool"
|
|
98
|
-
* - Content that is already a string → used as-is
|
|
99
|
-
* - Content that is an array of content blocks → joined to a single string
|
|
100
|
-
*/
|
|
101
|
-
function normalizeMessages(messages: AgentMessage[]): SimpleMessage[] {
|
|
102
|
-
return messages.map((msg) => {
|
|
103
|
-
const role = msg.role === "toolResult" ? "tool" : (msg.role as string);
|
|
104
|
-
|
|
105
|
-
let content: string;
|
|
106
|
-
if (!("content" in msg) || (msg as { content: unknown }).content == null) {
|
|
107
|
-
content = "";
|
|
108
|
-
} else if (typeof (msg as { content: unknown }).content === "string") {
|
|
109
|
-
content = (msg as { content: string }).content;
|
|
110
|
-
} else if (Array.isArray((msg as { content: unknown[] }).content)) {
|
|
111
|
-
const blocks = (msg as { content: unknown[] }).content;
|
|
112
|
-
content = blocks
|
|
113
|
-
.map((block) => {
|
|
114
|
-
if (!block || typeof block !== "object") return "";
|
|
115
|
-
const b = block as Record<string, unknown>;
|
|
116
|
-
if (b.type === "text" && typeof b.text === "string") return b.text;
|
|
117
|
-
if (b.type === "toolResult" || b.type === "tool_result") {
|
|
118
|
-
const inner = b.content;
|
|
119
|
-
if (typeof inner === "string") return inner;
|
|
120
|
-
if (Array.isArray(inner)) {
|
|
121
|
-
return (inner as unknown[])
|
|
122
|
-
.map((ib) => {
|
|
123
|
-
if (!ib || typeof ib !== "object") return "";
|
|
124
|
-
const ibr = ib as Record<string, unknown>;
|
|
125
|
-
return ibr.type === "text" && typeof ibr.text === "string" ? ibr.text : "";
|
|
126
|
-
})
|
|
127
|
-
.filter(Boolean)
|
|
128
|
-
.join("\n");
|
|
129
|
-
}
|
|
130
|
-
return "";
|
|
131
|
-
}
|
|
132
|
-
return "";
|
|
133
|
-
})
|
|
134
|
-
.filter(Boolean)
|
|
135
|
-
.join("\n");
|
|
136
|
-
} else {
|
|
137
|
-
content = "";
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return { role, content };
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Rough token estimate: ~4 chars per token */
|
|
145
|
-
function estimateTokens(messages: AgentMessage[]): number {
|
|
146
|
-
return messages.reduce((sum, m) => {
|
|
147
|
-
const c =
|
|
148
|
-
"content" in m && typeof (m as { content: unknown }).content === "string"
|
|
149
|
-
? ((m as { content: string }).content).length
|
|
150
|
-
: 0;
|
|
151
|
-
return sum + Math.ceil(c / 4);
|
|
152
|
-
}, 0);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// ContextEngine implementation
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
type CapsuleConfig = {
|
|
160
|
-
minMessages: number;
|
|
161
|
-
keepRecentMessages: number;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
class ContextCapsuleEngine implements ContextEngine {
|
|
165
|
-
readonly info: ContextEngineInfo = {
|
|
166
|
-
id: "context-capsule",
|
|
167
|
-
name: "Context Capsule",
|
|
168
|
-
version: "1.3.0",
|
|
169
|
-
ownsCompaction: false,
|
|
170
|
-
turnMaintenanceMode: "background",
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
private readonly cfg: CapsuleConfig;
|
|
174
|
-
|
|
175
|
-
constructor(cfg: Partial<CapsuleConfig> = {}) {
|
|
176
|
-
this.cfg = {
|
|
177
|
-
minMessages: cfg.minMessages ?? DEFAULT_MIN_MESSAGES,
|
|
178
|
-
keepRecentMessages: cfg.keepRecentMessages ?? DEFAULT_KEEP_RECENT,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Required: ingest — accept each message as the transcript grows
|
|
183
|
-
async ingest(_params: {
|
|
184
|
-
sessionId: string;
|
|
185
|
-
sessionKey?: string;
|
|
186
|
-
message: AgentMessage;
|
|
187
|
-
isHeartbeat?: boolean;
|
|
188
|
-
}): Promise<IngestResult> {
|
|
189
|
-
return { ingested: true };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Required: assemble — build the context window for the next model call
|
|
193
|
-
async assemble(params: {
|
|
194
|
-
sessionId: string;
|
|
195
|
-
sessionKey?: string;
|
|
196
|
-
messages: AgentMessage[];
|
|
197
|
-
tokenBudget?: number;
|
|
198
|
-
availableTools?: Set<string>;
|
|
199
|
-
model?: string;
|
|
200
|
-
prompt?: string;
|
|
201
|
-
}): Promise<AssembleResult> {
|
|
202
|
-
const { messages } = params;
|
|
203
|
-
|
|
204
|
-
// Vault gate applied to ALL messages on ALL paths — including short sessions
|
|
205
|
-
// and the verbatim tail — so no content reaches the model unscanned.
|
|
206
|
-
// Runs locally, logs category counts only, never matched values.
|
|
207
|
-
const scannedMessages = messages.map((msg) => {
|
|
208
|
-
const raw =
|
|
209
|
-
"content" in msg && typeof (msg as { content: unknown }).content === "string"
|
|
210
|
-
? (msg as { content: string }).content
|
|
211
|
-
: "";
|
|
212
|
-
if (!raw) return msg;
|
|
213
|
-
const clean = vaultGuard(raw, `msg[${(msg as { role: string }).role ?? "unknown"}]`);
|
|
214
|
-
return clean === raw ? msg : { ...msg, content: clean };
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// Short sessions: pass through (vault-scanned above)
|
|
218
|
-
if (scannedMessages.length < this.cfg.minMessages) {
|
|
219
|
-
return {
|
|
220
|
-
messages: scannedMessages,
|
|
221
|
-
estimatedTokens: estimateTokens(scannedMessages),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Compress the older history, keep the tail verbatim (both already scanned)
|
|
226
|
-
const tail = scannedMessages.slice(-this.cfg.keepRecentMessages);
|
|
227
|
-
const older = scannedMessages.slice(0, -this.cfg.keepRecentMessages);
|
|
228
|
-
const normalized = normalizeMessages(older);
|
|
229
|
-
|
|
230
|
-
// Older messages were already vault-scanned above; pass directly to compression.
|
|
231
|
-
const safeMessages = normalized;
|
|
232
|
-
|
|
233
|
-
let summaryText: string;
|
|
234
|
-
try {
|
|
235
|
-
const capsule = await compressContext(safeMessages);
|
|
236
|
-
const injected = await injectCapsule(capsule);
|
|
237
|
-
summaryText = typeof injected === "string" ? injected : JSON.stringify(injected);
|
|
238
|
-
} catch (err) {
|
|
239
|
-
// Compression failed — fall back to vault-scanned messages so the vault
|
|
240
|
-
// guarantee is NEVER broken by a compression error (fail-closed, not fail-open).
|
|
241
|
-
// Emit a visible warning so the operator knows compression was skipped.
|
|
242
|
-
console.warn(
|
|
243
|
-
"[context-capsule] Compression failed — falling back to scanned-but-uncompressed messages. " +
|
|
244
|
-
"Vault redaction still applied; no secrets exposed. " +
|
|
245
|
-
"See ./compression.ts for the compression core.",
|
|
246
|
-
err instanceof Error ? err.message : String(err),
|
|
247
|
-
);
|
|
248
|
-
return {
|
|
249
|
-
messages: scannedMessages,
|
|
250
|
-
estimatedTokens: estimateTokens(scannedMessages),
|
|
251
|
-
promptAuthority: "preassembly_may_overflow",
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Prepend capsule as a system context message
|
|
256
|
-
const capsuleSystemMessage = {
|
|
257
|
-
role: "system",
|
|
258
|
-
content: `[Context Capsule — compressed history]\n${summaryText}`,
|
|
259
|
-
} as unknown as AgentMessage;
|
|
260
|
-
|
|
261
|
-
const assembled = [capsuleSystemMessage, ...tail];
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
messages: assembled,
|
|
265
|
-
estimatedTokens: estimateTokens(assembled),
|
|
266
|
-
systemPromptAddition:
|
|
267
|
-
"Earlier conversation history has been compressed into the context capsule above.",
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Required: compact — delegate to runtime (engine does not own compaction)
|
|
272
|
-
async compact(_params: {
|
|
273
|
-
sessionId: string;
|
|
274
|
-
sessionKey?: string;
|
|
275
|
-
sessionFile: string;
|
|
276
|
-
tokenBudget?: number;
|
|
277
|
-
force?: boolean;
|
|
278
|
-
currentTokenCount?: number;
|
|
279
|
-
compactionTarget?: "budget" | "threshold";
|
|
280
|
-
customInstructions?: string;
|
|
281
|
-
runtimeContext?: unknown;
|
|
282
|
-
abortSignal?: AbortSignal;
|
|
283
|
-
}): Promise<CompactResult> {
|
|
284
|
-
return {
|
|
285
|
-
ok: true,
|
|
286
|
-
compacted: false,
|
|
287
|
-
reason: "delegated-to-runtime",
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
// Plugin registration
|
|
294
|
-
// ---------------------------------------------------------------------------
|
|
295
|
-
|
|
296
|
-
export default definePluginEntry({
|
|
297
|
-
id: "context-capsule",
|
|
298
|
-
name: "Context Capsule",
|
|
299
|
-
description:
|
|
300
|
-
"Context engine that compresses long agent session history (default: > 20 " +
|
|
301
|
-
"messages) into a compact capsule before the model call, keeping the most " +
|
|
302
|
-
"recent 10 messages verbatim. Use it to cut token cost on long-running chat " +
|
|
303
|
-
"sessions with any model (Ollama, LM Studio, GPT, Mistral, Claude). It " +
|
|
304
|
-
"activates only as the registered context engine for sessions past the " +
|
|
305
|
-
"message threshold — it is NOT a command, has no keyword triggers, makes no " +
|
|
306
|
-
"network or file-system calls, and does no on-chain anchoring. Do NOT use it " +
|
|
307
|
-
"where exact verbatim transcript fidelity is required, as older history is " +
|
|
308
|
-
"summarized. Compression and best-effort secret redaction run fully locally.",
|
|
309
|
-
register(api) {
|
|
310
|
-
api.registerContextEngine("context-capsule", (_ctx) => new ContextCapsuleEngine());
|
|
311
|
-
},
|
|
312
|
-
});
|