@pentatonic-ai/ai-agent-sdk 0.8.0 → 0.8.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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.4",
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.4",
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
  # ----------------------------------------------------------------------
@@ -401,8 +416,14 @@ async def health():
401
416
  "engine": "pentatonic-memory-engine",
402
417
  "layers": {},
403
418
  }
404
- # NV-Embed exposes /health alongside /v1/embeddings.
405
- nv_embed_health = NV_EMBED_URL.replace("/v1/embeddings", "/health")
419
+ # NV-Embed (or the upstream gateway) exposes /health at the host root.
420
+ # Use urlparse so we swap *just* the path component instead of doing a
421
+ # string replace — that breaks the moment NV_EMBED_URL is /v1/embed,
422
+ # /v1/embeddings, or bare-host. The probe is informational only; gateways
423
+ # that return non-200 on root-/health don't block engine operation.
424
+ from urllib.parse import urlparse, urlunparse
425
+ _u = urlparse(NV_EMBED_URL)
426
+ nv_embed_health = urlunparse((_u.scheme, _u.netloc, "/health", "", "", ""))
406
427
 
407
428
  import asyncio
408
429
  l2_v, l4_v, l5_v, l6_v, nv_v, l3_v = await asyncio.gather(
@@ -912,18 +933,88 @@ async def forget(req: ForgetRequest):
912
933
  # Also wipe L0 BM25 + L4 QMD + L3 KG so bench resets fully.
913
934
  # No per-id forget for these — bench harness uses /forget once at
914
935
  # start of each run with empty filters to reset state.
936
+ #
937
+ # Three forwarding rules:
938
+ # - metadata_contains has `arena` → tenant-scoped delete (forwards
939
+ # the arena to the internal endpoint, which only wipes that
940
+ # tenant's L3 data).
941
+ # - metadata_contains is set, no arena → targeted L6-only delete;
942
+ # do NOT call the internal wipe (we don't have arena scope and
943
+ # a global wipe would be wildly wrong here).
944
+ # - empty filters → bench-reset semantics; explicitly request the
945
+ # unsafe global wipe via the new `confirm: GLOBAL_WIPE` gate.
946
+ arena_for_internal = None
947
+ if isinstance(req.metadata_contains, dict):
948
+ meta_arena = req.metadata_contains.get("arena")
949
+ if isinstance(meta_arena, str) and meta_arena:
950
+ arena_for_internal = meta_arena
915
951
  try:
916
- r = await _client().post(f"{L2_PROXY_URL}/forget-internal",
917
- json={}, timeout=15.0)
918
- if r.status_code == 200:
919
- d = r.json().get("deleted", {})
920
- deleted_total += sum(int(v or 0) for v in d.values())
952
+ if arena_for_internal:
953
+ payload = {"arena": arena_for_internal}
954
+ elif req.metadata_contains:
955
+ payload = None # skip — targeted delete shouldn't wipe shared layers
956
+ else:
957
+ payload = {"confirm": "GLOBAL_WIPE"}
958
+ if payload is not None:
959
+ r = await _client().post(
960
+ f"{L2_PROXY_URL}/forget-internal", json=payload, timeout=15.0,
961
+ )
962
+ if r.status_code == 200:
963
+ d = r.json().get("deleted", {})
964
+ deleted_total += sum(int(v or 0) for v in d.values())
921
965
  except Exception as exc:
922
966
  print(f"[shim] L2 /forget-internal failed: {exc}")
923
967
 
924
968
  return {"deleted": deleted_total, "engine": "pentatonic-memory-engine"}
925
969
 
926
970
 
971
+ @app.post("/aggregate")
972
+ async def aggregate(req: AggregateRequest) -> dict[str, Any]:
973
+ """Aggregate communications history with one person.
974
+
975
+ Pass-through to the L2 proxy's /aggregate-internal which runs a
976
+ single Cypher query against L3's typed-Person graph
977
+ ((:Person)-[:COMMUNICATED]->(:Chunk)). Used by TES personFacets
978
+ once we cut over from the over-fetch v1.
979
+
980
+ The shim's job here is shape validation + arena enforcement; the
981
+ real aggregation lives in L3. No layer fan-out, no metadata
982
+ filter scanning — when the typed-Person nodes don't exist for
983
+ this contact (older memories, tenants pre-#28), the response is
984
+ `total: 0` and the caller falls back to whatever it had before.
985
+ """
986
+ arena = (req.arena or "").strip()
987
+ if not arena:
988
+ raise HTTPException(status_code=400, detail="arena is required")
989
+ if not (req.contact_email or req.contact_name):
990
+ raise HTTPException(
991
+ status_code=400,
992
+ detail="provide contact_email and/or contact_name to identify the Person",
993
+ )
994
+ payload: dict[str, Any] = {
995
+ "arena": arena,
996
+ "group_by": req.group_by or ["channel"],
997
+ }
998
+ if req.contact_email:
999
+ payload["contact_email"] = req.contact_email
1000
+ if req.contact_name:
1001
+ payload["contact_name"] = req.contact_name
1002
+ try:
1003
+ r = await _client().post(
1004
+ f"{L2_PROXY_URL}/aggregate-internal", json=payload, timeout=15.0,
1005
+ )
1006
+ if r.status_code != 200:
1007
+ raise HTTPException(
1008
+ status_code=r.status_code,
1009
+ detail=f"aggregate failed: {r.text[:200]}",
1010
+ )
1011
+ return r.json()
1012
+ except HTTPException:
1013
+ raise
1014
+ except Exception as exc:
1015
+ raise HTTPException(status_code=502, detail=f"aggregate upstream: {exc}")
1016
+
1017
+
927
1018
  # ----------------------------------------------------------------------
928
1019
  # Entrypoint
929
1020
  # ----------------------------------------------------------------------