mayamemo 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mayamemo-0.1.0/LICENSE +21 -0
- mayamemo-0.1.0/PKG-INFO +103 -0
- mayamemo-0.1.0/README.md +83 -0
- mayamemo-0.1.0/mayamemo/__init__.py +7 -0
- mayamemo-0.1.0/mayamemo/client.py +284 -0
- mayamemo-0.1.0/mayamemo/integrations.py +147 -0
- mayamemo-0.1.0/mayamemo.egg-info/PKG-INFO +103 -0
- mayamemo-0.1.0/mayamemo.egg-info/SOURCES.txt +12 -0
- mayamemo-0.1.0/mayamemo.egg-info/dependency_links.txt +1 -0
- mayamemo-0.1.0/mayamemo.egg-info/requires.txt +3 -0
- mayamemo-0.1.0/mayamemo.egg-info/top_level.txt +1 -0
- mayamemo-0.1.0/pyproject.toml +32 -0
- mayamemo-0.1.0/setup.cfg +4 -0
- mayamemo-0.1.0/tests/test_sdk.py +214 -0
mayamemo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MayaMemo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mayamemo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mayamemo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MayaMemo Python SDK — evidence-backed context, memories and cross-agent checkpoints for AI coding agents. Zero dependencies.
|
|
5
|
+
Author: MayaMemo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Coin1511/mayamemo
|
|
8
|
+
Project-URL: Repository, https://github.com/Coin1511/mayamemo/tree/main/sdk/python
|
|
9
|
+
Keywords: mayamemo,ai-agents,memory,context,checkpoints,mcp
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: langchain
|
|
18
|
+
Requires-Dist: langchain-core>=0.2; extra == "langchain"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# MayaMemo — Python SDK
|
|
22
|
+
|
|
23
|
+
Zero-dependency Python client for the [MayaMemo](../..) API: projects, memories
|
|
24
|
+
(with tags), semantic search, the context compiler, checkpoints and cross-agent resume.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# from the repo
|
|
28
|
+
pip install -e sdk/python # or: pip install -e "sdk/python[langchain]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from mayamemo import MayaMemo
|
|
35
|
+
|
|
36
|
+
client = MayaMemo(api_key="mm_sk_...", api_url="http://localhost:4000")
|
|
37
|
+
|
|
38
|
+
project = client.create_project("my-app")
|
|
39
|
+
|
|
40
|
+
client.add_memory(
|
|
41
|
+
project["id"],
|
|
42
|
+
"Checkout retries go through an idempotency table keyed by cart_id + attempt",
|
|
43
|
+
type="decision",
|
|
44
|
+
tags=["checkout", "billing"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
hits = client.search_memories(project["id"], "how do we avoid double charges?")
|
|
48
|
+
print(hits[0]["statement"], hits[0]["score"])
|
|
49
|
+
|
|
50
|
+
checkpoint = client.create_checkpoint(
|
|
51
|
+
project["id"], "billing groundwork",
|
|
52
|
+
"Idempotency decision captured; outbox worker noted.",
|
|
53
|
+
next_steps=["wire refund metrics"], agent="my-agent",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
briefing = client.resume_checkpoint(checkpoint["id"], agent="another-agent")["briefing_markdown"]
|
|
57
|
+
print(briefing)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Environment defaults: `MAYAMEMO_API_KEY` (or `MAYAMEMO_TOKEN`) and `MAYAMEMO_API_URL`
|
|
61
|
+
(`http://localhost:4000`). API keys are created in the workspace (`POST /v1/keys`),
|
|
62
|
+
scoped per workspace and revocable.
|
|
63
|
+
|
|
64
|
+
## Framework adapters
|
|
65
|
+
|
|
66
|
+
`mayamemo.integrations` ships a LangChain-style retriever and chat-memory adapter.
|
|
67
|
+
They subclass the real `langchain-core` classes when it is installed (true drop-in)
|
|
68
|
+
and work standalone otherwise.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from mayamemo import MayaMemo, MayaMemoRetriever, MayaMemoMemory
|
|
72
|
+
|
|
73
|
+
retriever = MayaMemoRetriever(client, project["id"], tags=["checkout"])
|
|
74
|
+
docs = retriever.invoke("how do retries work?") # [{page_content, metadata}, …]
|
|
75
|
+
|
|
76
|
+
memory = MayaMemoMemory(client, project["id"]) # BaseMemory-compatible
|
|
77
|
+
memory.load_memory_variables({"input": "how do deploys work?"}) # {"mayamemo_context": "…"}
|
|
78
|
+
memory.save_context({"input": "…"}, {"output": "…"}) # recorded as a `discovery` memory
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`MayaMemoMemory.clear()` is a no-op by design — the MayaMemo ledger is append-only
|
|
82
|
+
(use retention windows or supersession; history is evidence).
|
|
83
|
+
|
|
84
|
+
## Tests
|
|
85
|
+
|
|
86
|
+
Live tests run against a real API (they skip without a key):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cd sdk/python
|
|
90
|
+
MAYAMEMO_TEST_KEY_FILE=/path/to/.ctxkey python -m pytest tests -q
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Each run creates its own project (`sdk-suite-<run>`) — semantic dedupe (0.92 cosine)
|
|
94
|
+
correctly treats near-identical statements as duplicates, so per-run isolation keeps
|
|
95
|
+
re-runs meaningful. Point the test key at a workspace with project-quota headroom
|
|
96
|
+
(the runs consume project slots by design). `examples/quickstart.py` is idempotent:
|
|
97
|
+
a duplicate is reported as `already captured — semantic dedupe` and reused.
|
|
98
|
+
|
|
99
|
+
## Coverage
|
|
100
|
+
|
|
101
|
+
`projects` · `memories` (add / search / list / patch / tags) · `compile_context` ·
|
|
102
|
+
`checkpoints` (create / list / resume) · `events` · `public_status` · branded
|
|
103
|
+
`MayaMemoError(status, code, message, request_id)` on every non-2xx.
|
mayamemo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# MayaMemo — Python SDK
|
|
2
|
+
|
|
3
|
+
Zero-dependency Python client for the [MayaMemo](../..) API: projects, memories
|
|
4
|
+
(with tags), semantic search, the context compiler, checkpoints and cross-agent resume.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
# from the repo
|
|
8
|
+
pip install -e sdk/python # or: pip install -e "sdk/python[langchain]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from mayamemo import MayaMemo
|
|
15
|
+
|
|
16
|
+
client = MayaMemo(api_key="mm_sk_...", api_url="http://localhost:4000")
|
|
17
|
+
|
|
18
|
+
project = client.create_project("my-app")
|
|
19
|
+
|
|
20
|
+
client.add_memory(
|
|
21
|
+
project["id"],
|
|
22
|
+
"Checkout retries go through an idempotency table keyed by cart_id + attempt",
|
|
23
|
+
type="decision",
|
|
24
|
+
tags=["checkout", "billing"],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
hits = client.search_memories(project["id"], "how do we avoid double charges?")
|
|
28
|
+
print(hits[0]["statement"], hits[0]["score"])
|
|
29
|
+
|
|
30
|
+
checkpoint = client.create_checkpoint(
|
|
31
|
+
project["id"], "billing groundwork",
|
|
32
|
+
"Idempotency decision captured; outbox worker noted.",
|
|
33
|
+
next_steps=["wire refund metrics"], agent="my-agent",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
briefing = client.resume_checkpoint(checkpoint["id"], agent="another-agent")["briefing_markdown"]
|
|
37
|
+
print(briefing)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Environment defaults: `MAYAMEMO_API_KEY` (or `MAYAMEMO_TOKEN`) and `MAYAMEMO_API_URL`
|
|
41
|
+
(`http://localhost:4000`). API keys are created in the workspace (`POST /v1/keys`),
|
|
42
|
+
scoped per workspace and revocable.
|
|
43
|
+
|
|
44
|
+
## Framework adapters
|
|
45
|
+
|
|
46
|
+
`mayamemo.integrations` ships a LangChain-style retriever and chat-memory adapter.
|
|
47
|
+
They subclass the real `langchain-core` classes when it is installed (true drop-in)
|
|
48
|
+
and work standalone otherwise.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from mayamemo import MayaMemo, MayaMemoRetriever, MayaMemoMemory
|
|
52
|
+
|
|
53
|
+
retriever = MayaMemoRetriever(client, project["id"], tags=["checkout"])
|
|
54
|
+
docs = retriever.invoke("how do retries work?") # [{page_content, metadata}, …]
|
|
55
|
+
|
|
56
|
+
memory = MayaMemoMemory(client, project["id"]) # BaseMemory-compatible
|
|
57
|
+
memory.load_memory_variables({"input": "how do deploys work?"}) # {"mayamemo_context": "…"}
|
|
58
|
+
memory.save_context({"input": "…"}, {"output": "…"}) # recorded as a `discovery` memory
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`MayaMemoMemory.clear()` is a no-op by design — the MayaMemo ledger is append-only
|
|
62
|
+
(use retention windows or supersession; history is evidence).
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
Live tests run against a real API (they skip without a key):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd sdk/python
|
|
70
|
+
MAYAMEMO_TEST_KEY_FILE=/path/to/.ctxkey python -m pytest tests -q
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each run creates its own project (`sdk-suite-<run>`) — semantic dedupe (0.92 cosine)
|
|
74
|
+
correctly treats near-identical statements as duplicates, so per-run isolation keeps
|
|
75
|
+
re-runs meaningful. Point the test key at a workspace with project-quota headroom
|
|
76
|
+
(the runs consume project slots by design). `examples/quickstart.py` is idempotent:
|
|
77
|
+
a duplicate is reported as `already captured — semantic dedupe` and reused.
|
|
78
|
+
|
|
79
|
+
## Coverage
|
|
80
|
+
|
|
81
|
+
`projects` · `memories` (add / search / list / patch / tags) · `compile_context` ·
|
|
82
|
+
`checkpoints` (create / list / resume) · `events` · `public_status` · branded
|
|
83
|
+
`MayaMemoError(status, code, message, request_id)` on every non-2xx.
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""MayaMemo Python SDK — thin, zero-dependency client for the MayaMemo API.
|
|
2
|
+
|
|
3
|
+
pip install -e sdk/python (or copy the `mayamemo/` package — no deps)
|
|
4
|
+
|
|
5
|
+
Quickstart:
|
|
6
|
+
from mayamemo import MayaMemo
|
|
7
|
+
client = MayaMemo(api_key="mm_sk_...", api_url="http://localhost:4000")
|
|
8
|
+
project = client.create_project("my-app")
|
|
9
|
+
client.add_memory(project["id"], "Retries go through an idempotency table", type="decision",
|
|
10
|
+
tags=["checkout"])
|
|
11
|
+
hits = client.search_memories(project["id"], "how do we avoid double charges?")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
__all__ = ["MayaMemo", "MayaMemoError"]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MayaMemoError(Exception):
|
|
29
|
+
"""Branded API error: {status} {code}: {message} — request_id for support.
|
|
30
|
+
|
|
31
|
+
`details` carries any structured extras the API attached, e.g. a 409
|
|
32
|
+
DUPLICATE_MEMORY ships `{"duplicate_of": "<memory id>"}`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
status: int,
|
|
38
|
+
code: str,
|
|
39
|
+
message: str,
|
|
40
|
+
request_id: str | None = None,
|
|
41
|
+
details: dict[str, Any] | None = None,
|
|
42
|
+
):
|
|
43
|
+
super().__init__(f"{status} {code}: {message}")
|
|
44
|
+
self.status = status
|
|
45
|
+
self.code = code
|
|
46
|
+
self.message = message
|
|
47
|
+
self.request_id = request_id
|
|
48
|
+
self.details = details or {}
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def duplicate_of(self) -> str | None:
|
|
52
|
+
value = self.details.get("duplicate_of")
|
|
53
|
+
return value if isinstance(value, str) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MayaMemo:
|
|
57
|
+
"""Synchronous zero-dependency client. Auth: workspace API key (mm_sk_…)."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, api_key: str | None = None, api_url: str | None = None, timeout: float = 60.0):
|
|
60
|
+
self.api_key = api_key or os.environ.get("MAYAMEMO_API_KEY") or os.environ.get("MAYAMEMO_TOKEN")
|
|
61
|
+
if not self.api_key:
|
|
62
|
+
raise ValueError("api_key is required (or set MAYAMEMO_API_KEY / MAYAMEMO_TOKEN)")
|
|
63
|
+
self.api_url = (api_url or os.environ.get("MAYAMEMO_API_URL") or "http://localhost:4000").rstrip("/")
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ HTTP
|
|
67
|
+
|
|
68
|
+
def _request(
|
|
69
|
+
self,
|
|
70
|
+
method: str,
|
|
71
|
+
path: str,
|
|
72
|
+
body: Any = None,
|
|
73
|
+
params: dict[str, Any] | None = None,
|
|
74
|
+
) -> Any:
|
|
75
|
+
url = f"{self.api_url}{path}"
|
|
76
|
+
if params:
|
|
77
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
78
|
+
if clean:
|
|
79
|
+
url += "?" + urllib.parse.urlencode(clean, doseq=True)
|
|
80
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
81
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
82
|
+
req.add_header("Authorization", f"Bearer {self.api_key}")
|
|
83
|
+
req.add_header("Accept", "application/json")
|
|
84
|
+
if data is not None:
|
|
85
|
+
req.add_header("Content-Type", "application/json")
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
88
|
+
raw = res.read().decode()
|
|
89
|
+
return json.loads(raw) if raw.strip() else None
|
|
90
|
+
except urllib.error.HTTPError as exc:
|
|
91
|
+
raw = exc.read().decode(errors="replace")
|
|
92
|
+
code, message, request_id, details = "HTTP_ERROR", raw[:300], None, {}
|
|
93
|
+
try:
|
|
94
|
+
err = dict((json.loads(raw) or {}).get("error") or {})
|
|
95
|
+
code = err.pop("code", code)
|
|
96
|
+
message = err.pop("message", message)
|
|
97
|
+
request_id = err.pop("request_id", None)
|
|
98
|
+
details = err
|
|
99
|
+
except ValueError:
|
|
100
|
+
pass
|
|
101
|
+
raise MayaMemoError(exc.code, code, message, request_id, details) from None
|
|
102
|
+
|
|
103
|
+
# -------------------------------------------------------------- projects
|
|
104
|
+
|
|
105
|
+
def list_projects(self) -> list[dict[str, Any]]:
|
|
106
|
+
return self._request("GET", "/v1/projects")
|
|
107
|
+
|
|
108
|
+
def create_project(self, name: str, description: str | None = None) -> dict[str, Any]:
|
|
109
|
+
body: dict[str, Any] = {"name": name}
|
|
110
|
+
if description is not None:
|
|
111
|
+
body["description"] = description
|
|
112
|
+
return self._request("POST", "/v1/projects", body)
|
|
113
|
+
|
|
114
|
+
# -------------------------------------------------------------- memories
|
|
115
|
+
|
|
116
|
+
def add_memory(
|
|
117
|
+
self,
|
|
118
|
+
project_id: str,
|
|
119
|
+
statement: str,
|
|
120
|
+
*,
|
|
121
|
+
type: str = "fact", # noqa: A002 - mirrors the API field name
|
|
122
|
+
tags: list[str] | None = None,
|
|
123
|
+
confidence: float | None = None,
|
|
124
|
+
importance: float | None = None,
|
|
125
|
+
authority: str | None = None,
|
|
126
|
+
source_session_id: str | None = None,
|
|
127
|
+
source_event_id: str | None = None,
|
|
128
|
+
entities: list[dict[str, Any]] | None = None,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
body: dict[str, Any] = {"project_id": project_id, "statement": statement, "type": type}
|
|
131
|
+
for key, value in (
|
|
132
|
+
("tags", tags),
|
|
133
|
+
("confidence", confidence),
|
|
134
|
+
("importance", importance),
|
|
135
|
+
("authority", authority),
|
|
136
|
+
("source_session_id", source_session_id),
|
|
137
|
+
("source_event_id", source_event_id),
|
|
138
|
+
("entities", entities),
|
|
139
|
+
):
|
|
140
|
+
if value is not None:
|
|
141
|
+
body[key] = value
|
|
142
|
+
return self._request("POST", "/v1/memories", body)
|
|
143
|
+
|
|
144
|
+
def search_memories(
|
|
145
|
+
self,
|
|
146
|
+
project_id: str,
|
|
147
|
+
query: str,
|
|
148
|
+
*,
|
|
149
|
+
tags: list[str] | None = None,
|
|
150
|
+
limit: int = 10,
|
|
151
|
+
status: str | None = None,
|
|
152
|
+
) -> list[dict[str, Any]]:
|
|
153
|
+
body: dict[str, Any] = {"project_id": project_id, "query": query, "limit": limit}
|
|
154
|
+
if tags:
|
|
155
|
+
body["tags"] = tags
|
|
156
|
+
if status:
|
|
157
|
+
body["status"] = status
|
|
158
|
+
return self._request("POST", "/v1/memories/search", body)
|
|
159
|
+
|
|
160
|
+
def list_memories(
|
|
161
|
+
self,
|
|
162
|
+
project_id: str,
|
|
163
|
+
*,
|
|
164
|
+
tag: str | None = None,
|
|
165
|
+
type: str | None = None, # noqa: A002
|
|
166
|
+
status: str | None = None,
|
|
167
|
+
limit: int | None = None,
|
|
168
|
+
) -> list[dict[str, Any]]:
|
|
169
|
+
return self._request(
|
|
170
|
+
"GET",
|
|
171
|
+
"/v1/memories",
|
|
172
|
+
params={"project_id": project_id, "tag": tag, "type": type, "status": status, "limit": limit},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def patch_memory(self, memory_id: str, **changes: Any) -> dict[str, Any]:
|
|
176
|
+
return self._request("PATCH", f"/v1/memories/{memory_id}", changes)
|
|
177
|
+
|
|
178
|
+
# ----------------------------------------------------------- checkpoints
|
|
179
|
+
|
|
180
|
+
def create_checkpoint(
|
|
181
|
+
self,
|
|
182
|
+
project_id: str,
|
|
183
|
+
title: str,
|
|
184
|
+
summary: str,
|
|
185
|
+
*,
|
|
186
|
+
next_steps: list[str] | None = None,
|
|
187
|
+
blockers: list[str] | None = None,
|
|
188
|
+
agent: str | None = None,
|
|
189
|
+
session_id: str | None = None,
|
|
190
|
+
token_budget: int | None = None,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
body: dict[str, Any] = {"project_id": project_id, "title": title, "summary": summary}
|
|
193
|
+
for key, value in (
|
|
194
|
+
("next_steps", next_steps),
|
|
195
|
+
("blockers", blockers),
|
|
196
|
+
("agent", agent),
|
|
197
|
+
("session_id", session_id),
|
|
198
|
+
("token_budget", token_budget),
|
|
199
|
+
):
|
|
200
|
+
if value is not None:
|
|
201
|
+
body[key] = value
|
|
202
|
+
return self._request("POST", "/v1/checkpoints", body)
|
|
203
|
+
|
|
204
|
+
def list_checkpoints(self, project_id: str) -> list[dict[str, Any]]:
|
|
205
|
+
data = self._request("GET", f"/v1/projects/{project_id}/checkpoints")
|
|
206
|
+
return (data or {}).get("checkpoints", [])
|
|
207
|
+
|
|
208
|
+
def resume_checkpoint(self, checkpoint_id: str, *, agent: str) -> dict[str, Any]:
|
|
209
|
+
"""Resume as `agent` (any agent key). Returns checkpoint, resumed_session and briefing_markdown."""
|
|
210
|
+
return self._request("POST", f"/v1/checkpoints/{checkpoint_id}/resume", {"agent": agent})
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------- context
|
|
213
|
+
|
|
214
|
+
def compile_context(
|
|
215
|
+
self,
|
|
216
|
+
project_id: str,
|
|
217
|
+
title: str,
|
|
218
|
+
*,
|
|
219
|
+
description: str | None = None,
|
|
220
|
+
token_budget: int | None = None,
|
|
221
|
+
branch: str | None = None,
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
body: dict[str, Any] = {"project_id": project_id, "title": title}
|
|
224
|
+
for key, value in (("description", description), ("token_budget", token_budget), ("branch", branch)):
|
|
225
|
+
if value is not None:
|
|
226
|
+
body[key] = value
|
|
227
|
+
return self._request("POST", "/v1/context/compile", body)
|
|
228
|
+
|
|
229
|
+
# ----------------------------------------------------------------- graph
|
|
230
|
+
|
|
231
|
+
def observe_entities(
|
|
232
|
+
self,
|
|
233
|
+
project_id: str,
|
|
234
|
+
entities: list[dict[str, Any]],
|
|
235
|
+
relations: list[dict[str, Any]] | None = None,
|
|
236
|
+
) -> dict[str, Any]:
|
|
237
|
+
"""Observe code entities + relations (idempotent by kind+name).
|
|
238
|
+
|
|
239
|
+
entities: [{"kind": "file", "name": "src/cart.js"}, ...] (max 50)
|
|
240
|
+
relations: [{"source": {...}, "target": {...}, "kind": "imports"}, ...] (max 100)
|
|
241
|
+
"""
|
|
242
|
+
body: dict[str, Any] = {"project_id": project_id, "entities": entities}
|
|
243
|
+
if relations:
|
|
244
|
+
body["relations"] = relations
|
|
245
|
+
return self._request("POST", "/v1/entities/observe", body)
|
|
246
|
+
|
|
247
|
+
def list_entities(
|
|
248
|
+
self,
|
|
249
|
+
project_id: str,
|
|
250
|
+
*,
|
|
251
|
+
kind: str | None = None,
|
|
252
|
+
q: str | None = None,
|
|
253
|
+
limit: int | None = None,
|
|
254
|
+
) -> list[dict[str, Any]]:
|
|
255
|
+
data = self._request(
|
|
256
|
+
"GET",
|
|
257
|
+
f"/v1/projects/{project_id}/entities",
|
|
258
|
+
params={"kind": kind, "q": q, "limit": limit},
|
|
259
|
+
)
|
|
260
|
+
return (data or {}).get("entities", [])
|
|
261
|
+
|
|
262
|
+
def get_entity(self, entity_id: str) -> dict[str, Any]:
|
|
263
|
+
"""Entity detail: relations (direction, other) + linked active memories."""
|
|
264
|
+
return self._request("GET", f"/v1/entities/{entity_id}")
|
|
265
|
+
|
|
266
|
+
def entity_neighbors(self, entity_id: str, *, depth: int = 1) -> dict[str, Any]:
|
|
267
|
+
"""BFS over relations (depth 1..2): {root, nodes, edges, truncated}."""
|
|
268
|
+
return self._request("GET", f"/v1/entities/{entity_id}/neighbors", params={"depth": depth})
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------- events
|
|
271
|
+
|
|
272
|
+
def list_events(self, project_id: str, *, limit: int = 50, cursor: int | None = None) -> dict[str, Any]:
|
|
273
|
+
return self._request(
|
|
274
|
+
"GET", f"/v1/projects/{project_id}/events", params={"limit": limit, "cursor": cursor}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# ----------------------------------------------------------------- misc
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def public_status(cls, api_url: str | None = None, timeout: float = 15.0) -> dict[str, Any]:
|
|
281
|
+
"""Public /v1/status — no API key required."""
|
|
282
|
+
url = (api_url or os.environ.get("MAYAMEMO_API_URL") or "http://localhost:4000").rstrip("/")
|
|
283
|
+
with urllib.request.urlopen(urllib.request.Request(f"{url}/v1/status"), timeout=timeout) as res:
|
|
284
|
+
return json.loads(res.read().decode())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Framework adapters — LangChain-style retriever + chat memory.
|
|
2
|
+
|
|
3
|
+
Duck-typed by design: if `langchain-core` is installed these subclass the real
|
|
4
|
+
base classes (true drop-in); if not, they still work standalone and the module
|
|
5
|
+
imports without any dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .client import MayaMemo
|
|
14
|
+
|
|
15
|
+
try: # optional — real LangChain integration when present
|
|
16
|
+
from langchain_core.memory import BaseMemory as _LCBaseMemory # type: ignore[import-not-found]
|
|
17
|
+
except Exception: # pragma: no cover - exercised implicitly everywhere langchain is absent
|
|
18
|
+
_LCBaseMemory = object # type: ignore[assignment,misc]
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from langchain_core.retrievers import BaseRetriever as _LCBaseRetriever # type: ignore[import-not-found]
|
|
22
|
+
except Exception: # pragma: no cover
|
|
23
|
+
_LCBaseRetriever = object # type: ignore[assignment,misc]
|
|
24
|
+
|
|
25
|
+
__all__ = ["MayaMemoRetriever", "MayaMemoMemory"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MayaMemoRetriever(_LCBaseRetriever): # type: ignore[misc]
|
|
29
|
+
"""Retrieve project memories as documents for RAG-style prompt building."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, client: MayaMemo, project_id: str, *, limit: int = 8, tags: list[str] | None = None):
|
|
32
|
+
self._client = client
|
|
33
|
+
self._project_id = project_id
|
|
34
|
+
self._limit = limit
|
|
35
|
+
self._tags = tags
|
|
36
|
+
|
|
37
|
+
def _to_document(self, hit: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"page_content": hit.get("statement", ""),
|
|
40
|
+
"metadata": {
|
|
41
|
+
"id": hit.get("id"),
|
|
42
|
+
"type": hit.get("type"),
|
|
43
|
+
"tags": hit.get("tags", []),
|
|
44
|
+
"confidence": hit.get("confidence"),
|
|
45
|
+
"importance": hit.get("importance"),
|
|
46
|
+
"score": hit.get("score"),
|
|
47
|
+
"created_at": hit.get("createdAt"),
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def invoke(self, query: Any, config: Any = None) -> list[dict[str, Any]]:
|
|
52
|
+
q = query if isinstance(query, str) else str((query or {}).get("query", ""))
|
|
53
|
+
return self._get_relevant_documents(q)
|
|
54
|
+
|
|
55
|
+
def _get_relevant_documents(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:
|
|
56
|
+
hits = self._client.search_memories(
|
|
57
|
+
self._project_id, query, limit=self._limit, tags=self._tags
|
|
58
|
+
)
|
|
59
|
+
return [self._to_document(hit) for hit in hits]
|
|
60
|
+
|
|
61
|
+
def get_relevant_documents(self, query: str) -> list[dict[str, Any]]: # legacy LangChain name
|
|
62
|
+
return self._get_relevant_documents(query)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MayaMemoMemory(_LCBaseMemory): # type: ignore[misc]
|
|
66
|
+
"""Chat-memory adapter: load compiles project context; save records the exchange.
|
|
67
|
+
|
|
68
|
+
The MayaMemo ledger is append-only by design — `clear()` cannot delete history;
|
|
69
|
+
it resets only local buffering (documented behavior, not a bug).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
memory_variables: list[str] = ["mayamemo_context"]
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
client: MayaMemo,
|
|
77
|
+
project_id: str,
|
|
78
|
+
*,
|
|
79
|
+
token_budget: int = 4000,
|
|
80
|
+
query_key: str = "input",
|
|
81
|
+
memory_type: str = "discovery",
|
|
82
|
+
tags: list[str] | None = None,
|
|
83
|
+
):
|
|
84
|
+
self._client = client
|
|
85
|
+
self._project_id = project_id
|
|
86
|
+
self._token_budget = token_budget
|
|
87
|
+
self._query_key = query_key
|
|
88
|
+
self._memory_type = memory_type
|
|
89
|
+
self._tags = tags or ["langchain"]
|
|
90
|
+
|
|
91
|
+
# -- BaseMemory surface -------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def load_memory_variables(self, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
query = str(inputs.get(self._query_key) or inputs.get("question") or "").strip()
|
|
95
|
+
if not query:
|
|
96
|
+
return {"mayamemo_context": ""}
|
|
97
|
+
try:
|
|
98
|
+
compiled = self._client.compile_context(
|
|
99
|
+
self._project_id, query, token_budget=self._token_budget
|
|
100
|
+
)
|
|
101
|
+
except Exception: # never break the host chain on a context hiccup
|
|
102
|
+
return {"mayamemo_context": ""}
|
|
103
|
+
lines: list[str] = []
|
|
104
|
+
for item in compiled.get("items", []):
|
|
105
|
+
reason = item.get("reason") or {}
|
|
106
|
+
preview = reason.get("preview") or item.get("statement") or ""
|
|
107
|
+
if preview:
|
|
108
|
+
lines.append(f"- [{item.get('source_type', 'item')}] {preview}")
|
|
109
|
+
return {"mayamemo_context": "\n".join(lines)}
|
|
110
|
+
|
|
111
|
+
def save_context(self, inputs: dict[str, Any], outputs: dict[str, Any]) -> None:
|
|
112
|
+
statement = self._summarize(inputs, outputs)
|
|
113
|
+
if not statement:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
self._client.add_memory(
|
|
117
|
+
self._project_id,
|
|
118
|
+
statement[:2000],
|
|
119
|
+
type=self._memory_type,
|
|
120
|
+
tags=self._tags,
|
|
121
|
+
authority="model",
|
|
122
|
+
)
|
|
123
|
+
except Exception: # best-effort, like all memory capture
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def clear(self) -> None:
|
|
127
|
+
"""No-op: the ledger is append-only. Kept for interface compatibility."""
|
|
128
|
+
|
|
129
|
+
# -- helpers ------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _stringify(value: Any) -> str:
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
return value
|
|
135
|
+
if isinstance(value, list):
|
|
136
|
+
return " / ".join(MayaMemoMemory._stringify(v) for v in value)
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
return json.dumps(value, ensure_ascii=False)
|
|
139
|
+
return str(value)
|
|
140
|
+
|
|
141
|
+
def _summarize(self, inputs: dict[str, Any], outputs: dict[str, Any]) -> str:
|
|
142
|
+
user = self._stringify(inputs.get(self._query_key) or inputs.get("question") or "")
|
|
143
|
+
reply = self._stringify(outputs.get("output") or outputs.get("answer") or outputs)
|
|
144
|
+
user, reply = user.strip(), reply.strip()
|
|
145
|
+
if not user and not reply:
|
|
146
|
+
return ""
|
|
147
|
+
return f"Exchange — user: {user[:400]} · assistant: {reply[:800]}"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mayamemo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MayaMemo Python SDK — evidence-backed context, memories and cross-agent checkpoints for AI coding agents. Zero dependencies.
|
|
5
|
+
Author: MayaMemo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Coin1511/mayamemo
|
|
8
|
+
Project-URL: Repository, https://github.com/Coin1511/mayamemo/tree/main/sdk/python
|
|
9
|
+
Keywords: mayamemo,ai-agents,memory,context,checkpoints,mcp
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: langchain
|
|
18
|
+
Requires-Dist: langchain-core>=0.2; extra == "langchain"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# MayaMemo — Python SDK
|
|
22
|
+
|
|
23
|
+
Zero-dependency Python client for the [MayaMemo](../..) API: projects, memories
|
|
24
|
+
(with tags), semantic search, the context compiler, checkpoints and cross-agent resume.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# from the repo
|
|
28
|
+
pip install -e sdk/python # or: pip install -e "sdk/python[langchain]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from mayamemo import MayaMemo
|
|
35
|
+
|
|
36
|
+
client = MayaMemo(api_key="mm_sk_...", api_url="http://localhost:4000")
|
|
37
|
+
|
|
38
|
+
project = client.create_project("my-app")
|
|
39
|
+
|
|
40
|
+
client.add_memory(
|
|
41
|
+
project["id"],
|
|
42
|
+
"Checkout retries go through an idempotency table keyed by cart_id + attempt",
|
|
43
|
+
type="decision",
|
|
44
|
+
tags=["checkout", "billing"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
hits = client.search_memories(project["id"], "how do we avoid double charges?")
|
|
48
|
+
print(hits[0]["statement"], hits[0]["score"])
|
|
49
|
+
|
|
50
|
+
checkpoint = client.create_checkpoint(
|
|
51
|
+
project["id"], "billing groundwork",
|
|
52
|
+
"Idempotency decision captured; outbox worker noted.",
|
|
53
|
+
next_steps=["wire refund metrics"], agent="my-agent",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
briefing = client.resume_checkpoint(checkpoint["id"], agent="another-agent")["briefing_markdown"]
|
|
57
|
+
print(briefing)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Environment defaults: `MAYAMEMO_API_KEY` (or `MAYAMEMO_TOKEN`) and `MAYAMEMO_API_URL`
|
|
61
|
+
(`http://localhost:4000`). API keys are created in the workspace (`POST /v1/keys`),
|
|
62
|
+
scoped per workspace and revocable.
|
|
63
|
+
|
|
64
|
+
## Framework adapters
|
|
65
|
+
|
|
66
|
+
`mayamemo.integrations` ships a LangChain-style retriever and chat-memory adapter.
|
|
67
|
+
They subclass the real `langchain-core` classes when it is installed (true drop-in)
|
|
68
|
+
and work standalone otherwise.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from mayamemo import MayaMemo, MayaMemoRetriever, MayaMemoMemory
|
|
72
|
+
|
|
73
|
+
retriever = MayaMemoRetriever(client, project["id"], tags=["checkout"])
|
|
74
|
+
docs = retriever.invoke("how do retries work?") # [{page_content, metadata}, …]
|
|
75
|
+
|
|
76
|
+
memory = MayaMemoMemory(client, project["id"]) # BaseMemory-compatible
|
|
77
|
+
memory.load_memory_variables({"input": "how do deploys work?"}) # {"mayamemo_context": "…"}
|
|
78
|
+
memory.save_context({"input": "…"}, {"output": "…"}) # recorded as a `discovery` memory
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`MayaMemoMemory.clear()` is a no-op by design — the MayaMemo ledger is append-only
|
|
82
|
+
(use retention windows or supersession; history is evidence).
|
|
83
|
+
|
|
84
|
+
## Tests
|
|
85
|
+
|
|
86
|
+
Live tests run against a real API (they skip without a key):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cd sdk/python
|
|
90
|
+
MAYAMEMO_TEST_KEY_FILE=/path/to/.ctxkey python -m pytest tests -q
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Each run creates its own project (`sdk-suite-<run>`) — semantic dedupe (0.92 cosine)
|
|
94
|
+
correctly treats near-identical statements as duplicates, so per-run isolation keeps
|
|
95
|
+
re-runs meaningful. Point the test key at a workspace with project-quota headroom
|
|
96
|
+
(the runs consume project slots by design). `examples/quickstart.py` is idempotent:
|
|
97
|
+
a duplicate is reported as `already captured — semantic dedupe` and reused.
|
|
98
|
+
|
|
99
|
+
## Coverage
|
|
100
|
+
|
|
101
|
+
`projects` · `memories` (add / search / list / patch / tags) · `compile_context` ·
|
|
102
|
+
`checkpoints` (create / list / resume) · `events` · `public_status` · branded
|
|
103
|
+
`MayaMemoError(status, code, message, request_id)` on every non-2xx.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mayamemo/__init__.py
|
|
5
|
+
mayamemo/client.py
|
|
6
|
+
mayamemo/integrations.py
|
|
7
|
+
mayamemo.egg-info/PKG-INFO
|
|
8
|
+
mayamemo.egg-info/SOURCES.txt
|
|
9
|
+
mayamemo.egg-info/dependency_links.txt
|
|
10
|
+
mayamemo.egg-info/requires.txt
|
|
11
|
+
mayamemo.egg-info/top_level.txt
|
|
12
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mayamemo
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mayamemo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MayaMemo Python SDK — evidence-backed context, memories and cross-agent checkpoints for AI coding agents. Zero dependencies."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "MayaMemo" }]
|
|
9
|
+
keywords = ["mayamemo", "ai-agents", "memory", "context", "checkpoints", "mcp"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
langchain = ["langchain-core>=0.2"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/Coin1511/mayamemo"
|
|
22
|
+
Repository = "https://github.com/Coin1511/mayamemo/tree/main/sdk/python"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["setuptools>=68"]
|
|
26
|
+
build-backend = "setuptools.build_meta"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["mayamemo"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
mayamemo-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Live SDK tests — run against a real MayaMemo API.
|
|
2
|
+
|
|
3
|
+
MAYAMEMO_TEST_KEY=mm_sk_... (or MAYAMEMO_TEST_KEY_FILE=/path/to/key)
|
|
4
|
+
python -m pytest tests -q
|
|
5
|
+
|
|
6
|
+
Design notes:
|
|
7
|
+
- ONE find-or-create project per workspace ("sdk-suite-live"): live tests must not
|
|
8
|
+
create a project per test — the free plan's project quota (correctly) rejects that.
|
|
9
|
+
- Every run tags its memories with a unique `run-<hex>` tag, so count assertions are
|
|
10
|
+
stable across re-runs against the same project.
|
|
11
|
+
- Tests skip (not fail) when no key is configured, so the suite is CI-safe.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from mayamemo import MayaMemo, MayaMemoError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_key() -> str | None:
|
|
25
|
+
key = os.environ.get("MAYAMEMO_TEST_KEY")
|
|
26
|
+
if key:
|
|
27
|
+
return key.strip()
|
|
28
|
+
path = os.environ.get("MAYAMEMO_TEST_KEY_FILE")
|
|
29
|
+
if path and os.path.exists(path):
|
|
30
|
+
with open(path, encoding="utf-8") as fh:
|
|
31
|
+
return fh.read().strip()
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
KEY = _load_key()
|
|
36
|
+
pytestmark = pytest.mark.skipif(KEY is None, reason="no MAYAMEMO_TEST_KEY / _FILE configured")
|
|
37
|
+
|
|
38
|
+
RUN = uuid.uuid4().hex[:8]
|
|
39
|
+
RUN_TAG = f"run-{RUN}"
|
|
40
|
+
|
|
41
|
+
# Each run gets its own project. Reusing one project across runs fights semantic
|
|
42
|
+
# dedupe head-on (0.92 cosine correctly flags paraphrases), so instead every run
|
|
43
|
+
# is isolated — the test workspace just needs project quota headroom (see README).
|
|
44
|
+
PROJECT_NAME = f"sdk-suite-{RUN}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope="module")
|
|
48
|
+
def client() -> MayaMemo:
|
|
49
|
+
return MayaMemo(api_key=KEY, api_url=os.environ.get("MAYAMEMO_API_URL", "http://localhost:4000"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture(scope="module")
|
|
53
|
+
def project(client: MayaMemo) -> dict:
|
|
54
|
+
try:
|
|
55
|
+
return client.create_project(PROJECT_NAME)
|
|
56
|
+
except MayaMemoError as exc:
|
|
57
|
+
if exc.status == 402:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"test workspace project quota is full — give the MAYAMEMO_TEST_KEY workspace "
|
|
60
|
+
"project headroom (live runs create one project per run by design)"
|
|
61
|
+
) from exc
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_projects_and_memories_roundtrip(client: MayaMemo, project: dict) -> None:
|
|
66
|
+
created = client.add_memory(
|
|
67
|
+
project["id"],
|
|
68
|
+
"Checkout retries go through an idempotency table keyed by cart_id + attempt",
|
|
69
|
+
type="decision",
|
|
70
|
+
tags=[RUN_TAG, "SDK", "checkout"],
|
|
71
|
+
confidence=0.9,
|
|
72
|
+
importance=0.8,
|
|
73
|
+
)
|
|
74
|
+
assert created["tags"] == [RUN_TAG, "sdk", "checkout"], "tags must lowercase + dedupe, order-preserving"
|
|
75
|
+
assert created["confidence"] == pytest.approx(0.9)
|
|
76
|
+
|
|
77
|
+
# semantic search: different wording, no shared keywords
|
|
78
|
+
hits = client.search_memories(project["id"], "how do we avoid double charging customers?", tags=[RUN_TAG])
|
|
79
|
+
assert len(hits) == 1
|
|
80
|
+
assert "idempotency" in hits[0]["statement"]
|
|
81
|
+
assert hits[0].get("score") is not None
|
|
82
|
+
|
|
83
|
+
# tag-filtered variants
|
|
84
|
+
assert len(client.list_memories(project["id"], tag=RUN_TAG)) == 1
|
|
85
|
+
assert len(client.list_memories(project["id"], tag="definitely-not-a-tag")) == 0
|
|
86
|
+
assert len(client.search_memories(project["id"], "retry strategy", tags=[RUN_TAG])) == 1
|
|
87
|
+
assert len(client.search_memories(project["id"], "retry strategy", tags=["other-not-a-tag"])) == 0
|
|
88
|
+
|
|
89
|
+
# patch replaces tags
|
|
90
|
+
patched = client.patch_memory(created["id"], tags=["release"])
|
|
91
|
+
assert patched["tags"] == ["release"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_checkpoint_create_list_resume(client: MayaMemo, project: dict) -> None:
|
|
95
|
+
client.add_memory(project["id"], "Auth uses refresh-token rotation", type="architecture")
|
|
96
|
+
checkpoint = client.create_checkpoint(
|
|
97
|
+
project["id"],
|
|
98
|
+
f"sdk handoff {RUN}",
|
|
99
|
+
"SDK created memories and a checkpoint end-to-end",
|
|
100
|
+
next_steps=["resume with another agent"],
|
|
101
|
+
agent="python-sdk",
|
|
102
|
+
)
|
|
103
|
+
assert checkpoint["status"] == "ACTIVE"
|
|
104
|
+
|
|
105
|
+
listed = client.list_checkpoints(project["id"])
|
|
106
|
+
assert any(c["id"] == checkpoint["id"] for c in listed)
|
|
107
|
+
|
|
108
|
+
resumed = client.resume_checkpoint(checkpoint["id"], agent="langchain-adapter")
|
|
109
|
+
assert resumed["resumed_session"]["agent"] == "langchain-adapter"
|
|
110
|
+
briefing = resumed["briefing_markdown"]
|
|
111
|
+
assert f"sdk handoff {RUN}" in briefing and "Next steps" in briefing
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_compile_context_and_events(client: MayaMemo, project: dict) -> None:
|
|
115
|
+
client.add_memory(project["id"], "The billing worker runs every 30 seconds", type="fact")
|
|
116
|
+
compiled = client.compile_context(project["id"], f"billing worker cadence {RUN}", token_budget=2000)
|
|
117
|
+
assert compiled["items"], "compiler should surface at least one item"
|
|
118
|
+
assert compiled["token_count"] >= 0
|
|
119
|
+
|
|
120
|
+
events = client.list_events(project["id"], limit=30)
|
|
121
|
+
types = [e["event_type"] for e in events["events"]]
|
|
122
|
+
assert "project.created" in types
|
|
123
|
+
assert "memory.created" in types
|
|
124
|
+
assert "context.compiled" in types
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_semantic_dedupe_surfaces_duplicate_of(client: MayaMemo, project: dict) -> None:
|
|
128
|
+
first = client.add_memory(project["id"], "Sessions expire after thirty minutes of inactivity", type="fact")
|
|
129
|
+
with pytest.raises(MayaMemoError) as excinfo:
|
|
130
|
+
client.add_memory(
|
|
131
|
+
project["id"],
|
|
132
|
+
"Sessions expire after thirty minutes of idle time", # near-identical wording
|
|
133
|
+
type="fact",
|
|
134
|
+
)
|
|
135
|
+
assert excinfo.value.status == 409
|
|
136
|
+
assert excinfo.value.code == "DUPLICATE_MEMORY"
|
|
137
|
+
assert excinfo.value.duplicate_of == first["id"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_graph_entities_relations_and_memory_links(client: MayaMemo, project: dict) -> None:
|
|
141
|
+
observed = client.observe_entities(
|
|
142
|
+
project["id"],
|
|
143
|
+
[
|
|
144
|
+
{"kind": "file", "name": "sdk/graph_a.py"},
|
|
145
|
+
{"kind": "file", "name": "sdk/graph_b.py"},
|
|
146
|
+
],
|
|
147
|
+
relations=[
|
|
148
|
+
{
|
|
149
|
+
"source": {"kind": "file", "name": "sdk/graph_a.py"},
|
|
150
|
+
"target": {"kind": "file", "name": "sdk/graph_b.py"},
|
|
151
|
+
"kind": "imports",
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
assert observed["entity_count"] == 2 and observed["relation_count"] == 1
|
|
156
|
+
|
|
157
|
+
listed = client.list_entities(project["id"], q="sdk/graph")
|
|
158
|
+
assert len(listed) == 2
|
|
159
|
+
a = next(e for e in listed if e["name"].endswith("graph_a.py"))
|
|
160
|
+
|
|
161
|
+
detail = client.get_entity(a["id"])
|
|
162
|
+
assert any(r["other"]["name"].endswith("graph_b.py") for r in detail["relations"])
|
|
163
|
+
|
|
164
|
+
neighbors = client.entity_neighbors(a["id"], depth=1)
|
|
165
|
+
assert {n["name"] for n in neighbors["nodes"]} == {"sdk/graph_a.py", "sdk/graph_b.py"}
|
|
166
|
+
|
|
167
|
+
memory = client.add_memory(
|
|
168
|
+
project["id"],
|
|
169
|
+
"SDK graph proof: graph_a imports graph_b for the pipeline",
|
|
170
|
+
type="fact",
|
|
171
|
+
entities=[{"kind": "file", "name": "sdk/graph_a.py"}],
|
|
172
|
+
)
|
|
173
|
+
detail2 = client.get_entity(a["id"])
|
|
174
|
+
assert any(m["id"] == memory["id"] for m in detail2["memories"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_errors_are_branded(client: MayaMemo) -> None:
|
|
178
|
+
bad = MayaMemo(api_key="mm_sk_thisisnotarealkey000000000000000000", api_url=client.api_url)
|
|
179
|
+
with pytest.raises(MayaMemoError) as excinfo:
|
|
180
|
+
bad.list_projects()
|
|
181
|
+
assert excinfo.value.status == 401
|
|
182
|
+
assert excinfo.value.code == "UNAUTHENTICATED"
|
|
183
|
+
|
|
184
|
+
with pytest.raises(MayaMemoError) as excinfo:
|
|
185
|
+
client.list_memories("00000000-0000-4000-8000-000000000000")
|
|
186
|
+
assert excinfo.value.status in (404, 400)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_retriever_and_memory_adapters(client: MayaMemo, project: dict) -> None:
|
|
190
|
+
from mayamemo import MayaMemoMemory, MayaMemoRetriever
|
|
191
|
+
|
|
192
|
+
client.add_memory(
|
|
193
|
+
project["id"],
|
|
194
|
+
"Deploys are blue-green via the proxy swap",
|
|
195
|
+
type="architecture",
|
|
196
|
+
tags=[RUN_TAG, "deploy"],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
retriever = MayaMemoRetriever(client, project["id"], limit=5, tags=[RUN_TAG])
|
|
200
|
+
docs = retriever.invoke("how do releases go out?")
|
|
201
|
+
assert docs and "page_content" in docs[0] and "metadata" in docs[0]
|
|
202
|
+
|
|
203
|
+
memory = MayaMemoMemory(client, project["id"])
|
|
204
|
+
variables = memory.load_memory_variables({"input": f"how are deploys performed {RUN}?"})
|
|
205
|
+
assert isinstance(variables["mayamemo_context"], str)
|
|
206
|
+
assert "blue-green" in variables["mayamemo_context"]
|
|
207
|
+
|
|
208
|
+
memory.save_context(
|
|
209
|
+
{"input": "which proxy swaps traffic during an incident?"},
|
|
210
|
+
{"output": "the edge proxy does it"},
|
|
211
|
+
)
|
|
212
|
+
stored = client.search_memories(project["id"], "edge proxy swaps traffic incident")
|
|
213
|
+
assert any(m.get("type") == "discovery" for m in stored)
|
|
214
|
+
memory.clear() # no-op by design
|