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 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,4 @@
1
+ from .client import PCMIClient
2
+ from .webhook import verify_signature
3
+
4
+ __all__ = ["PCMIClient", "verify_signature"]
@@ -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())