@pentatonic-ai/ai-agent-sdk 0.10.20 → 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.20";
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.20";
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.20",
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",
@@ -913,6 +913,34 @@ def _resolve_arenas(req: GraphQueryRequest) -> list[str]:
913
913
  return arenas
914
914
 
915
915
 
916
+ # Decay access signal (RFC-decay-and-fusion Part B1): the Fusion Drive decay pass
917
+ # ages salience by the most recent of (last_accessed, last_seen/asserted_at), so
918
+ # without an access bump a frequently-retrieved memory still decays and can be
919
+ # evicted. Bump last_accessed on the nodes a read returns so retrieval keeps them
920
+ # alive. THROTTLED to once / _ACCESS_BUMP_THROTTLE per node (the UPDATE no-ops for
921
+ # nodes touched recently) to bound write amplification on these hot read paths,
922
+ # and BEST-EFFORT — a bump failure must never fail the read. `table` is always a
923
+ # trusted literal (never user input).
924
+ _ACCESS_BUMP_THROTTLE = "6 hours"
925
+
926
+
927
+ async def _bump_last_accessed(conn, cur, table: str, ids: list[str]) -> None:
928
+ ids = [i for i in ids if i]
929
+ if not ids:
930
+ return
931
+ try:
932
+ await cur.execute(
933
+ f"UPDATE {table} SET last_accessed = NOW() "
934
+ f"WHERE id = ANY(%s) AND (last_accessed IS NULL "
935
+ f"OR last_accessed < NOW() - interval '{_ACCESS_BUMP_THROTTLE}')",
936
+ (ids,),
937
+ )
938
+ await conn.commit()
939
+ except Exception as e: # noqa: BLE001 — never let the access bump break a read
940
+ await conn.rollback()
941
+ log.warning("last_accessed bump failed on %s: %s", table, e)
942
+
943
+
916
944
  @app.post("/entities")
917
945
  async def list_entities(req: GraphQueryRequest):
918
946
  """Filter entities by arena + optional type + optional name pattern.
@@ -930,10 +958,10 @@ async def list_entities(req: GraphQueryRequest):
930
958
  params.extend([pattern, pattern])
931
959
  sql = f"""
932
960
  SELECT id, arena, entity_type, canonical_name, aliases,
933
- provenance_event_ids, attributes, last_seen
961
+ provenance_event_ids, attributes, salience, last_seen
934
962
  FROM entities
935
963
  WHERE {' AND '.join(conditions)}
936
- ORDER BY last_seen DESC
964
+ ORDER BY salience DESC, last_seen DESC
937
965
  LIMIT %s
938
966
  """
939
967
  params.append(req.limit)
@@ -941,6 +969,7 @@ async def list_entities(req: GraphQueryRequest):
941
969
  async with conn.cursor() as cur:
942
970
  await cur.execute(sql, params)
943
971
  rows = await cur.fetchall()
972
+ await _bump_last_accessed(conn, cur, "entities", [r["id"] for r in rows])
944
973
  return {"results": [dict(r) for r in rows]}
945
974
 
946
975
 
@@ -977,10 +1006,10 @@ async def list_facts(req: GraphQueryRequest):
977
1006
  SELECT f.id, f.arena, f.category, f.predicate, f.statement,
978
1007
  f.subject_entity_id, f.object_entity_id,
979
1008
  f.confidence, f.stage, f.asserted_at,
980
- f.provenance_event_ids
1009
+ f.salience, f.provenance_event_ids
981
1010
  FROM facts f
982
1011
  WHERE {' AND '.join(conditions)}
983
- ORDER BY f.asserted_at DESC
1012
+ ORDER BY f.salience DESC, f.asserted_at DESC
984
1013
  LIMIT %s
985
1014
  """
986
1015
  params.append(req.limit)
@@ -988,6 +1017,7 @@ async def list_facts(req: GraphQueryRequest):
988
1017
  async with conn.cursor() as cur:
989
1018
  await cur.execute(sql, params)
990
1019
  rows = await cur.fetchall()
1020
+ await _bump_last_accessed(conn, cur, "facts", [r["id"] for r in rows])
991
1021
  return {"results": [dict(r) for r in rows]}
992
1022
 
993
1023
 
@@ -1013,13 +1043,13 @@ async def list_relationships(req: GraphQueryRequest):
1013
1043
  r.from_entity_id, r.to_entity_id,
1014
1044
  ef.canonical_name AS from_name,
1015
1045
  et.canonical_name AS to_name,
1016
- r.first_seen, r.last_seen,
1046
+ r.first_seen, r.last_seen, r.salience,
1017
1047
  r.provenance_event_ids
1018
1048
  FROM relationships r
1019
1049
  JOIN entities ef ON ef.id = r.from_entity_id
1020
1050
  JOIN entities et ON et.id = r.to_entity_id
1021
1051
  WHERE {' AND '.join(conditions)}
1022
- ORDER BY r.last_seen DESC
1052
+ ORDER BY r.salience DESC, r.last_seen DESC
1023
1053
  LIMIT %s
1024
1054
  """
1025
1055
  params.append(req.limit)
@@ -1027,6 +1057,7 @@ async def list_relationships(req: GraphQueryRequest):
1027
1057
  async with conn.cursor() as cur:
1028
1058
  await cur.execute(sql, params)
1029
1059
  rows = await cur.fetchall()
1060
+ await _bump_last_accessed(conn, cur, "relationships", [r["id"] for r in rows])
1030
1061
  return {"results": [dict(r) for r in rows]}
1031
1062
 
1032
1063
 
@@ -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