@pentatonic-ai/ai-agent-sdk 0.7.8 → 0.7.10

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.7.8",
3
+ "version": "0.7.10",
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",
@@ -41,6 +41,18 @@ import { distill } from "./distill.js";
41
41
  * ids) — pass the raw form here so retries of the same logical event
42
42
  * match across runs whose prefixes differ by a few ms. Defaults to
43
43
  * `content`.
44
+ * @param {"client" | "user"} [opts.dedupScope="client"] - Scope of the
45
+ * dedup match. Default `"client"`: byte-equal content for the tenant
46
+ * collapses to one row regardless of who emitted it (today's behaviour,
47
+ * appropriate when each row is single-owner). Set `"user"` to also
48
+ * require `user_id` equality, which lets multiple users legitimately
49
+ * own their own copy of the same shared content (private chat-channel
50
+ * members, group meeting attendees) — emit one ingest per member with
51
+ * the same content but different `userId`, all with
52
+ * `dedupScope: "user"`, and each user gets their own row with native
53
+ * per-user access counting / recency / decay. Requires `userId` to be
54
+ * set; when `userId` is null the option degrades to `"client"` scope
55
+ * (a global shared row still collapses cross-emit).
44
56
  * @param {number} [opts.dedupLegacyWindowDays=7] - How far back the
45
57
  * `[<iso>] <content>` legacy-form `LIKE` match scans. Default 7 days.
46
58
  * The leading-wildcard `LIKE` can't use a btree index, so without a
@@ -94,6 +106,11 @@ export async function ingest(db, ai, llm, content, opts = {}) {
94
106
  opts.dedupLegacyWindowDays === undefined
95
107
  ? 7
96
108
  : Number(opts.dedupLegacyWindowDays);
109
+ // Per-user dedup requires `userId`; degrade to client-scope otherwise so
110
+ // a misuse can't accidentally relax the dedup boundary (we'd rather over-
111
+ // collapse than fragment the corpus on a missing userId).
112
+ const userScopedDedup = opts.dedupScope === "user" && !!opts.userId;
113
+ const userClause = userScopedDedup ? ` AND user_id = $4` : "";
97
114
  try {
98
115
  const sql =
99
116
  legacyWindowDays > 0
@@ -107,17 +124,18 @@ export async function ingest(db, ai, llm, content, opts = {}) {
107
124
  content LIKE '%] ' || $2
108
125
  AND created_at > NOW() - ($3 || ' days')::interval
109
126
  )
110
- )
127
+ )${userScopedDedup ? "\n AND user_id = $4" : ""}
111
128
  LIMIT 1`
112
129
  : `SELECT id, 'exact' AS match_kind
113
130
  FROM memory_nodes
114
131
  WHERE client_id = $1
115
- AND content = $2
132
+ AND content = $2${userClause}
116
133
  LIMIT 1`;
117
- const params =
134
+ const baseParams =
118
135
  legacyWindowDays > 0
119
136
  ? [clientId, dedupKey, String(legacyWindowDays)]
120
137
  : [clientId, dedupKey];
138
+ const params = userScopedDedup ? [...baseParams, opts.userId] : baseParams;
121
139
  const dupCheck = await db(sql, params);
122
140
  if (dupCheck.rows?.length) {
123
141
  const matchKind = dupCheck.rows[0].match_kind || "exact";
@@ -30,7 +30,6 @@ Environment:
30
30
  L6_DOC_URL default http://l6:8037
31
31
  NV_EMBED_URL default http://nv-embed:8041/v1/embeddings
32
32
  PORT default 8099 (matches pentatonic-memory v0.5)
33
- CLIENT_ID default "default"
34
33
  """
35
34
 
36
35
  import hashlib
@@ -63,7 +62,18 @@ NEO4J_AUTH = os.environ.get("NEO4J_AUTH", "neo4j/local-dev-pw")
63
62
  NEO4J_DB = os.environ.get("NEO4J_DB", "neo4j")
64
63
 
65
64
  PORT = int(os.environ.get("PORT", "8099"))
66
- CLIENT_ID = os.environ.get("CLIENT_ID", "default")
65
+
66
+
67
+ # Layer types we surface as the SDK 4-layer projection. Engine stores
68
+ # everything as chunks tagged with arena + layer_type metadata; this
69
+ # helper renders the legacy `ml_<arena>_<type>` layer-id from the
70
+ # per-row arena, so the response reflects the actual data not a
71
+ # deployment-wide constant. Falls back to "episodic" when arena or
72
+ # layer_type is missing.
73
+ def _layer_id(arena: Optional[str], layer_type: Optional[str] = None) -> str:
74
+ a = arena or "general"
75
+ t = layer_type or "episodic"
76
+ return f"ml_{a}_{t}"
67
77
 
68
78
  # Test/isolated mode: bypass the L2 HybridRAG orchestrator and query L6 directly.
69
79
  # Useful for bench harnesses where you want to validate the ingest+search
@@ -384,7 +394,6 @@ async def health():
384
394
  """
385
395
  out = {
386
396
  "status": "ok",
387
- "client": CLIENT_ID,
388
397
  "version": VERSION,
389
398
  "engine": "pentatonic-memory-engine",
390
399
  "layers": {},
@@ -476,7 +485,7 @@ async def store(req: StoreRequest):
476
485
  return {
477
486
  "id": rid,
478
487
  "content": req.content,
479
- "layerId": f"ml_{CLIENT_ID}_episodic",
488
+ "layerId": _layer_id(arena, (req.metadata or {}).get("layer_type")),
480
489
  "engine": {
481
490
  "l0": l2_internal.get("l0", 0),
482
491
  "l3_chunks": l2_internal.get("l3_chunks", 0),
@@ -717,13 +726,14 @@ async def search(req: SearchRequest):
717
726
  if item.get(k)
718
727
  }
719
728
  merged_meta = {**raw_top_level, **(attached_meta or item.get("metadata") or {})}
729
+ row_arena = merged_meta.get("arena")
730
+ row_layer_type = merged_meta.get("layer_type")
720
731
  out_results.append({
721
732
  "id": key,
722
733
  "content": item.get("text") or item.get("content") or item.get("snippet") or "",
723
734
  "metadata": merged_meta,
724
735
  "similarity": float(rrf_scores[key]),
725
- "layer_id": f"ml_{CLIENT_ID}_episodic",
726
- "client_id": CLIENT_ID,
736
+ "layer_id": _layer_id(row_arena, row_layer_type),
727
737
  "source": item.get("source_file") or item.get("path") or "",
728
738
  "engine_layer": "+".join(sorted(set(layer_provenance.get(key, [])))),
729
739
  })
@@ -819,13 +829,14 @@ async def search(req: SearchRequest):
819
829
  if item.get(k)
820
830
  }
821
831
  merged_meta = {**raw_top_level, **(attached_meta or item.get("metadata") or {})}
832
+ row_arena = merged_meta.get("arena")
833
+ row_layer_type = merged_meta.get("layer_type")
822
834
  out_results.append({
823
835
  "id": chosen_id,
824
836
  "content": item.get("text") or item.get("content") or item.get("snippet") or "",
825
837
  "metadata": merged_meta,
826
838
  "similarity": float(item.get("score") or item.get("similarity") or 0.0),
827
- "layer_id": f"ml_{CLIENT_ID}_episodic",
828
- "client_id": CLIENT_ID,
839
+ "layer_id": _layer_id(row_arena, row_layer_type),
829
840
  "source": item.get("source", item.get("source_file", "")),
830
841
  "engine_layer": item.get("layer", item.get("source_layer", "")),
831
842
  })
@@ -215,7 +215,6 @@ services:
215
215
  L5_MILVUS_URL: http://l5:8034
216
216
  L6_DOC_URL: http://l6:8037
217
217
  NV_EMBED_URL: ${NV_EMBED_URL:-http://host.docker.internal:8041/v1/embeddings}
218
- CLIENT_ID: ${CLIENT_ID:-default}
219
218
  BYPASS_L2_PROXY: ${BYPASS_L2_PROXY:-0}
220
219
  extra_hosts:
221
220
  - "host.docker.internal:host-gateway"