hebbrix 2.3.0__tar.gz → 2.3.1__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,23 @@
1
+ # Changelog
2
+
3
+ ## 2.3.1 — 2026-08-26
4
+
5
+ - Make `wait_for_index=True` poll the authoritative memory or job readiness
6
+ endpoint instead of returning a non-searchable receipt.
7
+ - Add `memories.wait_until_searchable(...)` to the async and synchronous
8
+ clients, with explicit timeout and terminal-failure handling.
9
+ - Preserve valid evidence-bound search rows during a degraded or abstaining
10
+ server response while retaining the safety metadata; malformed and explicit
11
+ no-match envelopes still fail closed.
12
+ - Keep the synchronous and asynchronous memory create, scope, idempotency, and
13
+ search-safety behavior aligned.
14
+ - Add `temporal.delete_fact(fact_id)` for tenant-scoped, idempotent cleanup of
15
+ facts created through the temporal API.
16
+
17
+ The supported server/SDK release pair is published by the server OpenAPI
18
+ document in `info.x-hebbrix-sdk-compatibility`. Patch releases preserve the
19
+ public API within the same major version.
20
+
21
+ ## 2.3.0
22
+
23
+ - 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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.3.0
3
+ Version: 2.3.1
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>
@@ -92,6 +92,10 @@ async def main():
92
92
  wait_for_index=True,
93
93
  )
94
94
 
95
+ # wait_for_index=True polls the returned memory status through the SDK.
96
+ # It returns only when searchable=true, raises on terminal failure, and
97
+ # raises TimeoutError if the caller's readiness deadline expires.
98
+
95
99
  # Search memories
96
100
  results = await client.search(
97
101
  query="What programming language does user like?",
@@ -51,6 +51,10 @@ 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
+
54
58
  # Search memories
55
59
  results = await client.search(
56
60
  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.1"
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.1",
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,99 @@ 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 wait_until_searchable(
439
+ self,
440
+ memory_id: str,
441
+ *,
442
+ timeout: float = 60.0,
443
+ poll_interval: float = 0.5,
444
+ ) -> Dict[str, Any]:
445
+ """Poll the authoritative memory status until indexing is terminal."""
446
+
447
+ deadline = time.monotonic() + max(0.0, timeout)
448
+ while True:
449
+ memory = await self.get(memory_id)
450
+ state = str(memory.get("processing_status") or "").casefold()
451
+ if memory.get("searchable") is True:
452
+ return memory
453
+ if state == "completed":
454
+ raise RuntimeError(
455
+ f"memory {memory_id} reported completed without searchable=true"
456
+ )
457
+ if state in {"failed", "cancelled", "canceled"}:
458
+ raise RuntimeError(
459
+ f"memory {memory_id} indexing reached terminal state {state}"
460
+ )
461
+ if time.monotonic() >= deadline:
462
+ raise TimeoutError(
463
+ f"memory {memory_id} was not searchable within {timeout}s"
464
+ )
465
+ await asyncio.sleep(max(0.05, poll_interval))
466
+
467
+ async def _ensure_searchable_receipt(
468
+ self,
469
+ receipt: Dict[str, Any],
470
+ *,
471
+ timeout: float,
472
+ poll_interval: float,
473
+ ) -> Dict[str, Any]:
474
+ if (
475
+ receipt.get("searchable") is True
476
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
477
+ ):
478
+ return receipt
479
+ job_id = str(receipt.get("job_id") or "")
480
+ if job_id:
481
+ deadline = time.monotonic() + max(0.0, timeout)
482
+ while True:
483
+ job = await self.client.get(f"/v1/memory-jobs/{job_id}")
484
+ state = str(job.get("status") or "").casefold()
485
+ if state == "completed":
486
+ receipt.update(job)
487
+ receipt["searchable"] = True
488
+ receipt["processing_status"] = "completed"
489
+ return receipt
490
+ if state in {"failed", "cancelled", "canceled"}:
491
+ raise RuntimeError(
492
+ f"memory job {job_id} reached terminal state {state}"
493
+ )
494
+ if time.monotonic() >= deadline:
495
+ raise TimeoutError(
496
+ f"memory job {job_id} did not become searchable within {timeout}s"
497
+ )
498
+ await asyncio.sleep(max(0.05, poll_interval))
499
+
500
+ candidates = [
501
+ receipt.get("id"),
502
+ *(
503
+ item.get("id") or item.get("memory_id")
504
+ for item in (receipt.get("results") or [])
505
+ if isinstance(item, dict)
506
+ ),
507
+ ]
508
+ memory_id = next((str(value) for value in candidates if value), "")
509
+ if not memory_id:
510
+ raise RuntimeError(
511
+ "wait_for_index response contained neither a memory id nor a job id"
512
+ )
513
+ ready = await self.wait_until_searchable(
514
+ memory_id,
515
+ timeout=timeout,
516
+ poll_interval=poll_interval,
517
+ )
518
+ receipt["searchable"] = True
519
+ receipt["processing_status"] = "completed"
520
+ receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
521
+ return receipt
426
522
 
427
523
  async def list_page(
428
524
  self,
@@ -1333,6 +1429,11 @@ class TemporalResource(BaseResource):
1333
1429
  },
1334
1430
  )
1335
1431
 
1432
+ async def delete_fact(self, fact_id: str) -> Dict[str, Any]:
1433
+ """Permanently delete a tenant-scoped temporal fact by stable ID."""
1434
+
1435
+ return await self.client.delete(f"/temporal/facts/{fact_id}")
1436
+
1336
1437
 
1337
1438
  class WorkingMemoryResource(BaseResource):
1338
1439
  """Working memory buffer endpoints."""
@@ -1,7 +1,7 @@
1
1
  """Synchronous Hebbrix client for the core memory and ProofLoop lifecycle."""
2
2
 
3
- import os
4
3
  import json
4
+ import os
5
5
  import time
6
6
  from typing import Any, Dict, List, Optional
7
7
 
@@ -65,6 +65,8 @@ class SyncMemoriesResource:
65
65
  tags: Optional[List[str]] = None,
66
66
  source: Optional[str] = None,
67
67
  idempotency_key: Optional[str] = None,
68
+ index_timeout: float = 60.0,
69
+ index_poll_interval: float = 0.5,
68
70
  ) -> Dict[str, Any]:
69
71
  request_kwargs: Dict[str, Any] = {
70
72
  "json": _memory_create_payload(
@@ -90,7 +92,89 @@ class SyncMemoriesResource:
90
92
  }
91
93
  if idempotency_key:
92
94
  request_kwargs["headers"] = {"Idempotency-Key": idempotency_key}
93
- return self.client.post("/v1/memories", **request_kwargs)
95
+ receipt = self.client.post("/v1/memories", **request_kwargs)
96
+ if wait_for_index:
97
+ receipt = self._ensure_searchable_receipt(
98
+ receipt,
99
+ timeout=index_timeout,
100
+ poll_interval=index_poll_interval,
101
+ )
102
+ return receipt
103
+
104
+ def wait_until_searchable(
105
+ self,
106
+ memory_id: str,
107
+ *,
108
+ timeout: float = 60.0,
109
+ poll_interval: float = 0.5,
110
+ ) -> Dict[str, Any]:
111
+ deadline = time.monotonic() + max(0.0, timeout)
112
+ while True:
113
+ memory = self.get(memory_id)
114
+ state = str(memory.get("processing_status") or "").casefold()
115
+ if memory.get("searchable") is True:
116
+ return memory
117
+ if state == "completed":
118
+ raise RuntimeError(
119
+ f"memory {memory_id} reported completed without searchable=true"
120
+ )
121
+ if state in {"failed", "cancelled", "canceled"}:
122
+ raise RuntimeError(
123
+ f"memory {memory_id} indexing reached terminal state {state}"
124
+ )
125
+ if time.monotonic() >= deadline:
126
+ raise TimeoutError(
127
+ f"memory {memory_id} was not searchable within {timeout}s"
128
+ )
129
+ time.sleep(max(0.05, poll_interval))
130
+
131
+ def _ensure_searchable_receipt(
132
+ self,
133
+ receipt: Dict[str, Any],
134
+ *,
135
+ timeout: float,
136
+ poll_interval: float,
137
+ ) -> Dict[str, Any]:
138
+ if (
139
+ receipt.get("searchable") is True
140
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
141
+ ):
142
+ return receipt
143
+ job_id = str(receipt.get("job_id") or "")
144
+ if job_id:
145
+ job = SyncMemoryJobsResource(self.client).wait(
146
+ job_id,
147
+ timeout=timeout,
148
+ poll_interval=poll_interval,
149
+ )
150
+ if str(job.get("status") or "").casefold() != "completed":
151
+ raise RuntimeError(f"memory job {job_id} did not complete")
152
+ receipt.update(job)
153
+ receipt["searchable"] = True
154
+ receipt["processing_status"] = "completed"
155
+ return receipt
156
+ candidates = [
157
+ receipt.get("id"),
158
+ *(
159
+ item.get("id") or item.get("memory_id")
160
+ for item in (receipt.get("results") or [])
161
+ if isinstance(item, dict)
162
+ ),
163
+ ]
164
+ memory_id = next((str(value) for value in candidates if value), "")
165
+ if not memory_id:
166
+ raise RuntimeError(
167
+ "wait_for_index response contained neither a memory id nor a job id"
168
+ )
169
+ ready = self.wait_until_searchable(
170
+ memory_id,
171
+ timeout=timeout,
172
+ poll_interval=poll_interval,
173
+ )
174
+ receipt["searchable"] = True
175
+ receipt["processing_status"] = "completed"
176
+ receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
177
+ return receipt
94
178
 
95
179
  def list_page(
96
180
  self,
@@ -460,7 +544,7 @@ class SyncMemoryClient:
460
544
  self.source = source or os.getenv("HEBBRIX_SOURCE")
461
545
  headers = {
462
546
  "Content-Type": "application/json",
463
- "User-Agent": "hebbrix-python/2.3.0",
547
+ "User-Agent": "hebbrix-python/2.3.1",
464
548
  }
465
549
  if api_key:
466
550
  headers["Authorization"] = f"Bearer {api_key}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.3.0
3
+ Version: 2.3.1
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>
@@ -92,6 +92,10 @@ async def main():
92
92
  wait_for_index=True,
93
93
  )
94
94
 
95
+ # wait_for_index=True polls the returned memory status through the SDK.
96
+ # It returns only when searchable=true, raises on terminal failure, and
97
+ # raises TimeoutError if the caller's readiness deadline expires.
98
+
95
99
  # Search memories
96
100
  results = await client.search(
97
101
  query="What programming language does user like?",
@@ -1,3 +1,4 @@
1
+ CHANGELOG.md
1
2
  LICENSE
2
3
  MANIFEST.in
3
4
  README.md
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hebbrix"
7
- version = "2.3.0"
7
+ version = "2.3.1"
8
8
  description = "Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -1,5 +1,6 @@
1
- import pytest
2
1
  from unittest.mock import AsyncMock
2
+
3
+ import pytest
3
4
  from hebbrix.resources import (
4
5
  CorrectionsResource,
5
6
  MemoriesResource,
@@ -15,7 +16,11 @@ class RecordingClient:
15
16
 
16
17
  async def post(self, path, **kwargs):
17
18
  self.calls.append((path, kwargs))
18
- return {"results": [{"id": "memory-1"}]}
19
+ return {
20
+ "results": [{"id": "memory-1"}],
21
+ "processing_status": "completed",
22
+ "searchable": True,
23
+ }
19
24
 
20
25
  async def get(self, path, **kwargs):
21
26
  self.calls.append((path, kwargs))
@@ -108,6 +113,75 @@ async def test_create_rejects_empty_content_before_network_io():
108
113
  assert client.calls == []
109
114
 
110
115
 
116
+ @pytest.mark.asyncio
117
+ async def test_wait_for_index_polls_until_the_memory_is_actually_searchable(
118
+ monkeypatch,
119
+ ):
120
+ client = RecordingClient()
121
+ states = iter(
122
+ [
123
+ {"id": "memory-1", "processing_status": "processing", "searchable": False},
124
+ {"id": "memory-1", "processing_status": "completed", "searchable": True},
125
+ ]
126
+ )
127
+
128
+ async def post(_path, **_kwargs):
129
+ return {
130
+ "results": [{"id": "memory-1"}],
131
+ "processing_status": "processing",
132
+ "searchable": False,
133
+ }
134
+
135
+ async def get(_path, **_kwargs):
136
+ return next(states)
137
+
138
+ client.post = post
139
+ client.get = get
140
+ monkeypatch.setattr("hebbrix.resources.asyncio.sleep", AsyncMock())
141
+
142
+ receipt = await MemoriesResource(client).create(
143
+ content="A fact",
144
+ wait_for_index=True,
145
+ index_timeout=2,
146
+ index_poll_interval=0.05,
147
+ )
148
+
149
+ assert receipt["searchable"] is True
150
+ assert receipt["processing_status"] == "completed"
151
+
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_wait_for_index_never_returns_a_failed_terminal_state():
155
+ client = RecordingClient()
156
+
157
+ async def post(_path, **_kwargs):
158
+ return {
159
+ "results": [{"id": "memory-1"}],
160
+ "processing_status": "processing",
161
+ "searchable": False,
162
+ }
163
+
164
+ async def get(_path, **_kwargs):
165
+ return {"processing_status": "failed", "searchable": False}
166
+
167
+ client.post = post
168
+ client.get = get
169
+ with pytest.raises(RuntimeError, match="terminal state failed"):
170
+ await MemoriesResource(client).create(content="A fact", wait_for_index=True)
171
+
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_wait_for_index_never_invents_searchability_from_completed_state():
175
+ client = RecordingClient()
176
+
177
+ async def get(_path, **_kwargs):
178
+ return {"processing_status": "completed", "searchable": False}
179
+
180
+ client.get = get
181
+ with pytest.raises(RuntimeError, match="completed without searchable=true"):
182
+ await MemoriesResource(client).wait_until_searchable("memory-1")
183
+
184
+
111
185
  @pytest.mark.asyncio
112
186
  async def test_create_supports_the_conversation_message_input_mode():
113
187
  client = RecordingClient()
@@ -261,6 +335,32 @@ async def test_search_sdk_fails_closed_when_safety_metadata_is_missing():
261
335
  assert response["sdk_safety_reason"].startswith("missing_safety_fields:")
262
336
 
263
337
 
338
+ @pytest.mark.asyncio
339
+ async def test_search_sdk_preserves_evidence_bound_degraded_results():
340
+ row = {"memory_id": "memory-1", "content": "exact fact", "score": 0.8}
341
+ client = SearchRecordingClient(
342
+ {
343
+ "results": [row],
344
+ "total": 1,
345
+ "no_match": False,
346
+ "abstain_recommended": True,
347
+ "query_confidence": 0.45,
348
+ "grounding": {"status": "degraded", "reason": "reranker_unavailable"},
349
+ "evidence_ids": ["memory-1"],
350
+ "evidence_claims": [],
351
+ "safety_contract_version": "search-safety-v1",
352
+ "degraded": True,
353
+ }
354
+ )
355
+
356
+ response = await SearchResource(client).search_with_proof("exact fact")
357
+
358
+ assert response["results"] == [row]
359
+ assert response["no_match"] is False
360
+ assert response["abstain_recommended"] is True
361
+ assert response["sdk_safety_reason"] == "degraded_evidence_preserved"
362
+
363
+
264
364
  @pytest.mark.asyncio
265
365
  async def test_reason_forwards_scopes_facets_and_preserves_safety_envelope():
266
366
  response = {
@@ -12,7 +12,11 @@ class RecordingSyncClient:
12
12
 
13
13
  def post(self, path, **kwargs):
14
14
  self.calls.append(("POST", path, kwargs))
15
- return {"decision_id": "decision-1"}
15
+ return {
16
+ "decision_id": "decision-1",
17
+ "processing_status": "completed",
18
+ "searchable": True,
19
+ }
16
20
 
17
21
  def get(self, path, **kwargs):
18
22
  self.calls.append(("GET", path, kwargs))
File without changes
File without changes
File without changes
File without changes
File without changes