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.
- {memwal-0.1.3.dev0 → memwal-0.1.4}/.gitignore +2 -0
- memwal-0.1.4/CHANGELOG.md +67 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/PKG-INFO +18 -11
- {memwal-0.1.3.dev0 → memwal-0.1.4}/README.md +17 -10
- {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/.env.example +2 -2
- {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/__init__.py +3 -3
- {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/client.py +135 -13
- {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/middleware.py +27 -7
- memwal-0.1.4/notebooks/walrus_memory_python_sdk.ipynb +804 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/pyproject.toml +4 -1
- {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_client.py +119 -1
- {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_integration.py +8 -6
- {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_middleware.py +32 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_signing.py +3 -3
- memwal-0.1.3.dev0/CHANGELOG.md +0 -45
- {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/.gitignore +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/interactive_demo.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/examples/verify_credentials.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/compatibility.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/types.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/memwal/utils.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/run_tests.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/__init__.py +0 -0
- {memwal-0.1.3.dev0 → memwal-0.1.4}/tests/test_env_presets.py +0 -0
|
@@ -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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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="
|
|
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?)` |
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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="
|
|
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?)` |
|
|
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
|
|
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=
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
421
|
-
|
|
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 =
|
|
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
|
-
|
|
472
|
+
def _analyze() -> None:
|
|
454
473
|
try:
|
|
455
|
-
|
|
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
|
-
|
|
478
|
+
thread = threading.Thread(target=_analyze, daemon=True)
|
|
479
|
+
thread.start()
|
|
460
480
|
|
|
461
481
|
return result
|
|
462
482
|
|