hebbrix 2.3.0__tar.gz → 2.3.2__tar.gz

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.
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## 2.3.2 — 2026-08-27
4
+
5
+ - Align procedure create/list/get/update/execute/delete with the canonical
6
+ `/v1/procedures` REST and OpenAPI contract, including idempotent `204`
7
+ deletion.
8
+ - Add explicit synchronous/asynchronous batch readiness receipts to the async
9
+ and synchronous clients.
10
+ - Add batch wait helpers that poll every accepted memory, propagate terminal
11
+ failures, respect deadlines, and support asynchronous cancellation.
12
+
13
+ ## 2.3.1 — 2026-08-26
14
+
15
+ - Make `wait_for_index=True` poll the authoritative memory or job readiness
16
+ endpoint instead of returning a non-searchable receipt.
17
+ - Add `memories.wait_until_searchable(...)` to the async and synchronous
18
+ clients, with explicit timeout and terminal-failure handling.
19
+ - Preserve valid evidence-bound search rows during a degraded or abstaining
20
+ server response while retaining the safety metadata; malformed and explicit
21
+ no-match envelopes still fail closed.
22
+ - Keep the synchronous and asynchronous memory create, scope, idempotency, and
23
+ search-safety behavior aligned.
24
+ - Add `temporal.delete_fact(fact_id)` for tenant-scoped, idempotent cleanup of
25
+ facts created through the temporal API.
26
+
27
+ The supported server/SDK release pair is published by the server OpenAPI
28
+ document in `info.x-hebbrix-sdk-compatibility`. Patch releases preserve the
29
+ public API within the same major version.
30
+
31
+ ## 2.3.0
32
+
33
+ - Added the GA scoped memory, corrections, search proof, and ProofLoop surface.
@@ -1,4 +1,5 @@
1
1
  include README.md
2
+ include CHANGELOG.md
2
3
  include LICENSE
3
4
  include pyproject.toml
4
5
  recursive-include hebbrix *.py
@@ -1,10 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration
5
5
  Author-email: Hebbrix Team <support@hebbrix.com>
6
6
  Maintainer-email: Hebbrix Team <support@hebbrix.com>
7
- License-Expression: MIT
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 Hebbrix
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
8
29
  Project-URL: Homepage, https://hebbrix.com
9
30
  Project-URL: Documentation, https://docs.hebbrix.com
10
31
  Project-URL: Repository, https://github.com/hebbrix/hebbrix-python
@@ -92,6 +113,22 @@ async def main():
92
113
  wait_for_index=True,
93
114
  )
94
115
 
116
+ # wait_for_index=True polls the returned memory status through the SDK.
117
+ # It returns only when searchable=true, raises on terminal failure, and
118
+ # raises TimeoutError if the caller's readiness deadline expires.
119
+
120
+ # Batch contract: a successful synchronous return means every accepted item
121
+ # is searchable. A bounded server timeout raises explicitly and is safe to
122
+ # retry with the same idempotency key; it is never a successful 202.
123
+ batch = await client.memories.create_batch(
124
+ [{"content": "First fact"}, {"content": "Second fact"}],
125
+ collection_id=collection["id"],
126
+ wait_for_index=True,
127
+ idempotency_key="import-42",
128
+ )
129
+ # For wait_for_index=False receipts:
130
+ # batch = await client.memories.wait_batch_until_searchable(batch)
131
+
95
132
  # Search memories
96
133
  results = await client.search(
97
134
  query="What programming language does user like?",
@@ -51,6 +51,22 @@ async def main():
51
51
  wait_for_index=True,
52
52
  )
53
53
 
54
+ # wait_for_index=True polls the returned memory status through the SDK.
55
+ # It returns only when searchable=true, raises on terminal failure, and
56
+ # raises TimeoutError if the caller's readiness deadline expires.
57
+
58
+ # Batch contract: a successful synchronous return means every accepted item
59
+ # is searchable. A bounded server timeout raises explicitly and is safe to
60
+ # retry with the same idempotency key; it is never a successful 202.
61
+ batch = await client.memories.create_batch(
62
+ [{"content": "First fact"}, {"content": "Second fact"}],
63
+ collection_id=collection["id"],
64
+ wait_for_index=True,
65
+ idempotency_key="import-42",
66
+ )
67
+ # For wait_for_index=False receipts:
68
+ # batch = await client.memories.wait_batch_until_searchable(batch)
69
+
54
70
  # Search memories
55
71
  results = await client.search(
56
72
  query="What programming language does user like?",
@@ -41,7 +41,7 @@ Features:
41
41
  - ✅ Type hints throughout
42
42
  """
43
43
 
44
- __version__ = "2.3.0"
44
+ __version__ = "2.3.2"
45
45
  __author__ = "Hebbrix Team"
46
46
  __license__ = "MIT"
47
47
 
@@ -91,7 +91,7 @@ class MemoryClient:
91
91
  """Get request headers."""
92
92
  headers = {
93
93
  "Content-Type": "application/json",
94
- "User-Agent": "hebbrix-python/2.3.0",
94
+ "User-Agent": "hebbrix-python/2.3.2",
95
95
  }
96
96
 
97
97
  if self.api_key:
@@ -127,12 +127,12 @@ def _canonical_search_envelope(
127
127
  elif data.get("no_match") and (row_ids or evidence_ids):
128
128
  reason = "no_match_contains_evidence"
129
129
 
130
- if (
131
- reason
132
- or data.get("degraded") is True
133
- or data.get("no_match") is True
134
- or data.get("abstain_recommended") is True
135
- ):
130
+ # Degradation and abstention are confidence signals, not proof that the
131
+ # API returned no evidence. Erasing valid, evidence-bound rows here made
132
+ # the SDK disagree with the REST response and converted a safe fallback
133
+ # into a false negative. Fail closed only when the envelope is malformed
134
+ # or the server explicitly reports no match.
135
+ if reason or data.get("no_match") is True:
136
136
  data[rows_key] = []
137
137
  if rows_key == "results":
138
138
  data["total"] = 0
@@ -147,6 +147,8 @@ def _canonical_search_envelope(
147
147
  "status": "no_grounded_match",
148
148
  "reason": reason,
149
149
  }
150
+ elif data.get("degraded") is True or data.get("abstain_recommended") is True:
151
+ data["sdk_safety_reason"] = "degraded_evidence_preserved"
150
152
  return data
151
153
 
152
154
 
@@ -347,6 +349,8 @@ class MemoriesResource(BaseResource):
347
349
  tags: Optional[List[str]] = None,
348
350
  source: Optional[str] = None,
349
351
  idempotency_key: Optional[str] = None,
352
+ index_timeout: float = 60.0,
353
+ index_poll_interval: float = 0.5,
350
354
  ) -> Dict[str, Any]:
351
355
  """
352
356
  Create a memory.
@@ -422,7 +426,199 @@ class MemoriesResource(BaseResource):
422
426
  request_kwargs: Dict[str, Any] = {"json": payload}
423
427
  if idempotency_key:
424
428
  request_kwargs["headers"] = {"Idempotency-Key": idempotency_key}
425
- return await self.client.post("/v1/memories", **request_kwargs)
429
+ receipt = await self.client.post("/v1/memories", **request_kwargs)
430
+ if wait_for_index:
431
+ receipt = await self._ensure_searchable_receipt(
432
+ receipt,
433
+ timeout=index_timeout,
434
+ poll_interval=index_poll_interval,
435
+ )
436
+ return receipt
437
+
438
+ async def create_batch(
439
+ self,
440
+ memories: List[Dict[str, Any]],
441
+ *,
442
+ collection_id: Optional[str] = None,
443
+ user_id: Optional[str] = None,
444
+ agent_id: Optional[str] = None,
445
+ run_id: Optional[str] = None,
446
+ app_id: Optional[str] = None,
447
+ namespace: Optional[str] = None,
448
+ wait_for_index: bool = False,
449
+ idempotency_key: Optional[str] = None,
450
+ ) -> Dict[str, Any]:
451
+ """Create a durable batch with explicit synchronous/async readiness.
452
+
453
+ When ``wait_for_index`` is true, a successful return is guaranteed to
454
+ have ``processing_status=completed`` and ``searchable=true``. A bounded
455
+ server timeout raises an HTTP error and may be retried with the same
456
+ idempotency key; it is never returned as a successful processing state.
457
+ """
458
+
459
+ if not 1 <= len(memories) <= 100:
460
+ raise ValueError("memories must contain between 1 and 100 items")
461
+ if any(not str(item.get("content") or "").strip() for item in memories):
462
+ raise ValueError("every batch memory must contain non-empty content")
463
+ payload = {
464
+ "memories": memories,
465
+ "wait_for_index": wait_for_index,
466
+ }
467
+ optional = {
468
+ "collection_id": collection_id,
469
+ "user_id": user_id,
470
+ "agent_id": agent_id,
471
+ "run_id": run_id,
472
+ "app_id": app_id,
473
+ "namespace": namespace,
474
+ }
475
+ payload.update(
476
+ {key: value for key, value in optional.items() if value is not None}
477
+ )
478
+ kwargs: Dict[str, Any] = {"json": payload}
479
+ if idempotency_key:
480
+ kwargs["headers"] = {"Idempotency-Key": idempotency_key}
481
+ receipt = await self.client.post("/v1/memories/batch", **kwargs)
482
+ if wait_for_index and not (
483
+ receipt.get("searchable") is True
484
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
485
+ ):
486
+ raise RuntimeError(
487
+ "wait_for_index batch response was not fully searchable; retry "
488
+ "with the same Idempotency-Key"
489
+ )
490
+ return receipt
491
+
492
+ async def wait_batch_until_searchable(
493
+ self,
494
+ receipt: Dict[str, Any],
495
+ *,
496
+ timeout: float = 60.0,
497
+ poll_interval: float = 0.5,
498
+ ) -> Dict[str, Any]:
499
+ """Poll every item in an asynchronous batch receipt to one terminal state.
500
+
501
+ Cancellation uses normal asyncio task cancellation. A failed/cancelled
502
+ item or a local deadline raises explicitly; a successful return means
503
+ every batch item reported ``searchable=true``.
504
+ """
505
+
506
+ memory_ids = list(dict.fromkeys(map(str, receipt.get("memory_ids") or [])))
507
+ if not memory_ids:
508
+ raise ValueError("batch receipt does not contain memory_ids")
509
+ deadline = time.monotonic() + max(0.0, timeout)
510
+ while True:
511
+ rows = await asyncio.gather(
512
+ *(self.get(memory_id) for memory_id in memory_ids)
513
+ )
514
+ for row in rows:
515
+ state = str(row.get("processing_status") or "").casefold()
516
+ if state in {"failed", "cancelled", "canceled"}:
517
+ raise RuntimeError(
518
+ f"memory {row.get('id')} indexing reached terminal state {state}"
519
+ )
520
+ if all(row.get("searchable") is True for row in rows):
521
+ return {
522
+ **receipt,
523
+ "processing_status": "completed",
524
+ "searchable": True,
525
+ "results": [
526
+ {
527
+ "id": memory_id,
528
+ "memory_id": memory_id,
529
+ "processing_status": "completed",
530
+ }
531
+ for memory_id in memory_ids
532
+ ],
533
+ }
534
+ if time.monotonic() >= deadline:
535
+ raise TimeoutError(f"batch was not searchable within {timeout}s")
536
+ await asyncio.sleep(max(0.05, poll_interval))
537
+
538
+ async def wait_until_searchable(
539
+ self,
540
+ memory_id: str,
541
+ *,
542
+ timeout: float = 60.0,
543
+ poll_interval: float = 0.5,
544
+ ) -> Dict[str, Any]:
545
+ """Poll the authoritative memory status until indexing is terminal."""
546
+
547
+ deadline = time.monotonic() + max(0.0, timeout)
548
+ while True:
549
+ memory = await self.get(memory_id)
550
+ state = str(memory.get("processing_status") or "").casefold()
551
+ if memory.get("searchable") is True:
552
+ return memory
553
+ if state == "completed":
554
+ raise RuntimeError(
555
+ f"memory {memory_id} reported completed without searchable=true"
556
+ )
557
+ if state in {"failed", "cancelled", "canceled"}:
558
+ raise RuntimeError(
559
+ f"memory {memory_id} indexing reached terminal state {state}"
560
+ )
561
+ if time.monotonic() >= deadline:
562
+ raise TimeoutError(
563
+ f"memory {memory_id} was not searchable within {timeout}s"
564
+ )
565
+ await asyncio.sleep(max(0.05, poll_interval))
566
+
567
+ async def _ensure_searchable_receipt(
568
+ self,
569
+ receipt: Dict[str, Any],
570
+ *,
571
+ timeout: float,
572
+ poll_interval: float,
573
+ ) -> Dict[str, Any]:
574
+ if (
575
+ receipt.get("searchable") is True
576
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
577
+ ):
578
+ return receipt
579
+ job_id = str(receipt.get("job_id") or "")
580
+ if job_id:
581
+ deadline = time.monotonic() + max(0.0, timeout)
582
+ while True:
583
+ job = await self.client.get(f"/v1/memory-jobs/{job_id}")
584
+ state = str(job.get("status") or "").casefold()
585
+ if state == "completed":
586
+ receipt.update(job)
587
+ receipt["searchable"] = True
588
+ receipt["processing_status"] = "completed"
589
+ return receipt
590
+ if state in {"failed", "cancelled", "canceled"}:
591
+ raise RuntimeError(
592
+ f"memory job {job_id} reached terminal state {state}"
593
+ )
594
+ if time.monotonic() >= deadline:
595
+ raise TimeoutError(
596
+ f"memory job {job_id} did not become searchable within {timeout}s"
597
+ )
598
+ await asyncio.sleep(max(0.05, poll_interval))
599
+
600
+ candidates = [
601
+ receipt.get("id"),
602
+ *(
603
+ item.get("id") or item.get("memory_id")
604
+ for item in (receipt.get("results") or [])
605
+ if isinstance(item, dict)
606
+ ),
607
+ ]
608
+ memory_id = next((str(value) for value in candidates if value), "")
609
+ if not memory_id:
610
+ raise RuntimeError(
611
+ "wait_for_index response contained neither a memory id nor a job id"
612
+ )
613
+ ready = await self.wait_until_searchable(
614
+ memory_id,
615
+ timeout=timeout,
616
+ poll_interval=poll_interval,
617
+ )
618
+ receipt["searchable"] = True
619
+ receipt["processing_status"] = "completed"
620
+ receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
621
+ return receipt
426
622
 
427
623
  async def list_page(
428
624
  self,
@@ -1091,6 +1287,15 @@ class RLResource(BaseResource):
1091
1287
  class ProceduralResource(BaseResource):
1092
1288
  """Procedural memory endpoints."""
1093
1289
 
1290
+ @staticmethod
1291
+ def _unwrap_procedure(response: Dict[str, Any]) -> Dict[str, Any]:
1292
+ procedure = response.get("procedure")
1293
+ if isinstance(procedure, dict):
1294
+ return procedure
1295
+ if response.get("procedure_id") and not response.get("id"):
1296
+ return {**response, "id": response["procedure_id"]}
1297
+ return response
1298
+
1094
1299
  async def create(
1095
1300
  self,
1096
1301
  name: str,
@@ -1116,18 +1321,19 @@ class ProceduralResource(BaseResource):
1116
1321
  Returns:
1117
1322
  Created procedure
1118
1323
  """
1119
- return await self.client.post(
1120
- "/procedural",
1324
+ response = await self.client.post(
1325
+ "/v1/procedures",
1121
1326
  json={
1122
1327
  "name": name,
1123
1328
  "description": description,
1124
- "trigger_condition": trigger_condition,
1125
- "action_sequence": action_sequence,
1329
+ "condition": {"expression": trigger_condition},
1330
+ "action": {"steps": action_sequence},
1126
1331
  "collection_id": collection_id,
1127
1332
  "category": category,
1128
- "metadata": metadata or {},
1333
+ "parameters": metadata or {},
1129
1334
  },
1130
1335
  )
1336
+ return self._unwrap_procedure(response)
1131
1337
 
1132
1338
  async def list(
1133
1339
  self,
@@ -1154,7 +1360,10 @@ class ProceduralResource(BaseResource):
1154
1360
  if category:
1155
1361
  params["category"] = category
1156
1362
 
1157
- return await self.client.get("/procedural", params=params)
1363
+ response = await self.client.get("/v1/procedures", params=params)
1364
+ return (
1365
+ response if isinstance(response, list) else response.get("procedures", [])
1366
+ )
1158
1367
 
1159
1368
  async def get(self, procedure_id: str) -> Dict[str, Any]:
1160
1369
  """
@@ -1166,7 +1375,8 @@ class ProceduralResource(BaseResource):
1166
1375
  Returns:
1167
1376
  Procedure data
1168
1377
  """
1169
- return await self.client.get(f"/procedural/{procedure_id}")
1378
+ response = await self.client.get(f"/v1/procedures/{procedure_id}")
1379
+ return self._unwrap_procedure(response)
1170
1380
 
1171
1381
  async def execute(
1172
1382
  self,
@@ -1183,10 +1393,12 @@ class ProceduralResource(BaseResource):
1183
1393
  Returns:
1184
1394
  Execution result
1185
1395
  """
1186
- return await self.client.post(
1187
- f"/procedural/{procedure_id}/execute",
1188
- json={"context": context or {}},
1396
+ response = await self.client.post(
1397
+ f"/v1/procedures/{procedure_id}/execute",
1398
+ json={"input_state": context or {}},
1189
1399
  )
1400
+ result = response.get("execution_result")
1401
+ return result if isinstance(result, dict) else response
1190
1402
 
1191
1403
  async def update(
1192
1404
  self,
@@ -1217,13 +1429,14 @@ class ProceduralResource(BaseResource):
1217
1429
  if description is not None:
1218
1430
  data["description"] = description
1219
1431
  if trigger_condition is not None:
1220
- data["trigger_condition"] = trigger_condition
1432
+ data["condition"] = {"expression": trigger_condition}
1221
1433
  if action_sequence is not None:
1222
- data["action_sequence"] = action_sequence
1434
+ data["action"] = {"steps": action_sequence}
1223
1435
  if metadata is not None:
1224
- data["metadata"] = metadata
1436
+ data["parameters"] = metadata
1225
1437
 
1226
- return await self.client.patch(f"/procedural/{procedure_id}", json=data)
1438
+ response = await self.client.patch(f"/v1/procedures/{procedure_id}", json=data)
1439
+ return self._unwrap_procedure(response)
1227
1440
 
1228
1441
  async def delete(self, procedure_id: str) -> None:
1229
1442
  """
@@ -1232,7 +1445,7 @@ class ProceduralResource(BaseResource):
1232
1445
  Args:
1233
1446
  procedure_id: Procedure ID
1234
1447
  """
1235
- await self.client.delete(f"/procedural/{procedure_id}")
1448
+ await self.client.delete(f"/v1/procedures/{procedure_id}")
1236
1449
 
1237
1450
 
1238
1451
  class TemporalResource(BaseResource):
@@ -1333,6 +1546,11 @@ class TemporalResource(BaseResource):
1333
1546
  },
1334
1547
  )
1335
1548
 
1549
+ async def delete_fact(self, fact_id: str) -> Dict[str, Any]:
1550
+ """Permanently delete a tenant-scoped temporal fact by stable ID."""
1551
+
1552
+ return await self.client.delete(f"/temporal/facts/{fact_id}")
1553
+
1336
1554
 
1337
1555
  class WorkingMemoryResource(BaseResource):
1338
1556
  """Working memory buffer endpoints."""