@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/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
- });