@pentatonic-ai/ai-agent-sdk 0.10.21 → 0.10.22

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/dist/index.cjs CHANGED
@@ -878,7 +878,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
878
878
  }
879
879
 
880
880
  // src/telemetry.js
881
- var VERSION = "0.10.21";
881
+ var VERSION = "0.10.22";
882
882
  var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
883
883
  function machineId() {
884
884
  const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
package/dist/index.js CHANGED
@@ -847,7 +847,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
847
847
  }
848
848
 
849
849
  // src/telemetry.js
850
- var VERSION = "0.10.21";
850
+ var VERSION = "0.10.22";
851
851
  var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
852
852
  function machineId() {
853
853
  const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
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",
@@ -327,3 +327,45 @@ def test_external_id_keys_a_domainless_record_node() -> None:
327
327
  assert worker.entity_id("arena1", "org", ext) != worker.entity_id(
328
328
  "arena1", "org", "Acme Corp"
329
329
  )
330
+
331
+
332
+ # ----------------------------------------------------------------------
333
+ # _person_email_domain — person email/domain stamped as a node attribute
334
+ # (SEE-192). Pure helper; the upsert SQL that consumes it is covered by the
335
+ # engine import check, same posture as the org-domain stamp above.
336
+ # ----------------------------------------------------------------------
337
+
338
+ def test_person_email_domain_plain() -> None:
339
+ assert worker._person_email_domain(
340
+ ["Johann Boedecker", "johann@pentatonic.com"]
341
+ ) == ("johann@pentatonic.com", "pentatonic.com")
342
+
343
+
344
+ def test_person_email_domain_lowercases() -> None:
345
+ assert worker._person_email_domain(["J@PENTATONIC.com"]) == (
346
+ "j@pentatonic.com",
347
+ "pentatonic.com",
348
+ )
349
+
350
+
351
+ def test_person_email_domain_none_without_an_email() -> None:
352
+ # name-only aliases (a fragmented person with no email) → nothing to stamp.
353
+ assert worker._person_email_domain(["Johann Boedecker", "Bödecker, Johann"]) == (
354
+ None,
355
+ None,
356
+ )
357
+ assert worker._person_email_domain([]) == (None, None)
358
+
359
+
360
+ def test_person_email_domain_only_bare_addresses() -> None:
361
+ # mirrors the id-key selection: a display-name form ("X <a@b.com>", has a
362
+ # space) is NOT treated as the person's bare address — graph aliases carry
363
+ # the bare email, and this keeps the attribute aligned with person_id_key.
364
+ assert worker._person_email_domain(["Bob Smith <bob@lego.com>"]) == (None, None)
365
+
366
+
367
+ def test_person_email_domain_subdomain_preserved() -> None:
368
+ assert worker._person_email_domain(["a@mail.acme.co.uk"]) == (
369
+ "a@mail.acme.co.uk",
370
+ "mail.acme.co.uk",
371
+ )
@@ -1098,6 +1098,18 @@ def _domain_of(value: str) -> str | None:
1098
1098
  return m.group(1).lower() if m else None
1099
1099
 
1100
1100
 
1101
+ def _person_email_domain(aliases: list[str]) -> tuple[str | None, str | None]:
1102
+ """A person's own email (first alias that looks like a bare address) and its
1103
+ domain, for stamping as queryable node attributes (SEE-192). Returns
1104
+ `(lowercased_email, domain)` or `(None, None)`. Callers run this AFTER the
1105
+ email-alias guard, so a surviving address is the person's own — not a
1106
+ bystander. Mirrors the org-domain stamp; reuses `_domain_of`."""
1107
+ email = next((a for a in aliases if a and "@" in a and " " not in a), None)
1108
+ if not email:
1109
+ return None, None
1110
+ return email.strip().lower(), _domain_of(email)
1111
+
1112
+
1101
1113
  def _bare_domain(value: str) -> str | None:
1102
1114
  """Normalise a structured domain field to a bare host — 'acme.com' from
1103
1115
  'Acme.com', 'https://www.acme.com/about', etc. Used for system-of-record
@@ -1416,6 +1428,21 @@ def upsert_entities(
1416
1428
  if subject_ext_id not in aliases:
1417
1429
  aliases.append(subject_ext_id)
1418
1430
  attrs_patch["external_id"] = subject_ext_id
1431
+ # Person email/domain as a QUERYABLE node attribute (SEE-192). The
1432
+ # email already mints the id (person_id_key) and survives the alias
1433
+ # guard above, but was never persisted as a discrete attribute — so
1434
+ # retrieval / context-aware mention resolution had to dig it out of
1435
+ # the aliases array (text-search, not indexed) and 0/37,648 person
1436
+ # nodes carried `attributes.email`/`domain`. Stamp it (lowercased) +
1437
+ # its domain, mirroring the org-domain stamp, so a person node is
1438
+ # directly matchable by email/domain. Merge-only (`|| jsonb`); the
1439
+ # guard already dropped bystander emails, so this is the person's own.
1440
+ if etype == "person":
1441
+ _pemail, _pdomain = _person_email_domain(aliases)
1442
+ if _pemail:
1443
+ attrs_patch["email"] = _pemail
1444
+ if _pdomain:
1445
+ attrs_patch["domain"] = _pdomain
1419
1446
  attrs_update = json.dumps(attrs_patch)
1420
1447
 
1421
1448
  # Sort (don't `list(set(...))`) so lock acquisition order
@@ -36,6 +36,8 @@ from entity_id import entity_id, normalize_surface_form, person_id_key # noqa:
36
36
  # Source-time parsing — byte-identical copy in extractor-async (source_time.py).
37
37
  from source_time import event_source_time
38
38
 
39
+ import json
40
+
39
41
  import psycopg
40
42
  import psycopg.rows
41
43
  from fastapi import FastAPI, HTTPException
@@ -270,6 +272,18 @@ def _person_entity(
270
272
  if a:
271
273
  aliases_set.add(a)
272
274
 
275
+ # Email/domain as a QUERYABLE node attribute (SEE-192). The sync pass keys
276
+ # the node on the ENVELOPE email (the most reliable source — not LLM-
277
+ # promoted), so stamp it (lowercased) + its domain here so a person node is
278
+ # directly matchable by email/domain instead of only via the aliases array.
279
+ attrs: dict[str, str] = {}
280
+ if email:
281
+ em = email.strip().lower()
282
+ attrs["email"] = em
283
+ dom = em.rsplit("@", 1)[-1] if "@" in em else ""
284
+ if dom and "." in dom and " " not in dom:
285
+ attrs["domain"] = dom
286
+
273
287
  return {
274
288
  "id": _entity_id(req.arena, "person", id_key),
275
289
  "arena": req.arena,
@@ -279,6 +293,7 @@ def _person_entity(
279
293
  "provenance_event_ids": [event_id],
280
294
  "participant_set": req.attributes.get("participant_set", [req.arena]),
281
295
  "disclosure_class": req.attributes.get("disclosure_class", "private"),
296
+ "attributes": attrs,
282
297
  }
283
298
 
284
299
 
@@ -550,10 +565,10 @@ async def _upsert_entities(
550
565
  INSERT INTO entities (
551
566
  id, arena, entity_type, canonical_name, aliases,
552
567
  provenance_event_ids, participant_set, disclosure_class,
553
- first_seen, last_seen, salience
568
+ first_seen, last_seen, salience, attributes
554
569
  ) VALUES (
555
570
  %s, %s, %s, %s, %s, %s, %s, %s::disclosure_class,
556
- COALESCE(%s, NOW()), COALESCE(%s, NOW()), %s
571
+ COALESCE(%s, NOW()), COALESCE(%s, NOW()), %s, %s::jsonb
557
572
  )
558
573
  ON CONFLICT (id) DO UPDATE SET
559
574
  aliases = (
@@ -562,6 +577,9 @@ async def _upsert_entities(
562
577
  provenance_event_ids = (
563
578
  SELECT ARRAY(SELECT DISTINCT UNNEST(entities.provenance_event_ids || EXCLUDED.provenance_event_ids))
564
579
  ),
580
+ -- merge the email/domain hard key (no-op for the `{}` default
581
+ -- on entity types that don't stamp it); never clobbers.
582
+ attributes = entities.attributes || EXCLUDED.attributes,
565
583
  salience = GREATEST(entities.salience, EXCLUDED.salience),
566
584
  last_seen = GREATEST(entities.last_seen, EXCLUDED.last_seen),
567
585
  first_seen = LEAST(entities.first_seen, EXCLUDED.first_seen)
@@ -569,7 +587,8 @@ async def _upsert_entities(
569
587
  (e["id"], e["arena"], e["entity_type"], e["canonical_name"],
570
588
  e["aliases"], e["provenance_event_ids"],
571
589
  e["participant_set"], e["disclosure_class"],
572
- event_time, event_time, _sal),
590
+ event_time, event_time, _sal,
591
+ json.dumps(e.get("attributes") or {})),
573
592
  )
574
593
 
575
594