memwal 0.1.3.dev0__tar.gz → 0.1.4__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 (25) hide show
  1. {memwal-0.1.3.dev0 → memwal-0.1.4}/.gitignore +2 -0
  2. memwal-0.1.4/CHANGELOG.md +67 -0
  3. {memwal-0.1.3.dev0 → memwal-0.1.4}/PKG-INFO +18 -11
  4. {memwal-0.1.3.dev0 → memwal-0.1.4}/README.md +17 -10
  5. {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/.env.example +2 -2
  6. {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/__init__.py +3 -3
  7. {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/client.py +135 -13
  8. {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/middleware.py +27 -7
  9. memwal-0.1.4/notebooks/walrus_memory_python_sdk.ipynb +804 -0
  10. {memwal-0.1.3.dev0 → memwal-0.1.4}/pyproject.toml +4 -1
  11. {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_client.py +119 -1
  12. {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_integration.py +8 -6
  13. {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_middleware.py +32 -0
  14. {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_signing.py +3 -3
  15. memwal-0.1.3.dev0/CHANGELOG.md +0 -45
  16. {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/.gitignore +0 -0
  17. {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/async_remember_demo.py +0 -0
  18. {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/interactive_demo.py +0 -0
  19. {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/verify_credentials.py +0 -0
  20. {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/compatibility.py +0 -0
  21. {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/types.py +0 -0
  22. {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/utils.py +0 -0
  23. {memwal-0.1.3.dev0 → memwal-0.1.4}/run_tests.py +0 -0
  24. {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/__init__.py +0 -0
  25. {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_env_presets.py +0 -0
@@ -60,6 +60,8 @@ share/python-wheels/
60
60
  # Jupyter
61
61
  .ipynb_checkpoints/
62
62
  *.ipynb
63
+ !notebooks/
64
+ !notebooks/walrus_memory_python_sdk.ipynb
63
65
 
64
66
  # Docs build
65
67
  site/
@@ -0,0 +1,67 @@
1
+ # memwal
2
+
3
+ ## Unreleased
4
+
5
+ ### Added
6
+
7
+ - Added a runnable [Walrus Memory Python SDK Colab](https://colab.research.google.com/drive/1SaKjkSp0DXnM_nktWSiEC-l9qGtVr6ph) covering installation, secure `staging` configuration, optional `prod`, `MemWalSync`, health/compatibility checks, delegate public-key/address derivation, `remember`, `remember_async`, async job waiting, `recall`, bulk remember, `remember_bulk_async`, `remember_bulk_and_wait`, optional `ask`, `analyze`, `analyze_and_wait`, `embed`, manual methods with scoring weights, `restore`, optional OpenAI/LangChain middleware, OpenAI-compatible provider settings such as `OPENAI_BASE_URL`, and troubleshooting.
8
+
9
+ ### Fixed
10
+
11
+ - Fixed `MemWalSync` reuse inside notebooks so repeated calls do not reuse an HTTP transport from a closed event loop.
12
+
13
+ ## 0.1.4
14
+
15
+ ### Added
16
+
17
+ - Added optional `occurred_at` to `analyze()` and `analyze_and_wait()` (both async and sync) for temporal anchoring of extracted facts. When supplied, the server resolves in-turn relative references ("last Friday", "yesterday") into absolute dates inside the extracted fact text before embedding and encryption.
18
+ - Accepts `datetime` or RFC-3339 string. Wire format is RFC-3339 UTC with millisecond precision (e.g. `"2023-05-25T17:50:00.000Z"`) — byte-identical to the TypeScript SDK.
19
+ - Field is omitted from the request body when not supplied.
20
+
21
+ ### Changed
22
+
23
+ - `occurred_at` validates input at the SDK boundary rather than forwarding malformed values to the server: naïve `datetime` instances raise `ValueError` (silently assuming UTC would mis-anchor by N hours for callers outside UTC), and malformed RFC-3339 strings raise `ValueError` with a diagnostic message instead of surfacing as opaque 400s.
24
+
25
+ ## 0.1.3
26
+
27
+ ### Added
28
+
29
+ - Added `RecallParams` for object-style `recall(...)` calls.
30
+
31
+ ### Changed
32
+
33
+ - Changed the default `restore()` limit from `50` to `10` to match the relayer and TypeScript SDK.
34
+ - Documented `restore()` response fields, default limit, pagination behavior, and performance expectations.
35
+
36
+ ## 0.1.2
37
+
38
+ ### Added
39
+
40
+ - Added `max_distance` to async and sync `recall()`.
41
+ - Added credential verification helper.
42
+
43
+ ### Changed
44
+
45
+ - Updated docs/examples to use `MEMWAL_PRIVATE_KEY`.
46
+ - Rebranded package metadata and documentation from MemWal to Walrus Memory.
47
+
48
+ ### Fixed
49
+
50
+ - Made `401` relayer errors more actionable.
51
+
52
+ ## 0.1.1
53
+
54
+ ### Added
55
+
56
+ - Added relayer `env` presets.
57
+ - Added compatibility checks and `compatibility()` helpers.
58
+
59
+ ## 0.1.0
60
+
61
+ ### Initial Release
62
+
63
+ - `MemWal` async client and `MemWalSync` sync wrapper
64
+ - Memory APIs: `remember`, `recall`, `analyze`, `ask`, `restore`, `health`
65
+ - Async job helpers for remember, bulk remember, and analyze
66
+ - LangChain/OpenAI middleware and delegate-key utilities
67
+ - Ed25519 delegate-key auth with namespace-scoped memory isolation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memwal
3
- Version: 0.1.3.dev0
3
+ Version: 0.1.4
4
4
  Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memwal.ai
6
6
  Project-URL: Documentation, https://docs.memwal.ai
@@ -57,6 +57,10 @@ pip install memwal[openai] # OpenAI SDK support
57
57
  pip install memwal[all] # Everything
58
58
  ```
59
59
 
60
+ ## Try It In Colab
61
+
62
+ Open the runnable [Walrus Memory Python SDK Colab](https://colab.research.google.com/drive/1SaKjkSp0DXnM_nktWSiEC-l9qGtVr6ph) for a notebook walkthrough covering installation, secure `staging` configuration, optional `prod`, health checks, `remember`, `remember_async`, async job waiting, `recall`, bulk remember, `remember_bulk_async`, `remember_bulk_and_wait`, optional SDK utilities, OpenAI/LangChain middleware, OpenAI-compatible provider settings such as `OPENAI_BASE_URL`, and troubleshooting.
63
+
60
64
  ## Quick Start
61
65
 
62
66
  Set your environment variables first:
@@ -84,8 +88,8 @@ async def main():
84
88
  server_url=os.environ.get("MEMWAL_SERVER_URL", "https://relayer.memwal.ai"),
85
89
  )
86
90
 
87
- # Store a memory
88
- result = await memwal.remember("I'm allergic to peanuts")
91
+ # Store a memory and wait until the background job is searchable
92
+ result = await memwal.remember_and_wait("I'm allergic to peanuts")
89
93
  print(result.blob_id)
90
94
 
91
95
  # Recall memories
@@ -93,8 +97,8 @@ async def main():
93
97
  for memory in matches.results:
94
98
  print(f"{memory.text} (relevance: {1 - memory.distance:.2f})")
95
99
 
96
- # Analyze conversation for facts
97
- analysis = await memwal.analyze("I love coffee and live in Tokyo")
100
+ # Analyze conversation for facts and wait until extracted facts are searchable
101
+ analysis = await memwal.analyze_and_wait("I love coffee and live in Tokyo")
98
102
  for fact in analysis.facts:
99
103
  print(fact.text)
100
104
 
@@ -115,7 +119,7 @@ client = MemWalSync.create(
115
119
  server_url=os.environ.get("MEMWAL_SERVER_URL", "https://relayer.memwal.ai"),
116
120
  )
117
121
 
118
- result = client.remember("I'm allergic to peanuts")
122
+ result = client.remember_and_wait("I'm allergic to peanuts")
119
123
  matches = client.recall(RecallParams(query="food allergies"))
120
124
  client.close()
121
125
  ```
@@ -130,7 +134,7 @@ async with MemWal.create(
130
134
  key=os.environ["MEMWAL_PRIVATE_KEY"],
131
135
  account_id=os.environ["MEMWAL_ACCOUNT_ID"],
132
136
  ) as memwal:
133
- await memwal.remember("I prefer dark mode")
137
+ await memwal.remember_and_wait("I prefer dark mode")
134
138
  ```
135
139
 
136
140
  ## Environment Presets
@@ -144,16 +148,14 @@ from memwal import MemWal
144
148
  memwal = MemWal.create(
145
149
  key=os.environ["MEMWAL_PRIVATE_KEY"],
146
150
  account_id=os.environ["MEMWAL_ACCOUNT_ID"],
147
- env="prod", # prod | dev | staging | local
151
+ env="staging", # staging for testing, prod for production
148
152
  )
149
153
  ```
150
154
 
151
155
  | `env` | Relayer URL |
152
156
  |-------|-------------|
153
157
  | `prod` | `https://relayer.memwal.ai` |
154
- | `dev` | `https://relayer.dev.memwal.ai` |
155
158
  | `staging` | `https://relayer.staging.memwal.ai` |
156
- | `local` | `http://127.0.0.1:8000` |
157
159
 
158
160
  Precedence: an explicit non-default **`server_url` wins over `env`**, which wins
159
161
  over the default. An unknown preset raises `ValueError`. `env` is also accepted
@@ -215,7 +217,12 @@ Create a new async client.
215
217
 
216
218
  | Method | Description |
217
219
  |--------|-------------|
218
- | `await remember(text, namespace?)` | Store a memory |
220
+ | `await remember(text, namespace?)` | Accept a background remember job and return `job_id` |
221
+ | `await wait_for_remember_job(job_id, ...)` | Poll one remember job until it is searchable |
222
+ | `await remember_and_wait(text, namespace?, ...)` | Store a memory and wait until it is searchable |
223
+ | `await remember_bulk(items)` | Accept several background remember jobs |
224
+ | `await wait_for_remember_jobs(job_ids, opts?)` | Poll several remember jobs together |
225
+ | `await remember_bulk_and_wait(items, opts?)` | Store several memories and wait for completion |
219
226
  | `await recall(RecallParams(query, limit?, namespace?, max_distance?))` | Search memories, optionally filtering by distance |
220
227
  | `await analyze(text, namespace?)` | Extract and store facts |
221
228
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
@@ -18,6 +18,10 @@ pip install memwal[openai] # OpenAI SDK support
18
18
  pip install memwal[all] # Everything
19
19
  ```
20
20
 
21
+ ## Try It In Colab
22
+
23
+ Open the runnable [Walrus Memory Python SDK Colab](https://colab.research.google.com/drive/1SaKjkSp0DXnM_nktWSiEC-l9qGtVr6ph) for a notebook walkthrough covering installation, secure `staging` configuration, optional `prod`, health checks, `remember`, `remember_async`, async job waiting, `recall`, bulk remember, `remember_bulk_async`, `remember_bulk_and_wait`, optional SDK utilities, OpenAI/LangChain middleware, OpenAI-compatible provider settings such as `OPENAI_BASE_URL`, and troubleshooting.
24
+
21
25
  ## Quick Start
22
26
 
23
27
  Set your environment variables first:
@@ -45,8 +49,8 @@ async def main():
45
49
  server_url=os.environ.get("MEMWAL_SERVER_URL", "https://relayer.memwal.ai"),
46
50
  )
47
51
 
48
- # Store a memory
49
- result = await memwal.remember("I'm allergic to peanuts")
52
+ # Store a memory and wait until the background job is searchable
53
+ result = await memwal.remember_and_wait("I'm allergic to peanuts")
50
54
  print(result.blob_id)
51
55
 
52
56
  # Recall memories
@@ -54,8 +58,8 @@ async def main():
54
58
  for memory in matches.results:
55
59
  print(f"{memory.text} (relevance: {1 - memory.distance:.2f})")
56
60
 
57
- # Analyze conversation for facts
58
- analysis = await memwal.analyze("I love coffee and live in Tokyo")
61
+ # Analyze conversation for facts and wait until extracted facts are searchable
62
+ analysis = await memwal.analyze_and_wait("I love coffee and live in Tokyo")
59
63
  for fact in analysis.facts:
60
64
  print(fact.text)
61
65
 
@@ -76,7 +80,7 @@ client = MemWalSync.create(
76
80
  server_url=os.environ.get("MEMWAL_SERVER_URL", "https://relayer.memwal.ai"),
77
81
  )
78
82
 
79
- result = client.remember("I'm allergic to peanuts")
83
+ result = client.remember_and_wait("I'm allergic to peanuts")
80
84
  matches = client.recall(RecallParams(query="food allergies"))
81
85
  client.close()
82
86
  ```
@@ -91,7 +95,7 @@ async with MemWal.create(
91
95
  key=os.environ["MEMWAL_PRIVATE_KEY"],
92
96
  account_id=os.environ["MEMWAL_ACCOUNT_ID"],
93
97
  ) as memwal:
94
- await memwal.remember("I prefer dark mode")
98
+ await memwal.remember_and_wait("I prefer dark mode")
95
99
  ```
96
100
 
97
101
  ## Environment Presets
@@ -105,16 +109,14 @@ from memwal import MemWal
105
109
  memwal = MemWal.create(
106
110
  key=os.environ["MEMWAL_PRIVATE_KEY"],
107
111
  account_id=os.environ["MEMWAL_ACCOUNT_ID"],
108
- env="prod", # prod | dev | staging | local
112
+ env="staging", # staging for testing, prod for production
109
113
  )
110
114
  ```
111
115
 
112
116
  | `env` | Relayer URL |
113
117
  |-------|-------------|
114
118
  | `prod` | `https://relayer.memwal.ai` |
115
- | `dev` | `https://relayer.dev.memwal.ai` |
116
119
  | `staging` | `https://relayer.staging.memwal.ai` |
117
- | `local` | `http://127.0.0.1:8000` |
118
120
 
119
121
  Precedence: an explicit non-default **`server_url` wins over `env`**, which wins
120
122
  over the default. An unknown preset raises `ValueError`. `env` is also accepted
@@ -176,7 +178,12 @@ Create a new async client.
176
178
 
177
179
  | Method | Description |
178
180
  |--------|-------------|
179
- | `await remember(text, namespace?)` | Store a memory |
181
+ | `await remember(text, namespace?)` | Accept a background remember job and return `job_id` |
182
+ | `await wait_for_remember_job(job_id, ...)` | Poll one remember job until it is searchable |
183
+ | `await remember_and_wait(text, namespace?, ...)` | Store a memory and wait until it is searchable |
184
+ | `await remember_bulk(items)` | Accept several background remember jobs |
185
+ | `await wait_for_remember_jobs(job_ids, opts?)` | Poll several remember jobs together |
186
+ | `await remember_bulk_and_wait(items, opts?)` | Store several memories and wait for completion |
180
187
  | `await recall(RecallParams(query, limit?, namespace?, max_distance?))` | Search memories, optionally filtering by distance |
181
188
  | `await analyze(text, namespace?)` | Extract and store facts |
182
189
  | `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
@@ -1,10 +1,10 @@
1
1
  # Local server (default) or remote relayer
2
2
  MEMWAL_SERVER_URL=http://localhost:8000
3
3
  # Ed25519 delegate private key (64-hex). Get from Walrus Memory dashboard.
4
- MEMWAL_PRIVATE_KEY=21b423e72282dcc47805de48ef9130331b642667b7b2a5cd621767928205e360
4
+ MEMWAL_PRIVATE_KEY=<your-ed25519-delegate-private-key-hex>
5
5
  # Optional: paste the dashboard delegate public key so verification can catch mismatches.
6
6
  MEMWAL_DELEGATE_PUBLIC_KEY=
7
7
  # Walrus Memory account object ID on Sui (the wallet's account)
8
- MEMWAL_ACCOUNT_ID=0x8a1121b8f95d79e68bd07efaf71689ce6fd832b369cdb1b2a943ec7beb822392
8
+ MEMWAL_ACCOUNT_ID=0x-your-walrus-memory-account-id
9
9
  # Namespace for these test memories
10
10
  MEMWAL_NAMESPACE=python-sdk-example
@@ -13,13 +13,13 @@ Quick start::
13
13
  )
14
14
 
15
15
  # Async
16
- result = await memwal.remember("I love coffee")
16
+ result = await memwal.remember_and_wait("I love coffee")
17
17
  matches = await memwal.recall(RecallParams(query="beverage preferences"))
18
18
 
19
19
  # Sync wrapper
20
20
  from memwal import MemWalSync
21
21
  client = MemWalSync.create(key="...", account_id="0x...")
22
- result = client.remember("I love coffee")
22
+ result = client.remember_and_wait("I love coffee")
23
23
  """
24
24
 
25
25
  from .client import (
@@ -116,4 +116,4 @@ __all__ = [
116
116
  "RecallManualResult",
117
117
  ]
118
118
 
119
- __version__ = "0.1.3.dev0"
119
+ __version__ = "0.1.4"
@@ -30,7 +30,8 @@ import base64
30
30
  import json
31
31
  import random
32
32
  import time
33
- from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar
33
+ from datetime import datetime, timezone
34
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
34
35
 
35
36
  import httpx
36
37
  import nacl.signing
@@ -123,6 +124,85 @@ def _is_transient_polling_status(status: int) -> bool:
123
124
  return status == 0 or status == 429 or status >= 500
124
125
 
125
126
 
127
+ def _occurred_at_to_wire(
128
+ occurred_at: Optional[Union[str, datetime]],
129
+ ) -> Optional[str]:
130
+ """Render an ``occurred_at`` argument to the wire format.
131
+
132
+ The server's ``AnalyzeRequest.occurred_at`` field expects RFC-3339
133
+ UTC with a trailing ``Z``. Output precision matches the TS SDK's
134
+ ``Date.toISOString()`` (milliseconds), e.g.
135
+ ``"2023-05-25T17:50:00.000Z"`` — so the two SDKs produce
136
+ byte-identical wire payloads for the same instant.
137
+
138
+ Aware ``datetime`` objects are converted to UTC. **Naïve datetimes
139
+ are rejected** with ``ValueError``: silently assuming UTC would
140
+ produce timezone-off-by-N anchors for callers outside UTC and
141
+ undermine WALM-55's "honest temporal anchoring" guarantee. Callers
142
+ should pass ``datetime.now(timezone.utc)`` or attach a ``tzinfo``
143
+ explicitly.
144
+
145
+ String inputs are validated as RFC-3339 / ISO-8601 (accepting
146
+ trailing ``Z`` as a UTC shorthand, per RFC-3339 §4.2) and
147
+ re-formatted to canonical form. Invalid strings raise
148
+ ``ValueError`` at the SDK boundary rather than being forwarded as
149
+ a 400 from the server.
150
+
151
+ Returns ``None`` when no anchor is supplied so the field is
152
+ omitted from the request body.
153
+ """
154
+
155
+ if occurred_at is None:
156
+ return None
157
+ if isinstance(occurred_at, datetime):
158
+ if occurred_at.tzinfo is None:
159
+ raise ValueError(
160
+ "occurred_at datetime must be timezone-aware. Pass "
161
+ "datetime.now(timezone.utc), datetime(..., tzinfo=...), "
162
+ "or an RFC-3339 string. Naïve datetimes are rejected "
163
+ "because they would be silently mis-anchored for "
164
+ "callers outside UTC."
165
+ )
166
+ dt = occurred_at.astimezone(timezone.utc)
167
+ # Drop tzinfo before `isoformat` to suppress the "+00:00"
168
+ # suffix; we append "Z" manually to match the TS SDK + server
169
+ # canonical form. `timespec="milliseconds"` matches JS
170
+ # `Date.toISOString()` precision so the two SDKs are
171
+ # byte-identical for the same instant.
172
+ return dt.replace(tzinfo=None).isoformat(timespec="milliseconds") + "Z"
173
+ if isinstance(occurred_at, str):
174
+ # Validate at the SDK boundary so a bad timestamp doesn't
175
+ # bring down the whole analyze() call with an opaque 400 from
176
+ # the server's serde layer. RFC-3339 §4.2 allows "Z" as a UTC
177
+ # shorthand; `fromisoformat` only accepts it on Python 3.11+,
178
+ # so we normalise to "+00:00" before parsing for 3.9/3.10
179
+ # compatibility.
180
+ normalised = (
181
+ occurred_at.replace("Z", "+00:00", 1)
182
+ if occurred_at.endswith("Z")
183
+ else occurred_at
184
+ )
185
+ try:
186
+ parsed = datetime.fromisoformat(normalised)
187
+ except ValueError as exc:
188
+ raise ValueError(
189
+ f"occurred_at must be RFC-3339 / ISO-8601, got: {occurred_at!r}"
190
+ ) from exc
191
+ # Round-trip through the datetime branch so the wire format is
192
+ # canonical (UTC, milliseconds, trailing "Z"). Naïve inputs
193
+ # here are rare but possible; reuse the aware-required guard
194
+ # by attaching tzinfo if the string carried one.
195
+ if parsed.tzinfo is None:
196
+ raise ValueError(
197
+ f"occurred_at string must carry a UTC offset or 'Z' suffix, "
198
+ f"got: {occurred_at!r}"
199
+ )
200
+ return _occurred_at_to_wire(parsed)
201
+ raise TypeError(
202
+ f"occurred_at must be datetime, str, or None; got {type(occurred_at).__name__}"
203
+ )
204
+
205
+
126
206
  class MemWal:
127
207
  """Async-native Walrus Memory client.
128
208
 
@@ -533,7 +613,12 @@ class MemWal:
533
613
  return RecallResult(results=memories, total=len(memories))
534
614
  return RecallResult(results=memories, total=data.get("total", len(memories)))
535
615
 
536
- async def analyze(self, text: str, namespace: Optional[str] = None) -> AnalyzeResult:
616
+ async def analyze(
617
+ self,
618
+ text: str,
619
+ namespace: Optional[str] = None,
620
+ occurred_at: Optional[Union[str, datetime]] = None,
621
+ ) -> AnalyzeResult:
537
622
  """Analyze conversation text and return as soon as facts are accepted.
538
623
 
539
624
  Per PR #121: server extracts atomic facts synchronously via LLM, then
@@ -546,15 +631,40 @@ class MemWal:
546
631
  Args:
547
632
  text: Conversation text to analyze.
548
633
  namespace: Override the default namespace.
634
+ occurred_at: Optional valid-time timestamp — when the
635
+ conversation/event actually happened. When supplied, the
636
+ server extractor uses it as a temporal anchor and
637
+ resolves in-turn relative references ("last Friday",
638
+ "yesterday") into absolute dates inside the fact text
639
+ before embedding/encryption. Accepts a
640
+ :class:`datetime.datetime` (preferred — **must be
641
+ timezone-aware**; naïve datetimes raise ``ValueError``
642
+ because silently assuming UTC would mis-anchor by N
643
+ hours for callers outside UTC) or an ISO-8601 / RFC-3339
644
+ string (must carry a ``Z`` suffix or UTC offset; raises
645
+ ``ValueError`` if malformed or naïve). Wire format is
646
+ RFC-3339 UTC with millisecond precision and trailing
647
+ ``Z`` (byte-identical to the TypeScript SDK). Omit when
648
+ no anchor is available — the server will not invent one
649
+ (no ``now()`` fallback). The resolved date lives only
650
+ inside the encrypted fact text + embedding; there is no
651
+ server-readable metadata column for it (Architecture A).
549
652
 
550
653
  Returns:
551
654
  :class:`AnalyzeResult` with extracted ``facts`` + per-fact
552
655
  ``job_ids`` for downstream polling.
553
656
  """
657
+ body: Dict[str, Any] = {
658
+ "text": text,
659
+ "namespace": namespace or self._namespace,
660
+ }
661
+ wire_occurred_at = _occurred_at_to_wire(occurred_at)
662
+ if wire_occurred_at is not None:
663
+ body["occurred_at"] = wire_occurred_at
554
664
  data = await self._signed_request(
555
665
  "POST",
556
666
  "/api/analyze",
557
- {"text": text, "namespace": namespace or self._namespace},
667
+ body,
558
668
  accepted_statuses=(200, 202),
559
669
  )
560
670
  # Backward-compat: older server shape returned `facts[].id` and
@@ -583,6 +693,7 @@ class MemWal:
583
693
  text: str,
584
694
  namespace: Optional[str] = None,
585
695
  opts: Optional[RememberBulkOptions] = None,
696
+ occurred_at: Optional[Union[str, datetime]] = None,
586
697
  ) -> AnalyzeWaitResult:
587
698
  """Analyze + wait for every extracted fact to finish persisting.
588
699
 
@@ -590,9 +701,12 @@ class MemWal:
590
701
  :meth:`wait_for_remember_jobs` on the returned ``job_ids``. The
591
702
  result combines the analyze fact list with the bulk-style settled
592
703
  per-job results.
704
+
705
+ ``occurred_at`` carries the same temporal-anchor semantics as
706
+ :meth:`analyze` — see that method's docstring for details.
593
707
  """
594
708
 
595
- accepted = await self.analyze(text, namespace)
709
+ accepted = await self.analyze(text, namespace, occurred_at=occurred_at)
596
710
  completed = await self.wait_for_remember_jobs(accepted.job_ids, opts)
597
711
  return AnalyzeWaitResult(
598
712
  results=completed.results,
@@ -1141,6 +1255,12 @@ class MemWalSync:
1141
1255
  except RuntimeError:
1142
1256
  loop = None
1143
1257
 
1258
+ # Reset the httpx client before every asyncio.run() path so it is
1259
+ # recreated inside the loop that will use it. This matters in
1260
+ # notebooks/Jupyter where the sync wrapper runs coroutines in worker
1261
+ # threads with short-lived event loops.
1262
+ self._inner._client = None
1263
+
1144
1264
  if loop is not None and loop.is_running():
1145
1265
  # Already inside an event loop (e.g. Jupyter).
1146
1266
  # Create a new loop in a thread.
@@ -1149,12 +1269,6 @@ class MemWalSync:
1149
1269
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
1150
1270
  return pool.submit(asyncio.run, coro).result()
1151
1271
  else:
1152
- # Reset the httpx client before each asyncio.run() so it is
1153
- # recreated fresh inside the new event loop. Without this,
1154
- # reusing a MemWalSync instance across multiple calls raises
1155
- # "RuntimeError: Event loop is closed" because the client's
1156
- # transport is still bound to the previous (now-closed) loop.
1157
- self._inner._client = None
1158
1272
  return asyncio.run(coro)
1159
1273
 
1160
1274
  def remember(
@@ -1245,18 +1359,26 @@ class MemWalSync:
1245
1359
  :class:`RecallParams` for the recommended object-style call)."""
1246
1360
  return self._run(self._inner.recall(query, limit, namespace, max_distance))
1247
1361
 
1248
- def analyze(self, text: str, namespace: Optional[str] = None) -> AnalyzeResult:
1362
+ def analyze(
1363
+ self,
1364
+ text: str,
1365
+ namespace: Optional[str] = None,
1366
+ occurred_at: Optional[Union[str, datetime]] = None,
1367
+ ) -> AnalyzeResult:
1249
1368
  """Synchronous version of :meth:`MemWal.analyze`."""
1250
- return self._run(self._inner.analyze(text, namespace))
1369
+ return self._run(self._inner.analyze(text, namespace, occurred_at=occurred_at))
1251
1370
 
1252
1371
  def analyze_and_wait(
1253
1372
  self,
1254
1373
  text: str,
1255
1374
  namespace: Optional[str] = None,
1256
1375
  opts: Optional[RememberBulkOptions] = None,
1376
+ occurred_at: Optional[Union[str, datetime]] = None,
1257
1377
  ) -> AnalyzeWaitResult:
1258
1378
  """Synchronous version of :meth:`MemWal.analyze_and_wait`."""
1259
- return self._run(self._inner.analyze_and_wait(text, namespace, opts))
1379
+ return self._run(
1380
+ self._inner.analyze_and_wait(text, namespace, opts, occurred_at=occurred_at)
1381
+ )
1260
1382
 
1261
1383
  def embed(self, text: str) -> EmbedResult:
1262
1384
  """Synchronous version of :meth:`MemWal.embed`."""
@@ -128,6 +128,22 @@ def _fire_and_forget(coro: Any) -> None:
128
128
  thread.start()
129
129
 
130
130
 
131
+ def _run_blocking(coro_factory: Callable[[], Any]) -> Any:
132
+ """Run a coroutine factory from sync code, including notebooks."""
133
+ try:
134
+ loop = asyncio.get_running_loop()
135
+ except RuntimeError:
136
+ loop = None
137
+
138
+ if loop is not None and loop.is_running():
139
+ import concurrent.futures
140
+
141
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
142
+ return pool.submit(lambda: asyncio.run(coro_factory())).result()
143
+
144
+ return asyncio.run(coro_factory())
145
+
146
+
131
147
  # ============================================================
132
148
  # LangChain Integration
133
149
  # ============================================================
@@ -417,9 +433,12 @@ def _wrap_sync_openai(
417
433
  """Wrap a sync OpenAI client's chat.completions.create."""
418
434
  original_create = client.chat.completions.create
419
435
 
420
- def patched_create(*args: Any, **kwargs: Any) -> Any:
421
- import asyncio
436
+ def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
437
+ # Keep httpx clients bound to the short-lived loop that uses them.
438
+ memwal._client = None
439
+ return _run_blocking(coro_factory)
422
440
 
441
+ def patched_create(*args: Any, **kwargs: Any) -> Any:
423
442
  messages = kwargs.get("messages") or (args[0] if args else None)
424
443
  if messages is None:
425
444
  return original_create(*args, **kwargs)
@@ -428,8 +447,8 @@ def _wrap_sync_openai(
428
447
  user_text = _find_last_user_message(messages)
429
448
  if user_text:
430
449
  try:
431
- recall_result = asyncio.run(
432
- memwal.recall(user_text, max_memories, namespace)
450
+ recall_result = _run_memwal(
451
+ lambda: memwal.recall(user_text, max_memories, namespace)
433
452
  )
434
453
  relevant = [
435
454
  m for m in recall_result.results
@@ -450,13 +469,14 @@ def _wrap_sync_openai(
450
469
 
451
470
  # Fire-and-forget analyze
452
471
  if auto_save and user_text:
453
- async def _analyze() -> None:
472
+ def _analyze() -> None:
454
473
  try:
455
- await memwal.analyze(user_text, namespace)
474
+ _run_memwal(lambda: memwal.analyze(user_text, namespace))
456
475
  except Exception as e:
457
476
  log(f"[Walrus Memory] Auto-save failed: {e}")
458
477
 
459
- _fire_and_forget(_analyze())
478
+ thread = threading.Thread(target=_analyze, daemon=True)
479
+ thread.start()
460
480
 
461
481
  return result
462
482