@pentatonic-ai/ai-agent-sdk 0.5.5 → 0.5.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.5.5",
4
- "description": "TES SDK LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
3
+ "version": "0.5.7",
4
+ "description": "TES SDK \u2014 LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -13,7 +13,8 @@
13
13
  "./memory": "./packages/memory/src/index.js",
14
14
  "./memory/server": "./packages/memory/src/server.js",
15
15
  "./memory/openclaw": "./packages/memory/src/openclaw/index.js",
16
- "./doctor": "./packages/doctor/src/index.js"
16
+ "./doctor": "./packages/doctor/src/index.js",
17
+ "./memory/hosted": "./packages/memory/src/hosted.js"
17
18
  },
18
19
  "bin": {
19
20
  "ai-agent-sdk": "./bin/cli.js"
@@ -54,7 +55,9 @@
54
55
  "model-context-protocol"
55
56
  ],
56
57
  "openclaw": {
57
- "extensions": ["./packages/memory/src/openclaw/index.js"],
58
+ "extensions": [
59
+ "./packages/memory/src/openclaw/index.js"
60
+ ],
58
61
  "hooks": {}
59
62
  },
60
63
  "license": "MIT",
@@ -0,0 +1,135 @@
1
+ /**
2
+ * OpenClaw plugin — memory-content sanitization tests.
3
+ *
4
+ * Verifies the sanitizer is actually applied at the two format
5
+ * surfaces: the context-engine `assemble` output and the
6
+ * `pentatonic_memory_search` tool result.
7
+ */
8
+
9
+ import plugin from "../index.js";
10
+
11
+ const realFetch = globalThis.fetch;
12
+
13
+ afterEach(() => {
14
+ globalThis.fetch = realFetch;
15
+ });
16
+
17
+ function mockSearchReturns(results) {
18
+ globalThis.fetch = async (_url, init) => {
19
+ const body = init?.body ? JSON.parse(init.body) : null;
20
+ const query = body?.query || "";
21
+ if (query.includes("semanticSearchMemories")) {
22
+ return {
23
+ ok: true,
24
+ status: 200,
25
+ json: async () => ({ data: { semanticSearchMemories: results } }),
26
+ };
27
+ }
28
+ return {
29
+ ok: true,
30
+ status: 200,
31
+ json: async () => ({ data: {} }),
32
+ };
33
+ };
34
+ }
35
+
36
+ function makeEngine(extraConfig = {}) {
37
+ let factory;
38
+ plugin.register({
39
+ pluginConfig: {
40
+ tes_endpoint: "https://x.test",
41
+ tes_client_id: "c",
42
+ tes_api_key: "tes_c_xyz",
43
+ ...extraConfig,
44
+ },
45
+ registerTool: () => {},
46
+ registerContextEngine: (_name, fn) => {
47
+ factory = fn;
48
+ },
49
+ });
50
+ if (!factory) throw new Error("no engine registered");
51
+ return factory();
52
+ }
53
+
54
+ describe("openclaw-plugin — assemble applies memory sanitizer", () => {
55
+ it("strips TES dashboard noise before injecting into systemPromptAddition", async () => {
56
+ const noisy = [
57
+ "[2026-04-21T11:47:04.826Z] I have a subaru and hyundai.",
58
+ "anonymous",
59
+ "ml_phil-h-claude_episodic",
60
+ "100% match",
61
+ "Confidence: 100%",
62
+ "Accessed: 2x",
63
+ "<1h ago",
64
+ "Decay: 0.05",
65
+ ].join("\n");
66
+ mockSearchReturns([{ id: "m1", content: noisy, similarity: 0.9 }]);
67
+ const engine = makeEngine();
68
+
69
+ const result = await engine.assemble({
70
+ sessionId: "s",
71
+ messages: [{ role: "user", content: "what car do I drive?" }],
72
+ });
73
+
74
+ const addition = result.systemPromptAddition || "";
75
+ expect(addition).toMatch(/I have a subaru and hyundai/);
76
+ expect(addition).not.toMatch(/ml_phil-h-claude_episodic/);
77
+ expect(addition).not.toMatch(/Confidence:/);
78
+ expect(addition).not.toMatch(/Accessed: 2x/);
79
+ expect(addition).not.toMatch(/Decay:/);
80
+ expect(addition).not.toMatch(/\[2026-04-21T/);
81
+ });
82
+
83
+ it("strips trailing JSON metadata blobs", async () => {
84
+ const content = [
85
+ "User said: I drive a Subaru.",
86
+ "{",
87
+ ' "event_id": "abc",',
88
+ ' "event_type": "CHAT_TURN"',
89
+ "}",
90
+ ].join("\n");
91
+ mockSearchReturns([{ id: "m1", content, similarity: 0.9 }]);
92
+ const engine = makeEngine();
93
+
94
+ const result = await engine.assemble({
95
+ sessionId: "s",
96
+ messages: [{ role: "user", content: "q" }],
97
+ });
98
+
99
+ expect(result.systemPromptAddition).toMatch(/User said: I drive a Subaru\./);
100
+ expect(result.systemPromptAddition).not.toMatch(/event_id/);
101
+ expect(result.systemPromptAddition).not.toMatch(/entity_type/);
102
+ });
103
+
104
+ it("caps verbose memories so a single transcript dump can't dominate", async () => {
105
+ // 2400-char "memory" — well over the 600-char cap
106
+ const long = "Phil owns a Subaru. ".repeat(120);
107
+ mockSearchReturns([{ id: "m1", content: long, similarity: 0.9 }]);
108
+ const engine = makeEngine();
109
+
110
+ const result = await engine.assemble({
111
+ sessionId: "s",
112
+ messages: [{ role: "user", content: "q" }],
113
+ });
114
+
115
+ const addition = result.systemPromptAddition || "";
116
+ // Full content would be ~2400 chars; capped version ~600 + …
117
+ expect(addition).toMatch(/Phil owns a Subaru\./);
118
+ expect(addition).toMatch(/…/);
119
+ });
120
+
121
+ it("keeps clean content unchanged", async () => {
122
+ mockSearchReturns([
123
+ { id: "m1", content: "Phil drinks cortado.", similarity: 0.9 },
124
+ ]);
125
+ const engine = makeEngine();
126
+
127
+ const result = await engine.assemble({
128
+ sessionId: "s",
129
+ messages: [{ role: "user", content: "q" }],
130
+ });
131
+
132
+ expect(result.systemPromptAddition).toMatch(/Phil drinks cortado\./);
133
+ expect(result.systemPromptAddition).not.toMatch(/…/); // no truncation
134
+ });
135
+ });
@@ -434,10 +434,61 @@ async function tesGetApiKey(accessToken, clientId) {
434
434
 
435
435
  // --- Format helpers ---
436
436
 
437
+ // Strip TES dashboard/metadata noise from a stored memory's content
438
+ // before we show it to the model. Same shape as the Claude Code hook's
439
+ // sanitizer in `hooks/scripts/shared.js` — duplicated inline here to
440
+ // keep the published openclaw-plugin package fully standalone. Update
441
+ // both if you change this.
442
+ const TES_META_FIELDS =
443
+ "event_id|event_type|entity_type|source|clientId|correlationId|timestamp|session_id|layer_id|confidence|decay_rate|user_id";
444
+ const MEMORY_MAX_LEN = 600;
445
+
446
+ function sanitizeMemoryContent(content) {
447
+ if (typeof content !== "string") return content;
448
+ let out = content;
449
+ // Trailing JSON metadata blob (no `m` flag — `$` = end-of-string).
450
+ out = out.replace(/\n\{\s*\n[\s\S]*?\n\s*\}\s*$/, "");
451
+ // Inline JSON metadata blobs (2+ consecutive TES metadata fields).
452
+ out = out.replace(
453
+ new RegExp(
454
+ `\\{\\s*\\n(\\s*"(?:${TES_META_FIELDS})"[^\\n]*\\n){2,}\\s*\\}`,
455
+ "g"
456
+ ),
457
+ ""
458
+ );
459
+ // Dashboard-UI standalone lines.
460
+ const linePatterns = [
461
+ /^\s*anonymous\s*$/gm,
462
+ /^\s*ml_[a-z0-9_-]+_(episodic|semantic|procedural|working)\s*$/gm,
463
+ /^\s*\d+%\s*match\s*$/gm,
464
+ /^\s*Confidence:\s*\d+%\s*$/gm,
465
+ /^\s*Accessed:\s*\d+x?\s*$/gm,
466
+ /^\s*<?\s*\d+[smhd]\s*ago\s*$/gm,
467
+ /^\s*Decay:\s*[\d.]+\s*$/gm,
468
+ /^\s*Metadata\s*$/gm,
469
+ ];
470
+ for (const pat of linePatterns) out = out.replace(pat, "");
471
+ // Leading ISO timestamps — strip prefix, keep line content.
472
+ out = out.replace(/^\s*\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]\s*/gm, "");
473
+ // Collapse consecutive blank lines.
474
+ out = out.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
475
+ // Cap verbose transcript dumps.
476
+ if (out.length > MEMORY_MAX_LEN) {
477
+ out = out.slice(0, MEMORY_MAX_LEN).trimEnd() + "…";
478
+ }
479
+ // Fallback to original if we stripped everything.
480
+ const wordCount = (out.match(/\b\w{2,}\b/g) || []).length;
481
+ if (wordCount < 2) return content;
482
+ return out;
483
+ }
484
+
437
485
  function formatResults(results) {
438
486
  if (!results.length) return "No relevant memories found.";
439
487
  return results
440
- .map((m, i) => `${i + 1}. [${Math.round((m.similarity || 0) * 100)}%] ${m.content}`)
488
+ .map(
489
+ (m, i) =>
490
+ `${i + 1}. [${Math.round((m.similarity || 0) * 100)}%] ${sanitizeMemoryContent(m.content)}`
491
+ )
441
492
  .join("\n\n");
442
493
  }
443
494
 
@@ -644,7 +695,10 @@ export default {
644
695
  stats.lastAssembleCount = results.length;
645
696
 
646
697
  const memoryText = results
647
- .map((m) => `- [${Math.round((m.similarity || 0) * 100)}%] ${m.content}`)
698
+ .map(
699
+ (m) =>
700
+ `- [${Math.round((m.similarity || 0) * 100)}%] ${sanitizeMemoryContent(m.content)}`
701
+ )
648
702
  .join("\n");
649
703
 
650
704
  // Visibility marker: instruct the model to append a footer so the
@@ -2,7 +2,7 @@
2
2
  "id": "pentatonic-memory",
3
3
  "name": "Pentatonic Memory",
4
4
  "description": "Persistent, searchable memory with multi-signal retrieval and HyDE query expansion. Local (Docker + Ollama) or hosted (Pentatonic TES).",
5
- "version": "0.5.2",
5
+ "version": "0.5.3",
6
6
  "kind": "context-engine",
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/openclaw-memory-plugin",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Pentatonic Memory plugin for OpenClaw — persistent, searchable memory with multi-signal retrieval and HyDE query expansion",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -24,7 +24,7 @@
24
24
  "license": "MIT",
25
25
  "repository": {
26
26
  "type": "git",
27
- "url": "https://github.com/Pentatonic-Ltd/ai-agent-sdk.git",
27
+ "url": "git+https://github.com/Pentatonic-Ltd/ai-agent-sdk.git",
28
28
  "directory": "packages/memory/openclaw-plugin"
29
29
  },
30
30
  "keywords": ["openclaw", "plugin", "memory", "context-engine", "pentatonic", "tes"]
@@ -263,6 +263,62 @@ describe("createAIClient", () => {
263
263
  await client.chat([{ role: "user", content: "q" }]);
264
264
  expect(hitUrl).toBe("http://localhost:11434/v1/chat/completions");
265
265
  });
266
+
267
+ it("embedBatch sends all inputs in one HTTP call", async () => {
268
+ let callCount = 0;
269
+ let lastBody;
270
+ globalThis.fetch = async (_url, opts) => {
271
+ callCount++;
272
+ lastBody = JSON.parse(opts.body);
273
+ return {
274
+ ok: true,
275
+ json: async () => ({
276
+ data: lastBody.input.map((_, i) => ({
277
+ embedding: [0.1, 0.2, 0.3],
278
+ index: i,
279
+ })),
280
+ }),
281
+ };
282
+ };
283
+ const client = createAIClient({
284
+ url: "http://localhost:11434/v1",
285
+ model: "m",
286
+ });
287
+ const out = await client.embedBatch(["a", "b", "c"], "passage");
288
+ expect(callCount).toBe(1);
289
+ expect(lastBody.input).toEqual(["a", "b", "c"]);
290
+ expect(out.length).toBe(3);
291
+ expect(out.every((r) => r.embedding.length === 3)).toBe(true);
292
+ });
293
+
294
+ it("embedBatch returns nulls on non-2xx without throwing", async () => {
295
+ globalThis.fetch = async () => ({ ok: false, json: async () => ({}) });
296
+ const client = createAIClient({
297
+ url: "http://localhost:11434/v1",
298
+ model: "m",
299
+ });
300
+ const out = await client.embedBatch(["a", "b"]);
301
+ expect(out).toEqual([null, null]);
302
+ });
303
+
304
+ it("embedBatch parses Ollama/Pentatonic-style {embeddings: [[...]]} response", async () => {
305
+ globalThis.fetch = async () => ({
306
+ ok: true,
307
+ json: async () => ({
308
+ embeddings: [
309
+ [0.1, 0.2],
310
+ [0.3, 0.4],
311
+ ],
312
+ }),
313
+ });
314
+ const client = createAIClient({
315
+ url: "http://localhost:11434/v1",
316
+ model: "m",
317
+ });
318
+ const out = await client.embedBatch(["x", "y"]);
319
+ expect(out[0].embedding).toEqual([0.1, 0.2]);
320
+ expect(out[1].embedding).toEqual([0.3, 0.4]);
321
+ });
266
322
  });
267
323
 
268
324
  // --- Search options contract ---
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Tests for the hosted-mode helpers (semanticSearchMemories +
3
+ * createModuleEvent over HTTPS with a tes_* bearer).
4
+ *
5
+ * Stub global fetch and assert request/response shape — the helpers
6
+ * have no other side effects, so this is sufficient.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
10
+ import {
11
+ hostedSearch,
12
+ hostedEmitChatTurn,
13
+ hostedStoreMemory,
14
+ buildHostedHeaders,
15
+ } from "../hosted.js";
16
+
17
+ const CONFIG = {
18
+ endpoint: "https://acme.api.example.com",
19
+ clientId: "acme",
20
+ apiKey: "tes_acme_xxxxxxxxxxxxxxxx",
21
+ };
22
+
23
+ const SVC_CONFIG = {
24
+ endpoint: "https://acme.api.example.com",
25
+ clientId: "acme",
26
+ apiKey: "internal-service-key",
27
+ };
28
+
29
+ let originalFetch;
30
+ let lastCall;
31
+
32
+ function stubFetch(handler) {
33
+ originalFetch = globalThis.fetch;
34
+ globalThis.fetch = async (url, init) => {
35
+ lastCall = {
36
+ url: url.toString(),
37
+ headers: init?.headers || {},
38
+ body: init?.body ? JSON.parse(init.body) : null,
39
+ };
40
+ return handler(lastCall);
41
+ };
42
+ }
43
+
44
+ afterEach(() => {
45
+ if (originalFetch) globalThis.fetch = originalFetch;
46
+ originalFetch = null;
47
+ lastCall = null;
48
+ });
49
+
50
+ // =============================================================================
51
+ // buildHostedHeaders
52
+ // =============================================================================
53
+
54
+ describe("buildHostedHeaders", () => {
55
+ it("uses Bearer auth for tes_* keys", () => {
56
+ const headers = buildHostedHeaders(CONFIG);
57
+ expect(headers["Authorization"]).toBe(`Bearer ${CONFIG.apiKey}`);
58
+ expect(headers["x-client-id"]).toBe(CONFIG.clientId);
59
+ expect(headers["x-service-key"]).toBeUndefined();
60
+ });
61
+
62
+ it("uses x-service-key for non-tes_ keys", () => {
63
+ const headers = buildHostedHeaders(SVC_CONFIG);
64
+ expect(headers["x-service-key"]).toBe(SVC_CONFIG.apiKey);
65
+ expect(headers["Authorization"]).toBeUndefined();
66
+ });
67
+
68
+ it("accepts legacy tes_endpoint/tes_client_id/tes_api_key keys", () => {
69
+ const legacy = {
70
+ tes_endpoint: CONFIG.endpoint,
71
+ tes_client_id: CONFIG.clientId,
72
+ tes_api_key: CONFIG.apiKey,
73
+ };
74
+ const headers = buildHostedHeaders(legacy);
75
+ expect(headers["Authorization"]).toBe(`Bearer ${CONFIG.apiKey}`);
76
+ });
77
+
78
+ it("throws on incomplete config", () => {
79
+ expect(() => buildHostedHeaders({})).toThrow(/requires/);
80
+ });
81
+ });
82
+
83
+ // =============================================================================
84
+ // hostedSearch
85
+ // =============================================================================
86
+
87
+ describe("hostedSearch", () => {
88
+ it("returns memories on a successful query", async () => {
89
+ stubFetch(() =>
90
+ new Response(
91
+ JSON.stringify({
92
+ data: {
93
+ semanticSearchMemories: [
94
+ { id: "m1", content: "User likes blue", similarity: 0.83 },
95
+ ],
96
+ },
97
+ }),
98
+ { status: 200 }
99
+ )
100
+ );
101
+
102
+ const out = await hostedSearch(CONFIG, "what colour", { limit: 4 });
103
+ expect(out.memories).toHaveLength(1);
104
+ expect(out.skipped).toBeUndefined();
105
+ expect(lastCall.body.variables.clientId).toBe("acme");
106
+ expect(lastCall.body.variables.limit).toBe(4);
107
+ expect(lastCall.headers["Authorization"]).toBe(`Bearer ${CONFIG.apiKey}`);
108
+ });
109
+
110
+ it("returns { memories: [], skipped: 'no_query' } when query is empty", async () => {
111
+ stubFetch(() => new Response("{}", { status: 200 }));
112
+ const out = await hostedSearch(CONFIG, "");
113
+ expect(out.skipped).toBe("no_query");
114
+ });
115
+
116
+ it("skips with tes_http_500 on 500 responses", async () => {
117
+ stubFetch(() => new Response("oops", { status: 500 }));
118
+ const out = await hostedSearch(CONFIG, "q");
119
+ expect(out.skipped).toBe("tes_http_500");
120
+ expect(out.memories).toEqual([]);
121
+ });
122
+
123
+ it("skips with tes_graphql:<reason> on graphql errors", async () => {
124
+ stubFetch(() =>
125
+ new Response(
126
+ JSON.stringify({
127
+ errors: [{ message: 'Module "deep-memory" is not enabled' }],
128
+ }),
129
+ { status: 200 }
130
+ )
131
+ );
132
+ const out = await hostedSearch(CONFIG, "q");
133
+ expect(out.skipped).toMatch(/^tes_graphql:/);
134
+ });
135
+
136
+ it("skips with tes_timeout on AbortError", async () => {
137
+ stubFetch(() => {
138
+ const err = new Error("aborted");
139
+ err.name = "AbortError";
140
+ throw err;
141
+ });
142
+ const out = await hostedSearch(CONFIG, "q", { timeoutMs: 5 });
143
+ expect(out.skipped).toBe("tes_timeout");
144
+ });
145
+
146
+ it("skips with tes_unreachable on generic fetch failure", async () => {
147
+ stubFetch(() => {
148
+ throw new Error("ECONNREFUSED");
149
+ });
150
+ const out = await hostedSearch(CONFIG, "q");
151
+ expect(out.skipped).toBe("tes_unreachable");
152
+ });
153
+ });
154
+
155
+ // =============================================================================
156
+ // hostedEmitChatTurn
157
+ // =============================================================================
158
+
159
+ describe("hostedEmitChatTurn", () => {
160
+ it("emits createModuleEvent with conversation-analytics moduleId", async () => {
161
+ stubFetch(() =>
162
+ new Response(
163
+ JSON.stringify({
164
+ data: { createModuleEvent: { success: true, eventId: "evt_1" } },
165
+ }),
166
+ { status: 200 }
167
+ )
168
+ );
169
+
170
+ const out = await hostedEmitChatTurn(
171
+ CONFIG,
172
+ {
173
+ userMessage: "hi",
174
+ assistantResponse: "hello!",
175
+ model: "gpt-4o",
176
+ sessionId: "sess_1",
177
+ },
178
+ { source: "my-app" }
179
+ );
180
+
181
+ expect(out.ok).toBe(true);
182
+ expect(out.eventId).toBe("evt_1");
183
+ expect(lastCall.body.variables.moduleId).toBe("conversation-analytics");
184
+ expect(lastCall.body.variables.input.eventType).toBe("CHAT_TURN");
185
+ expect(lastCall.body.variables.input.data.attributes.source).toBe("my-app");
186
+ expect(lastCall.body.variables.input.data.attributes.user_message).toBe(
187
+ "hi"
188
+ );
189
+ expect(lastCall.body.variables.input.data.entity_id).toBe("sess_1");
190
+ });
191
+
192
+ it("skips empty turns (no user + no assistant text)", async () => {
193
+ stubFetch(() => new Response("{}", { status: 200 }));
194
+ const out = await hostedEmitChatTurn(CONFIG, {});
195
+ expect(out.skipped).toBe("empty_turn");
196
+ });
197
+
198
+ it("merges payload.extra into attributes", async () => {
199
+ stubFetch(() =>
200
+ new Response(
201
+ JSON.stringify({
202
+ data: { createModuleEvent: { success: true, eventId: "x" } },
203
+ }),
204
+ { status: 200 }
205
+ )
206
+ );
207
+ await hostedEmitChatTurn(CONFIG, {
208
+ userMessage: "u",
209
+ assistantResponse: "a",
210
+ extra: { tes_skipped_reason: "passthrough_mode", custom_flag: 1 },
211
+ });
212
+ const attrs = lastCall.body.variables.input.data.attributes;
213
+ expect(attrs.tes_skipped_reason).toBe("passthrough_mode");
214
+ expect(attrs.custom_flag).toBe(1);
215
+ });
216
+ });
217
+
218
+ // =============================================================================
219
+ // hostedStoreMemory
220
+ // =============================================================================
221
+
222
+ describe("hostedStoreMemory", () => {
223
+ it("emits STORE_MEMORY against deep-memory", async () => {
224
+ stubFetch(() =>
225
+ new Response(
226
+ JSON.stringify({
227
+ data: { createModuleEvent: { success: true, eventId: "stored" } },
228
+ }),
229
+ { status: 200 }
230
+ )
231
+ );
232
+ const out = await hostedStoreMemory(
233
+ CONFIG,
234
+ "User owns a Subaru",
235
+ { session_id: "abc" },
236
+ { source: "my-app" }
237
+ );
238
+ expect(out.ok).toBe(true);
239
+ expect(lastCall.body.variables.moduleId).toBe("deep-memory");
240
+ expect(lastCall.body.variables.input.eventType).toBe("STORE_MEMORY");
241
+ expect(lastCall.body.variables.input.data.attributes.content).toBe(
242
+ "User owns a Subaru"
243
+ );
244
+ expect(lastCall.body.variables.input.data.attributes.source).toBe("my-app");
245
+ expect(lastCall.body.variables.input.data.entity_id).toBe("abc");
246
+ });
247
+
248
+ it("skips with no_content if content is empty", async () => {
249
+ stubFetch(() => new Response("{}", { status: 200 }));
250
+ const out = await hostedStoreMemory(CONFIG, "");
251
+ expect(out.skipped).toBe("no_content");
252
+ });
253
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Unit tests for the shared memory-content sanitizer.
3
+ *
4
+ * Same invariants the Claude Code hook tests already cover — we
5
+ * assert them here against the canonical module too so the published
6
+ * openclaw-plugin's inline copy and the hooks/scripts inline copy
7
+ * both have a reference to check against.
8
+ */
9
+
10
+ import {
11
+ sanitizeMemoryContent,
12
+ MEMORY_MAX_LEN,
13
+ } from "../sanitize.js";
14
+
15
+ describe("sanitizeMemoryContent", () => {
16
+ it("strips leading ISO timestamps on each line", () => {
17
+ const out = sanitizeMemoryContent(
18
+ "[2026-04-21T11:47:04.826Z] Phil owns a Subaru."
19
+ );
20
+ expect(out).toBe("Phil owns a Subaru.");
21
+ });
22
+
23
+ it("strips standalone dashboard metadata lines", () => {
24
+ const input = [
25
+ "Phil owns a Subaru.",
26
+ "anonymous",
27
+ "ml_phil-h-claude_episodic",
28
+ "100% match",
29
+ "Confidence: 100%",
30
+ "Accessed: 2x",
31
+ "<1h ago",
32
+ "Decay: 0.05",
33
+ "Metadata",
34
+ ].join("\n");
35
+ expect(sanitizeMemoryContent(input)).toBe("Phil owns a Subaru.");
36
+ });
37
+
38
+ it("strips trailing JSON metadata blob", () => {
39
+ const input = [
40
+ "Phil has two dogs named Max and Luna.",
41
+ "{",
42
+ ' "event_id": "abc-123",',
43
+ ' "event_type": "CHAT_TURN"',
44
+ "}",
45
+ ].join("\n");
46
+ expect(sanitizeMemoryContent(input)).toBe(
47
+ "Phil has two dogs named Max and Luna."
48
+ );
49
+ });
50
+
51
+ it("strips inline JSON metadata blobs with TES-style fields", () => {
52
+ const input = [
53
+ "User said: I have a Subaru.",
54
+ "{",
55
+ ' "event_id": "abc",',
56
+ ' "event_type": "CHAT_TURN",',
57
+ ' "entity_type": "conversation"',
58
+ "}",
59
+ "The next turn continued...",
60
+ ].join("\n");
61
+ const out = sanitizeMemoryContent(input);
62
+ expect(out).toMatch(/User said: I have a Subaru\./);
63
+ expect(out).toMatch(/The next turn continued\.\.\./);
64
+ expect(out).not.toMatch(/event_id/);
65
+ });
66
+
67
+ it("does NOT strip legitimate JSON code samples", () => {
68
+ const input = [
69
+ "Here's how to configure the client:",
70
+ "{",
71
+ ' "apiKey": "xxx",',
72
+ ' "endpoint": "https://api.test"',
73
+ "}",
74
+ "Then instantiate it.",
75
+ ].join("\n");
76
+ const out = sanitizeMemoryContent(input);
77
+ expect(out).toMatch(/apiKey/);
78
+ expect(out).toMatch(/endpoint/);
79
+ });
80
+
81
+ it("falls back to original when stripping would leave almost nothing", () => {
82
+ const input = "anonymous\nml_phil-h-claude_episodic\n100% match";
83
+ expect(sanitizeMemoryContent(input)).toBe(input);
84
+ });
85
+
86
+ it("is a no-op for clean content", () => {
87
+ const clean = "Phil prefers espresso in the morning, tea in the afternoon.";
88
+ expect(sanitizeMemoryContent(clean)).toBe(clean);
89
+ });
90
+
91
+ it("caps verbose content at MEMORY_MAX_LEN with ellipsis", () => {
92
+ const long = "fact. ".repeat(200); // 1200 chars
93
+ const out = sanitizeMemoryContent(long);
94
+ expect(out.length).toBeLessThanOrEqual(MEMORY_MAX_LEN + 1);
95
+ expect(out.endsWith("…")).toBe(true);
96
+ });
97
+
98
+ it("handles non-string input safely", () => {
99
+ expect(sanitizeMemoryContent(undefined)).toBeUndefined();
100
+ expect(sanitizeMemoryContent(null)).toBeNull();
101
+ expect(sanitizeMemoryContent(42)).toBe(42);
102
+ });
103
+ });