hebbrix 2.7.0__tar.gz → 2.8.0__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.
Files changed (53) hide show
  1. {hebbrix-2.7.0 → hebbrix-2.8.0}/CHANGELOG.md +8 -0
  2. {hebbrix-2.7.0/hebbrix.egg-info → hebbrix-2.8.0}/PKG-INFO +51 -4
  3. {hebbrix-2.7.0 → hebbrix-2.8.0}/README.md +50 -3
  4. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/__init__.py +27 -2
  5. hebbrix-2.8.0/hebbrix/answers.py +316 -0
  6. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/client.py +11 -2
  7. hebbrix-2.8.0/hebbrix/models.py +138 -0
  8. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/resources.py +28 -3
  9. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/sync_client.py +13 -1
  10. {hebbrix-2.7.0 → hebbrix-2.8.0/hebbrix.egg-info}/PKG-INFO +51 -4
  11. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/SOURCES.txt +3 -0
  12. {hebbrix-2.7.0 → hebbrix-2.8.0}/pyproject.toml +1 -1
  13. hebbrix-2.8.0/tests/test_answer_context.py +172 -0
  14. hebbrix-2.8.0/tests/test_answer_decision.py +385 -0
  15. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_customer_developer_experience.py +1 -1
  16. hebbrix-2.7.0/hebbrix/models.py +0 -61
  17. {hebbrix-2.7.0 → hebbrix-2.8.0}/LICENSE +0 -0
  18. {hebbrix-2.7.0 → hebbrix-2.8.0}/MANIFEST.in +0 -0
  19. {hebbrix-2.7.0 → hebbrix-2.8.0}/contracts/openapi-routes.json +0 -0
  20. {hebbrix-2.7.0 → hebbrix-2.8.0}/docs/native-experience-operations.md +0 -0
  21. {hebbrix-2.7.0 → hebbrix-2.8.0}/examples/native_reflection_worker.py +0 -0
  22. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/_advice.py +0 -0
  23. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/chat.py +0 -0
  24. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/exceptions.py +0 -0
  25. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/execution.py +0 -0
  26. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/experience.py +0 -0
  27. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/openai_reflection.py +0 -0
  28. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/reflection_worker.py +0 -0
  29. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/workflow.py +0 -0
  30. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/dependency_links.txt +0 -0
  31. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/entry_points.txt +0 -0
  32. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/requires.txt +0 -0
  33. {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/top_level.txt +0 -0
  34. {hebbrix-2.7.0 → hebbrix-2.8.0}/setup.cfg +0 -0
  35. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_advanced_resources.py +0 -0
  36. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_advisor_integrity.py +0 -0
  37. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_chat_answer_options.py +0 -0
  38. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_compact_search_default.py +0 -0
  39. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_evidence_loop_resource.py +0 -0
  40. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_evidence_safety_boundary.py +0 -0
  41. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_execution_permission.py +0 -0
  42. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_experience_request.py +0 -0
  43. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_experience_workflow.py +0 -0
  44. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_followup_helpers.py +0 -0
  45. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_memories_resource.py +0 -0
  46. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_openai_reflection.py +0 -0
  47. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_openapi_parity.py +0 -0
  48. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_outcome_advice_resource.py +0 -0
  49. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_procedural_resource.py +0 -0
  50. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_reflection_worker_cli.py +0 -0
  51. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_release_version.py +0 -0
  52. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_sync_client.py +0 -0
  53. {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_workflow_route_contract.py +0 -0
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0 — 2026-11-11 (answer decisions and answer context)
4
+
5
+ - New `hebbrix.should_answer(response)`: decides in code whether a `search_with_proof()` or `reason()` response may be answered from, returning an `AnswerDecision` with `action` (`answer`, `say_unknown`, `abstain`, `verify_authorization`, `ask_user`, `retry` or `say_no_longer_current`), `reason`, the usable `memories` and the `context` rows never to answer from. `history=True` counts superseded and historical versions for questions about the past.
6
+ - New `hebbrix.answer_context(response, decision)`: renders the end user's standing profile (facts with category, confidence and date, topics to avoid, scope, and whether it is still being projected or unavailable) beside the rows the decision allows, each with its date, memory type, subject and support.
7
+ - New `hebbrix.usable_memories(response)` and the response types `AnswerStatus`, `RevisionStatus`, `MemoryRow`, `MemoryStatus` and `ScopeIndexing`.
8
+ - `MemoryClient.search_with_proof()` accepts `include_profile` and `include_superseded`, as the synchronous client and `search()` already did.
9
+ - Docs: `memories.create()` stores verbatim unless `infer=True`, while `POST /v1/memories` extracts by default; examples pass `infer` and `wait_for_index` explicitly.
10
+
3
11
  ## 2.7.0 — 2026-10-19 (compact responses, entity scope, chat answers)
4
12
 
5
13
  - Behaviour change: `search()` and `search_with_proof()` now request `view="compact"` by default (rows keep content, identity, score, rank, timestamps, `low_confidence` and entity scope; the safety envelope and proof context are unchanged; ranking diagnostics need `debug=True`). Pass `view="full"` for the previous per-row metadata. `search()` returns rows only and no longer requests the unused ProofLoop token unless `include_proof_context=True`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.7.0
3
+ Version: 2.8.0
4
4
  Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
5
5
  Author-email: Hebbrix Team <support@hebbrix.com>
6
6
  Maintainer-email: Hebbrix Team <support@hebbrix.com>
@@ -65,14 +65,14 @@ Dynamic: license-file
65
65
 
66
66
  Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
67
67
 
68
- The current stable package is **2.7.0**. Start with memory storage and search, or
68
+ The current stable package is **2.8.0**. Start with memory storage and search, or
69
69
  the three-call outcome-learning workflow below. Advanced evidence and execution
70
70
  permission transports are opt-in; installing the SDK never permits an action.
71
71
 
72
72
  ## Install
73
73
 
74
74
  ```bash
75
- pip install hebbrix==2.7.0
75
+ pip install hebbrix==2.8.0
76
76
  ```
77
77
 
78
78
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -92,7 +92,8 @@ async def main():
92
92
  memory = await client.memories.create(
93
93
  collection_id=collection["id"],
94
94
  content="Customer prefers concise replies",
95
- wait_for_index=True,
95
+ infer=False, # store verbatim (the SDK default; REST defaults to True)
96
+ wait_for_index=True, # return once it is searchable
96
97
  idempotency_key="customer-42-preference-v1",
97
98
  )
98
99
  results = await client.search(
@@ -104,6 +105,51 @@ async def main():
104
105
  asyncio.run(main())
105
106
  ```
106
107
 
108
+ > **`memories.create()` and `POST /v1/memories` have different defaults.** The
109
+ > SDK stores the content verbatim as one memory unless you pass `infer=True`;
110
+ > the REST endpoint called without `infer` extracts facts with the server's
111
+ > language model (`infer` defaults to `true` there). Pass `infer` and
112
+ > `wait_for_index` explicitly, as above, so a call reads the same through the
113
+ > SDK and over HTTP. Use `infer=True` (or `/v1/memories/process`) for
114
+ > conversations; `infer=False` for a fact you already have in final form.
115
+
116
+ ## Decide before you invoke your model
117
+
118
+ A model told to abstain can still answer. Decide in code, and give it only the rows
119
+ that passed. `search()` returns only the rows, so use `search_with_proof()` or
120
+ `reason()`, which keep the fields this reads:
121
+
122
+ ```python
123
+ from hebbrix import answer_context, should_answer
124
+
125
+ response = await client.search_with_proof(
126
+ question, user_id="customer-42", include_profile=True
127
+ )
128
+ decision = should_answer(response)
129
+ if not decision:
130
+ # decision.action: ask_user, say_no_longer_current, say_unknown, retry,
131
+ # verify_authorization or abstain. decision.reason says why;
132
+ # decision.context holds the rows, never to answer from.
133
+ return my_reply_for(decision.action)
134
+ # The user's standing profile plus the rows you may answer from, each with its
135
+ # date, memory type, subject and support.
136
+ return await my_llm(question, context=answer_context(response, decision))
137
+ ```
138
+
139
+ `answer_context(response, decision)` keeps the end user's standing profile
140
+ (`include_profile=True`) beside the rows: preferences such as "keep answers
141
+ short" reach your model even when they did not rank for this message. It lists
142
+ each fact with its category, confidence and date, the topics to avoid, the
143
+ profile's `scope`, and says so when the profile is still being updated
144
+ (`projection.status: "pending"`) or could not be read, so a missing preference
145
+ is not taken as absent. When the decision is not to answer it returns the
146
+ profile alone. `usable_memories(response)` returns just the rows. Pass
147
+ `history=True` for a question about the past, when superseded and historical
148
+ versions should count. `reason()` takes `deadline_ms`; a `timed_out` response is
149
+ an abstention. `verify_authorization` means the returned rule limits the action
150
+ to a group the store cannot place the user in: use the rule, and check the
151
+ permission in your own system.
152
+
107
153
  ## Learn from outcomes in three calls
108
154
 
109
155
  Use server-side selection when Hebbrix should choose. Read-only advice is for a
@@ -191,6 +237,7 @@ from hebbrix import IndexingTimeoutError
191
237
  try:
192
238
  created = await client.memories.create(
193
239
  content="Customer prefers concise replies",
240
+ infer=False,
194
241
  wait_for_index=True,
195
242
  idempotency_key="customer-42-preference-v1",
196
243
  index_timeout=5,
@@ -2,14 +2,14 @@
2
2
 
3
3
  Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
4
4
 
5
- The current stable package is **2.7.0**. Start with memory storage and search, or
5
+ The current stable package is **2.8.0**. Start with memory storage and search, or
6
6
  the three-call outcome-learning workflow below. Advanced evidence and execution
7
7
  permission transports are opt-in; installing the SDK never permits an action.
8
8
 
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- pip install hebbrix==2.7.0
12
+ pip install hebbrix==2.8.0
13
13
  ```
14
14
 
15
15
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -29,7 +29,8 @@ async def main():
29
29
  memory = await client.memories.create(
30
30
  collection_id=collection["id"],
31
31
  content="Customer prefers concise replies",
32
- wait_for_index=True,
32
+ infer=False, # store verbatim (the SDK default; REST defaults to True)
33
+ wait_for_index=True, # return once it is searchable
33
34
  idempotency_key="customer-42-preference-v1",
34
35
  )
35
36
  results = await client.search(
@@ -41,6 +42,51 @@ async def main():
41
42
  asyncio.run(main())
42
43
  ```
43
44
 
45
+ > **`memories.create()` and `POST /v1/memories` have different defaults.** The
46
+ > SDK stores the content verbatim as one memory unless you pass `infer=True`;
47
+ > the REST endpoint called without `infer` extracts facts with the server's
48
+ > language model (`infer` defaults to `true` there). Pass `infer` and
49
+ > `wait_for_index` explicitly, as above, so a call reads the same through the
50
+ > SDK and over HTTP. Use `infer=True` (or `/v1/memories/process`) for
51
+ > conversations; `infer=False` for a fact you already have in final form.
52
+
53
+ ## Decide before you invoke your model
54
+
55
+ A model told to abstain can still answer. Decide in code, and give it only the rows
56
+ that passed. `search()` returns only the rows, so use `search_with_proof()` or
57
+ `reason()`, which keep the fields this reads:
58
+
59
+ ```python
60
+ from hebbrix import answer_context, should_answer
61
+
62
+ response = await client.search_with_proof(
63
+ question, user_id="customer-42", include_profile=True
64
+ )
65
+ decision = should_answer(response)
66
+ if not decision:
67
+ # decision.action: ask_user, say_no_longer_current, say_unknown, retry,
68
+ # verify_authorization or abstain. decision.reason says why;
69
+ # decision.context holds the rows, never to answer from.
70
+ return my_reply_for(decision.action)
71
+ # The user's standing profile plus the rows you may answer from, each with its
72
+ # date, memory type, subject and support.
73
+ return await my_llm(question, context=answer_context(response, decision))
74
+ ```
75
+
76
+ `answer_context(response, decision)` keeps the end user's standing profile
77
+ (`include_profile=True`) beside the rows: preferences such as "keep answers
78
+ short" reach your model even when they did not rank for this message. It lists
79
+ each fact with its category, confidence and date, the topics to avoid, the
80
+ profile's `scope`, and says so when the profile is still being updated
81
+ (`projection.status: "pending"`) or could not be read, so a missing preference
82
+ is not taken as absent. When the decision is not to answer it returns the
83
+ profile alone. `usable_memories(response)` returns just the rows. Pass
84
+ `history=True` for a question about the past, when superseded and historical
85
+ versions should count. `reason()` takes `deadline_ms`; a `timed_out` response is
86
+ an abstention. `verify_authorization` means the returned rule limits the action
87
+ to a group the store cannot place the user in: use the rule, and check the
88
+ permission in your own system.
89
+
44
90
  ## Learn from outcomes in three calls
45
91
 
46
92
  Use server-side selection when Hebbrix should choose. Read-only advice is for a
@@ -128,6 +174,7 @@ from hebbrix import IndexingTimeoutError
128
174
  try:
129
175
  created = await client.memories.create(
130
176
  content="Customer prefers concise replies",
177
+ infer=False,
131
178
  wait_for_index=True,
132
179
  idempotency_key="customer-42-preference-v1",
133
180
  index_timeout=5,
@@ -5,10 +5,16 @@ production OpenAPI and ``GET /v1/users/me/capabilities``. The experimental
5
5
  World Model is intentionally absent from this release.
6
6
  """
7
7
 
8
- __version__ = "2.7.0"
8
+ __version__ = "2.8.0"
9
9
  __author__ = "Hebbrix Team"
10
10
  __license__ = "MIT"
11
11
 
12
+ from hebbrix.answers import (
13
+ AnswerDecision,
14
+ answer_context,
15
+ should_answer,
16
+ usable_memories,
17
+ )
12
18
  from hebbrix.chat import MemoryChat
13
19
  from hebbrix.client import MemoryClient
14
20
  from hebbrix.exceptions import (
@@ -21,7 +27,17 @@ from hebbrix.exceptions import (
21
27
  ServerError,
22
28
  ValidationError,
23
29
  )
24
- from hebbrix.models import ActionAdviceReceipt, EvidenceClaim, GroundingReceipt, SearchSafetyEnvelope
30
+ from hebbrix.models import (
31
+ ActionAdviceReceipt,
32
+ AnswerStatus,
33
+ EvidenceClaim,
34
+ GroundingReceipt,
35
+ MemoryRow,
36
+ MemoryStatus,
37
+ RevisionStatus,
38
+ ScopeIndexing,
39
+ SearchSafetyEnvelope,
40
+ )
25
41
  from hebbrix.sync_client import SyncMemoryClient
26
42
 
27
43
  __all__ = [
@@ -40,4 +56,13 @@ __all__ = [
40
56
  "EvidenceClaim",
41
57
  "SearchSafetyEnvelope",
42
58
  "ActionAdviceReceipt",
59
+ "AnswerDecision",
60
+ "AnswerStatus",
61
+ "MemoryRow",
62
+ "MemoryStatus",
63
+ "RevisionStatus",
64
+ "ScopeIndexing",
65
+ "answer_context",
66
+ "should_answer",
67
+ "usable_memories",
43
68
  ]
@@ -0,0 +1,316 @@
1
+ """Decide in code what a search or Reason response lets you do.
2
+
3
+ A model that is told "abstain_recommended: true" can still answer. Call
4
+ ``should_answer`` before invoking the model and give it only
5
+ ``decision.memories``: when the decision is not to answer, the model is never
6
+ called, so it cannot ignore the flag.
7
+
8
+ response = await client.search_with_proof(
9
+ question, user_id=user_id, include_profile=True
10
+ )
11
+ decision = should_answer(response)
12
+ if not decision:
13
+ return reply_for(decision.action) # your own fixed reply per action
14
+ return await my_llm(question, context=answer_context(response, decision))
15
+
16
+ ``answer_context`` renders the end user's standing profile beside those rows,
17
+ so a preference that did not rank for this message still reaches the model.
18
+
19
+ ``search()`` returns only the rows, without the fields this needs; use
20
+ ``search_with_proof()`` or ``reason()``. The decision is derived from the
21
+ response alone: nothing here calls the API, and it fails closed on a response it
22
+ does not understand.
23
+ """
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
27
+
28
+ ANSWER_STATUSES = (
29
+ "answerable",
30
+ "conflicting",
31
+ "superseded",
32
+ "unverified",
33
+ "indexing",
34
+ "no_relevant_memory",
35
+ )
36
+ REVISION_STATUSES = ("current", "superseded", "conflicting", "historical")
37
+
38
+ # What to do instead of answering, for each ``answer_status``.
39
+ ACTIONS = {
40
+ "answerable": "answer",
41
+ "conflicting": "ask_user",
42
+ "superseded": "say_no_longer_current",
43
+ "unverified": "abstain",
44
+ "indexing": "retry",
45
+ "no_relevant_memory": "say_unknown",
46
+ }
47
+ # A permission question answered by a rule that restricts the act to a group no
48
+ # memory ties the asker to (``confidence_reason`` "authorization_not_verifiable"):
49
+ # the rule is in ``context``; check the asker's role in your own system.
50
+ AUTHORIZATION_NOT_VERIFIABLE = "authorization_not_verifiable"
51
+ VERIFY_AUTHORIZATION = "verify_authorization"
52
+ # Set by the SDK on a response whose rows it kept although it is degraded: not a
53
+ # malformed envelope, so it is not a reason to fail closed on its own.
54
+ _PRESERVED = "degraded_evidence_preserved"
55
+ _PAST = frozenset({"superseded", "historical"})
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class AnswerDecision:
60
+ """What a response lets you do; true only when you may answer from it."""
61
+
62
+ answer: bool
63
+ reason: str
64
+ action: str
65
+ # Safe to pass to a model as evidence; empty unless ``answer`` is true.
66
+ memories: List[Dict[str, Any]] = field(default_factory=list)
67
+ # The other rows returned, each with its revision_status and low_confidence:
68
+ # to show or to ask about ("which of these holds?"), never to answer from.
69
+ context: List[Dict[str, Any]] = field(default_factory=list)
70
+ # Reason only: context for a decision it could not verify, with citations.
71
+ cited_context: Optional[str] = None
72
+
73
+ def __bool__(self) -> bool:
74
+ return self.answer
75
+
76
+
77
+ def _rows(response: Mapping[str, Any]) -> Tuple[List[Dict[str, Any]], bool]:
78
+ """The response's rows, and whether they are search rows (which always say
79
+ ``low_confidence``) rather than Reason's cited sources."""
80
+
81
+ results = response.get("results")
82
+ if isinstance(results, list):
83
+ return [row for row in results if isinstance(row, dict)], True
84
+ sources = response.get("sources")
85
+ if isinstance(sources, list):
86
+ return [row for row in sources if isinstance(row, dict)], False
87
+ return [], False
88
+
89
+
90
+ def _is_evidence(row: Mapping[str, Any], *, search_row: bool) -> bool:
91
+ """A current row established as evidence. A row that does not say so is not."""
92
+
93
+ if row.get("revision_status") != "current" or row.get("is_current") is False:
94
+ return False
95
+ confidence = row.get("low_confidence")
96
+ return confidence is False if search_row else confidence is not True
97
+
98
+
99
+ def _is_history(row: Mapping[str, Any], *, search_row: bool) -> bool:
100
+ """A row a question about the past may read: an earlier version, labelled as
101
+ one (the server flags those low_confidence for the present), or evidence."""
102
+
103
+ return row.get("revision_status") in _PAST or _is_evidence(
104
+ row, search_row=search_row
105
+ )
106
+
107
+
108
+ def should_answer(
109
+ response: Optional[Mapping[str, Any]], *, history: bool = False
110
+ ) -> AnswerDecision:
111
+ """Whether you may answer from ``response`` (a ``search_with_proof`` or
112
+ ``reason`` result), the rows to answer from, and what to do when you may not.
113
+
114
+ ``history=True`` is for a question about the past that your application
115
+ recognises ("Where did I live before?"): superseded and historical rows then
116
+ count, whatever the response says of the present. Keep each row's
117
+ ``revision_status`` and ``stated_at`` in what you pass to the model, so it can
118
+ tell the versions apart. Conflicting rows never count.
119
+ """
120
+
121
+ if not isinstance(response, Mapping) or "answer_status" not in response:
122
+ return AnswerDecision(False, "unsupported_response", "abstain")
123
+ status = response.get("answer_status")
124
+ safety = response.get("sdk_safety_reason")
125
+ if status not in ANSWER_STATUSES or (safety and safety != _PRESERVED):
126
+ return AnswerDecision(False, "malformed_response", "abstain")
127
+ rows, search_rows = _rows(response)
128
+ cited = response.get("cited_context")
129
+ cited = cited if isinstance(cited, str) and cited else None
130
+
131
+ def declined(reason: str, action: str) -> AnswerDecision:
132
+ return AnswerDecision(False, reason, action, [], rows, cited)
133
+
134
+ if status == "conflicting":
135
+ return declined(status, ACTIONS[status])
136
+ if response.get("confidence_reason") == AUTHORIZATION_NOT_VERIFIABLE:
137
+ return declined(AUTHORIZATION_NOT_VERIFIABLE, VERIFY_AUTHORIZATION)
138
+ if history:
139
+ usable = [row for row in rows if _is_history(row, search_row=search_rows)]
140
+ if usable:
141
+ others = [row for row in rows if row not in usable]
142
+ return AnswerDecision(True, "history", "answer", usable, others, cited)
143
+ if status != "answerable":
144
+ return declined(status, ACTIONS[status])
145
+ for flag in ("no_match", "abstain_recommended", "decision_abstained"):
146
+ if response.get(flag) is True:
147
+ return declined(flag, "abstain")
148
+ usable = [row for row in rows if _is_evidence(row, search_row=search_rows)]
149
+ if not usable:
150
+ return declined("no_usable_memories", "abstain")
151
+ others = [row for row in rows if row not in usable]
152
+ return AnswerDecision(True, status, "answer", usable, others, cited)
153
+
154
+
155
+ def usable_memories(
156
+ response: Optional[Mapping[str, Any]], *, history: bool = False
157
+ ) -> List[Dict[str, Any]]:
158
+ """The rows of ``response`` safe to pass to a model as evidence; empty when
159
+ ``should_answer`` says not to answer."""
160
+
161
+ return should_answer(response, history=history).memories
162
+
163
+
164
+ def _day(value: Any) -> Optional[str]:
165
+ """The calendar date of an ISO timestamp (the API's dates are ISO 8601)."""
166
+
167
+ return str(value)[:10] if isinstance(value, str) and value else None
168
+
169
+
170
+ def _count(value: Any) -> int:
171
+ try:
172
+ return int(float(value or 0))
173
+ except (TypeError, ValueError):
174
+ return 0
175
+
176
+
177
+ def _profile_lines(profile: Any) -> List[str]:
178
+ if not isinstance(profile, Mapping):
179
+ return []
180
+ if profile.get("status") != "ok":
181
+ # Unreadable is not empty: a preference may exist that was not returned.
182
+ return ["Standing profile of this user: unavailable for this request."]
183
+ facts = [
184
+ fact
185
+ for fact in profile.get("facts") or []
186
+ if isinstance(fact, Mapping) and fact.get("value") not in (None, "")
187
+ ]
188
+ avoid = [t for t in profile.get("avoid_topics") or [] if isinstance(t, str) and t]
189
+ projection = profile.get("projection")
190
+ projection = projection if isinstance(projection, Mapping) else {}
191
+ pending = projection.get("status") == "pending"
192
+ if not facts and not avoid and not pending:
193
+ return []
194
+ scope = profile.get("scope")
195
+ scope = (
196
+ scope
197
+ if isinstance(scope, Mapping)
198
+ else {"end_user_id": profile.get("end_user_id")}
199
+ )
200
+ details = [f"{key} {value}" for key, value in scope.items() if value]
201
+ if _day(profile.get("as_of")):
202
+ details.append(f"as of {_day(profile.get('as_of'))}")
203
+ if projection.get("status"):
204
+ details.append(f"projection {projection['status']}")
205
+ header = "Standing profile of this user (applies to every reply"
206
+ lines = [header + ("; " + "; ".join(details) if details else "") + "):"]
207
+ for fact in facts:
208
+ name = " / ".join(str(fact[k]) for k in ("category", "key") if fact.get(k))
209
+ meta = []
210
+ if isinstance(fact.get("confidence"), (int, float)):
211
+ meta.append(f"confidence {float(fact['confidence']):.2f}")
212
+ if _day(fact.get("updated_at")):
213
+ meta.append(f"updated {_day(fact.get('updated_at'))}")
214
+ text = f"{name}: {fact['value']}" if name else str(fact["value"])
215
+ lines.append(f"- {text}" + (f" ({', '.join(meta)})" if meta else ""))
216
+ if avoid:
217
+ lines.append("- avoid topics: " + ", ".join(avoid))
218
+ if pending:
219
+ count = projection.get("pending_writes")
220
+ writes = (
221
+ f"{'at least ' if projection.get('capped') else ''}{count} recent writes"
222
+ if isinstance(count, int) and count > 0
223
+ else "recent writes"
224
+ )
225
+ lines.append(
226
+ f"- still being updated: {writes} not yet reflected; a fact here may be "
227
+ "missing or about to change."
228
+ )
229
+ return lines
230
+
231
+
232
+ def _memory_details(row: Mapping[str, Any]) -> List[str]:
233
+ metadata = row.get("metadata") if isinstance(row.get("metadata"), Mapping) else {}
234
+ details = []
235
+ memory_type = metadata.get("memory_type") or row.get("memory_type")
236
+ if memory_type:
237
+ details.append(str(memory_type))
238
+ if _day(row.get("stated_at")):
239
+ details.append(f"stated {_day(row.get('stated_at'))}")
240
+ elif _day(row.get("created_at")):
241
+ details.append(f"saved {_day(row.get('created_at'))}")
242
+ if _day(row.get("valid_until")):
243
+ details.append(f"valid until {_day(row.get('valid_until'))}")
244
+ if row.get("subject"):
245
+ details.append(f"about {row['subject']}")
246
+ applies_to = row.get("applies_to")
247
+ names = (
248
+ [n for n in applies_to if isinstance(n, str) and n]
249
+ if isinstance(applies_to, list)
250
+ else []
251
+ )
252
+ if names:
253
+ details.append("names " + ", ".join(names))
254
+ if row.get("revision_status"):
255
+ details.append(str(row["revision_status"]))
256
+ grounding = row.get("grounding")
257
+ if not isinstance(grounding, Mapping):
258
+ grounding = metadata.get("grounding")
259
+ if isinstance(grounding, Mapping):
260
+ support = grounding.get("support_basis") or grounding.get("status")
261
+ if support:
262
+ details.append(f"support {support}")
263
+ outcomes = metadata.get("outcome_evidence")
264
+ if isinstance(outcomes, Mapping) and (
265
+ outcomes.get("successes") or outcomes.get("failures")
266
+ ):
267
+ details.append(
268
+ f"outcomes {_count(outcomes.get('successes'))} worked, "
269
+ f"{_count(outcomes.get('failures'))} failed"
270
+ )
271
+ return details
272
+
273
+
274
+ def answer_context(
275
+ response: Optional[Mapping[str, Any]],
276
+ decision: Optional[AnswerDecision] = None,
277
+ *,
278
+ history: bool = False,
279
+ ) -> str:
280
+ """The text to give your own model with the user's message: the end user's
281
+ standing profile and the memories ``should_answer`` lets you answer from.
282
+
283
+ Pass a ``search_with_proof(..., include_profile=True)`` or ``reason()``
284
+ response, and the decision you already made on it (computed here when
285
+ omitted). The profile section lists each standing fact with its category,
286
+ confidence and date, the topics to avoid, the profile's scope, and whether
287
+ it is still being projected from recent writes; an unavailable profile is
288
+ said to be unavailable, never left out as if empty. The memories section
289
+ lists only ``decision.memories``, numbered for citation, each with its
290
+ memory type, date, whom it is about, the names it states, its revision
291
+ status and its support. When the decision is not to answer, only the
292
+ profile is returned: standing preferences still shape a reply such as
293
+ "I don't know that yet". Returns "" when there is nothing to add.
294
+ """
295
+
296
+ if decision is None:
297
+ decision = should_answer(response, history=history)
298
+ sections = []
299
+ profile = _profile_lines(
300
+ response.get("profile") if isinstance(response, Mapping) else None
301
+ )
302
+ if profile:
303
+ sections.append("\n".join(profile))
304
+ if decision.answer and decision.memories:
305
+ heading = (
306
+ "Memories to answer from (earlier versions are labelled):"
307
+ if decision.reason == "history"
308
+ else "Memories to answer from (current, supported for this message):"
309
+ )
310
+ lines = [heading]
311
+ for index, row in enumerate(decision.memories, start=1):
312
+ details = _memory_details(row)
313
+ lines.append(f"[{index}]" + (f" ({'; '.join(details)})" if details else ""))
314
+ lines.append(str(row.get("content") or ""))
315
+ sections.append("\n".join(lines))
316
+ return "\n\n".join(sections)
@@ -93,7 +93,7 @@ class MemoryClient:
93
93
  """Get request headers."""
94
94
  headers = {
95
95
  "Content-Type": "application/json",
96
- "User-Agent": "hebbrix-python/2.7.0",
96
+ "User-Agent": "hebbrix-python/2.8.0",
97
97
  }
98
98
 
99
99
  if self.api_key:
@@ -302,8 +302,12 @@ class MemoryClient:
302
302
  group_by_source: bool = True,
303
303
  debug: bool = False,
304
304
  view: Optional[str] = None,
305
+ include_superseded: bool = False,
306
+ include_profile: bool = False,
305
307
  ) -> Dict[str, Any]:
306
- """Search and return both results and the automatic ProofLoop context."""
308
+ """Search and return the response's rows, its abstention fields
309
+ (``answer_status``, ``abstain_recommended``: ``hebbrix.should_answer``
310
+ decides from them) and the automatic ProofLoop context."""
307
311
 
308
312
  return await self.search_resource.search_with_proof(
309
313
  query=query,
@@ -320,6 +324,8 @@ class MemoryClient:
320
324
  group_by_source=group_by_source,
321
325
  debug=debug,
322
326
  view=view,
327
+ include_superseded=include_superseded,
328
+ include_profile=include_profile,
323
329
  )
324
330
 
325
331
  async def reason(
@@ -334,6 +340,7 @@ class MemoryClient:
334
340
  facets: Optional[List[str]] = None,
335
341
  view: str = "compact",
336
342
  debug: bool = False,
343
+ deadline_ms: Optional[int] = None,
337
344
  ) -> Dict[str, Any]:
338
345
  """
339
346
  Perform reasoning over memories.
@@ -349,6 +356,7 @@ class MemoryClient:
349
356
  facets: Optional typed decomposition facets
350
357
  view: ``"compact"`` (default) or ``"full"`` diagnostics
351
358
  debug: Return the full diagnostic response
359
+ deadline_ms: The response's latency budget, at most the server's
352
360
 
353
361
  Returns:
354
362
  Reasoning result with answer and sources
@@ -364,6 +372,7 @@ class MemoryClient:
364
372
  facets=facets,
365
373
  view=view,
366
374
  debug=debug,
375
+ deadline_ms=deadline_ms,
367
376
  )
368
377
 
369
378
  async def close(self):