@pentatonic-ai/ai-agent-sdk 0.7.7 → 0.7.9
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.
|
|
3
|
+
"version": "0.7.9",
|
|
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
|
|
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";
|
|
@@ -438,12 +438,25 @@ async def health():
|
|
|
438
438
|
return out
|
|
439
439
|
|
|
440
440
|
|
|
441
|
+
def _arena_scoped_rid(arena: str, content: str) -> str:
|
|
442
|
+
"""Stable, arena-scoped record id.
|
|
443
|
+
|
|
444
|
+
Same content in different arenas hashes to *different* ids — so two
|
|
445
|
+
tenants storing the byte-identical chunk get distinct primary keys
|
|
446
|
+
in the underlying stores (L4 sqlite-vec, L5 Milvus, L6 Milvus).
|
|
447
|
+
Without this, arena=tenant-b's write would overwrite arena=tenant-a's
|
|
448
|
+
row of the same content, silently breaking multi-tenant isolation
|
|
449
|
+
for any duplicated phrase.
|
|
450
|
+
"""
|
|
451
|
+
return hashlib.sha1(f"{arena}:{content}".encode()).hexdigest()[:32]
|
|
452
|
+
|
|
453
|
+
|
|
441
454
|
@app.post("/store")
|
|
442
455
|
async def store(req: StoreRequest):
|
|
443
456
|
"""Single-record ingest. Same wire format as pentatonic-memory v0.5."""
|
|
444
|
-
rid = (req.metadata or {}).get("id") or hashlib.sha1(req.content.encode()).hexdigest()[:32]
|
|
445
|
-
record = {"id": rid, "content": req.content, "metadata": req.metadata or {}}
|
|
446
457
|
arena = (req.metadata or {}).get("arena", "general")
|
|
458
|
+
rid = (req.metadata or {}).get("id") or _arena_scoped_rid(arena, req.content)
|
|
459
|
+
record = {"id": rid, "content": req.content, "metadata": req.metadata or {}}
|
|
447
460
|
|
|
448
461
|
# Stash the full metadata under every key shape any layer could echo back.
|
|
449
462
|
# L5/L6 use derivatives of rid; L2-internal returns paths shaped like
|
|
@@ -483,16 +496,16 @@ async def store_batch(req: StoreBatchRequest):
|
|
|
483
496
|
return {"inserted": 0, "ids": []}
|
|
484
497
|
|
|
485
498
|
# Normalise each record to {id, content, metadata}.
|
|
499
|
+
arena = req.arena or "general"
|
|
486
500
|
normalised = []
|
|
487
501
|
for r in req.records:
|
|
488
502
|
content = r.get("content") or r.get("text") or ""
|
|
489
503
|
if not content:
|
|
490
504
|
continue
|
|
491
|
-
rid = r.get("id") or
|
|
505
|
+
rid = r.get("id") or _arena_scoped_rid(arena, content)
|
|
492
506
|
normalised.append({"id": rid, "content": content, "metadata": r.get("metadata") or {}})
|
|
493
507
|
|
|
494
508
|
# Stash metadata for every record so /search can re-attach it.
|
|
495
|
-
arena = req.arena or "general"
|
|
496
509
|
for r in normalised:
|
|
497
510
|
_stash_all_keys(r["id"], r.get("metadata") or {}, arena)
|
|
498
511
|
|
|
@@ -970,6 +970,77 @@ def serve(port: int = DEFAULT_PORT):
|
|
|
970
970
|
milvus.load_collection(COLLECTION_NAME)
|
|
971
971
|
return {"status": "rebuilt"}
|
|
972
972
|
|
|
973
|
+
@api.post("/repair-fts")
|
|
974
|
+
def api_repair_fts():
|
|
975
|
+
"""Backfill the SQLite `chunks` content table from Milvus.
|
|
976
|
+
|
|
977
|
+
Pre-v0.7.6 the /index-batch path wrote straight to the FTS5
|
|
978
|
+
virtual table and never populated `chunks`. The result: BM25
|
|
979
|
+
search (which JOINs chunks ON rowid) returned zero hits even
|
|
980
|
+
though Milvus had the data, and /stats fts_chunks reported 0.
|
|
981
|
+
|
|
982
|
+
v0.7.6 fixed new writes; this endpoint cleans up old rows by
|
|
983
|
+
walking Milvus and INSERT-OR-REPLACE'ing every row into chunks.
|
|
984
|
+
The trigger on chunks then mirrors them into chunks_fts. Idempotent.
|
|
985
|
+
Runs in-process so the L6 service's already-open Milvus handle
|
|
986
|
+
is reused — no file-lock conflict (Milvus Lite locks the .db).
|
|
987
|
+
"""
|
|
988
|
+
milvus = get_milvus()
|
|
989
|
+
# Milvus Lite caps query() at 16384 per call; page through.
|
|
990
|
+
page_size = 16384
|
|
991
|
+
offset = 0
|
|
992
|
+
rows: list = []
|
|
993
|
+
while True:
|
|
994
|
+
page = milvus.query(
|
|
995
|
+
COLLECTION_NAME,
|
|
996
|
+
filter="id != ''",
|
|
997
|
+
output_fields=[
|
|
998
|
+
"id", "text", "source_file", "arena", "doc_type",
|
|
999
|
+
"heading", "chunk_index", "content_hash",
|
|
1000
|
+
"entities_json", "indexed_at",
|
|
1001
|
+
],
|
|
1002
|
+
limit=page_size,
|
|
1003
|
+
offset=offset,
|
|
1004
|
+
)
|
|
1005
|
+
if not page:
|
|
1006
|
+
break
|
|
1007
|
+
rows.extend(page)
|
|
1008
|
+
if len(page) < page_size:
|
|
1009
|
+
break
|
|
1010
|
+
offset += page_size
|
|
1011
|
+
fts_conn = get_fts_db()
|
|
1012
|
+
repaired = 0
|
|
1013
|
+
for r in rows:
|
|
1014
|
+
try:
|
|
1015
|
+
fts_conn.execute(
|
|
1016
|
+
"INSERT OR REPLACE INTO chunks "
|
|
1017
|
+
"(id, text, source_file, arena, doc_type, heading, "
|
|
1018
|
+
" chunk_index, content_hash, entities_json, indexed_at) "
|
|
1019
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1020
|
+
(
|
|
1021
|
+
r.get("id"),
|
|
1022
|
+
r.get("text", "") or "",
|
|
1023
|
+
r.get("source_file", "") or "",
|
|
1024
|
+
r.get("arena", "") or "general",
|
|
1025
|
+
r.get("doc_type", "") or "general",
|
|
1026
|
+
r.get("heading", "") or "",
|
|
1027
|
+
int(r.get("chunk_index", 0) or 0),
|
|
1028
|
+
r.get("content_hash", "") or "",
|
|
1029
|
+
r.get("entities_json", "") or "[]",
|
|
1030
|
+
r.get("indexed_at", "") or "",
|
|
1031
|
+
),
|
|
1032
|
+
)
|
|
1033
|
+
repaired += 1
|
|
1034
|
+
except Exception as exc:
|
|
1035
|
+
log.warning("repair-fts: skipping row %s: %s", r.get("id"), exc)
|
|
1036
|
+
fts_conn.commit()
|
|
1037
|
+
fts_conn.close()
|
|
1038
|
+
return {
|
|
1039
|
+
"status": "ok",
|
|
1040
|
+
"milvus_rows": len(rows),
|
|
1041
|
+
"repaired": repaired,
|
|
1042
|
+
}
|
|
1043
|
+
|
|
973
1044
|
log.info(f"L6 Document Store — http://127.0.0.1:{port}")
|
|
974
1045
|
uvicorn.run(api, host=os.environ.get("HOST","127.0.0.1"), port=port, log_level="info")
|
|
975
1046
|
|
|
@@ -125,6 +125,37 @@ print("yes" if ok and data else "no")')
|
|
|
125
125
|
[ "$all_match" = "yes" ] && ok "metadata_filter scopes to probe + arena" \
|
|
126
126
|
|| fail "metadata_filter let other rows through"
|
|
127
127
|
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Same content across two arenas — proves the arena-aware id derivation.
|
|
130
|
+
# Pre-v0.7.8, identical content collapsed to one row in L4/L5/L6 because
|
|
131
|
+
# the id was sha1(content); the second tenant's write overwrote the first.
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
echo ""
|
|
135
|
+
echo "=== same-content-across-arenas ==="
|
|
136
|
+
post '{"content":"shared phrase about Eclipse","metadata":{"arena":"e2e-tenant-x","probe":"e2e-arena"}}' >/dev/null
|
|
137
|
+
post '{"content":"shared phrase about Eclipse","metadata":{"arena":"e2e-tenant-y","probe":"e2e-arena"}}' >/dev/null
|
|
138
|
+
sleep 3
|
|
139
|
+
|
|
140
|
+
SX=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
|
|
141
|
+
-d '{"query":"Eclipse","limit":10,"arena":"e2e-tenant-x"}')
|
|
142
|
+
SY=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
|
|
143
|
+
-d '{"query":"Eclipse","limit":10,"arena":"e2e-tenant-y"}')
|
|
144
|
+
|
|
145
|
+
x_has_phrase=$(echo "$SX" | python3 -c '
|
|
146
|
+
import json,sys
|
|
147
|
+
data=json.load(sys.stdin).get("results",[])
|
|
148
|
+
print("yes" if any("Eclipse" in r.get("content","") for r in data) else "no")')
|
|
149
|
+
y_has_phrase=$(echo "$SY" | python3 -c '
|
|
150
|
+
import json,sys
|
|
151
|
+
data=json.load(sys.stdin).get("results",[])
|
|
152
|
+
print("yes" if any("Eclipse" in r.get("content","") for r in data) else "no")')
|
|
153
|
+
|
|
154
|
+
[ "$x_has_phrase" = "yes" ] && ok "tenant-x: shared phrase preserved" \
|
|
155
|
+
|| fail "tenant-x lost the shared phrase (id collision?)"
|
|
156
|
+
[ "$y_has_phrase" = "yes" ] && ok "tenant-y: shared phrase preserved" \
|
|
157
|
+
|| fail "tenant-y lost the shared phrase (id collision?)"
|
|
158
|
+
|
|
128
159
|
# ---------------------------------------------------------------------------
|
|
129
160
|
# /forget — by metadata_contains. Cleans up so reruns are idempotent.
|
|
130
161
|
# ---------------------------------------------------------------------------
|