pcmi 1.51.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.
- pcmi-1.51.0/.gitignore +86 -0
- pcmi-1.51.0/PKG-INFO +69 -0
- pcmi-1.51.0/README.md +47 -0
- pcmi-1.51.0/admin_smoke.py +21 -0
- pcmi-1.51.0/pcmi/__init__.py +4 -0
- pcmi-1.51.0/pcmi/client.py +434 -0
- pcmi-1.51.0/pcmi/models.py +44 -0
- pcmi-1.51.0/pcmi/test_sessions.py +86 -0
- pcmi-1.51.0/pcmi/webhook.py +52 -0
- pcmi-1.51.0/pyproject.toml +33 -0
- pcmi-1.51.0/smoke.py +31 -0
pcmi-1.51.0/.gitignore
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# ====================== ENVIRONMENT & SECRETS ======================
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
.env.*.local
|
|
5
|
+
.env.pcmi-test-backup
|
|
6
|
+
.env.pre-manual-tests
|
|
7
|
+
.env.bak
|
|
8
|
+
!.env.example
|
|
9
|
+
|
|
10
|
+
# ====================== GO BUILD & RUNTIME ======================
|
|
11
|
+
*.exe
|
|
12
|
+
*.exe~
|
|
13
|
+
*.dll
|
|
14
|
+
*.so
|
|
15
|
+
*.dylib
|
|
16
|
+
*.test
|
|
17
|
+
*.out
|
|
18
|
+
|
|
19
|
+
# Compiled service binaries (local `go build` / smoke scripts; CI builds fresh to bin/ or in Docker)
|
|
20
|
+
bin/
|
|
21
|
+
/api
|
|
22
|
+
/worker
|
|
23
|
+
/pcmi-api
|
|
24
|
+
/pcmi-worker
|
|
25
|
+
|
|
26
|
+
# Dependency directories
|
|
27
|
+
vendor/
|
|
28
|
+
|
|
29
|
+
# Go workspace
|
|
30
|
+
go.work
|
|
31
|
+
go.work.sum
|
|
32
|
+
|
|
33
|
+
# ====================== DOCKER & CONTAINERS ======================
|
|
34
|
+
docker-compose.override.yml
|
|
35
|
+
*.log
|
|
36
|
+
**/.dockerignore
|
|
37
|
+
|
|
38
|
+
# Volumes (non committare dati locali)
|
|
39
|
+
postgres_data/
|
|
40
|
+
pcmi_postgres_data/
|
|
41
|
+
|
|
42
|
+
# ====================== IDE & EDITOR ======================
|
|
43
|
+
# VS Code: commit shared workspace defaults; keep personal/debug configs local.
|
|
44
|
+
.vscode/*
|
|
45
|
+
!.vscode/settings.json
|
|
46
|
+
!.vscode/extensions.json
|
|
47
|
+
.idea/
|
|
48
|
+
*.swp
|
|
49
|
+
*.swo
|
|
50
|
+
.DS_Store
|
|
51
|
+
Thumbs.db
|
|
52
|
+
|
|
53
|
+
# ====================== LOGS & TEMP ======================
|
|
54
|
+
*.log
|
|
55
|
+
logs/
|
|
56
|
+
tmp/
|
|
57
|
+
temp/
|
|
58
|
+
|
|
59
|
+
# ====================== TEST & COVERAGE ======================
|
|
60
|
+
coverage.out
|
|
61
|
+
coverage.html
|
|
62
|
+
coverage-summary.md
|
|
63
|
+
coverage-summary.txt
|
|
64
|
+
coverage-badge.txt
|
|
65
|
+
|
|
66
|
+
# ====================== PCMI SPECIFIC ======================
|
|
67
|
+
# Distillation e2e test artifacts (local only)
|
|
68
|
+
.venv_e2e/
|
|
69
|
+
.pcmi_test_out/
|
|
70
|
+
|
|
71
|
+
# Smoke script venv (local only)
|
|
72
|
+
examples/temporal/.venv_smoke/
|
|
73
|
+
examples/celery/.venv_smoke/
|
|
74
|
+
examples/langchain/.venv/
|
|
75
|
+
examples/llamaindex/.venv/
|
|
76
|
+
examples/autogen/.venv/
|
|
77
|
+
examples/crewai/.venv/
|
|
78
|
+
sdk/python/.venv/
|
|
79
|
+
sdk/python/pcmi.egg-info/
|
|
80
|
+
sdk/typescript/node_modules/
|
|
81
|
+
scripts/e2e/node_modules/
|
|
82
|
+
sdk/typescript/dist/
|
|
83
|
+
__pycache__/
|
|
84
|
+
*.py[cod]
|
|
85
|
+
.cursorignore
|
|
86
|
+
.claude/
|
pcmi-1.51.0/PKG-INFO
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pcmi
|
|
3
|
+
Version: 1.51.0
|
|
4
|
+
Summary: PCMI Python SDK — async HTTP client for the Persistent Cognitive Memory Infrastructure API
|
|
5
|
+
Project-URL: Homepage, https://github.com/marco-spagn/pcmi
|
|
6
|
+
Project-URL: Repository, https://github.com/marco-spagn/pcmi
|
|
7
|
+
Project-URL: Documentation, https://github.com/marco-spagn/pcmi/tree/main/sdk/python
|
|
8
|
+
Author: Marco Spagnuolo
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic>=2
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# PCMI Python SDK
|
|
24
|
+
|
|
25
|
+
Async **HTTP** client for [PCMI](https://github.com/marco-spagn/pcmi). For high-throughput store/retrieve, use gRPC or REST directly.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.10+
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
The `pcmi` package is published to PyPI on each GitHub Release. Until the first publish:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e . # from this directory
|
|
37
|
+
pip install "git+https://github.com/marco-spagn/pcmi.git#subdirectory=sdk/python"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
After publish:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install pcmi
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export PCMI_BASE_URL=http://localhost:8000 PCMI_API_KEY=testkey123
|
|
50
|
+
python smoke.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from pcmi import PCMIClient
|
|
56
|
+
|
|
57
|
+
async def main() -> None:
|
|
58
|
+
async with PCMIClient("http://localhost:8000", "your-api-key") as client:
|
|
59
|
+
await client.store("user.note", "hello", tags=["demo"])
|
|
60
|
+
result = await client.retrieve("user.note", tags=["demo"])
|
|
61
|
+
print(result)
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
- [SDK overview](../README.md)
|
|
69
|
+
- [HTTP API](../HTTP-API.md)
|
pcmi-1.51.0/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# PCMI Python SDK
|
|
2
|
+
|
|
3
|
+
Async **HTTP** client for [PCMI](https://github.com/marco-spagn/pcmi). For high-throughput store/retrieve, use gRPC or REST directly.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.10+
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
The `pcmi` package is published to PyPI on each GitHub Release. Until the first publish:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e . # from this directory
|
|
15
|
+
pip install "git+https://github.com/marco-spagn/pcmi.git#subdirectory=sdk/python"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
After publish:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install pcmi
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export PCMI_BASE_URL=http://localhost:8000 PCMI_API_KEY=testkey123
|
|
28
|
+
python smoke.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
from pcmi import PCMIClient
|
|
34
|
+
|
|
35
|
+
async def main() -> None:
|
|
36
|
+
async with PCMIClient("http://localhost:8000", "your-api-key") as client:
|
|
37
|
+
await client.store("user.note", "hello", tags=["demo"])
|
|
38
|
+
result = await client.retrieve("user.note", tags=["demo"])
|
|
39
|
+
print(result)
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
- [SDK overview](../README.md)
|
|
47
|
+
- [HTTP API](../HTTP-API.md)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Admin SDK smoke (read-only). Requires admin API key (testkey123 in default migrations)."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from pcmi import PCMIClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main() -> None:
|
|
10
|
+
base = os.environ.get("PCMI_BASE_URL", "http://localhost:8000")
|
|
11
|
+
key = os.environ.get("PCMI_API_KEY", "testkey123")
|
|
12
|
+
|
|
13
|
+
async with PCMIClient(base, key) as c:
|
|
14
|
+
tenants = await c.list_tenants(limit=5)
|
|
15
|
+
print("tenants total:", tenants.get("total", 0))
|
|
16
|
+
keys = await c.list_api_keys(limit=5)
|
|
17
|
+
print("api_keys total:", keys.get("total", 0))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .models import MemoryStore, MemoryRetrieve, MemoryRollback, IngestEvent, CompactMemory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PCMIClient:
|
|
11
|
+
def __init__(self, base_url: str, api_key: str):
|
|
12
|
+
self.base_url = base_url.rstrip("/")
|
|
13
|
+
self.api_key = api_key
|
|
14
|
+
self.client = httpx.AsyncClient(
|
|
15
|
+
base_url=self.base_url,
|
|
16
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
async def __aenter__(self):
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
async def __aexit__(self, *_args):
|
|
23
|
+
await self.close()
|
|
24
|
+
|
|
25
|
+
async def close(self):
|
|
26
|
+
await self.client.aclose()
|
|
27
|
+
|
|
28
|
+
async def store(
|
|
29
|
+
self,
|
|
30
|
+
path: str,
|
|
31
|
+
content: str,
|
|
32
|
+
metadata: dict | None = None,
|
|
33
|
+
*,
|
|
34
|
+
tags: list[str] | None = None,
|
|
35
|
+
embedding_model: str | None = None,
|
|
36
|
+
embedding_space: str | None = None,
|
|
37
|
+
embedding: list[float] | None = None,
|
|
38
|
+
source_agent_id: str | None = None,
|
|
39
|
+
encrypt_content: bool | None = None,
|
|
40
|
+
expires_at: str | None = None,
|
|
41
|
+
):
|
|
42
|
+
payload = MemoryStore(
|
|
43
|
+
path=path,
|
|
44
|
+
content=content,
|
|
45
|
+
metadata=metadata or {},
|
|
46
|
+
tags=tags,
|
|
47
|
+
embedding_model=embedding_model,
|
|
48
|
+
embedding_space=embedding_space,
|
|
49
|
+
embedding=embedding,
|
|
50
|
+
source_agent_id=source_agent_id,
|
|
51
|
+
encrypt_content=encrypt_content,
|
|
52
|
+
expires_at=expires_at,
|
|
53
|
+
)
|
|
54
|
+
resp = await self.client.post("/v1/memories", json=payload.model_dump(exclude_none=True))
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
return resp.json()
|
|
57
|
+
|
|
58
|
+
async def retrieve(
|
|
59
|
+
self,
|
|
60
|
+
path_prefix: str,
|
|
61
|
+
query: str = "",
|
|
62
|
+
limit: int = 10,
|
|
63
|
+
*,
|
|
64
|
+
as_of: str | None = None,
|
|
65
|
+
source_agent_id: str | None = None,
|
|
66
|
+
embedding_space: str | None = None,
|
|
67
|
+
tags: list[str] | None = None,
|
|
68
|
+
tags_match: str | None = None,
|
|
69
|
+
):
|
|
70
|
+
payload = MemoryRetrieve(
|
|
71
|
+
path_prefix=path_prefix,
|
|
72
|
+
query=query,
|
|
73
|
+
limit=limit,
|
|
74
|
+
as_of=as_of,
|
|
75
|
+
source_agent_id=source_agent_id,
|
|
76
|
+
embedding_space=embedding_space,
|
|
77
|
+
tags=tags,
|
|
78
|
+
tags_match=tags_match,
|
|
79
|
+
)
|
|
80
|
+
resp = await self.client.post("/v1/retrieve", json=payload.model_dump(exclude_none=True))
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
return resp.json()
|
|
83
|
+
|
|
84
|
+
async def rollback(
|
|
85
|
+
self,
|
|
86
|
+
path: str,
|
|
87
|
+
*,
|
|
88
|
+
version: int | None = None,
|
|
89
|
+
as_of: str | None = None,
|
|
90
|
+
):
|
|
91
|
+
payload = MemoryRollback(path=path, version=version, as_of=as_of)
|
|
92
|
+
resp = await self.client.post(
|
|
93
|
+
"/v1/memories/rollback", json=payload.model_dump(exclude_none=True)
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
return resp.json()
|
|
97
|
+
|
|
98
|
+
async def ingest_event(
|
|
99
|
+
self,
|
|
100
|
+
event_type: str,
|
|
101
|
+
payload: dict | None = None,
|
|
102
|
+
*,
|
|
103
|
+
agent_id: str | None = None,
|
|
104
|
+
correlation_id: str | None = None,
|
|
105
|
+
):
|
|
106
|
+
body = IngestEvent(
|
|
107
|
+
event_type=event_type,
|
|
108
|
+
agent_id=agent_id,
|
|
109
|
+
correlation_id=correlation_id,
|
|
110
|
+
payload=payload or {},
|
|
111
|
+
)
|
|
112
|
+
resp = await self.client.post("/v1/events", json=body.model_dump(exclude_none=True))
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
return resp.json()
|
|
115
|
+
|
|
116
|
+
async def get_memory(self, path: str, *, version: int | None = None, as_of: str | None = None):
|
|
117
|
+
params: dict[str, str | int] = {}
|
|
118
|
+
if version is not None:
|
|
119
|
+
params["version"] = version
|
|
120
|
+
if as_of:
|
|
121
|
+
params["as_of"] = as_of
|
|
122
|
+
resp = await self.client.get(f"/v1/memories/{path}", params=params)
|
|
123
|
+
resp.raise_for_status()
|
|
124
|
+
return resp.json()
|
|
125
|
+
|
|
126
|
+
async def batch_store(self, items: list[dict]):
|
|
127
|
+
resp = await self.client.post("/v1/memories/batch", json={"items": items})
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
return resp.json()
|
|
130
|
+
|
|
131
|
+
async def batch_retrieve(self, queries: list[dict]):
|
|
132
|
+
resp = await self.client.post("/v1/retrieve/batch", json={"queries": queries})
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
return resp.json()
|
|
135
|
+
|
|
136
|
+
async def export_memories(self, path_prefix: str, limit: int = 500, include_embeddings: bool = False):
|
|
137
|
+
resp = await self.client.post(
|
|
138
|
+
"/v1/memories/export",
|
|
139
|
+
json={"path_prefix": path_prefix, "limit": limit, "include_embeddings": include_embeddings},
|
|
140
|
+
)
|
|
141
|
+
resp.raise_for_status()
|
|
142
|
+
return resp.json()
|
|
143
|
+
|
|
144
|
+
async def import_memories(self, entries: list[dict], mode: str = "skip"):
|
|
145
|
+
resp = await self.client.post("/v1/memories/import", json={"entries": entries, "mode": mode})
|
|
146
|
+
resp.raise_for_status()
|
|
147
|
+
return resp.json()
|
|
148
|
+
|
|
149
|
+
async def list_tenants(self, limit: int = 100):
|
|
150
|
+
resp = await self.client.get("/v1/admin/tenants", params={"limit": limit})
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
return resp.json()
|
|
153
|
+
|
|
154
|
+
async def create_tenant(self, slug: str, name: str, settings: dict | None = None):
|
|
155
|
+
resp = await self.client.post(
|
|
156
|
+
"/v1/admin/tenants",
|
|
157
|
+
json={"slug": slug, "name": name, "settings": settings or {}},
|
|
158
|
+
)
|
|
159
|
+
resp.raise_for_status()
|
|
160
|
+
return resp.json()
|
|
161
|
+
|
|
162
|
+
async def list_api_keys(self, *, tenant_id: str | None = None, limit: int = 50):
|
|
163
|
+
params: dict[str, str | int] = {"limit": limit}
|
|
164
|
+
if tenant_id:
|
|
165
|
+
params["tenant_id"] = tenant_id
|
|
166
|
+
resp = await self.client.get("/v1/admin/api-keys", params=params)
|
|
167
|
+
resp.raise_for_status()
|
|
168
|
+
return resp.json()
|
|
169
|
+
|
|
170
|
+
async def create_api_key(
|
|
171
|
+
self,
|
|
172
|
+
name: str,
|
|
173
|
+
*,
|
|
174
|
+
tenant_id: str | None = None,
|
|
175
|
+
role: str = "user",
|
|
176
|
+
expires_at: str | None = None,
|
|
177
|
+
):
|
|
178
|
+
body: dict = {"name": name, "role": role}
|
|
179
|
+
if tenant_id:
|
|
180
|
+
body["tenant_id"] = tenant_id
|
|
181
|
+
if expires_at:
|
|
182
|
+
body["expires_at"] = expires_at
|
|
183
|
+
resp = await self.client.post("/v1/admin/api-keys", json=body)
|
|
184
|
+
resp.raise_for_status()
|
|
185
|
+
return resp.json()
|
|
186
|
+
|
|
187
|
+
async def rotate_api_key(self, key_id: str, name: str = ""):
|
|
188
|
+
resp = await self.client.post(
|
|
189
|
+
f"/v1/admin/api-keys/{key_id}/rotate",
|
|
190
|
+
json={"name": name},
|
|
191
|
+
)
|
|
192
|
+
resp.raise_for_status()
|
|
193
|
+
return resp.json()
|
|
194
|
+
|
|
195
|
+
async def get_history(self, path: str, limit: int = 50):
|
|
196
|
+
resp = await self.client.get(
|
|
197
|
+
"/v1/memories/history",
|
|
198
|
+
params={"path": path, "limit": limit},
|
|
199
|
+
)
|
|
200
|
+
resp.raise_for_status()
|
|
201
|
+
return resp.json()
|
|
202
|
+
|
|
203
|
+
async def list_audit(self, limit: int = 50, offset: int = 0, since: str | None = None):
|
|
204
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
205
|
+
if since:
|
|
206
|
+
params["since"] = since
|
|
207
|
+
resp = await self.client.get("/v1/audit", params=params)
|
|
208
|
+
resp.raise_for_status()
|
|
209
|
+
return resp.json()
|
|
210
|
+
|
|
211
|
+
async def list_event_schemas(self):
|
|
212
|
+
resp = await self.client.get("/v1/events/schemas")
|
|
213
|
+
resp.raise_for_status()
|
|
214
|
+
return resp.json()
|
|
215
|
+
|
|
216
|
+
async def summarize(self, path_prefix: str, limit: int = 20, style: str = "brief"):
|
|
217
|
+
resp = await self.client.post(
|
|
218
|
+
"/v1/memories/summarize",
|
|
219
|
+
json={"path_prefix": path_prefix, "limit": limit, "style": style},
|
|
220
|
+
)
|
|
221
|
+
resp.raise_for_status()
|
|
222
|
+
return resp.json()
|
|
223
|
+
|
|
224
|
+
async def list_webhook_dead_letter(self, limit: int = 50):
|
|
225
|
+
resp = await self.client.get("/v1/webhooks/dead-letter", params={"limit": limit})
|
|
226
|
+
resp.raise_for_status()
|
|
227
|
+
return resp.json()
|
|
228
|
+
|
|
229
|
+
async def list_distilled(self, path_prefix: str, limit: int = 50):
|
|
230
|
+
resp = await self.client.get(
|
|
231
|
+
"/v1/distilled",
|
|
232
|
+
params={"path_prefix": path_prefix, "limit": limit},
|
|
233
|
+
)
|
|
234
|
+
resp.raise_for_status()
|
|
235
|
+
return resp.json()
|
|
236
|
+
|
|
237
|
+
async def compact(self, path: str, *, keep_superseded: int = 20):
|
|
238
|
+
payload = CompactMemory(path=path, keep_superseded=keep_superseded)
|
|
239
|
+
resp = await self.client.post(
|
|
240
|
+
"/v1/memories/compact", json=payload.model_dump(exclude_none=True)
|
|
241
|
+
)
|
|
242
|
+
resp.raise_for_status()
|
|
243
|
+
return resp.json()
|
|
244
|
+
|
|
245
|
+
async def register_webhook(
|
|
246
|
+
self,
|
|
247
|
+
url: str,
|
|
248
|
+
*,
|
|
249
|
+
event_types: list[str] | None = None,
|
|
250
|
+
secret: str = "",
|
|
251
|
+
):
|
|
252
|
+
resp = await self.client.post(
|
|
253
|
+
"/v1/webhooks",
|
|
254
|
+
json={"url": url, "event_types": event_types or [], "secret": secret},
|
|
255
|
+
)
|
|
256
|
+
resp.raise_for_status()
|
|
257
|
+
return resp.json()
|
|
258
|
+
|
|
259
|
+
async def list_webhooks(self, limit: int = 50):
|
|
260
|
+
resp = await self.client.get("/v1/webhooks", params={"limit": limit})
|
|
261
|
+
resp.raise_for_status()
|
|
262
|
+
return resp.json()
|
|
263
|
+
|
|
264
|
+
async def migrate_embeddings(
|
|
265
|
+
self,
|
|
266
|
+
path_prefix: str,
|
|
267
|
+
*,
|
|
268
|
+
target_model: str = "",
|
|
269
|
+
embedding_space: str | None = None,
|
|
270
|
+
):
|
|
271
|
+
body: dict[str, Any] = {"path_prefix": path_prefix, "target_model": target_model}
|
|
272
|
+
if embedding_space:
|
|
273
|
+
body["embedding_space"] = embedding_space
|
|
274
|
+
resp = await self.client.post("/v1/embeddings/migrate", json=body)
|
|
275
|
+
resp.raise_for_status()
|
|
276
|
+
return resp.json()
|
|
277
|
+
|
|
278
|
+
async def refine(self, path_prefix: str) -> dict:
|
|
279
|
+
"""Queue asynchronous distillation for a path prefix (worker consumes Redis event)."""
|
|
280
|
+
resp = await self.client.post(
|
|
281
|
+
"/v1/memories/refine",
|
|
282
|
+
json={"path_prefix": path_prefix},
|
|
283
|
+
)
|
|
284
|
+
resp.raise_for_status()
|
|
285
|
+
return resp.json()
|
|
286
|
+
|
|
287
|
+
async def memory_lineage(self, path: str):
|
|
288
|
+
resp = await self.client.get("/v1/lineage/memory", params={"path": path})
|
|
289
|
+
resp.raise_for_status()
|
|
290
|
+
return resp.json()
|
|
291
|
+
|
|
292
|
+
async def distilled_lineage(self, distilled_id: int):
|
|
293
|
+
resp = await self.client.get(f"/v1/lineage/distilled/{distilled_id}")
|
|
294
|
+
resp.raise_for_status()
|
|
295
|
+
return resp.json()
|
|
296
|
+
|
|
297
|
+
async def create_link(
|
|
298
|
+
self,
|
|
299
|
+
from_path: str,
|
|
300
|
+
to_path: str,
|
|
301
|
+
*,
|
|
302
|
+
link_type: str = "related",
|
|
303
|
+
metadata: dict | None = None,
|
|
304
|
+
):
|
|
305
|
+
resp = await self.client.post(
|
|
306
|
+
"/v1/memories/links",
|
|
307
|
+
json={
|
|
308
|
+
"from_path": from_path,
|
|
309
|
+
"to_path": to_path,
|
|
310
|
+
"link_type": link_type,
|
|
311
|
+
"metadata": metadata or {},
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
resp.raise_for_status()
|
|
315
|
+
return resp.json()
|
|
316
|
+
|
|
317
|
+
async def list_links(self, **params):
|
|
318
|
+
resp = await self.client.get("/v1/memories/links", params=params)
|
|
319
|
+
resp.raise_for_status()
|
|
320
|
+
return resp.json()
|
|
321
|
+
|
|
322
|
+
async def tenant_stats(self):
|
|
323
|
+
resp = await self.client.get("/v1/stats")
|
|
324
|
+
resp.raise_for_status()
|
|
325
|
+
return resp.json()
|
|
326
|
+
|
|
327
|
+
async def subscribe(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
types: list[str] | None = None,
|
|
331
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
332
|
+
"""Stream events from GET /v1/events (SSE). Yields `{type, payload}` objects."""
|
|
333
|
+
params: dict[str, str] = {}
|
|
334
|
+
if types:
|
|
335
|
+
params["types"] = ",".join(types)
|
|
336
|
+
async with httpx.AsyncClient(
|
|
337
|
+
base_url=self.base_url,
|
|
338
|
+
headers={"X-API-Key": self.api_key, "Accept": "text/event-stream"},
|
|
339
|
+
timeout=None,
|
|
340
|
+
) as stream_client:
|
|
341
|
+
async with stream_client.stream("GET", "/v1/events", params=params) as resp:
|
|
342
|
+
resp.raise_for_status()
|
|
343
|
+
buffer = ""
|
|
344
|
+
async for chunk in resp.aiter_text():
|
|
345
|
+
buffer += chunk
|
|
346
|
+
while "\n\n" in buffer:
|
|
347
|
+
block, buffer = buffer.split("\n\n", 1)
|
|
348
|
+
data = ""
|
|
349
|
+
for line in block.split("\n"):
|
|
350
|
+
if line.startswith("data:"):
|
|
351
|
+
data += line[5:].lstrip()
|
|
352
|
+
if not data:
|
|
353
|
+
continue
|
|
354
|
+
yield json.loads(data)
|
|
355
|
+
|
|
356
|
+
# ── Session API ─────────────────────────────────────────────────────────
|
|
357
|
+
# FIX-9: Sessions were missing from the Python SDK. The Go and TypeScript
|
|
358
|
+
# SDKs both expose session lifecycle (create / end / store / list / promote).
|
|
359
|
+
# Python agents using sessions had to fall back to raw HTTP calls.
|
|
360
|
+
|
|
361
|
+
async def create_session(
|
|
362
|
+
self,
|
|
363
|
+
agent_id: str | None = None,
|
|
364
|
+
metadata: dict | None = None,
|
|
365
|
+
) -> dict:
|
|
366
|
+
"""Start a new agent session (POST /v1/sessions)."""
|
|
367
|
+
body: dict = {}
|
|
368
|
+
if agent_id:
|
|
369
|
+
body["agent_id"] = agent_id
|
|
370
|
+
if metadata:
|
|
371
|
+
body["metadata"] = metadata
|
|
372
|
+
resp = await self.client.post("/v1/sessions", json=body)
|
|
373
|
+
resp.raise_for_status()
|
|
374
|
+
return resp.json()
|
|
375
|
+
|
|
376
|
+
async def end_session(self, session_id: str) -> dict:
|
|
377
|
+
"""End an agent session (DELETE /v1/sessions/{id})."""
|
|
378
|
+
resp = await self.client.delete(f"/v1/sessions/{session_id}")
|
|
379
|
+
resp.raise_for_status()
|
|
380
|
+
return resp.json()
|
|
381
|
+
|
|
382
|
+
async def store_session_memory(
|
|
383
|
+
self,
|
|
384
|
+
session_id: str,
|
|
385
|
+
path: str,
|
|
386
|
+
content: str,
|
|
387
|
+
metadata: dict | None = None,
|
|
388
|
+
tags: list[str] | None = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Store working memory scoped to a session."""
|
|
391
|
+
body: dict = {"path": path, "content": content}
|
|
392
|
+
if metadata:
|
|
393
|
+
body["metadata"] = metadata
|
|
394
|
+
if tags:
|
|
395
|
+
body["tags"] = tags
|
|
396
|
+
resp = await self.client.post(
|
|
397
|
+
f"/v1/sessions/{session_id}/memories", json=body
|
|
398
|
+
)
|
|
399
|
+
resp.raise_for_status()
|
|
400
|
+
|
|
401
|
+
async def list_session_memories(
|
|
402
|
+
self,
|
|
403
|
+
session_id: str,
|
|
404
|
+
*,
|
|
405
|
+
limit: int = 50,
|
|
406
|
+
path_prefix: str | None = None,
|
|
407
|
+
include_long_term: bool = False,
|
|
408
|
+
) -> dict:
|
|
409
|
+
"""List working-memory entries for a session."""
|
|
410
|
+
params: dict = {"limit": limit}
|
|
411
|
+
if path_prefix:
|
|
412
|
+
params["path_prefix"] = path_prefix
|
|
413
|
+
if include_long_term:
|
|
414
|
+
params["include_long_term"] = "true"
|
|
415
|
+
resp = await self.client.get(
|
|
416
|
+
f"/v1/sessions/{session_id}/memories", params=params
|
|
417
|
+
)
|
|
418
|
+
resp.raise_for_status()
|
|
419
|
+
return resp.json()
|
|
420
|
+
|
|
421
|
+
async def promote_session(
|
|
422
|
+
self,
|
|
423
|
+
session_id: str,
|
|
424
|
+
target_prefix: str | None = None,
|
|
425
|
+
) -> dict:
|
|
426
|
+
"""Promote working memory to long-term paths."""
|
|
427
|
+
body: dict = {}
|
|
428
|
+
if target_prefix:
|
|
429
|
+
body["target_prefix"] = target_prefix
|
|
430
|
+
resp = await self.client.post(
|
|
431
|
+
f"/v1/sessions/{session_id}/promote", json=body
|
|
432
|
+
)
|
|
433
|
+
resp.raise_for_status()
|
|
434
|
+
return resp.json()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MemoryStore(BaseModel):
|
|
6
|
+
path: str
|
|
7
|
+
content: str
|
|
8
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
9
|
+
tags: list[str] | None = None
|
|
10
|
+
embedding_model: str | None = None
|
|
11
|
+
embedding_space: str | None = None
|
|
12
|
+
embedding: list[float] | None = None
|
|
13
|
+
source_agent_id: str | None = None
|
|
14
|
+
encrypt_content: bool | None = None
|
|
15
|
+
expires_at: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MemoryRetrieve(BaseModel):
|
|
19
|
+
path_prefix: str = ""
|
|
20
|
+
query: str = ""
|
|
21
|
+
limit: int = 10
|
|
22
|
+
as_of: str | None = None
|
|
23
|
+
source_agent_id: str | None = None
|
|
24
|
+
embedding_space: str | None = None
|
|
25
|
+
tags: list[str] | None = None
|
|
26
|
+
tags_match: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CompactMemory(BaseModel):
|
|
30
|
+
path: str
|
|
31
|
+
keep_superseded: int = 20
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IngestEvent(BaseModel):
|
|
35
|
+
event_type: str
|
|
36
|
+
agent_id: str | None = None
|
|
37
|
+
correlation_id: str | None = None
|
|
38
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemoryRollback(BaseModel):
|
|
42
|
+
path: str
|
|
43
|
+
version: int | None = None
|
|
44
|
+
as_of: str | None = None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tests for FIX-9: session methods in Python SDK.
|
|
2
|
+
Run with: pytest sdk/python/pcmi/test_sessions.py
|
|
3
|
+
"""
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
|
+
import pytest
|
|
6
|
+
from .client import PCMIClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def mock_client():
|
|
11
|
+
client = PCMIClient(base_url="http://localhost:8000", api_key="test-key")
|
|
12
|
+
client.client = MagicMock()
|
|
13
|
+
return client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_create_session_posts_to_sessions(mock_client):
|
|
18
|
+
mock_resp = MagicMock()
|
|
19
|
+
mock_resp.raise_for_status = MagicMock()
|
|
20
|
+
mock_resp.json.return_value = {"id": "sess-123", "status": "active"}
|
|
21
|
+
mock_client.client.post = AsyncMock(return_value=mock_resp)
|
|
22
|
+
|
|
23
|
+
result = await mock_client.create_session(agent_id="agent-1",
|
|
24
|
+
metadata={"k": "v"})
|
|
25
|
+
mock_client.client.post.assert_called_once_with(
|
|
26
|
+
"/v1/sessions", json={"agent_id": "agent-1", "metadata": {"k": "v"}}
|
|
27
|
+
)
|
|
28
|
+
assert result["id"] == "sess-123"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_end_session_deletes_session(mock_client):
|
|
33
|
+
mock_resp = MagicMock()
|
|
34
|
+
mock_resp.raise_for_status = MagicMock()
|
|
35
|
+
mock_resp.json.return_value = {"id": "sess-123", "status": "ended"}
|
|
36
|
+
mock_client.client.delete = AsyncMock(return_value=mock_resp)
|
|
37
|
+
|
|
38
|
+
result = await mock_client.end_session("sess-123")
|
|
39
|
+
mock_client.client.delete.assert_called_once_with("/v1/sessions/sess-123")
|
|
40
|
+
assert result["status"] == "ended"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_store_session_memory(mock_client):
|
|
45
|
+
mock_resp = MagicMock()
|
|
46
|
+
mock_resp.raise_for_status = MagicMock()
|
|
47
|
+
mock_client.client.post = AsyncMock(return_value=mock_resp)
|
|
48
|
+
|
|
49
|
+
await mock_client.store_session_memory(
|
|
50
|
+
"sess-123", "root.test", "content", tags=["a"]
|
|
51
|
+
)
|
|
52
|
+
mock_client.client.post.assert_called_once_with(
|
|
53
|
+
"/v1/sessions/sess-123/memories",
|
|
54
|
+
json={"path": "root.test", "content": "content", "tags": ["a"]},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
async def test_list_session_memories(mock_client):
|
|
60
|
+
mock_resp = MagicMock()
|
|
61
|
+
mock_resp.raise_for_status = MagicMock()
|
|
62
|
+
mock_resp.json.return_value = {"entries": [], "total": 0}
|
|
63
|
+
mock_client.client.get = AsyncMock(return_value=mock_resp)
|
|
64
|
+
|
|
65
|
+
await mock_client.list_session_memories("sess-123", limit=10,
|
|
66
|
+
path_prefix="root.test")
|
|
67
|
+
mock_client.client.get.assert_called_once_with(
|
|
68
|
+
"/v1/sessions/sess-123/memories",
|
|
69
|
+
params={"limit": 10, "path_prefix": "root.test"},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_promote_session(mock_client):
|
|
75
|
+
mock_resp = MagicMock()
|
|
76
|
+
mock_resp.raise_for_status = MagicMock()
|
|
77
|
+
mock_resp.json.return_value = {"promoted": 3}
|
|
78
|
+
mock_client.client.post = AsyncMock(return_value=mock_resp)
|
|
79
|
+
|
|
80
|
+
result = await mock_client.promote_session("sess-123",
|
|
81
|
+
target_prefix="root.agent")
|
|
82
|
+
mock_client.client.post.assert_called_once_with(
|
|
83
|
+
"/v1/sessions/sess-123/promote",
|
|
84
|
+
json={"target_prefix": "root.agent"},
|
|
85
|
+
)
|
|
86
|
+
assert result["promoted"] == 3
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Webhook signature verification for PCMI HTTP deliveries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import time
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
DEFAULT_MAX_AGE_SECS = 300
|
|
11
|
+
CLOCK_SKEW_SECS = 60
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_signature(
|
|
15
|
+
secret: str,
|
|
16
|
+
signature: str,
|
|
17
|
+
timestamp: str,
|
|
18
|
+
body: bytes,
|
|
19
|
+
*,
|
|
20
|
+
now: float | None = None,
|
|
21
|
+
max_age_secs: int = DEFAULT_MAX_AGE_SECS,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""Verify X-PCMI-Signature for a webhook POST body.
|
|
24
|
+
|
|
25
|
+
Signature format: sha256={hex(HMAC-SHA256(secret, timestamp + "." + body))}
|
|
26
|
+
"""
|
|
27
|
+
if not secret or not signature or not timestamp:
|
|
28
|
+
return False
|
|
29
|
+
if not signature.startswith("sha256="):
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
ts = int(timestamp)
|
|
33
|
+
except ValueError:
|
|
34
|
+
return False
|
|
35
|
+
now_ts = time.time() if now is None else now
|
|
36
|
+
age = now_ts - ts
|
|
37
|
+
if age > max_age_secs or ts - now_ts > CLOCK_SKEW_SECS:
|
|
38
|
+
return False
|
|
39
|
+
expected = _sign(secret, timestamp, body)
|
|
40
|
+
got_hex = signature[7:]
|
|
41
|
+
try:
|
|
42
|
+
got = bytes.fromhex(got_hex)
|
|
43
|
+
want = bytes.fromhex(expected[7:])
|
|
44
|
+
except ValueError:
|
|
45
|
+
return False
|
|
46
|
+
return hmac.compare_digest(got, want)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sign(secret: str, timestamp: str, body: bytes) -> str:
|
|
50
|
+
msg = timestamp.encode() + b"." + body
|
|
51
|
+
digest = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
|
52
|
+
return f"sha256={digest}"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pcmi"
|
|
3
|
+
version = "1.51.0"
|
|
4
|
+
description = "PCMI Python SDK — async HTTP client for the Persistent Cognitive Memory Infrastructure API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Marco Spagnuolo"},
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.10",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["httpx>=0.27", "pydantic>=2"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/marco-spagn/pcmi"
|
|
25
|
+
Repository = "https://github.com/marco-spagn/pcmi"
|
|
26
|
+
Documentation = "https://github.com/marco-spagn/pcmi/tree/main/sdk/python"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["pcmi"]
|
pcmi-1.51.0/smoke.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manual SDK smoke (HTTP). From sdk/python with venv active:
|
|
3
|
+
export PCMI_BASE_URL=http://localhost:8000 PCMI_API_KEY=testkey123
|
|
4
|
+
python smoke.py
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from pcmi import PCMIClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def main() -> None:
|
|
13
|
+
base = os.environ.get("PCMI_BASE_URL", "http://localhost:8000")
|
|
14
|
+
key = os.environ.get("PCMI_API_KEY", "testkey123")
|
|
15
|
+
path = "root.sdk.python.smoke"
|
|
16
|
+
|
|
17
|
+
async with PCMIClient(base, key) as c:
|
|
18
|
+
await c.store(
|
|
19
|
+
path,
|
|
20
|
+
"hello from python sdk",
|
|
21
|
+
tags=["sdk-smoke"],
|
|
22
|
+
embedding_model="unspecified",
|
|
23
|
+
)
|
|
24
|
+
out = await c.retrieve(path, tags=["sdk-smoke"], tags_match="all", limit=5)
|
|
25
|
+
print("retrieve total:", out["total"])
|
|
26
|
+
compact = await c.compact(path, keep_superseded=20)
|
|
27
|
+
print("compact:", compact)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
asyncio.run(main())
|