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.
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/CHANGELOG.md +12 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/PKG-INFO +1 -1
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/__init__.py +1 -1
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/client.py +125 -7
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/pyproject.toml +1 -1
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_client.py +94 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/.gitignore +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/README.md +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/.env.example +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/.gitignore +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/interactive_demo.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/examples/verify_credentials.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/compatibility.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/middleware.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/types.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/memwal/utils.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/run_tests.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/__init__.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_env_presets.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_integration.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4.dev0}/tests/test_middleware.py +0 -0
- {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
|
|
@@ -30,7 +30,8 @@ import base64
|
|
|
30
30
|
import json
|
|
31
31
|
import random
|
|
32
32
|
import time
|
|
33
|
-
from
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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`."""
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|