@pentatonic-ai/ai-agent-sdk 0.9.6 → 0.10.0

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.
Files changed (127) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +1 -1
  3. package/bin/commands/config.js +1 -1
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +2 -2
  7. package/packages/doctor/src/checks/local-memory.js +2 -2
  8. package/packages/memory/README.md +2 -2
  9. package/packages/memory/openclaw-plugin/README.md +2 -2
  10. package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
  11. package/packages/memory/src/server.js +2 -2
  12. package/packages/memory-engine-v2/.env.example +30 -0
  13. package/packages/memory-engine-v2/README.md +125 -0
  14. package/packages/memory-engine-v2/compat/Dockerfile +11 -0
  15. package/packages/memory-engine-v2/compat/requirements.txt +6 -0
  16. package/packages/memory-engine-v2/compat/server.py +1047 -0
  17. package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
  18. package/packages/memory-engine-v2/docker-compose.yml +206 -0
  19. package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
  20. package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
  21. package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
  22. package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
  23. package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
  24. package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
  25. package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
  26. package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
  27. package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
  28. package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
  29. package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
  30. package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
  31. package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
  32. package/packages/memory-engine/.env.example +0 -13
  33. package/packages/memory-engine/MIGRATION.md +0 -219
  34. package/packages/memory-engine/README.md +0 -145
  35. package/packages/memory-engine/bench/README.md +0 -99
  36. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
  37. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
  38. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
  39. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
  40. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
  41. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
  42. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
  43. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
  44. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
  45. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
  48. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
  49. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
  50. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
  51. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
  60. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
  61. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
  62. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
  63. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
  66. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
  67. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
  68. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
  69. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
  78. package/packages/memory-engine/compat/Dockerfile +0 -22
  79. package/packages/memory-engine/compat/server.py +0 -1255
  80. package/packages/memory-engine/docker-compose.test.yml +0 -59
  81. package/packages/memory-engine/docker-compose.yml +0 -255
  82. package/packages/memory-engine/engine/README.md +0 -52
  83. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
  84. package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
  85. package/packages/memory-engine/engine/l6-document-store.py +0 -1018
  86. package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
  87. package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -562
  88. package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
  89. package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
  90. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
  91. package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
  92. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
  93. package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
  94. package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
  95. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
  96. package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
  97. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  98. package/packages/memory-engine/pme_memory/__main__.py +0 -129
  99. package/packages/memory-engine/pme_memory/artifacts.py +0 -95
  100. package/packages/memory-engine/pme_memory/embed.py +0 -74
  101. package/packages/memory-engine/pme_memory/health.py +0 -36
  102. package/packages/memory-engine/pme_memory/hygiene.py +0 -159
  103. package/packages/memory-engine/pme_memory/indexer.py +0 -200
  104. package/packages/memory-engine/pme_memory/needs.py +0 -55
  105. package/packages/memory-engine/pme_memory/provenance.py +0 -80
  106. package/packages/memory-engine/pme_memory/scoring.py +0 -168
  107. package/packages/memory-engine/pme_memory/search.py +0 -52
  108. package/packages/memory-engine/pme_memory/store.py +0 -86
  109. package/packages/memory-engine/pme_memory/synthesis.py +0 -114
  110. package/packages/memory-engine/pyproject.toml +0 -65
  111. package/packages/memory-engine/scripts/kg-extractor.py +0 -557
  112. package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
  113. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
  114. package/packages/memory-engine/tests/e2e_arena.sh +0 -259
  115. package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
  116. package/packages/memory-engine/tests/embed_stub/server.py +0 -80
  117. package/packages/memory-engine/tests/test_aggregate.py +0 -333
  118. package/packages/memory-engine/tests/test_api_contract.sh +0 -57
  119. package/packages/memory-engine/tests/test_arena_safety.py +0 -232
  120. package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
  121. package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
  122. package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
  123. package/packages/memory-engine/tests/test_embed_provider.py +0 -693
  124. package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
  125. package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
  126. package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
  127. package/packages/memory-engine/tests/test_people_list_reader.py +0 -432
@@ -0,0 +1,356 @@
1
+ """End-to-end smoke tests for pentatonic-memory-engine v2.
2
+
3
+ Run on the engine host against compat at http://localhost:8199. Verifies:
4
+
5
+ 1. /health + /health/deep
6
+ 2. Single /store works end-to-end (compat → extractor-sync → org-model +
7
+ vector-index)
8
+ 3. /search finds the stored record, late-materialisation hydrates content
9
+ 4. Idempotency: same arena+content → same content-hash ID
10
+ 5. /store-batch works with the exact wire shape TES's
11
+ engineStoreBatch helper posts (this is the TES-cutover gate)
12
+ 6. Cross-arena isolation: search in arena A doesn't return arena B records
13
+ 7. /forget by id deletes from both stores
14
+ 8. /forget by metadata_contains (with arena filter) deletes matching set
15
+ 9. FORGET cascade: extracted entities for an event vanish when the event
16
+ is forgotten
17
+
18
+ Exits 0 on success, 1 on any failure. Prints a clear pass/fail line per
19
+ test so the SSM output is greppable.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ import time
27
+ import urllib.request
28
+ import urllib.error
29
+
30
+ BASE = "http://localhost:8199"
31
+
32
+
33
+ def _http(method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
34
+ data = json.dumps(body).encode() if body is not None else None
35
+ req = urllib.request.Request(
36
+ f"{BASE}{path}",
37
+ method=method,
38
+ data=data,
39
+ headers={"Content-Type": "application/json"} if body else {},
40
+ )
41
+ try:
42
+ with urllib.request.urlopen(req, timeout=30) as r:
43
+ return r.status, json.loads(r.read())
44
+ except urllib.error.HTTPError as e:
45
+ return e.code, {"_error": e.read().decode(errors="replace")[:500]}
46
+
47
+
48
+ _passes = 0
49
+ _fails: list[str] = []
50
+
51
+
52
+ def _assert(cond: bool, msg: str) -> None:
53
+ global _passes
54
+ if cond:
55
+ _passes += 1
56
+ print(f" PASS {msg}")
57
+ else:
58
+ _fails.append(msg)
59
+ print(f" FAIL {msg}")
60
+
61
+
62
+ def section(name: str) -> None:
63
+ print(f"\n=== {name} ===")
64
+
65
+
66
+ # ----------------------------------------------------------------------
67
+ # 1. Health
68
+ # ----------------------------------------------------------------------
69
+
70
+ section("health")
71
+ code, body = _http("GET", "/health")
72
+ _assert(code == 200, f"GET /health returns 200 (got {code})")
73
+ _assert(body.get("status") == "healthy", f"health.status == healthy (got {body.get('status')})")
74
+ _assert(body.get("service") == "pme2-compat", f"health.service identifies pme2-compat")
75
+
76
+ code, body = _http("GET", "/health/deep")
77
+ _assert(code == 200, f"GET /health/deep returns 200 (got {code})")
78
+ stores = body.get("stores", {})
79
+ _assert(stores.get("org_model", {}).get("status") == "ok", "health/deep: org_model ok")
80
+ _assert(stores.get("vector_index", {}).get("status") == "ok", "health/deep: vector_index ok")
81
+ _assert(stores.get("embed_gateway", {}).get("status") == "ok", "health/deep: embed_gateway ok")
82
+ _assert(stores.get("embed_gateway", {}).get("dim") == 4096, "health/deep: embed dim is 4096")
83
+
84
+
85
+ # ----------------------------------------------------------------------
86
+ # 2-4. /store + /search + idempotency
87
+ # ----------------------------------------------------------------------
88
+
89
+ section("/store + /search + idempotency")
90
+
91
+ # Use a unique arena per test run so we don't collide with anything else
92
+ # accumulating in the engine. Time-based suffix.
93
+ ARENA_A = f"e2e-test-a-{int(time.time())}"
94
+ ARENA_B = f"e2e-test-b-{int(time.time())}"
95
+
96
+ # Single store.
97
+ store_body = {
98
+ "content": "The quarterly review confirmed that the integration project is on track for Q3 delivery",
99
+ "metadata": {"arena": ARENA_A, "clientId": ARENA_A, "kind": "doc"},
100
+ }
101
+ code, resp = _http("POST", "/store", store_body)
102
+ _assert(code == 200, f"POST /store returns 200 (got {code})")
103
+ event_id_1 = resp.get("id")
104
+ _assert(isinstance(event_id_1, str) and len(event_id_1) == 32, f"store returns 32-char hex id (got {event_id_1!r})")
105
+ _assert(resp.get("layerId", "").startswith("ml_"), "store returns layerId in v1-compat shape")
106
+
107
+ # Idempotency: same arena + content → same id.
108
+ code2, resp2 = _http("POST", "/store", store_body)
109
+ _assert(code2 == 200, "second /store with same content returns 200")
110
+ _assert(resp2.get("id") == event_id_1, f"idempotent: same content → same id (got {resp2.get('id')} vs {event_id_1})")
111
+
112
+ # Different content → different id.
113
+ store_body2 = {**store_body, "content": "Different content same arena should yield a different id deterministically"}
114
+ code3, resp3 = _http("POST", "/store", store_body2)
115
+ _assert(code3 == 200, "third /store (different content) returns 200")
116
+ _assert(resp3.get("id") != event_id_1, "different content → different id")
117
+ event_id_2 = resp3.get("id")
118
+
119
+ # Search should find both.
120
+ time.sleep(1) # let Qdrant settle
121
+ search_body = {"query": "quarterly review integration project", "arena": ARENA_A, "limit": 5}
122
+ code, sresp = _http("POST", "/search", search_body)
123
+ _assert(code == 200, f"POST /search returns 200 (got {code})")
124
+ results = sresp.get("results", [])
125
+ _assert(len(results) >= 1, f"/search returns at least 1 result (got {len(results)})")
126
+ ids_found = [r["id"] for r in results]
127
+ _assert(event_id_1 in ids_found, f"/search finds first stored event ({event_id_1[:8]}...)")
128
+ _assert(all(r.get("similarity", 0) > 0 for r in results), "every result has similarity > 0")
129
+ # Late materialisation: content is hydrated from org-model
130
+ _assert(any("integration project" in r.get("content", "") for r in results),
131
+ "late materialisation: full content (not just preview) in response")
132
+
133
+
134
+ # ----------------------------------------------------------------------
135
+ # 5. TES wire shape — exact engineStoreBatch body
136
+ # ----------------------------------------------------------------------
137
+
138
+ section("TES engineStoreBatch wire shape")
139
+
140
+ # This is the exact body TES's SDK posts (see ai-agent-sdk packages/memory/src/engine.js).
141
+ # Gate: v2 must accept this without modification.
142
+ tes_arena = f"e2e-tes-{int(time.time())}"
143
+ tes_body = {
144
+ "records": [
145
+ {
146
+ "id": "stable-dedup-key-1",
147
+ "content": "First record from the simulated TES consumer drain",
148
+ "metadata": {
149
+ "arena": tes_arena,
150
+ "layer_type": "episodic",
151
+ "actor_user_id": "u-philh",
152
+ "kind": "chat",
153
+ "channel": "slack",
154
+ },
155
+ },
156
+ {
157
+ "id": "stable-dedup-key-2",
158
+ "content": "Second record, same batch, different content payload",
159
+ "metadata": {
160
+ "arena": tes_arena,
161
+ "layer_type": "episodic",
162
+ "actor_user_id": "u-philh",
163
+ "kind": "chat",
164
+ "channel": "slack",
165
+ },
166
+ },
167
+ {
168
+ # No caller-supplied id — v2 derives content-hash id.
169
+ "content": "Third record without explicit id, server derives the content-hash",
170
+ "metadata": {
171
+ "arena": tes_arena,
172
+ "layer_type": "episodic",
173
+ "actor_user_id": "u-philh",
174
+ "kind": "chat",
175
+ },
176
+ },
177
+ ],
178
+ }
179
+ code, resp = _http("POST", "/store-batch", tes_body)
180
+ _assert(code == 200, f"POST /store-batch (TES shape) returns 200 (got {code})")
181
+ _assert(resp.get("status") == "ok", f"store-batch.status == ok (got {resp.get('status')})")
182
+ _assert(resp.get("inserted") == 3, f"store-batch.inserted == 3 (got {resp.get('inserted')})")
183
+ ids = resp.get("ids", [])
184
+ _assert(len(ids) == 3, f"store-batch.ids has 3 entries (got {len(ids)})")
185
+ _assert(all(isinstance(i, str) and len(i) == 32 for i in ids), "all ids are 32-char hex content-hashes")
186
+
187
+ # Search for one of them.
188
+ time.sleep(1)
189
+ code, sresp = _http("POST", "/search", {"query": "simulated TES consumer", "arena": tes_arena, "limit": 5})
190
+ _assert(code == 200, "/search (TES arena) returns 200")
191
+ _assert(len(sresp.get("results", [])) >= 1, "TES batch records are findable via /search")
192
+
193
+
194
+ # ----------------------------------------------------------------------
195
+ # 6. Cross-arena isolation
196
+ # ----------------------------------------------------------------------
197
+
198
+ section("cross-arena isolation")
199
+
200
+ # Write something to arena B.
201
+ code, resp = _http("POST", "/store", {
202
+ "content": "Secrets that must not leak into the other tenant under any condition",
203
+ "metadata": {"arena": ARENA_B, "clientId": ARENA_B, "kind": "doc"},
204
+ })
205
+ _assert(code == 200, "store in arena B succeeds")
206
+
207
+ time.sleep(1)
208
+ # Search arena A — should NOT find the arena B record. Match is
209
+ # case-insensitive — stored content uses capital "Secrets".
210
+ code, sresp = _http("POST", "/search", {"query": "secrets must not leak", "arena": ARENA_A, "limit": 5})
211
+ _assert(code == 200, "search in arena A returns 200")
212
+ contents_a = [r.get("content", "").lower() for r in sresp.get("results", [])]
213
+ _assert(not any("secrets" in c for c in contents_a),
214
+ "isolation: arena B's content does NOT appear in arena A search")
215
+
216
+ # Search arena B — should find its own record.
217
+ code, sresp = _http("POST", "/search", {"query": "secrets must not leak", "arena": ARENA_B, "limit": 5})
218
+ contents_b = [r.get("content", "").lower() for r in sresp.get("results", [])]
219
+ _assert(any("secrets" in c for c in contents_b),
220
+ "arena B can find its own records")
221
+
222
+
223
+ # ----------------------------------------------------------------------
224
+ # 7. /forget by id
225
+ # ----------------------------------------------------------------------
226
+
227
+ section("/forget by id")
228
+
229
+ # Forget event_id_2 (the second arena-A record).
230
+ code, fresp = _http("POST", "/forget", {"id": event_id_2})
231
+ _assert(code == 200, "/forget by id returns 200")
232
+ _assert(fresp.get("deleted") == 1, f"/forget deletes exactly 1 (got {fresp.get('deleted')})")
233
+
234
+ time.sleep(1)
235
+ # Now search arena A — event_id_1 should still be there, event_id_2 should be gone.
236
+ code, sresp = _http("POST", "/search", {"query": "Different content same arena", "arena": ARENA_A, "limit": 5})
237
+ ids_after = [r["id"] for r in sresp.get("results", [])]
238
+ _assert(event_id_2 not in ids_after, f"forgotten event no longer in search results")
239
+
240
+
241
+ # ----------------------------------------------------------------------
242
+ # 8. /forget by metadata_contains
243
+ # ----------------------------------------------------------------------
244
+
245
+ section("/forget by metadata_contains")
246
+
247
+ # Forget the entire TES batch by arena filter.
248
+ code, fresp = _http("POST", "/forget", {"metadata_contains": {"arena": tes_arena}})
249
+ _assert(code == 200, "/forget by metadata_contains returns 200")
250
+ _assert(fresp.get("deleted", 0) >= 3, f"forget-by-arena deletes >= 3 (got {fresp.get('deleted')})")
251
+
252
+ time.sleep(1)
253
+ # Search should return nothing.
254
+ code, sresp = _http("POST", "/search", {"query": "simulated TES consumer", "arena": tes_arena, "limit": 5})
255
+ _assert(len(sresp.get("results", [])) == 0,
256
+ f"TES arena search after forget returns empty (got {len(sresp.get('results', []))})")
257
+
258
+
259
+ # ----------------------------------------------------------------------
260
+ # 9. /search requires arena (no implicit "general" wildcard)
261
+ # ----------------------------------------------------------------------
262
+
263
+ section("/search rejects unscoped query")
264
+
265
+ code, sresp = _http("POST", "/search", {"query": "no arena specified", "limit": 5})
266
+ _assert(code == 400, f"/search without arena returns 400 (got {code}) — v2 explicit-scope contract")
267
+
268
+
269
+ # ----------------------------------------------------------------------
270
+ # 10. TES engineSearch wire shape — arenas[] list + arena single-field
271
+ # ----------------------------------------------------------------------
272
+
273
+ section("TES engineSearch wire shape (arenas list)")
274
+
275
+ # This is what the SDK's engineSearch helper actually posts: arenas[]
276
+ # list (authoritative) + arena single-field (back-compat). v2 reads
277
+ # arenas[] when present; ignoring arena is the right behaviour.
278
+ tes_search_arena = f"e2e-tes-search-{int(time.time())}"
279
+ tes_user_arena = f"{tes_search_arena}:u-philh"
280
+ _http("POST", "/store", {
281
+ "content": "User-scoped memory only visible in the user-arena lane",
282
+ "metadata": {"arena": tes_user_arena, "clientId": tes_search_arena, "kind": "chat"},
283
+ })
284
+ _http("POST", "/store", {
285
+ "content": "Tenant-wide memory visible across the whole client",
286
+ "metadata": {"arena": tes_search_arena, "clientId": tes_search_arena, "kind": "doc"},
287
+ })
288
+ time.sleep(1)
289
+
290
+ # engineSearch with userId composes arenas=[clientId, clientId:userId] —
291
+ # caller sees their own AND tenant-wide records, never another user's.
292
+ body = {
293
+ "query": "memory tenant user-scoped visible",
294
+ "arenas": [tes_search_arena, tes_user_arena],
295
+ "arena": tes_search_arena,
296
+ "limit": 10,
297
+ "min_score": 0.001,
298
+ }
299
+ code, sresp = _http("POST", "/search", body)
300
+ _assert(code == 200, f"engineSearch wire shape returns 200 (got {code})")
301
+ ids = [r["id"] for r in sresp.get("results", [])]
302
+ arenas_seen = {r.get("metadata", {}).get("arena") for r in sresp.get("results", [])}
303
+ _assert(len(sresp.get("results", [])) >= 2, f"multi-arena search returns both records (got {len(sresp.get('results', []))})")
304
+ _assert(tes_search_arena in arenas_seen and tes_user_arena in arenas_seen,
305
+ f"both arena and user-arena records present (got {arenas_seen})")
306
+
307
+
308
+ # ----------------------------------------------------------------------
309
+ # 11. TES engineForget wire shape — top-level arena + id
310
+ # ----------------------------------------------------------------------
311
+
312
+ section("TES engineForget wire shape (top-level arena + id)")
313
+
314
+ # Plant a record to forget.
315
+ code, resp = _http("POST", "/store", {
316
+ "content": "Record specifically to be forgotten via the engineForget(id=...) wire shape",
317
+ "metadata": {"arena": ARENA_A, "clientId": ARENA_A, "kind": "doc"},
318
+ })
319
+ forget_target_id = resp["id"]
320
+
321
+ # SDK engineForget(id=X) posts {arena: clientId, id: X}. v2 should use
322
+ # id and ignore the redundant top-level arena.
323
+ code, fresp = _http("POST", "/forget", {"arena": ARENA_A, "id": forget_target_id})
324
+ _assert(code == 200, f"engineForget(id) with top-level arena returns 200 (got {code})")
325
+ _assert(fresp.get("deleted") == 1, f"deletes exactly 1 by id (got {fresp.get('deleted')})")
326
+
327
+
328
+ # ----------------------------------------------------------------------
329
+ # 12. Endpoints NOT yet implemented (gracefully missing)
330
+ # ----------------------------------------------------------------------
331
+
332
+ section("Endpoints v2 doesn't implement yet")
333
+
334
+ # personFacets calls /aggregate; peopleList calls /people-list-internal.
335
+ # v2 doesn't implement these (waiting on keystone spec + recursive-CTE
336
+ # spike). They should 404, not crash — TES resolvers have fallback
337
+ # paths that handle missing aggregate endpoints gracefully.
338
+ code, _ = _http("POST", "/aggregate", {"arena": ARENA_A, "contact_email": "a@b.com"})
339
+ _assert(code == 404, f"/aggregate returns 404 (v2 doesn't implement yet) (got {code})")
340
+ code, _ = _http("POST", "/people-list-internal", {"arenas": [ARENA_A]})
341
+ _assert(code == 404, f"/people-list-internal returns 404 (v2 doesn't implement yet) (got {code})")
342
+
343
+
344
+ # ----------------------------------------------------------------------
345
+ # Done
346
+ # ----------------------------------------------------------------------
347
+
348
+ print(f"\n{'=' * 60}")
349
+ if _fails:
350
+ print(f"FAIL — {len(_fails)} failure(s):")
351
+ for f in _fails:
352
+ print(f" - {f}")
353
+ print(f"passes: {_passes}")
354
+ sys.exit(1)
355
+ print(f"OK — all {_passes} assertions passed")
356
+ sys.exit(0)