@pentatonic-ai/ai-agent-sdk 0.5.6 → 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 +7 -4
- package/packages/memory/openclaw-plugin/__tests__/sanitizer.test.js +135 -0
- package/packages/memory/openclaw-plugin/index.js +56 -2
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
- package/packages/memory/openclaw-plugin/package.json +2 -2
- package/packages/memory/src/__tests__/hosted.test.js +253 -0
- package/packages/memory/src/__tests__/sanitize.test.js +103 -0
- package/packages/memory/src/hosted.js +372 -0
- package/packages/memory/src/openclaw/index.js +37 -136
- package/packages/memory/src/sanitize.js +61 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "TES SDK
|
|
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": [
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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"]
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted-mode helpers for the Pentatonic memory system.
|
|
3
|
+
*
|
|
4
|
+
* These talk to a remote TES tenant over HTTPS using GraphQL, with a
|
|
5
|
+
* `tes_<clientId>_<rand>` bearer token in the Authorization header.
|
|
6
|
+
* They are deliberately thin wrappers around the GraphQL surface so
|
|
7
|
+
* any caller (the OpenClaw plugin, the LLM proxy worker, a custom
|
|
8
|
+
* integration) gets the same wire shape, the same error handling, and
|
|
9
|
+
* the same operational patterns.
|
|
10
|
+
*
|
|
11
|
+
* No `pg`, no Node-only APIs — Workers-compatible. Pure `fetch`.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { hostedSearch, hostedEmitChatTurn } from
|
|
15
|
+
* "@pentatonic-ai/ai-agent-sdk/memory/hosted";
|
|
16
|
+
*
|
|
17
|
+
* const config = {
|
|
18
|
+
* endpoint: "https://acme.api.pentatonic.com",
|
|
19
|
+
* clientId: "acme",
|
|
20
|
+
* apiKey: "tes_acme_xxxxx",
|
|
21
|
+
* };
|
|
22
|
+
*
|
|
23
|
+
* const { memories } = await hostedSearch(config, "What's my name?", {
|
|
24
|
+
* limit: 6, minScore: 0.55, timeoutMs: 800,
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* await hostedEmitChatTurn(config, {
|
|
28
|
+
* userMessage: "Hi",
|
|
29
|
+
* assistantResponse: "Hello!",
|
|
30
|
+
* model: "gpt-4o-mini",
|
|
31
|
+
* }, { source: "my-product" });
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const SEMANTIC_SEARCH_QUERY = `
|
|
35
|
+
query SemanticSearchMemories($clientId: String!, $query: String!, $limit: Int, $minScore: Float) {
|
|
36
|
+
semanticSearchMemories(clientId: $clientId, query: $query, limit: $limit, minScore: $minScore) {
|
|
37
|
+
id
|
|
38
|
+
content
|
|
39
|
+
similarity
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const CREATE_MODULE_EVENT_MUTATION = `
|
|
45
|
+
mutation CreateModuleEvent($moduleId: String!, $input: ModuleEventInput!) {
|
|
46
|
+
createModuleEvent(moduleId: $moduleId, input: $input) { success eventId }
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const DEFAULT_SEARCH_TIMEOUT_MS = 5000;
|
|
51
|
+
const DEFAULT_EMIT_TIMEOUT_MS = 10000;
|
|
52
|
+
const DEFAULT_SEARCH_LIMIT = 6;
|
|
53
|
+
const DEFAULT_SEARCH_MIN_SCORE = 0.55;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalise a config object — accepts both modern (`endpoint/clientId/apiKey`)
|
|
57
|
+
* and legacy openclaw-style (`tes_endpoint/tes_client_id/tes_api_key`) keys.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} config
|
|
60
|
+
* @returns {{endpoint: string, clientId: string, apiKey: string}}
|
|
61
|
+
*/
|
|
62
|
+
function normalizeConfig(config) {
|
|
63
|
+
if (!config) throw new Error("hosted: config is required");
|
|
64
|
+
const endpoint = config.endpoint || config.tes_endpoint;
|
|
65
|
+
const clientId = config.clientId || config.tes_client_id;
|
|
66
|
+
const apiKey = config.apiKey || config.tes_api_key;
|
|
67
|
+
if (!endpoint || !clientId || !apiKey) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"hosted: config requires { endpoint, clientId, apiKey } (or legacy tes_* equivalents)"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return { endpoint, clientId, apiKey };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the request headers TES expects for hosted-mode calls.
|
|
77
|
+
* Bearer auth if the apiKey starts with `tes_`; otherwise treated as a
|
|
78
|
+
* service key (for internal callers).
|
|
79
|
+
*/
|
|
80
|
+
export function buildHostedHeaders(config) {
|
|
81
|
+
const { clientId, apiKey } = normalizeConfig(config);
|
|
82
|
+
const headers = {
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"x-client-id": clientId,
|
|
85
|
+
};
|
|
86
|
+
if (apiKey.startsWith("tes_")) {
|
|
87
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
88
|
+
} else {
|
|
89
|
+
headers["x-service-key"] = apiKey;
|
|
90
|
+
}
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run a semantic memory search against a remote TES tenant.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} config — { endpoint, clientId, apiKey }
|
|
98
|
+
* @param {string} query — natural-language query
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {number} [opts.limit=6]
|
|
101
|
+
* @param {number} [opts.minScore=0.55]
|
|
102
|
+
* @param {number} [opts.timeoutMs=5000]
|
|
103
|
+
* @returns {Promise<{
|
|
104
|
+
* memories: Array<{id: string, content: string, similarity: number}>,
|
|
105
|
+
* skipped?: string,
|
|
106
|
+
* }>}
|
|
107
|
+
*
|
|
108
|
+
* Failure mode: any error returns `{ memories: [], skipped: <reason> }`.
|
|
109
|
+
* Callers (e.g. the LLM proxy) inspect `skipped` to set `X-TES-Skipped`
|
|
110
|
+
* on their response, then forward unmodified. We never throw — the
|
|
111
|
+
* fail-soft contract means a hosted-search call never breaks the
|
|
112
|
+
* caller's primary user-facing flow.
|
|
113
|
+
*/
|
|
114
|
+
export async function hostedSearch(config, query, opts = {}) {
|
|
115
|
+
if (!query) return { memories: [], skipped: "no_query" };
|
|
116
|
+
|
|
117
|
+
let cfg;
|
|
118
|
+
try {
|
|
119
|
+
cfg = normalizeConfig(config);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { memories: [], skipped: `config_error:${err.message}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const limit = opts.limit ?? DEFAULT_SEARCH_LIMIT;
|
|
125
|
+
const minScore = opts.minScore ?? DEFAULT_SEARCH_MIN_SCORE;
|
|
126
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS;
|
|
127
|
+
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
130
|
+
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetch(`${cfg.endpoint}/api/graphql`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: buildHostedHeaders(cfg),
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
query: SEMANTIC_SEARCH_QUERY,
|
|
138
|
+
variables: { clientId: cfg.clientId, query, limit, minScore },
|
|
139
|
+
}),
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
return {
|
|
145
|
+
memories: [],
|
|
146
|
+
skipped: err.name === "AbortError" ? "tes_timeout" : "tes_unreachable",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
return { memories: [], skipped: `tes_http_${response.status}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let payload;
|
|
156
|
+
try {
|
|
157
|
+
payload = await response.json();
|
|
158
|
+
} catch {
|
|
159
|
+
return { memories: [], skipped: "tes_invalid_json" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (payload.errors?.length) {
|
|
163
|
+
const reason = payload.errors[0].message || "tes_graphql_error";
|
|
164
|
+
return { memories: [], skipped: `tes_graphql:${shortenReason(reason)}` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { memories: payload.data?.semanticSearchMemories || [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Emit a CHAT_TURN event to the conversation-analytics module of a
|
|
172
|
+
* remote TES tenant. The deep-memory consumer also subscribes to
|
|
173
|
+
* CHAT_TURN, so a single emit lands in both pipelines via consumer
|
|
174
|
+
* fan-out at the queue layer.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} config — { endpoint, clientId, apiKey }
|
|
177
|
+
* @param {object} payload
|
|
178
|
+
* @param {string} [payload.userMessage]
|
|
179
|
+
* @param {string} [payload.assistantResponse]
|
|
180
|
+
* @param {string} [payload.model]
|
|
181
|
+
* @param {object} [payload.usage]
|
|
182
|
+
* @param {Array} [payload.toolCalls]
|
|
183
|
+
* @param {number} [payload.turnNumber]
|
|
184
|
+
* @param {string} [payload.systemPrompt]
|
|
185
|
+
* @param {string} [payload.sessionId]
|
|
186
|
+
* @param {string} [payload.userId]
|
|
187
|
+
* @param {object} [payload.extra] — additional attributes merged onto the event
|
|
188
|
+
* @param {object} [opts]
|
|
189
|
+
* @param {string} [opts.source="tes-sdk"] — attribution string written into attributes.source
|
|
190
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
191
|
+
* @returns {Promise<{ ok: boolean, eventId?: string, skipped?: string }>}
|
|
192
|
+
*/
|
|
193
|
+
export async function hostedEmitChatTurn(config, payload, opts = {}) {
|
|
194
|
+
if (!payload) return { ok: false, skipped: "no_payload" };
|
|
195
|
+
if (!payload.userMessage && !payload.assistantResponse) {
|
|
196
|
+
return { ok: false, skipped: "empty_turn" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let cfg;
|
|
200
|
+
try {
|
|
201
|
+
cfg = normalizeConfig(config);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { ok: false, skipped: `config_error:${err.message}` };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const source = opts.source || "tes-sdk";
|
|
207
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_EMIT_TIMEOUT_MS;
|
|
208
|
+
|
|
209
|
+
const attributes = { source };
|
|
210
|
+
if (payload.userMessage !== undefined)
|
|
211
|
+
attributes.user_message = payload.userMessage;
|
|
212
|
+
if (payload.assistantResponse !== undefined)
|
|
213
|
+
attributes.assistant_response = payload.assistantResponse;
|
|
214
|
+
if (payload.model) attributes.model = payload.model;
|
|
215
|
+
if (payload.usage) attributes.usage = payload.usage;
|
|
216
|
+
if (payload.toolCalls?.length) attributes.tool_calls = payload.toolCalls;
|
|
217
|
+
if (payload.turnNumber !== undefined)
|
|
218
|
+
attributes.turn_number = payload.turnNumber;
|
|
219
|
+
if (payload.systemPrompt) attributes.system_prompt = payload.systemPrompt;
|
|
220
|
+
if (payload.userId) attributes.user_id = payload.userId;
|
|
221
|
+
if (payload.extra && typeof payload.extra === "object") {
|
|
222
|
+
Object.assign(attributes, payload.extra);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const data = { attributes };
|
|
226
|
+
if (payload.sessionId) data.entity_id = payload.sessionId;
|
|
227
|
+
|
|
228
|
+
const input = { eventType: "CHAT_TURN", data };
|
|
229
|
+
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
232
|
+
|
|
233
|
+
let response;
|
|
234
|
+
try {
|
|
235
|
+
response = await fetch(`${cfg.endpoint}/api/graphql`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: buildHostedHeaders(cfg),
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
query: CREATE_MODULE_EVENT_MUTATION,
|
|
240
|
+
variables: { moduleId: "conversation-analytics", input },
|
|
241
|
+
}),
|
|
242
|
+
signal: controller.signal,
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
skipped: err.name === "AbortError" ? "tes_timeout" : "tes_unreachable",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
|
|
253
|
+
if (!response.ok) {
|
|
254
|
+
return { ok: false, skipped: `tes_http_${response.status}` };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let body;
|
|
258
|
+
try {
|
|
259
|
+
body = await response.json();
|
|
260
|
+
} catch {
|
|
261
|
+
return { ok: false, skipped: "tes_invalid_json" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (body.errors?.length) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
skipped: `tes_graphql:${shortenReason(body.errors[0].message)}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
ok: !!body.data?.createModuleEvent?.success,
|
|
273
|
+
eventId: body.data?.createModuleEvent?.eventId,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Emit a STORE_MEMORY event against the deep-memory module. Used by the
|
|
279
|
+
* OpenClaw plugin for explicit memory-write tools.
|
|
280
|
+
*
|
|
281
|
+
* @param {object} config
|
|
282
|
+
* @param {string} content
|
|
283
|
+
* @param {object} [metadata]
|
|
284
|
+
* @param {object} [opts]
|
|
285
|
+
* @param {string} [opts.source="tes-sdk"]
|
|
286
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
287
|
+
* @returns {Promise<{ ok: boolean, eventId?: string, skipped?: string }>}
|
|
288
|
+
*/
|
|
289
|
+
export async function hostedStoreMemory(
|
|
290
|
+
config,
|
|
291
|
+
content,
|
|
292
|
+
metadata = {},
|
|
293
|
+
opts = {}
|
|
294
|
+
) {
|
|
295
|
+
if (!content) return { ok: false, skipped: "no_content" };
|
|
296
|
+
|
|
297
|
+
let cfg;
|
|
298
|
+
try {
|
|
299
|
+
cfg = normalizeConfig(config);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return { ok: false, skipped: `config_error:${err.message}` };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const source = opts.source || "tes-sdk";
|
|
305
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_EMIT_TIMEOUT_MS;
|
|
306
|
+
|
|
307
|
+
const data = {
|
|
308
|
+
entity_id: metadata.session_id || metadata.sessionId || source,
|
|
309
|
+
attributes: {
|
|
310
|
+
...metadata,
|
|
311
|
+
content,
|
|
312
|
+
source,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
318
|
+
|
|
319
|
+
let response;
|
|
320
|
+
try {
|
|
321
|
+
response = await fetch(`${cfg.endpoint}/api/graphql`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: buildHostedHeaders(cfg),
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
query: CREATE_MODULE_EVENT_MUTATION,
|
|
326
|
+
variables: {
|
|
327
|
+
moduleId: "deep-memory",
|
|
328
|
+
input: { eventType: "STORE_MEMORY", data },
|
|
329
|
+
},
|
|
330
|
+
}),
|
|
331
|
+
signal: controller.signal,
|
|
332
|
+
});
|
|
333
|
+
} catch (err) {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
skipped: err.name === "AbortError" ? "tes_timeout" : "tes_unreachable",
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
return { ok: false, skipped: `tes_http_${response.status}` };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let body;
|
|
347
|
+
try {
|
|
348
|
+
body = await response.json();
|
|
349
|
+
} catch {
|
|
350
|
+
return { ok: false, skipped: "tes_invalid_json" };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (body.errors?.length) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
skipped: `tes_graphql:${shortenReason(body.errors[0].message)}`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
ok: !!body.data?.createModuleEvent?.success,
|
|
362
|
+
eventId: body.data?.createModuleEvent?.eventId,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function shortenReason(msg) {
|
|
367
|
+
if (typeof msg !== "string") return "unknown";
|
|
368
|
+
return msg
|
|
369
|
+
.toLowerCase()
|
|
370
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
371
|
+
.slice(0, 60);
|
|
372
|
+
}
|
|
@@ -38,6 +38,40 @@
|
|
|
38
38
|
import pg from "pg";
|
|
39
39
|
import { createMemorySystem } from "../index.js";
|
|
40
40
|
import { createContextEngine } from "./context-engine.js";
|
|
41
|
+
import { sanitizeMemoryContent } from "../sanitize.js";
|
|
42
|
+
import {
|
|
43
|
+
hostedSearch as _hostedSearch,
|
|
44
|
+
hostedEmitChatTurn as _hostedEmitChatTurn,
|
|
45
|
+
hostedStoreMemory as _hostedStoreMemory,
|
|
46
|
+
} from "../hosted.js";
|
|
47
|
+
|
|
48
|
+
// --- Hosted-mode adapters ---
|
|
49
|
+
//
|
|
50
|
+
// The OpenClaw plugin predates the public hosted-helper API (`packages/
|
|
51
|
+
// memory/src/hosted.js`). The wrappers below adapt the plugin's existing
|
|
52
|
+
// call shape to the public API so other consumers (the LLM proxy worker,
|
|
53
|
+
// custom integrations) hit the same code path. Adapters are tiny — they
|
|
54
|
+
// translate args and unwrap the result envelope. New code should import
|
|
55
|
+
// from `@pentatonic-ai/ai-agent-sdk/memory/hosted` directly.
|
|
56
|
+
|
|
57
|
+
async function hostedSearch(config, query, limit = 5, minScore = 0.3) {
|
|
58
|
+
const { memories } = await _hostedSearch(config, query, { limit, minScore });
|
|
59
|
+
return memories;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function hostedEmitChatTurn(config, sessionId, turn) {
|
|
63
|
+
return _hostedEmitChatTurn(
|
|
64
|
+
config,
|
|
65
|
+
{ ...turn, sessionId },
|
|
66
|
+
{ source: "openclaw-plugin" }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function hostedStore(config, content, metadata = {}) {
|
|
71
|
+
return _hostedStoreMemory(config, content, metadata, {
|
|
72
|
+
source: metadata.source || "openclaw-plugin",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
41
75
|
|
|
42
76
|
const { Pool } = pg;
|
|
43
77
|
|
|
@@ -74,139 +108,6 @@ function getLocalMemory(config) {
|
|
|
74
108
|
return memory;
|
|
75
109
|
}
|
|
76
110
|
|
|
77
|
-
// --- Hosted mode helpers ---
|
|
78
|
-
|
|
79
|
-
function tesHeaders(config) {
|
|
80
|
-
const headers = {
|
|
81
|
-
"Content-Type": "application/json",
|
|
82
|
-
"x-client-id": config.tes_client_id,
|
|
83
|
-
};
|
|
84
|
-
if (config.tes_api_key?.startsWith("tes_")) {
|
|
85
|
-
headers["Authorization"] = `Bearer ${config.tes_api_key}`;
|
|
86
|
-
} else {
|
|
87
|
-
headers["x-service-key"] = config.tes_api_key;
|
|
88
|
-
}
|
|
89
|
-
return headers;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function hostedSearch(config, query, limit = 5, minScore = 0.3) {
|
|
93
|
-
try {
|
|
94
|
-
const response = await fetch(`${config.tes_endpoint}/api/graphql`, {
|
|
95
|
-
method: "POST",
|
|
96
|
-
headers: tesHeaders(config),
|
|
97
|
-
body: JSON.stringify({
|
|
98
|
-
query: `query($clientId: String!, $query: String!, $limit: Int, $minScore: Float) {
|
|
99
|
-
semanticSearchMemories(clientId: $clientId, query: $query, limit: $limit, minScore: $minScore) {
|
|
100
|
-
id content similarity
|
|
101
|
-
}
|
|
102
|
-
}`,
|
|
103
|
-
variables: {
|
|
104
|
-
clientId: config.tes_client_id,
|
|
105
|
-
query,
|
|
106
|
-
limit,
|
|
107
|
-
minScore,
|
|
108
|
-
},
|
|
109
|
-
}),
|
|
110
|
-
signal: AbortSignal.timeout(5000),
|
|
111
|
-
});
|
|
112
|
-
if (!response.ok) return [];
|
|
113
|
-
const json = await response.json();
|
|
114
|
-
return json.data?.semanticSearchMemories || [];
|
|
115
|
-
} catch {
|
|
116
|
-
return [];
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Emit a CHAT_TURN event to TES so the conversation-analytics dashboard
|
|
122
|
-
* (Token Universe + Tools tabs) can render. Without this, the dashboard
|
|
123
|
-
* filters on eventType=CHAT_TURN and shows nothing for OpenClaw users
|
|
124
|
-
* because the only events emitted are STORE_MEMORY.
|
|
125
|
-
*
|
|
126
|
-
* Anything missing from the message metadata is omitted rather than
|
|
127
|
-
* defaulted to zero — that way the dashboard can distinguish "no data"
|
|
128
|
-
* from "zero usage".
|
|
129
|
-
*/
|
|
130
|
-
async function hostedEmitChatTurn(config, sessionId, turn) {
|
|
131
|
-
const attributes = {
|
|
132
|
-
source: "openclaw-plugin",
|
|
133
|
-
user_message: turn.userMessage,
|
|
134
|
-
assistant_response: turn.assistantResponse,
|
|
135
|
-
};
|
|
136
|
-
if (turn.model) attributes.model = turn.model;
|
|
137
|
-
if (turn.usage) attributes.usage = turn.usage;
|
|
138
|
-
if (turn.toolCalls?.length) attributes.tool_calls = turn.toolCalls;
|
|
139
|
-
if (turn.turnNumber !== undefined) attributes.turn_number = turn.turnNumber;
|
|
140
|
-
if (turn.systemPrompt) attributes.system_prompt = turn.systemPrompt;
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
const response = await fetch(`${config.tes_endpoint}/api/graphql`, {
|
|
144
|
-
method: "POST",
|
|
145
|
-
headers: tesHeaders(config),
|
|
146
|
-
// Route through createModuleEvent on the conversation-analytics
|
|
147
|
-
// module rather than the top-level emitEvent. The latter requires
|
|
148
|
-
// a permission most client API keys don't have ("Access denied:
|
|
149
|
-
// You don't have permission to update emitEvent"), but the
|
|
150
|
-
// module's manifest declares CHAT_TURN as a registered event
|
|
151
|
-
// type, so the module-scoped path is both authorised and
|
|
152
|
-
// consistent with how STORE_MEMORY is emitted.
|
|
153
|
-
body: JSON.stringify({
|
|
154
|
-
query: `mutation Cme($moduleId: String!, $input: ModuleEventInput!) {
|
|
155
|
-
createModuleEvent(moduleId: $moduleId, input: $input) { success eventId }
|
|
156
|
-
}`,
|
|
157
|
-
variables: {
|
|
158
|
-
moduleId: "conversation-analytics",
|
|
159
|
-
input: {
|
|
160
|
-
eventType: "CHAT_TURN",
|
|
161
|
-
data: {
|
|
162
|
-
entity_id: sessionId,
|
|
163
|
-
attributes,
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
}),
|
|
168
|
-
signal: AbortSignal.timeout(10000),
|
|
169
|
-
});
|
|
170
|
-
if (!response.ok) return null;
|
|
171
|
-
return response.json();
|
|
172
|
-
} catch {
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function hostedStore(config, content, metadata = {}) {
|
|
178
|
-
try {
|
|
179
|
-
const response = await fetch(`${config.tes_endpoint}/api/graphql`, {
|
|
180
|
-
method: "POST",
|
|
181
|
-
headers: tesHeaders(config),
|
|
182
|
-
body: JSON.stringify({
|
|
183
|
-
query: `mutation CreateModuleEvent($moduleId: String!, $input: ModuleEventInput!) {
|
|
184
|
-
createModuleEvent(moduleId: $moduleId, input: $input) { success eventId }
|
|
185
|
-
}`,
|
|
186
|
-
variables: {
|
|
187
|
-
moduleId: "deep-memory",
|
|
188
|
-
input: {
|
|
189
|
-
eventType: "STORE_MEMORY",
|
|
190
|
-
data: {
|
|
191
|
-
entity_id: metadata.session_id || "openclaw",
|
|
192
|
-
attributes: {
|
|
193
|
-
...metadata,
|
|
194
|
-
content,
|
|
195
|
-
source: "openclaw-plugin",
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
}),
|
|
201
|
-
signal: AbortSignal.timeout(10000),
|
|
202
|
-
});
|
|
203
|
-
if (!response.ok) return null;
|
|
204
|
-
return response.json();
|
|
205
|
-
} catch {
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
111
|
// --- Hosted context engine ---
|
|
211
112
|
|
|
212
113
|
// Per-session turn buffer. Holds the user message until the matching
|
|
@@ -440,7 +341,7 @@ function createHostedContextEngine(config, opts = {}) {
|
|
|
440
341
|
const memoryText = results
|
|
441
342
|
.map(
|
|
442
343
|
(m) =>
|
|
443
|
-
`- [${Math.round((m.similarity || 0) * 100)}%] ${m.content}`
|
|
344
|
+
`- [${Math.round((m.similarity || 0) * 100)}%] ${sanitizeMemoryContent(m.content)}`
|
|
444
345
|
)
|
|
445
346
|
.join("\n");
|
|
446
347
|
|
|
@@ -638,7 +539,7 @@ Tell the user to run step 1 first, then help them fill in the config with the cr
|
|
|
638
539
|
return results
|
|
639
540
|
.map(
|
|
640
541
|
(m, i) =>
|
|
641
|
-
`${i + 1}. [${Math.round((m.similarity || 0) * 100)}%] ${m.content}`
|
|
542
|
+
`${i + 1}. [${Math.round((m.similarity || 0) * 100)}%] ${sanitizeMemoryContent(m.content)}`
|
|
642
543
|
)
|
|
643
544
|
.join("\n\n");
|
|
644
545
|
},
|
|
@@ -705,7 +606,7 @@ Tell the user to run step 1 first, then help them fill in the config with the cr
|
|
|
705
606
|
return results
|
|
706
607
|
.map(
|
|
707
608
|
(m, i) =>
|
|
708
|
-
`${i + 1}. [${Math.round((m.similarity || 0) * 100)}%] ${m.content}`
|
|
609
|
+
`${i + 1}. [${Math.round((m.similarity || 0) * 100)}%] ${sanitizeMemoryContent(m.content)}`
|
|
709
610
|
)
|
|
710
611
|
.join("\n\n");
|
|
711
612
|
},
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-content sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* Stored memories from TES often contain dashboard-UI noise (leading
|
|
5
|
+
* timestamps, layer IDs, confidence/decay metadata, trailing JSON
|
|
6
|
+
* blobs). This strips them before showing content to the model — the
|
|
7
|
+
* fact-bearing text is what matters, the metadata just dilutes the
|
|
8
|
+
* signal and burns context budget.
|
|
9
|
+
*
|
|
10
|
+
* Conservative: if stripping would leave no real words, fall back to
|
|
11
|
+
* the original content. Better a noisy signal than none.
|
|
12
|
+
*
|
|
13
|
+
* Canonical implementation. The Claude Code hook (`hooks/scripts/
|
|
14
|
+
* shared.js`) and the published openclaw-plugin (`openclaw-plugin/
|
|
15
|
+
* index.js`) each inline the same logic — they're published
|
|
16
|
+
* standalone and can't cross-import. Update all three if changing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const TES_META_FIELDS =
|
|
20
|
+
"event_id|event_type|entity_type|source|clientId|correlationId|timestamp|session_id|layer_id|confidence|decay_rate|user_id";
|
|
21
|
+
|
|
22
|
+
export const MEMORY_MAX_LEN = 600;
|
|
23
|
+
|
|
24
|
+
export function sanitizeMemoryContent(content) {
|
|
25
|
+
if (typeof content !== "string") return content;
|
|
26
|
+
let out = content;
|
|
27
|
+
// Trailing JSON metadata blob (no `m` flag — `$` = end-of-string).
|
|
28
|
+
out = out.replace(/\n\{\s*\n[\s\S]*?\n\s*\}\s*$/, "");
|
|
29
|
+
// Inline JSON metadata blobs (2+ consecutive TES metadata fields).
|
|
30
|
+
out = out.replace(
|
|
31
|
+
new RegExp(
|
|
32
|
+
`\\{\\s*\\n(\\s*"(?:${TES_META_FIELDS})"[^\\n]*\\n){2,}\\s*\\}`,
|
|
33
|
+
"g"
|
|
34
|
+
),
|
|
35
|
+
""
|
|
36
|
+
);
|
|
37
|
+
// Dashboard-UI standalone lines.
|
|
38
|
+
const linePatterns = [
|
|
39
|
+
/^\s*anonymous\s*$/gm,
|
|
40
|
+
/^\s*ml_[a-z0-9_-]+_(episodic|semantic|procedural|working)\s*$/gm,
|
|
41
|
+
/^\s*\d+%\s*match\s*$/gm,
|
|
42
|
+
/^\s*Confidence:\s*\d+%\s*$/gm,
|
|
43
|
+
/^\s*Accessed:\s*\d+x?\s*$/gm,
|
|
44
|
+
/^\s*<?\s*\d+[smhd]\s*ago\s*$/gm,
|
|
45
|
+
/^\s*Decay:\s*[\d.]+\s*$/gm,
|
|
46
|
+
/^\s*Metadata\s*$/gm,
|
|
47
|
+
];
|
|
48
|
+
for (const pat of linePatterns) out = out.replace(pat, "");
|
|
49
|
+
// Leading ISO timestamps — strip prefix, keep line content.
|
|
50
|
+
out = out.replace(/^\s*\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]\s*/gm, "");
|
|
51
|
+
// Collapse consecutive blank lines.
|
|
52
|
+
out = out.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
|
|
53
|
+
// Cap verbose transcript dumps.
|
|
54
|
+
if (out.length > MEMORY_MAX_LEN) {
|
|
55
|
+
out = out.slice(0, MEMORY_MAX_LEN).trimEnd() + "…";
|
|
56
|
+
}
|
|
57
|
+
// Fallback to original if we stripped everything meaningful.
|
|
58
|
+
const wordCount = (out.match(/\b\w{2,}\b/g) || []).length;
|
|
59
|
+
if (wordCount < 2) return content;
|
|
60
|
+
return out;
|
|
61
|
+
}
|