@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.
@@ -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;
@@ -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
+ }
@@ -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": "0.1.1",
4
- "description": "context-capsule ContextEngine for OpenClaw 99.3% token reduction with recovery-score gate. Works with Ollama, GPT, Mistral, Claude.",
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": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
7
  "files": [
8
- "src/",
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
- "./src/index.ts"
24
+ "./dist/index.js"
24
25
  ],
25
- "compat": {
26
- "pluginApi": "1.x"
27
- },
28
26
  "build": {
29
- "openclawVersion": ">=1.0.0"
27
+ "openclawVersion": ">=2026.6.0"
30
28
  }
31
29
  },
32
- "dependencies": {
33
- "@parad0x_labs/context-capsule": "^0.1.0"
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/dna-x402"
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
- });