@pentatonic-ai/ai-agent-sdk 0.8.0 → 0.8.1
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 +1 -1
- package/packages/memory/openclaw-plugin/index.js +7 -0
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/engine.test.js +142 -0
- package/packages/memory/src/engine.js +65 -0
- package/packages/memory-engine/compat/server.py +90 -5
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +596 -58
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
- package/packages/memory-engine/tests/e2e_arena.sh +28 -4
- package/packages/memory-engine/tests/test_aggregate.py +333 -0
- package/packages/memory-engine/tests/test_arena_safety.py +232 -0
- package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -500,6 +500,13 @@ export default {
|
|
|
500
500
|
description: "Persistent, searchable memory with multi-signal retrieval and HyDE query expansion",
|
|
501
501
|
kind: "context-engine",
|
|
502
502
|
|
|
503
|
+
// Tool contracts (names + parameter schemas) live in openclaw.plugin.json
|
|
504
|
+
// under the top-level "contracts.tools" key — that's what the openclaw
|
|
505
|
+
// loader reads at install time. The execute bodies are registered at
|
|
506
|
+
// runtime via api.registerTool(...) inside register() below; names and
|
|
507
|
+
// schemas there MUST match the manifest contracts or 5.7+ will refuse
|
|
508
|
+
// to register the tool.
|
|
509
|
+
|
|
503
510
|
register(api) {
|
|
504
511
|
const config = api.pluginConfig || api.config?.plugins?.entries?.["pentatonic-memory"]?.config || api.config || {};
|
|
505
512
|
const hosted = !!(config.tes_endpoint && config.tes_api_key);
|
|
@@ -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.8.
|
|
5
|
+
"version": "0.8.5",
|
|
6
6
|
"kind": "context-engine",
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
@@ -75,5 +75,13 @@
|
|
|
75
75
|
},
|
|
76
76
|
"setup": {
|
|
77
77
|
"description": "Set up persistent memory — local (Docker + Ollama, fully private) or hosted (Pentatonic TES, team-wide)."
|
|
78
|
+
},
|
|
79
|
+
"contracts": {
|
|
80
|
+
"tools": [
|
|
81
|
+
"pentatonic_memory_search",
|
|
82
|
+
"pentatonic_memory_store",
|
|
83
|
+
"pentatonic_memory_status",
|
|
84
|
+
"pentatonic_memory_setup"
|
|
85
|
+
]
|
|
78
86
|
}
|
|
79
87
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/openclaw-memory-plugin",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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",
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
fetchEngine,
|
|
4
4
|
engineStore,
|
|
5
5
|
engineSearch,
|
|
6
|
+
engineAggregate,
|
|
6
7
|
engineForget,
|
|
7
8
|
composeArena,
|
|
8
9
|
composeArenas,
|
|
@@ -315,6 +316,147 @@ describe("engine HTTP client", () => {
|
|
|
315
316
|
});
|
|
316
317
|
});
|
|
317
318
|
|
|
319
|
+
describe("engineAggregate", () => {
|
|
320
|
+
it("posts to /aggregate with single arena (tenant-only when no userId)", async () => {
|
|
321
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
322
|
+
await engineAggregate("https://e", {
|
|
323
|
+
clientId: "acme",
|
|
324
|
+
contactEmail: "alex@x.io",
|
|
325
|
+
});
|
|
326
|
+
const body = JSON.parse(calls[0].init.body);
|
|
327
|
+
expect(calls[0].url).toBe("https://e/aggregate");
|
|
328
|
+
expect(body).toEqual({
|
|
329
|
+
arena: "acme",
|
|
330
|
+
contact_email: "alex@x.io",
|
|
331
|
+
group_by: ["channel"],
|
|
332
|
+
});
|
|
333
|
+
// Aggregate is single-arena by design (cross-tenant aggregation
|
|
334
|
+
// would be a multi-tenancy violation), so no `arenas` list.
|
|
335
|
+
expect(body).not.toHaveProperty("arenas");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("scopes to user-arena (clientId:userId) when userId is set", async () => {
|
|
339
|
+
mockOk({ arena: "acme:user-42", total: 0, last_seen: null, buckets: [] });
|
|
340
|
+
await engineAggregate("https://e", {
|
|
341
|
+
clientId: "acme",
|
|
342
|
+
userId: "user-42",
|
|
343
|
+
contactEmail: "alex@x.io",
|
|
344
|
+
});
|
|
345
|
+
const body = JSON.parse(calls[0].init.body);
|
|
346
|
+
expect(body.arena).toBe("acme:user-42");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("forwards contact_name when supplied (resolves Person by name alias)", async () => {
|
|
350
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
351
|
+
await engineAggregate("https://e", {
|
|
352
|
+
clientId: "acme",
|
|
353
|
+
contactName: "Alex Tong",
|
|
354
|
+
});
|
|
355
|
+
const body = JSON.parse(calls[0].init.body);
|
|
356
|
+
expect(body.contact_name).toBe("Alex Tong");
|
|
357
|
+
expect(body).not.toHaveProperty("contact_email");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("forwards both contact_email and contact_name when both supplied", async () => {
|
|
361
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
362
|
+
await engineAggregate("https://e", {
|
|
363
|
+
clientId: "acme",
|
|
364
|
+
contactEmail: "alex@x.io",
|
|
365
|
+
contactName: "Alex Tong",
|
|
366
|
+
});
|
|
367
|
+
const body = JSON.parse(calls[0].init.body);
|
|
368
|
+
expect(body.contact_email).toBe("alex@x.io");
|
|
369
|
+
expect(body.contact_name).toBe("Alex Tong");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("forwards custom group_by when non-empty", async () => {
|
|
373
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
374
|
+
await engineAggregate("https://e", {
|
|
375
|
+
clientId: "acme",
|
|
376
|
+
contactEmail: "alex@x.io",
|
|
377
|
+
groupBy: ["channel", "direction"],
|
|
378
|
+
});
|
|
379
|
+
const body = JSON.parse(calls[0].init.body);
|
|
380
|
+
expect(body.group_by).toEqual(["channel", "direction"]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("defaults group_by to ['channel'] when caller omits it", async () => {
|
|
384
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
385
|
+
await engineAggregate("https://e", {
|
|
386
|
+
clientId: "acme",
|
|
387
|
+
contactEmail: "alex@x.io",
|
|
388
|
+
});
|
|
389
|
+
const body = JSON.parse(calls[0].init.body);
|
|
390
|
+
expect(body.group_by).toEqual(["channel"]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("defaults group_by to ['channel'] when caller passes empty array", async () => {
|
|
394
|
+
// Defensive: TS callers can pass `groupBy: []` to mean "no
|
|
395
|
+
// grouping" but we treat that as "use the sensible default" so
|
|
396
|
+
// the default behaviour matches a missing field. The engine
|
|
397
|
+
// itself is the place that supports empty group_by → single
|
|
398
|
+
// bucket; expose that explicitly only when the caller asks.
|
|
399
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
400
|
+
await engineAggregate("https://e", {
|
|
401
|
+
clientId: "acme",
|
|
402
|
+
contactEmail: "alex@x.io",
|
|
403
|
+
groupBy: [],
|
|
404
|
+
});
|
|
405
|
+
const body = JSON.parse(calls[0].init.body);
|
|
406
|
+
expect(body.group_by).toEqual(["channel"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("requires clientId", async () => {
|
|
410
|
+
await expect(
|
|
411
|
+
engineAggregate("https://e", { contactEmail: "alex@x.io" })
|
|
412
|
+
).rejects.toThrow(/clientId required/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("requires contactEmail or contactName", async () => {
|
|
416
|
+
await expect(
|
|
417
|
+
engineAggregate("https://e", { clientId: "acme" })
|
|
418
|
+
).rejects.toThrow(/contactEmail.*contactName/i);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("forwards CF Access headers when supplied", async () => {
|
|
422
|
+
mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
|
|
423
|
+
await engineAggregate("https://e", {
|
|
424
|
+
clientId: "acme",
|
|
425
|
+
contactEmail: "alex@x.io",
|
|
426
|
+
headers: {
|
|
427
|
+
"CF-Access-Client-Id": "id123",
|
|
428
|
+
"CF-Access-Client-Secret": "sec456",
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
expect(calls[0].init.headers["CF-Access-Client-Id"]).toBe("id123");
|
|
432
|
+
expect(calls[0].init.headers["CF-Access-Client-Secret"]).toBe("sec456");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("returns the engine's response shape unchanged", async () => {
|
|
436
|
+
const expected = {
|
|
437
|
+
arena: "acme",
|
|
438
|
+
total: 47,
|
|
439
|
+
last_seen: "2026-05-09T09:00:00Z",
|
|
440
|
+
buckets: [
|
|
441
|
+
{
|
|
442
|
+
keys: { channel: "email" },
|
|
443
|
+
count: 35,
|
|
444
|
+
inbound: 14,
|
|
445
|
+
outbound: 21,
|
|
446
|
+
last_seen: "2026-05-09T09:00:00Z",
|
|
447
|
+
first_seen: "2025-09-01T08:00:00Z",
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
};
|
|
451
|
+
mockOk(expected);
|
|
452
|
+
const out = await engineAggregate("https://e", {
|
|
453
|
+
clientId: "acme",
|
|
454
|
+
contactEmail: "alex@x.io",
|
|
455
|
+
});
|
|
456
|
+
expect(out).toEqual(expected);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
318
460
|
describe("engineForget", () => {
|
|
319
461
|
it("forwards id when provided", async () => {
|
|
320
462
|
mockOk({ deleted: 1 });
|
|
@@ -281,3 +281,68 @@ export async function engineForget(engineUrl, opts) {
|
|
|
281
281
|
};
|
|
282
282
|
return fetchEngine(engineUrl, "/forget", body, { headers });
|
|
283
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Aggregate communications history about one person from the typed-
|
|
287
|
+
* Person graph (memory-engine #30 / 0.8.x). Backs ``personFacets``-
|
|
288
|
+
* style queries: total + per-channel breakdown + last_seen, all
|
|
289
|
+
* computed by a single Cypher aggregate over
|
|
290
|
+
* ``(:Person)-[:COMMUNICATED]->(:Chunk)`` edges.
|
|
291
|
+
*
|
|
292
|
+
* Caller must supply at least one of ``contactEmail`` or
|
|
293
|
+
* ``contactName``. ``arena`` is single-valued here (not an arenas
|
|
294
|
+
* list) — aggregating across arenas isn't a valid operation in a
|
|
295
|
+
* multi-tenant product, so we don't expose it.
|
|
296
|
+
*
|
|
297
|
+
* Returns ``null`` shape: ``{arena, total, last_seen, buckets: [{keys,
|
|
298
|
+
* count, inbound, outbound, last_seen, first_seen}]}``. ``total: 0``
|
|
299
|
+
* with empty ``buckets`` when no Person node exists for this contact
|
|
300
|
+
* in the requested arena (older memories pre-#28, tenants whose data
|
|
301
|
+
* isn't re-ingested under the new writer yet). The fallback to
|
|
302
|
+
* over-fetch search lives in TES — this helper is a thin wire bridge.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} engineUrl
|
|
305
|
+
* @param {object} opts
|
|
306
|
+
* @param {string} opts.clientId
|
|
307
|
+
* @param {string} [opts.userId] defaults to tenant-wide arena
|
|
308
|
+
* @param {string} [opts.contactEmail]
|
|
309
|
+
* @param {string} [opts.contactName]
|
|
310
|
+
* @param {string[]} [opts.groupBy=["channel"]] bucket keys; engine
|
|
311
|
+
* whitelists "channel"+"direction" today, unknown keys silently
|
|
312
|
+
* dropped.
|
|
313
|
+
* @param {Record<string,string>} [opts.headers] forwarded HTTP headers
|
|
314
|
+
* (e.g. CF Access service token pair).
|
|
315
|
+
* @returns {Promise<{
|
|
316
|
+
* arena: string,
|
|
317
|
+
* total: number,
|
|
318
|
+
* last_seen: string|null,
|
|
319
|
+
* buckets: Array<{
|
|
320
|
+
* keys: Record<string, string|null>,
|
|
321
|
+
* count: number,
|
|
322
|
+
* inbound: number,
|
|
323
|
+
* outbound: number,
|
|
324
|
+
* last_seen: string|null,
|
|
325
|
+
* first_seen: string|null,
|
|
326
|
+
* }>,
|
|
327
|
+
* }>}
|
|
328
|
+
*/
|
|
329
|
+
export async function engineAggregate(engineUrl, opts) {
|
|
330
|
+
const { clientId, userId, contactEmail, contactName, groupBy, headers } =
|
|
331
|
+
opts || {};
|
|
332
|
+
if (!clientId) throw new Error("engineAggregate: clientId required");
|
|
333
|
+
if (!contactEmail && !contactName) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
"engineAggregate: provide contactEmail and/or contactName",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
// Single-arena: clientId:userId when userId is set, clientId
|
|
339
|
+
// tenant-wide otherwise. Mirrors composeArenas's first element.
|
|
340
|
+
const arenas = composeArenas(clientId, userId);
|
|
341
|
+
const body = {
|
|
342
|
+
arena: arenas[arenas.length - 1],
|
|
343
|
+
group_by: groupBy && groupBy.length > 0 ? groupBy : ["channel"],
|
|
344
|
+
...(contactEmail ? { contact_email: contactEmail } : {}),
|
|
345
|
+
...(contactName ? { contact_name: contactName } : {}),
|
|
346
|
+
};
|
|
347
|
+
return fetchEngine(engineUrl, "/aggregate", body, { headers });
|
|
348
|
+
}
|
|
@@ -125,6 +125,21 @@ class ForgetRequest(BaseModel):
|
|
|
125
125
|
id: Optional[str] = None
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
class AggregateRequest(BaseModel):
|
|
129
|
+
"""Public-facing /aggregate request.
|
|
130
|
+
|
|
131
|
+
Mirrors /aggregate-internal on the L2 proxy with arena scoping
|
|
132
|
+
enforced at the shim layer (multi-arena spans aren't allowed
|
|
133
|
+
here — pick a single arena per call). The relationships UI
|
|
134
|
+
queries this directly via TES; future callers will too.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
arena: str
|
|
138
|
+
contact_email: Optional[str] = None
|
|
139
|
+
contact_name: Optional[str] = None
|
|
140
|
+
group_by: Optional[list[str]] = None
|
|
141
|
+
|
|
142
|
+
|
|
128
143
|
# ----------------------------------------------------------------------
|
|
129
144
|
# Engine clients (one per layer)
|
|
130
145
|
# ----------------------------------------------------------------------
|
|
@@ -912,18 +927,88 @@ async def forget(req: ForgetRequest):
|
|
|
912
927
|
# Also wipe L0 BM25 + L4 QMD + L3 KG so bench resets fully.
|
|
913
928
|
# No per-id forget for these — bench harness uses /forget once at
|
|
914
929
|
# start of each run with empty filters to reset state.
|
|
930
|
+
#
|
|
931
|
+
# Three forwarding rules:
|
|
932
|
+
# - metadata_contains has `arena` → tenant-scoped delete (forwards
|
|
933
|
+
# the arena to the internal endpoint, which only wipes that
|
|
934
|
+
# tenant's L3 data).
|
|
935
|
+
# - metadata_contains is set, no arena → targeted L6-only delete;
|
|
936
|
+
# do NOT call the internal wipe (we don't have arena scope and
|
|
937
|
+
# a global wipe would be wildly wrong here).
|
|
938
|
+
# - empty filters → bench-reset semantics; explicitly request the
|
|
939
|
+
# unsafe global wipe via the new `confirm: GLOBAL_WIPE` gate.
|
|
940
|
+
arena_for_internal = None
|
|
941
|
+
if isinstance(req.metadata_contains, dict):
|
|
942
|
+
meta_arena = req.metadata_contains.get("arena")
|
|
943
|
+
if isinstance(meta_arena, str) and meta_arena:
|
|
944
|
+
arena_for_internal = meta_arena
|
|
915
945
|
try:
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
946
|
+
if arena_for_internal:
|
|
947
|
+
payload = {"arena": arena_for_internal}
|
|
948
|
+
elif req.metadata_contains:
|
|
949
|
+
payload = None # skip — targeted delete shouldn't wipe shared layers
|
|
950
|
+
else:
|
|
951
|
+
payload = {"confirm": "GLOBAL_WIPE"}
|
|
952
|
+
if payload is not None:
|
|
953
|
+
r = await _client().post(
|
|
954
|
+
f"{L2_PROXY_URL}/forget-internal", json=payload, timeout=15.0,
|
|
955
|
+
)
|
|
956
|
+
if r.status_code == 200:
|
|
957
|
+
d = r.json().get("deleted", {})
|
|
958
|
+
deleted_total += sum(int(v or 0) for v in d.values())
|
|
921
959
|
except Exception as exc:
|
|
922
960
|
print(f"[shim] L2 /forget-internal failed: {exc}")
|
|
923
961
|
|
|
924
962
|
return {"deleted": deleted_total, "engine": "pentatonic-memory-engine"}
|
|
925
963
|
|
|
926
964
|
|
|
965
|
+
@app.post("/aggregate")
|
|
966
|
+
async def aggregate(req: AggregateRequest) -> dict[str, Any]:
|
|
967
|
+
"""Aggregate communications history with one person.
|
|
968
|
+
|
|
969
|
+
Pass-through to the L2 proxy's /aggregate-internal which runs a
|
|
970
|
+
single Cypher query against L3's typed-Person graph
|
|
971
|
+
((:Person)-[:COMMUNICATED]->(:Chunk)). Used by TES personFacets
|
|
972
|
+
once we cut over from the over-fetch v1.
|
|
973
|
+
|
|
974
|
+
The shim's job here is shape validation + arena enforcement; the
|
|
975
|
+
real aggregation lives in L3. No layer fan-out, no metadata
|
|
976
|
+
filter scanning — when the typed-Person nodes don't exist for
|
|
977
|
+
this contact (older memories, tenants pre-#28), the response is
|
|
978
|
+
`total: 0` and the caller falls back to whatever it had before.
|
|
979
|
+
"""
|
|
980
|
+
arena = (req.arena or "").strip()
|
|
981
|
+
if not arena:
|
|
982
|
+
raise HTTPException(status_code=400, detail="arena is required")
|
|
983
|
+
if not (req.contact_email or req.contact_name):
|
|
984
|
+
raise HTTPException(
|
|
985
|
+
status_code=400,
|
|
986
|
+
detail="provide contact_email and/or contact_name to identify the Person",
|
|
987
|
+
)
|
|
988
|
+
payload: dict[str, Any] = {
|
|
989
|
+
"arena": arena,
|
|
990
|
+
"group_by": req.group_by or ["channel"],
|
|
991
|
+
}
|
|
992
|
+
if req.contact_email:
|
|
993
|
+
payload["contact_email"] = req.contact_email
|
|
994
|
+
if req.contact_name:
|
|
995
|
+
payload["contact_name"] = req.contact_name
|
|
996
|
+
try:
|
|
997
|
+
r = await _client().post(
|
|
998
|
+
f"{L2_PROXY_URL}/aggregate-internal", json=payload, timeout=15.0,
|
|
999
|
+
)
|
|
1000
|
+
if r.status_code != 200:
|
|
1001
|
+
raise HTTPException(
|
|
1002
|
+
status_code=r.status_code,
|
|
1003
|
+
detail=f"aggregate failed: {r.text[:200]}",
|
|
1004
|
+
)
|
|
1005
|
+
return r.json()
|
|
1006
|
+
except HTTPException:
|
|
1007
|
+
raise
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
raise HTTPException(status_code=502, detail=f"aggregate upstream: {exc}")
|
|
1010
|
+
|
|
1011
|
+
|
|
927
1012
|
# ----------------------------------------------------------------------
|
|
928
1013
|
# Entrypoint
|
|
929
1014
|
# ----------------------------------------------------------------------
|