memwal 0.1.3.dev0__tar.gz → 0.1.4.dev0__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 (23) hide show
  1. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/CHANGELOG.md +12 -0
  2. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/PKG-INFO +1 -1
  3. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/__init__.py +1 -1
  4. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/client.py +125 -7
  5. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/pyproject.toml +1 -1
  6. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_client.py +94 -0
  7. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/.gitignore +0 -0
  8. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/README.md +0 -0
  9. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/.env.example +0 -0
  10. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/.gitignore +0 -0
  11. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/async_remember_demo.py +0 -0
  12. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/interactive_demo.py +0 -0
  13. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/verify_credentials.py +0 -0
  14. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/compatibility.py +0 -0
  15. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/middleware.py +0 -0
  16. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/types.py +0 -0
  17. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/utils.py +0 -0
  18. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/run_tests.py +0 -0
  19. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/__init__.py +0 -0
  20. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_env_presets.py +0 -0
  21. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_integration.py +0 -0
  22. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_middleware.py +0 -0
  23. {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_signing.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # memwal
2
2
 
3
+ ## 0.1.4
4
+
5
+ ### Added
6
+
7
+ - 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.
8
+ - 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.
9
+ - Field is omitted from the request body when not supplied.
10
+
11
+ ### Changed
12
+
13
+ - `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.
14
+
3
15
  ## 0.1.3
4
16
 
5
17
  ### Added
@@ -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.dev0
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
@@ -116,4 +116,4 @@ __all__ = [
116
116
  "RecallManualResult",
117
117
  ]
118
118
 
119
- __version__ = "0.1.3.dev0"
119
+ __version__ = "0.1.4.dev0"
@@ -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,81 @@ 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 = occurred_at.replace("Z", "+00:00", 1) if occurred_at.endswith("Z") else occurred_at
181
+ try:
182
+ parsed = datetime.fromisoformat(normalised)
183
+ except ValueError as exc:
184
+ raise ValueError(
185
+ f"occurred_at must be RFC-3339 / ISO-8601, got: {occurred_at!r}"
186
+ ) from exc
187
+ # Round-trip through the datetime branch so the wire format is
188
+ # canonical (UTC, milliseconds, trailing "Z"). Naïve inputs
189
+ # here are rare but possible; reuse the aware-required guard
190
+ # by attaching tzinfo if the string carried one.
191
+ if parsed.tzinfo is None:
192
+ raise ValueError(
193
+ f"occurred_at string must carry a UTC offset or 'Z' suffix, "
194
+ f"got: {occurred_at!r}"
195
+ )
196
+ return _occurred_at_to_wire(parsed)
197
+ raise TypeError(
198
+ f"occurred_at must be datetime, str, or None; got {type(occurred_at).__name__}"
199
+ )
200
+
201
+
126
202
  class MemWal:
127
203
  """Async-native Walrus Memory client.
128
204
 
@@ -533,7 +609,12 @@ class MemWal:
533
609
  return RecallResult(results=memories, total=len(memories))
534
610
  return RecallResult(results=memories, total=data.get("total", len(memories)))
535
611
 
536
- async def analyze(self, text: str, namespace: Optional[str] = None) -> AnalyzeResult:
612
+ async def analyze(
613
+ self,
614
+ text: str,
615
+ namespace: Optional[str] = None,
616
+ occurred_at: Optional[Union[str, datetime]] = None,
617
+ ) -> AnalyzeResult:
537
618
  """Analyze conversation text and return as soon as facts are accepted.
538
619
 
539
620
  Per PR #121: server extracts atomic facts synchronously via LLM, then
@@ -546,15 +627,40 @@ class MemWal:
546
627
  Args:
547
628
  text: Conversation text to analyze.
548
629
  namespace: Override the default namespace.
630
+ occurred_at: Optional valid-time timestamp — when the
631
+ conversation/event actually happened. When supplied, the
632
+ server extractor uses it as a temporal anchor and
633
+ resolves in-turn relative references ("last Friday",
634
+ "yesterday") into absolute dates inside the fact text
635
+ before embedding/encryption. Accepts a
636
+ :class:`datetime.datetime` (preferred — **must be
637
+ timezone-aware**; naïve datetimes raise ``ValueError``
638
+ because silently assuming UTC would mis-anchor by N
639
+ hours for callers outside UTC) or an ISO-8601 / RFC-3339
640
+ string (must carry a ``Z`` suffix or UTC offset; raises
641
+ ``ValueError`` if malformed or naïve). Wire format is
642
+ RFC-3339 UTC with millisecond precision and trailing
643
+ ``Z`` (byte-identical to the TypeScript SDK). Omit when
644
+ no anchor is available — the server will not invent one
645
+ (no ``now()`` fallback). The resolved date lives only
646
+ inside the encrypted fact text + embedding; there is no
647
+ server-readable metadata column for it (Architecture A).
549
648
 
550
649
  Returns:
551
650
  :class:`AnalyzeResult` with extracted ``facts`` + per-fact
552
651
  ``job_ids`` for downstream polling.
553
652
  """
653
+ body: Dict[str, Any] = {
654
+ "text": text,
655
+ "namespace": namespace or self._namespace,
656
+ }
657
+ wire_occurred_at = _occurred_at_to_wire(occurred_at)
658
+ if wire_occurred_at is not None:
659
+ body["occurred_at"] = wire_occurred_at
554
660
  data = await self._signed_request(
555
661
  "POST",
556
662
  "/api/analyze",
557
- {"text": text, "namespace": namespace or self._namespace},
663
+ body,
558
664
  accepted_statuses=(200, 202),
559
665
  )
560
666
  # Backward-compat: older server shape returned `facts[].id` and
@@ -583,6 +689,7 @@ class MemWal:
583
689
  text: str,
584
690
  namespace: Optional[str] = None,
585
691
  opts: Optional[RememberBulkOptions] = None,
692
+ occurred_at: Optional[Union[str, datetime]] = None,
586
693
  ) -> AnalyzeWaitResult:
587
694
  """Analyze + wait for every extracted fact to finish persisting.
588
695
 
@@ -590,9 +697,12 @@ class MemWal:
590
697
  :meth:`wait_for_remember_jobs` on the returned ``job_ids``. The
591
698
  result combines the analyze fact list with the bulk-style settled
592
699
  per-job results.
700
+
701
+ ``occurred_at`` carries the same temporal-anchor semantics as
702
+ :meth:`analyze` — see that method's docstring for details.
593
703
  """
594
704
 
595
- accepted = await self.analyze(text, namespace)
705
+ accepted = await self.analyze(text, namespace, occurred_at=occurred_at)
596
706
  completed = await self.wait_for_remember_jobs(accepted.job_ids, opts)
597
707
  return AnalyzeWaitResult(
598
708
  results=completed.results,
@@ -1245,18 +1355,26 @@ class MemWalSync:
1245
1355
  :class:`RecallParams` for the recommended object-style call)."""
1246
1356
  return self._run(self._inner.recall(query, limit, namespace, max_distance))
1247
1357
 
1248
- def analyze(self, text: str, namespace: Optional[str] = None) -> AnalyzeResult:
1358
+ def analyze(
1359
+ self,
1360
+ text: str,
1361
+ namespace: Optional[str] = None,
1362
+ occurred_at: Optional[Union[str, datetime]] = None,
1363
+ ) -> AnalyzeResult:
1249
1364
  """Synchronous version of :meth:`MemWal.analyze`."""
1250
- return self._run(self._inner.analyze(text, namespace))
1365
+ return self._run(self._inner.analyze(text, namespace, occurred_at=occurred_at))
1251
1366
 
1252
1367
  def analyze_and_wait(
1253
1368
  self,
1254
1369
  text: str,
1255
1370
  namespace: Optional[str] = None,
1256
1371
  opts: Optional[RememberBulkOptions] = None,
1372
+ occurred_at: Optional[Union[str, datetime]] = None,
1257
1373
  ) -> AnalyzeWaitResult:
1258
1374
  """Synchronous version of :meth:`MemWal.analyze_and_wait`."""
1259
- return self._run(self._inner.analyze_and_wait(text, namespace, opts))
1375
+ return self._run(
1376
+ self._inner.analyze_and_wait(text, namespace, opts, occurred_at=occurred_at)
1377
+ )
1260
1378
 
1261
1379
  def embed(self, text: str) -> EmbedResult:
1262
1380
  """Synchronous version of :meth:`MemWal.embed`."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.3.dev0"
7
+ version = "0.1.4.dev0"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import base64
11
11
  import json
12
+ from datetime import datetime, timedelta, timezone
12
13
  from typing import Any
13
14
 
14
15
  import httpx
@@ -494,10 +495,103 @@ class TestAnalyze:
494
495
 
495
496
  body = json.loads(route.calls[0].request.content)
496
497
  assert body["text"] == "I love coffee and live in Tokyo"
498
+ assert "occurred_at" not in body # omitted when not supplied
497
499
  assert len(result.facts) == 1
498
500
  assert result.facts[0].text == "User loves coffee"
499
501
  assert result.owner == "0xowner"
500
502
 
503
+ @respx.mock
504
+ async def test_analyze_with_occurred_at_datetime(
505
+ self, memwal_client: MemWal
506
+ ) -> None:
507
+ """A UTC-aware datetime renders as RFC-3339 millis with 'Z'."""
508
+ mock_seal_session_prereqs()
509
+ route = respx.post(f"{_TEST_SERVER}/api/analyze").mock(
510
+ return_value=httpx.Response(200, json={"facts": [], "total": 0, "owner": ""})
511
+ )
512
+
513
+ await memwal_client.analyze(
514
+ "I moved last Friday",
515
+ occurred_at=datetime(2023, 5, 25, 17, 50, tzinfo=timezone.utc),
516
+ )
517
+
518
+ body = json.loads(route.calls[0].request.content)
519
+ # Millisecond precision matches the TS SDK's Date.toISOString().
520
+ assert body["occurred_at"] == "2023-05-25T17:50:00.000Z"
521
+
522
+ @respx.mock
523
+ async def test_analyze_with_occurred_at_nonutc_tz(
524
+ self, memwal_client: MemWal
525
+ ) -> None:
526
+ """An aware datetime in a non-UTC tz is converted to UTC."""
527
+ mock_seal_session_prereqs()
528
+ route = respx.post(f"{_TEST_SERVER}/api/analyze").mock(
529
+ return_value=httpx.Response(200, json={"facts": [], "total": 0, "owner": ""})
530
+ )
531
+
532
+ # 17:50 in +07:00 (Hanoi) is 10:50 UTC.
533
+ ict = timezone(timedelta(hours=7))
534
+ await memwal_client.analyze(
535
+ "I moved last Friday",
536
+ occurred_at=datetime(2023, 5, 25, 17, 50, tzinfo=ict),
537
+ )
538
+
539
+ body = json.loads(route.calls[0].request.content)
540
+ assert body["occurred_at"] == "2023-05-25T10:50:00.000Z"
541
+
542
+ @respx.mock
543
+ async def test_analyze_with_occurred_at_string(
544
+ self, memwal_client: MemWal
545
+ ) -> None:
546
+ """An RFC-3339 string is validated and re-formatted to canonical."""
547
+ mock_seal_session_prereqs()
548
+ route = respx.post(f"{_TEST_SERVER}/api/analyze").mock(
549
+ return_value=httpx.Response(200, json={"facts": [], "total": 0, "owner": ""})
550
+ )
551
+
552
+ await memwal_client.analyze(
553
+ "I moved last Friday",
554
+ occurred_at="2023-05-25T17:50:00Z",
555
+ )
556
+
557
+ body = json.loads(route.calls[0].request.content)
558
+ # Canonical form: millis appended.
559
+ assert body["occurred_at"] == "2023-05-25T17:50:00.000Z"
560
+
561
+ async def test_analyze_naive_datetime_raises(
562
+ self, memwal_client: MemWal
563
+ ) -> None:
564
+ """Naïve datetimes are rejected — silently assuming UTC would
565
+ produce timezone-off-by-N anchors for callers outside UTC and
566
+ undermine WALM-55's honest-temporal-anchoring guarantee."""
567
+ with pytest.raises(ValueError, match="timezone-aware"):
568
+ await memwal_client.analyze(
569
+ "I moved last Friday",
570
+ occurred_at=datetime(2023, 5, 25, 17, 50), # naïve
571
+ )
572
+
573
+ async def test_analyze_garbage_string_raises(
574
+ self, memwal_client: MemWal
575
+ ) -> None:
576
+ """Malformed occurred_at strings are rejected at the SDK
577
+ boundary, not forwarded as an opaque 400 from the server."""
578
+ with pytest.raises(ValueError, match="RFC-3339"):
579
+ await memwal_client.analyze(
580
+ "I moved last Friday",
581
+ occurred_at="yesterday",
582
+ )
583
+
584
+ async def test_analyze_naive_string_raises(
585
+ self, memwal_client: MemWal
586
+ ) -> None:
587
+ """A timezone-less ISO string is rejected — same reasoning as
588
+ the naïve-datetime case."""
589
+ with pytest.raises(ValueError, match="UTC offset"):
590
+ await memwal_client.analyze(
591
+ "I moved last Friday",
592
+ occurred_at="2023-05-25T17:50:00", # no Z, no offset
593
+ )
594
+
501
595
 
502
596
  class TestRestore:
503
597
  @respx.mock
File without changes
File without changes
File without changes
File without changes
File without changes