diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a2f58..ae7240c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Backend compilation warnings in `backend/src/engine/mod.rs`. +- Aligned Python and TypeScript SDK calls with Cloud REST and MCP routes, including + identity, dream-cycle, and administrator registration contracts. ## [1.0.1] - 2026-06-21 diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index eb8af27..0b8b4c7 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -540,6 +540,81 @@ paths: labels: { type: array, items: { type: string } } timestamp: { type: integer } + /identity/step: + post: + tags: [Auth] + summary: Submit one identity ritual step + description: Complete steps 1 through 5 in order, then call `/identity/finalize`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [step, value] + properties: + step: + type: integer + minimum: 1 + maximum: 5 + value: + type: string + minLength: 1 + responses: + "200": + description: Identity ritual progress + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + step: { type: integer } + progress: + type: object + properties: + completed: { type: integer } + total: { type: integer, example: 5 } + current_step: { type: integer } + next_prompt: { type: string } + pending: + type: object + properties: + has_name: { type: boolean } + has_mission: { type: boolean } + has_author: { type: boolean } + has_personality: { type: boolean } + has_language: { type: boolean } + "400": + $ref: "#/components/responses/BadRequest" + + /identity/finalize: + post: + tags: [Auth] + summary: Complete the identity ritual + responses: + "200": + description: Identity awakened + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + awakened: { type: boolean } + identity: + type: object + properties: + name: { type: string } + mission: { type: string } + author: { type: string } + personality: { type: string } + language: { type: string } + confirmed: { type: boolean } + message: { type: string } + "400": + $ref: "#/components/responses/BadRequest" + /digest: post: tags: [Ingestion] diff --git a/backend/sdk/python/README.md b/backend/sdk/python/README.md index e285045..7688032 100644 --- a/backend/sdk/python/README.md +++ b/backend/sdk/python/README.md @@ -1,6 +1,6 @@ # Epicode SDK for Python -Python client library for the Epicode API. +Python client library for the Epicode API (v1.0.2). ## Installation @@ -16,6 +16,13 @@ from epicode import EpicodeClient client = EpicodeClient("your-api-key") +# A confirmed identity is required before working with memories. +for step, value in enumerate( + ["MyAssistant", "Help users", "Example author", "Helpful", "English"], start=1 +): + client.identity_step(step, value) +client.identity_finalize() + # Store a memory mem = client.remember("The project deadline is June 15.") print(mem.id, mem.labels) @@ -65,7 +72,7 @@ from epicode import EpicodeAdmin admin = EpicodeAdmin("your-admin-key") # Register a new user -user = admin.register("alice", plan="pro") +user = admin.register("alice", "a-secure-password", plan="pro") print(user.api_key, user.max_memories) # List users @@ -79,6 +86,13 @@ print(stats.max_users) admin.close() ``` +## API Compatibility + +The SDK calls the Cloud API under `/api/v1`. Complete the five identity steps +and call `identity_finalize()` before storing memories. `dream_cycle()` uses the +supported MCP JSON-RPC endpoint; graph relations are available through +`knowledge(id)`. + ## Error Handling ```python diff --git a/backend/sdk/python/epicode/__init__.py b/backend/sdk/python/epicode/__init__.py index 2096021..454c828 100644 --- a/backend/sdk/python/epicode/__init__.py +++ b/backend/sdk/python/epicode/__init__.py @@ -18,19 +18,22 @@ CreateNodeResponse, Emotion, HealthResponse, - KnowledgeGraphResponse, - Memory, - MemoryFragment, - NodeData, + IdentityFinalizeResponse, + IdentityStepResponse, + KnowledgeResponse, + McpToolResponse, + NodeResponse, RecallResponse, + RememberResponse, RegisterResponse, + SearchResult, SearchResponse, StatsResponse, TimelineEvent, TimelineResponse, ) -__version__ = "1.0.1" # x-release-please-version +__version__ = "1.0.2" # x-release-please-version __all__ = [ "EpicodeClient", "EpicodeAdmin", @@ -47,12 +50,15 @@ "CreateNodeResponse", "Emotion", "HealthResponse", - "KnowledgeGraphResponse", - "Memory", - "MemoryFragment", - "NodeData", + "IdentityFinalizeResponse", + "IdentityStepResponse", + "KnowledgeResponse", + "McpToolResponse", + "NodeResponse", "RecallResponse", + "RememberResponse", "RegisterResponse", + "SearchResult", "SearchResponse", "StatsResponse", "TimelineEvent", diff --git a/backend/sdk/python/epicode/admin.py b/backend/sdk/python/epicode/admin.py index fdfded6..4007ed6 100644 --- a/backend/sdk/python/epicode/admin.py +++ b/backend/sdk/python/epicode/admin.py @@ -35,7 +35,9 @@ def __init__( self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") self._timeout = timeout or self.DEFAULT_TIMEOUT self._session = session or requests.Session() - self._session.headers.update({"X-Admin-Key": self._admin_key, "Content-Type": "application/json"}) + self._session.headers.update( + {"X-Admin-Key": self._admin_key, "Content-Type": "application/json"} + ) # ------------------------------------------------------------------ # Internal helpers @@ -58,7 +60,9 @@ def _handle_response(resp: requests.Response) -> dict[str, Any]: if 200 <= code < 300: return body - message = body.get("error") or body.get("message") or resp.text or f"HTTP {code}" + message = ( + body.get("error") or body.get("message") or resp.text or f"HTTP {code}" + ) if code in (401, 403): raise AuthenticationError(message, status_code=code, response_body=body) @@ -77,9 +81,15 @@ def _handle_response(resp: requests.Response) -> dict[str, Any]: # Public admin API # ------------------------------------------------------------------ - def register(self, user_id: str, *, plan: str = "free") -> RegisterResponse: + def register( + self, user_id: str, password: str, *, plan: str = "free" + ) -> RegisterResponse: """Register a new user and obtain an API key.""" - data = self._request("POST", "/register", json={"user_id": user_id, "plan": plan}) + data = self._request( + "POST", + "/register", + json={"user_id": user_id, "password": password, "plan": plan}, + ) return RegisterResponse( success=data.get("success", False), user_id=data.get("user_id", ""), diff --git a/backend/sdk/python/epicode/client.py b/backend/sdk/python/epicode/client.py index 602a3c7..559722e 100644 --- a/backend/sdk/python/epicode/client.py +++ b/backend/sdk/python/epicode/client.py @@ -18,22 +18,18 @@ from epicode.models import ( AskResponse, CreateNodeResponse, - DreamCycleResponse, Emotion, HealthResponse, + IdentityFinalizeResponse, IdentityStepResponse, - KnowledgeGraphEdge, - KnowledgeGraphNode, - KnowledgeGraphResponse, KnowledgeResponse, + McpToolResponse, NodeResponse, RecallResponse, - RecallWithTiersResponse, RememberResponse, SearchResult, SearchResponse, StatsResponse, - TieredMemoryResult, TimelineResponse, ) @@ -63,7 +59,9 @@ def __init__( self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") self._timeout = timeout or self.DEFAULT_TIMEOUT self._session = session or requests.Session() - self._session.headers.update({"X-API-Key": self._api_key, "Content-Type": "application/json"}) + self._session.headers.update( + {"X-API-Key": self._api_key, "Content-Type": "application/json"} + ) def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: url = f"{self._base_url}{path}" @@ -82,7 +80,9 @@ def _handle_response(resp: requests.Response) -> dict[str, Any]: if 200 <= code < 300: return body - message = body.get("error") or body.get("message") or resp.text or f"HTTP {code}" + message = ( + body.get("error") or body.get("message") or resp.text or f"HTTP {code}" + ) if code in (401, 403): raise AuthenticationError(message, status_code=code, response_body=body) @@ -118,7 +118,7 @@ def remember(self, content: str) -> RememberResponse: data = self._request("POST", "/remember", json={"content": content}) return RememberResponse( success=data.get("success", False), - id=data.get("id", ""), + id=data.get("id", 0), labels=data.get("labels", []), ) @@ -130,7 +130,7 @@ def search(self, query: str, *, limit: int | None = None) -> SearchResponse: data = self._request("POST", "/search", json=payload) results = [ SearchResult( - id=r.get("id", ""), + id=r.get("id", 0), content=r.get("content", ""), labels=r.get("labels", []), similarity=r.get("similarity", 0.0), @@ -167,7 +167,7 @@ def recall(self, query: str, *, depth: int | None = None) -> RecallResponse: total_fragments=data.get("total_fragments", 0), associated_count=data.get("associated_count", 0), emotion=emotion, - memory_file=data.get("memory_file", ""), + memory_file=data.get("memory_file"), ) def ask(self, question: str, *, depth: int | None = None) -> AskResponse: @@ -189,7 +189,7 @@ def create_node( content: str, *, labels: list[str] | None = None, - timestamp: str | None = None, + timestamp: int | None = None, ) -> CreateNodeResponse: """Create a knowledge graph node.""" payload: dict[str, Any] = {"content": content} @@ -200,27 +200,27 @@ def create_node( data = self._request("POST", "/nodes", json=payload) return CreateNodeResponse( success=data.get("success", False), - id=data.get("id", ""), + id=data.get("id", 0), ) - def get_node(self, node_id: str) -> NodeResponse: + def get_node(self, node_id: int) -> NodeResponse: """Retrieve a knowledge graph node by ID.""" data = self._request("GET", f"/nodes/{node_id}") return NodeResponse( success=data.get("success", False), - id=data.get("id", ""), + id=data.get("id", 0), content=data.get("content", ""), labels=data.get("labels", []), ) - def knowledge(self, id: str) -> KnowledgeResponse: + def knowledge(self, id: int) -> KnowledgeResponse: """Expand a memory node into related knowledge.""" data = self._request("POST", "/knowledge", json={"id": id}) return KnowledgeResponse( success=data.get("success", False), - id=data.get("id", ""), - relations=data.get("relations", []), - details=data.get("details", {}), + id=data.get("id", 0), + relations=data.get("relations", 0), + details=data.get("details", []), ) def stats(self) -> StatsResponse: @@ -246,59 +246,7 @@ def timeline(self) -> TimelineResponse: total=data.get("total", 0), ) - def recall_with_tiers(self, query: str, depth: int = 2) -> RecallWithTiersResponse: - """Return tiered memory results with knowledge graph associations. - - This is Epicode's key differentiator — not just flat vector search, - but structured memory with tiers and KG relationships. SMRP (Structured - Memory Response Protocol) returns tiered, contextual memories with - emotional valence and spatial placement. - - Args: - query: The search query. - depth: How many tiers to traverse in the knowledge graph. - - Returns: - A ``RecallWithTiersResponse`` containing tiered results and KG edges. - """ - payload: dict[str, Any] = {"query": query, "depth": depth} - data = self._request("POST", "/recall/tiers", json=payload) - - tiers: list[list[TieredMemoryResult]] = [] - for tier_list in data.get("tiers", []): - tier_results: list[TieredMemoryResult] = [] - for r in tier_list: - raw_emotion = r.get("emotional_valence", {}) - emotion = Emotion( - pleasure=raw_emotion.get("pleasure", 0.0), - arousal=raw_emotion.get("arousal", 0.0), - dominance=raw_emotion.get("dominance", 0.0), - ) - coords = r.get("spatial_coords", [0.0, 0.0, 0.0]) - if len(coords) < 3: - coords = [0.0, 0.0, 0.0] - tier_results.append( - TieredMemoryResult( - id=r.get("id", ""), - content=r.get("content", ""), - tier=r.get("tier", 1), - similarity=r.get("similarity", 0.0), - kg_associations=r.get("kg_associations", []), - emotional_valence=emotion, - spatial_coords=(coords[0], coords[1], coords[2]), - ) - ) - tiers.append(tier_results) - - return RecallWithTiersResponse( - success=data.get("success", False), - query=data.get("query", ""), - tiers=tiers, - total_results=data.get("total_results", 0), - knowledge_graph_edges=data.get("knowledge_graph_edges", []), - ) - - def identity_step(self, step: int, agent_name: str) -> IdentityStepResponse: + def identity_step(self, step: int, value: str) -> IdentityStepResponse: """Perform the identity ritual step. Identity rituals give AI agents persistent personality across sessions. @@ -306,85 +254,57 @@ def identity_step(self, step: int, agent_name: str) -> IdentityStepResponse: storage, allowing agents to build and maintain a sense of self over time. Args: - step: The ritual step number (1-7). - agent_name: The name of the agent performing the ritual. + step: The ritual step number (1-5). + value: The answer for this ritual step. Returns: - An ``IdentityStepResponse`` with the updated ritual state. + An ``IdentityStepResponse`` with the current ceremony progress. """ - payload = {"step": step, "agent_name": agent_name} + payload = {"step": step, "value": value} data = self._request("POST", "/identity/step", json=payload) return IdentityStepResponse( success=data.get("success", False), step=data.get("step", 0), - agent_name=data.get("agent_name", ""), - ritual_state=data.get("ritual_state", ""), - personality_signature=data.get("personality_signature", {}), + progress=data.get("progress", {}), + next_prompt=data.get("next_prompt", ""), + pending=data.get("pending", {}), ) - def dream_cycle(self) -> DreamCycleResponse: - """Trigger background memory consolidation. - - The "living memory system" aspect of Epicode. Dream cycles run in the - background to consolidate memories, form new associations, and prune weak - connections — mimicking how biological brains strengthen memories during - sleep. This is not something flat vector databases can do. - - Returns: - A ``DreamCycleResponse`` with consolidation metrics. - """ - data = self._request("POST", "/dream/cycle") - return DreamCycleResponse( + def identity_finalize(self) -> IdentityFinalizeResponse: + """Complete the identity ritual after all five steps.""" + data = self._request("POST", "/identity/finalize") + return IdentityFinalizeResponse( success=data.get("success", False), - cycles_completed=data.get("cycles_completed", 0), - memories_consolidated=data.get("memories_consolidated", 0), - new_associations=data.get("new_associations", 0), - energy_delta=data.get("energy_delta", 0.0), + awakened=data.get("awakened", False), + identity=data.get("identity", {}), + message=data.get("message", ""), ) - def knowledge_graph(self, node_id: str) -> KnowledgeGraphResponse: - """Return knowledge graph visualization data for a node. - - Epicode automatically extracts knowledge graph relationships from - memories stored as tetrahedrons in 3D space. This method returns the - nodes, edges, and clusters that make up the graph around a given memory. - - Args: - node_id: The ID of the central node to visualize. - - Returns: - A ``KnowledgeGraphResponse`` with nodes, edges, and cluster data. - """ - data = self._request("GET", f"/knowledge-graph/{node_id}") - nodes = [ - KnowledgeGraphNode( - id=n.get("id", ""), - label=n.get("label", ""), - content=n.get("content", ""), - x=n.get("x", 0.0), - y=n.get("y", 0.0), - z=n.get("z", 0.0), - tier=n.get("tier", 1), - ) - for n in data.get("nodes", []) - ] - edges = [ - KnowledgeGraphEdge( - source=e.get("source", ""), - target=e.get("target", ""), - relation=e.get("relation", ""), - strength=e.get("strength", 0.5), - ) - for e in data.get("edges", []) - ] - return KnowledgeGraphResponse( - success=data.get("success", False), - node_id=data.get("node_id", ""), - nodes=nodes, - edges=edges, - clusters=data.get("clusters", []), + def call_mcp_tool( + self, name: str, arguments: dict[str, Any] | None = None + ) -> McpToolResponse: + """Call a supported MCP tool through the Cloud JSON-RPC endpoint.""" + data = self._request( + "POST", + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + }, + ) + return McpToolResponse( + jsonrpc=data.get("jsonrpc", ""), + id=data.get("id"), + result=data.get("result"), + error=data.get("error"), ) + def dream_cycle(self) -> McpToolResponse: + """Run the supported ``dream_cycle`` MCP tool.""" + return self.call_mcp_tool("dream_cycle") + def close(self) -> None: """Close the underlying HTTP session.""" self._session.close() diff --git a/backend/sdk/python/epicode/models.py b/backend/sdk/python/epicode/models.py index 4d3a56e..12cfa51 100644 --- a/backend/sdk/python/epicode/models.py +++ b/backend/sdk/python/epicode/models.py @@ -16,13 +16,13 @@ class HealthResponse: @dataclass(frozen=True) class RememberResponse: success: bool - id: str + id: int labels: list[str] = field(default_factory=list) @dataclass(frozen=True) class SearchResult: - id: str + id: int content: str labels: list[str] = field(default_factory=list) similarity: float = 0.0 @@ -50,7 +50,7 @@ class RecallResponse: total_fragments: int = 0 associated_count: int = 0 emotion: Emotion = field(default_factory=Emotion) - memory_file: str = "" + memory_file: dict[str, list[dict[str, Any]]] | None = None @dataclass(frozen=True) @@ -59,19 +59,19 @@ class AskResponse: question: str = "" answer: str = "" memory_count: int = 0 - memories: list[str] = field(default_factory=list) + memories: list[dict[str, Any]] = field(default_factory=list) @dataclass(frozen=True) class CreateNodeResponse: success: bool - id: str + id: int @dataclass(frozen=True) class NodeResponse: success: bool - id: str + id: int content: str labels: list[str] = field(default_factory=list) @@ -79,9 +79,9 @@ class NodeResponse: @dataclass(frozen=True) class KnowledgeResponse: success: bool - id: str - relations: list[Any] = field(default_factory=list) - details: dict[str, Any] = field(default_factory=dict) + id: int + relations: int = 0 + details: list[dict[str, Any]] = field(default_factory=list) @dataclass(frozen=True) @@ -131,75 +131,33 @@ class AdminStatsResponse: active_engines: int = 0 max_users: int = 0 -@dataclass(frozen=True) -class TieredMemoryResult: - """A single tiered memory result with knowledge graph associations.""" - id: str - content: str - tier: int - similarity: float = 0.0 - kg_associations: list[dict[str, Any]] = field(default_factory=list) - emotional_valence: Emotion = field(default_factory=Emotion) - spatial_coords: tuple[float, float, float] = (0.0, 0.0, 0.0) - - -@dataclass(frozen=True) -class RecallWithTiersResponse: - """Tiered memory recall response via SMRP (Structured Memory Response Protocol).""" - success: bool - query: str = "" - tiers: list[list[TieredMemoryResult]] = field(default_factory=list) - total_results: int = 0 - knowledge_graph_edges: list[dict[str, Any]] = field(default_factory=list) - @dataclass(frozen=True) class IdentityStepResponse: """Response from an identity ritual step.""" - success: bool - step: int = 0 - agent_name: str = "" - ritual_state: str = "" - personality_signature: dict[str, Any] = field(default_factory=dict) - -@dataclass(frozen=True) -class DreamCycleResponse: - """Response from triggering a background memory consolidation (dream cycle).""" success: bool - cycles_completed: int = 0 - memories_consolidated: int = 0 - new_associations: int = 0 - energy_delta: float = 0.0 + step: int = 0 + progress: dict[str, int] = field(default_factory=dict) + next_prompt: str = "" + pending: dict[str, bool] = field(default_factory=dict) @dataclass(frozen=True) -class KnowledgeGraphNode: - """A node in the knowledge graph visualization.""" - id: str - label: str - content: str - x: float = 0.0 - y: float = 0.0 - z: float = 0.0 - tier: int = 1 +class IdentityFinalizeResponse: + """Response from completing the identity ritual.""" - -@dataclass(frozen=True) -class KnowledgeGraphEdge: - """An edge in the knowledge graph visualization.""" - source: str - target: str - relation: str - strength: float = 0.5 + success: bool + awakened: bool = False + identity: dict[str, Any] = field(default_factory=dict) + message: str = "" @dataclass(frozen=True) -class KnowledgeGraphResponse: - """Knowledge graph visualization data.""" - success: bool - node_id: str = "" - nodes: list[KnowledgeGraphNode] = field(default_factory=list) - edges: list[KnowledgeGraphEdge] = field(default_factory=list) - clusters: list[dict[str, Any]] = field(default_factory=list) +class McpToolResponse: + """JSON-RPC response returned by an MCP tool call.""" + jsonrpc: str + id: int | str | None + result: Any = None + error: dict[str, Any] | None = None diff --git a/backend/sdk/python/pyproject.toml b/backend/sdk/python/pyproject.toml index 2ca3234..3eecb3b 100644 --- a/backend/sdk/python/pyproject.toml +++ b/backend/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "epicode-sdk" -version = "1.0.1" +version = "1.0.2" description = "Python SDK for the Epicode API — spatial AI memory system" readme = "README.md" license = {text = "MIT"} diff --git a/backend/sdk/test_contract.py b/backend/sdk/test_contract.py new file mode 100644 index 0000000..ccd689d --- /dev/null +++ b/backend/sdk/test_contract.py @@ -0,0 +1,209 @@ +"""Source-level contract checks shared by the published SDKs and Cloud API.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend" / "sdk" / "python")) + +from epicode import EpicodeAdmin, EpicodeClient # noqa: E402 + + +CLOUD_SOURCE = (ROOT / "backend" / "src" / "bin" / "cloud.rs").read_text( + encoding="utf-8" +) +OPENAPI = (ROOT / "backend" / "docs" / "openapi.yaml").read_text(encoding="utf-8") +PYTHON_CLIENT = ( + ROOT / "backend" / "sdk" / "python" / "epicode" / "client.py" +).read_text(encoding="utf-8") +PYTHON_ADMIN = (ROOT / "backend" / "sdk" / "python" / "epicode" / "admin.py").read_text( + encoding="utf-8" +) +TYPESCRIPT_CLIENT = ( + ROOT / "backend" / "sdk" / "typescript" / "src" / "epicode.ts" +).read_text(encoding="utf-8") + + +class RecordingResponse: + def __init__(self, body: dict[str, object]) -> None: + self.status_code = 200 + self.text = "" + self._body = body + + def json(self) -> dict[str, object]: + return self._body + + +class RecordingSession: + def __init__(self, responses: list[dict[str, object]]) -> None: + self.headers: dict[str, str] = {} + self.calls: list[tuple[str, str, dict[str, object]]] = [] + self._responses = responses + + def request(self, method: str, url: str, **kwargs: object) -> RecordingResponse: + self.calls.append((method, url, kwargs)) + return RecordingResponse(self._responses.pop(0)) + + +class SdkCloudContractTests(unittest.TestCase): + def test_documented_sdk_paths_have_cloud_v1_routes(self) -> None: + paths = ( + "/health", + "/register", + "/remember", + "/search", + "/recall", + "/ask", + "/nodes", + "/knowledge", + "/stats", + "/timeline", + "/identity/step", + "/identity/finalize", + "/mcp", + "/admin/users", + "/admin/stats", + ) + + for path in paths: + with self.subTest(path=path): + self.assertIn(f'.route("/v1{path}"', CLOUD_SOURCE) + self.assertIn(f"\n {path}:\n", OPENAPI) + + def test_sdk_uses_the_supported_identity_and_mcp_contracts(self) -> None: + for source in (PYTHON_CLIENT, TYPESCRIPT_CLIENT): + for unsupported_path in ( + "/recall/tiers", + "/dream/cycle", + "/knowledge-graph/", + ): + with self.subTest(source=source[:20], path=unsupported_path): + self.assertNotIn(unsupported_path, source) + + self.assertIn('payload = {"step": step, "value": value}', PYTHON_CLIENT) + self.assertIn("{ step, value }", TYPESCRIPT_CLIENT) + self.assertIn('"/mcp"', PYTHON_CLIENT) + self.assertIn('"/mcp"', TYPESCRIPT_CLIENT) + + def test_admin_registration_includes_required_password(self) -> None: + self.assertIn('"password": password', PYTHON_ADMIN) + self.assertIn("{ user_id: userId, password, plan }", TYPESCRIPT_CLIENT) + self.assertIn("required: [user_id, password]", OPENAPI) + + def test_python_sdk_sends_cloud_request_bodies(self) -> None: + session = RecordingSession( + [ + { + "success": True, + "step": 1, + "progress": {"completed": 1, "total": 5, "current_step": 2}, + "next_prompt": "Mission", + "pending": {}, + }, + {"jsonrpc": "2.0", "id": 1, "result": {"status": "complete"}}, + { + "success": True, + "query": "project context", + "memory_file": {}, + "seed_count": 0, + "associated_count": 0, + "total_fragments": 0, + "emotion": {}, + }, + ] + ) + client = EpicodeClient("api-key", session=session) + + client.identity_step(1, "Aurora") + client.dream_cycle() + client.recall("project context", depth=2) + + self.assertEqual( + ( + "POST", + "http://localhost:8080/api/v1/identity/step", + {"json": {"step": 1, "value": "Aurora"}, "timeout": 30}, + ), + session.calls[0], + ) + self.assertEqual("http://localhost:8080/api/v1/mcp", session.calls[1][1]) + self.assertEqual( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "dream_cycle", "arguments": {}}, + }, + session.calls[1][2]["json"], + ) + self.assertEqual( + {"query": "project context", "depth": 2}, session.calls[2][2]["json"] + ) + + def test_python_admin_registration_sends_password(self) -> None: + session = RecordingSession( + [ + { + "success": True, + "user_id": "alice", + "api_key": "tm-test", + "plan": "pro", + "max_memories": 1000, + } + ] + ) + admin = EpicodeAdmin("admin-key", session=session) + + admin.register("alice", "a-secure-password", plan="pro") + + self.assertEqual( + ( + "POST", + "http://localhost:8080/api/v1/register", + { + "json": { + "user_id": "alice", + "password": "a-secure-password", + "plan": "pro", + }, + "timeout": 30, + }, + ), + session.calls[0], + ) + + def test_sdk_versions_match(self) -> None: + python_project = ( + ROOT / "backend" / "sdk" / "python" / "pyproject.toml" + ).read_text(encoding="utf-8") + python_module = ( + ROOT / "backend" / "sdk" / "python" / "epicode" / "__init__.py" + ).read_text(encoding="utf-8") + typescript_package = json.loads( + (ROOT / "backend" / "sdk" / "typescript" / "package.json").read_text( + encoding="utf-8" + ) + ) + python_readme = (ROOT / "backend" / "sdk" / "python" / "README.md").read_text( + encoding="utf-8" + ) + typescript_readme = ( + ROOT / "backend" / "sdk" / "typescript" / "README.md" + ).read_text(encoding="utf-8") + + self.assertIn('version = "1.0.2"', python_project) + self.assertIn('__version__ = "1.0.2"', python_module) + self.assertEqual("1.0.2", typescript_package["version"]) + self.assertIn("v1.0.2", python_readme) + self.assertIn("v1.0.2", typescript_readme) + self.assertIn("identity_finalize()", python_readme) + self.assertIn("identityFinalize()", typescript_readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/sdk/typescript/README.md b/backend/sdk/typescript/README.md index 411ee4c..ba7f006 100644 --- a/backend/sdk/typescript/README.md +++ b/backend/sdk/typescript/README.md @@ -1,6 +1,6 @@ # Epicode SDK — TypeScript -Zero-dependency TypeScript SDK for the Epicode API. +Zero-dependency TypeScript SDK for the Epicode API (v1.0.2). ## Install @@ -15,6 +15,18 @@ import { EpicodeClient, EpicodeAdmin } from "epicode-sdk"; const client = new EpicodeClient("your-api-key"); +// A confirmed identity is required before working with memories. +for (const [index, value] of [ + "MyAssistant", + "Help users", + "Example author", + "Helpful", + "English", +].entries()) { + await client.identityStep(index + 1, value); +} +await client.identityFinalize(); + // Store a memory const mem = await client.remember("Deployed v2.3 to production"); console.log(mem.id, mem.labels); @@ -56,7 +68,7 @@ console.log(tl.total, "events"); ```ts const admin = new EpicodeAdmin("your-admin-key"); -const user = await admin.register("alice", "pro"); +const user = await admin.register("alice", "a-secure-password", "pro"); console.log(user.api_key); const users = await admin.users(); @@ -106,9 +118,12 @@ const client = new EpicodeClient("key", "http://localhost:9111/v1"); | POST | `/nodes` | `client.createNode(content, labels?, timestamp?)` | | GET | `/nodes/:id` | `client.getNode(id)` | | POST | `/knowledge` | `client.knowledge(id)` | +| POST | `/identity/step` | `client.identityStep(step, value)` | +| POST | `/identity/finalize` | `client.identityFinalize()` | +| POST | `/mcp` | `client.callMcpTool(name, args?)` / `client.dreamCycle()` | | GET | `/stats` | `client.stats()` | | GET | `/timeline` | `client.timeline()` | -| POST | `/register` | `admin.register(userId, plan?)` | +| POST | `/register` | `admin.register(userId, password, plan?)` | | GET | `/admin/users` | `admin.users()` | | GET | `/admin/stats` | `admin.stats()` | diff --git a/backend/sdk/typescript/package-lock.json b/backend/sdk/typescript/package-lock.json index c8e6400..3b4a182 100644 --- a/backend/sdk/typescript/package-lock.json +++ b/backend/sdk/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "epicode-sdk", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "epicode-sdk", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "devDependencies": { "typescript": "^5.9.3" diff --git a/backend/sdk/typescript/package.json b/backend/sdk/typescript/package.json index e915c2f..c59945c 100644 --- a/backend/sdk/typescript/package.json +++ b/backend/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "epicode-sdk", - "version": "1.0.1", + "version": "1.0.2", "description": "TypeScript SDK for the Epicode API — spatial AI memory system", "main": "dist/epicode.js", "types": "dist/epicode.d.ts", diff --git a/backend/sdk/typescript/src/epicode.ts b/backend/sdk/typescript/src/epicode.ts index d0c63fb..f001c33 100644 --- a/backend/sdk/typescript/src/epicode.ts +++ b/backend/sdk/typescript/src/epicode.ts @@ -23,12 +23,12 @@ export interface RememberRequest { export interface RememberResponse { success: boolean; - id: string; + id: number; labels: string[]; } export interface SearchResult { - id: string; + id: number; content: string; labels: string[]; similarity: number; @@ -63,7 +63,7 @@ export interface RecallResponse { total_fragments: number; associated_count: number; emotion: Emotion; - memory_file: string; + memory_file: Record | null; } export interface AskRequest { @@ -82,30 +82,30 @@ export interface AskResponse { export interface CreateNodeRequest { content: string; labels?: string[]; - timestamp?: string; + timestamp?: number; } export interface CreateNodeResponse { success: boolean; - id: string; + id: number; } export interface GetNodeResponse { success: boolean; - id: string; + id: number; content: string; labels: string[]; } export interface KnowledgeRequest { - id: string; + id: number; } export interface KnowledgeResponse { success: boolean; - id: string; - relations: unknown[]; - details: unknown; + id: number; + relations: number; + details: unknown[]; } export interface StatsResponse { @@ -128,68 +128,53 @@ export interface TimelineResponse { events: TimelineEvent[]; total: number; } -export interface TieredMemoryResult { - id: string; - content: string; - tier: number; - similarity: number; - kg_associations: unknown[]; - emotional_valence: Emotion; - spatial_coords: [number, number, number]; -} - -export interface RecallWithTiersResponse { - success: boolean; - query: string; - tiers: TieredMemoryResult[][]; - total_results: number; - knowledge_graph_edges: unknown[]; -} - export interface IdentityStepResponse { success: boolean; step: number; - agent_name: string; - ritual_state: string; - personality_signature: Record; + progress: { + completed: number; + total: number; + current_step: number; + }; + next_prompt: string; + pending: { + has_name: boolean; + has_mission: boolean; + has_author: boolean; + has_personality: boolean; + has_language: boolean; + }; } -export interface DreamCycleResponse { +export interface IdentityFinalizeResponse { success: boolean; - cycles_completed: number; - memories_consolidated: number; - new_associations: number; - energy_delta: number; -} - -export interface KnowledgeGraphNode { - id: string; - label: string; - content: string; - x: number; - y: number; - z: number; - tier: number; -} - -export interface KnowledgeGraphEdge { - source: string; - target: string; - relation: string; - strength: number; + awakened: boolean; + identity: { + name: string; + mission: string; + author: string; + personality: string; + language: string; + confirmed: boolean; + }; + message: string; } -export interface KnowledgeGraphResponse { - success: boolean; - node_id: string; - nodes: KnowledgeGraphNode[]; - edges: KnowledgeGraphEdge[]; - clusters: unknown[]; +export interface McpToolResponse { + jsonrpc: "2.0"; + id: number | string | null; + result?: T; + error?: { + code: number; + message: string; + data?: unknown; + }; } export interface RegisterRequest { user_id: string; + password: string; plan?: string; } @@ -319,7 +304,7 @@ export class EpicodeClient { createNode( content: string, labels?: string[], - timestamp?: string + timestamp?: number ): Promise { return request( this.baseUrl, @@ -330,7 +315,7 @@ export class EpicodeClient { ); } - getNode(id: string): Promise { + getNode(id: number): Promise { return request( this.baseUrl, `/nodes/${encodeURIComponent(id)}`, @@ -340,7 +325,7 @@ export class EpicodeClient { ); } - knowledge(id: string): Promise { + knowledge(id: number): Promise { return request( this.baseUrl, "/knowledge", @@ -370,78 +355,47 @@ export class EpicodeClient { ); } - /** - * Recall associative memories with tiered results via SMRP. - * - * SMRP (Structured Memory Response Protocol) returns tiered, contextual - * memories with emotional valence and spatial placement. Unlike flat - * vector databases, Epicode returns memories organized by relevance tiers - * with knowledge graph associations. - */ - recallWithTiers( - query: string, - depth?: number - ): Promise { - return request( - this.baseUrl, - "/recall/tiers", - "POST", - { query, depth }, - this.authHeaders() - ); - } - - /** - * Perform an identity ritual step. - * - * Identity rituals give AI agents persistent personality across sessions. - * This is a unique Epicode feature that goes far beyond simple vector - * storage, allowing agents to build and maintain a sense of self over time. - */ - identityStep(step: number, agentName: string): Promise { + identityStep(step: number, value: string): Promise { return request( this.baseUrl, "/identity/step", "POST", - { step, agent_name: agentName }, + { step, value }, this.authHeaders() ); } - /** - * Trigger background memory consolidation (dream cycle). - * - * The "living memory system" aspect of Epicode. Dream cycles run in the - * background to consolidate memories, form new associations, and prune weak - * connections — mimicking how biological brains strengthen memories during - * sleep. This is not something flat vector databases can do. - */ - dreamCycle(): Promise { - return request( + identityFinalize(): Promise { + return request( this.baseUrl, - "/dream/cycle", + "/identity/finalize", "POST", undefined, this.authHeaders() ); } - /** - * Return knowledge graph visualization data for a node. - * - * Epicode automatically extracts knowledge graph relationships from - * memories stored as tetrahedrons in 3D space. This method returns the - * nodes, edges, and clusters that make up the graph around a given memory. - */ - knowledgeGraph(nodeId: string): Promise { - return request( + callMcpTool( + name: string, + args: Record = {} + ): Promise> { + return request>( this.baseUrl, - `/knowledge-graph/${encodeURIComponent(nodeId)}`, - "GET", - undefined, + "/mcp", + "POST", + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }, this.authHeaders() ); } + + dreamCycle(): Promise { + return this.callMcpTool("dream_cycle"); + } } export class EpicodeAdmin { @@ -457,12 +411,16 @@ export class EpicodeAdmin { return { "X-Admin-Key": this.adminKey }; } - register(userId: string, plan?: string): Promise { + register( + userId: string, + password: string, + plan?: string + ): Promise { return request( this.baseUrl, "/register", "POST", - { user_id: userId, plan }, + { user_id: userId, password, plan }, this.authHeaders() ); } diff --git a/backend/src/bin/cloud.rs b/backend/src/bin/cloud.rs index 19bb1a0..dc68f21 100644 --- a/backend/src/bin/cloud.rs +++ b/backend/src/bin/cloud.rs @@ -218,12 +218,14 @@ async fn main() { next: middleware::Next| async move { let path = request.uri().path().to_string(); if path.starts_with("/health") + || path == "/v1/health" || path == "/" || path == "/docs" || path == "/openapi.yaml" || path == "/v1/login" || path == "/v1/skills/explore" || path == "/stats/public" + || path == "/v1/stats/public" || path == "/v1/agent-guide" { return next.run(request).await; @@ -259,14 +261,14 @@ async fn main() { } } - if path.starts_with("/admin") { + if path.starts_with("/admin") || path.starts_with("/v1/admin") { if let Err(resp) = require_admin(&st.admin_key, &headers) { return resp.into_response(); } return next.run(request).await; } - if path == "/register" { + if path == "/register" || path == "/v1/register" { return next.run(request).await; } @@ -298,11 +300,14 @@ async fn main() { let app = Router::new() .route("/health", get(health)) + .route("/v1/health", get(health)) .route("/v1/agent-guide", get(agent_guide)) .route("/stats/public", get(public_stats)) + .route("/v1/stats/public", get(public_stats)) .route("/docs", get(swagger_ui)) .route("/openapi.yaml", get(openapi_spec)) .route("/register", post(register_user)) + .route("/v1/register", post(register_user)) .route("/v1/login", post(login_user)) .route("/v1/digest", post(digest_content)) .route("/v1/remember", post(remember)) @@ -324,7 +329,9 @@ async fn main() { .route("/v1/memories/batch-delete", post(batch_delete_memories)) .route("/admin/panel", get(admin_panel)) .route("/admin/users", get(admin_list_users)) + .route("/v1/admin/users", get(admin_list_users)) .route("/admin/stats", get(admin_stats)) + .route("/v1/admin/stats", get(admin_stats)) .route("/admin/users/list", get(admin_users_list)) .route("/admin/users/:user_id", get(admin_user_detail)) .route("/admin/users/:user_id/reset-key", post(admin_reset_key)) @@ -341,6 +348,7 @@ async fn main() { .route("/admin/backups/:user_id", get(admin_list_user_backups)) .route("/admin/purge-pub-skills", post(admin_purge_pub_skills)) .route("/mcp", post(mcp_endpoint)) + .route("/v1/mcp", post(mcp_endpoint)) .route("/v1/subaccounts", get(list_subaccounts)) .route("/v1/subaccounts/create", post(create_subaccount)) .route("/v1/subaccounts/:user_id/revoke", post(revoke_subaccount)) diff --git a/docs/examples.md b/docs/examples.md index e9f3cfc..89a8fc4 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -27,28 +27,28 @@ python ai_agent_memory.py | `examples/node/basic-memory.mjs` | Node.js 18+ | minimal memory workflow with built-in `fetch` | | `examples/python/basic_memory.py` | Python 3 | minimal memory workflow with stdlib `urllib` | -## SDK Differentiation +## SDK Usage -The official SDKs showcase Epicode's unique capabilities beyond remember/search: +The official SDKs expose the Cloud HTTP and MCP contracts: ```python from epicode import EpicodeClient client = EpicodeClient("your-api-key") -# Tiered recall with SMRP — not just similar vectors -result = client.recall_with_tiers("project context", depth=2) -# Returns: Tier 1 (direct) → Tier 2 (contextual) → Tier 3 (KG relationships) -# Plus emotional valence and spatial placement metadata +# Associative recall returns grouped memory fragments and emotional context. +result = client.recall("project context", depth=2) -# Identity ritual — persistent agent personality -client.identity_step(1, agent_name="MyAssistant") +# Identity ritual: complete all five steps before writing memories. +client.identity_step(1, "MyAssistant") +# ... submit steps 2 through 5 ... +client.identity_finalize() -# Dream cycle — background memory consolidation -client.dream_cycle() +# Dream cycle is a supported MCP tool. +dream = client.dream_cycle() -# Knowledge graph visualization -kg = client.knowledge_graph(node_id="abc123") +# Knowledge graph relations for one memory. +relations = client.knowledge(123) ``` ## Why Epicode vs. Pinecone? @@ -72,7 +72,7 @@ All examples use the same public API shape: ## Official SDKs -- `backend/sdk/python` — Python SDK with SMRP tier support +- `backend/sdk/python` — Python SDK with Cloud REST and MCP support - `backend/sdk/typescript` — TypeScript SDK Those SDKs are best when you want a reusable client library instead of a single script. diff --git a/examples/python/ai_agent_memory.py b/examples/python/ai_agent_memory.py index be7f23e..73c744b 100644 --- a/examples/python/ai_agent_memory.py +++ b/examples/python/ai_agent_memory.py @@ -143,10 +143,34 @@ def print_tier_badge(tier: str) -> str: print(f" 🏷️ Version: {health.version}") # --------------------------------------------------------------------------- -# Step 2: Session 1 — Learning User Preferences +# Step 2: Identity Ritual — Establishing the Agent # --------------------------------------------------------------------------- -print_banner("Step 2: Session 1 — Learning Preferences", "💬") +print_banner("Step 2: Identity Ritual — Establishing Aurora", "🪞") +print(""" +Cloud memory operations require a confirmed identity. Complete the five-step +ritual once before storing or retrieving memories. +""") + +identity_steps = [ + "Aurora", + "Help people work with calm, clarity, and focus.", + "Epicode example", + "Thoughtful and concise", + "English", +] +for step, value in enumerate(identity_steps, start=1): + progress = client.identity_step(step, value) + print(f" ✅ Completed identity step {step}; next step: {progress.progress.get('current_step')}") + +identity = client.identity_finalize() +print(f" 🪞 Identity awakened: {identity.identity.get('name')}") + +# --------------------------------------------------------------------------- +# Step 3: Session 1 — Learning User Preferences +# --------------------------------------------------------------------------- + +print_banner("Step 3: Session 1 — Learning Preferences", "💬") print(""" Aurora meets the user for the first time. Each interaction is stored as a memory tetrahedron in 3D space. The LLM auto-classifies each memory into a @@ -197,7 +221,7 @@ def print_tier_badge(tier: str) -> str: print_emotion(recall.emotion) print(f"\n 📄 Memory file (associative chain):") -print(f" {recall.memory_file[:300]}...") +print(f" {json.dumps(recall.memory_file or {}, ensure_ascii=False)[:300]}...") # --------------------------------------------------------------------------- # Step 4: Knowledge Graph — Visualizing Relationships @@ -214,14 +238,10 @@ def print_tier_badge(tier: str) -> str: first_id = stored_ids[0] print(f" 🔍 Expanding knowledge for memory: {first_id}") knowledge = client.knowledge(first_id) - print(f"\n 📊 Found {len(knowledge.relations)} relations:") - for rel in knowledge.relations: + print(f"\n 📊 Found {knowledge.relations} relations:") + for rel in knowledge.details: print(f" • {rel}") - print(f"\n 🔎 Details:") - for key, value in knowledge.details.items(): - print(f" • {key}: {value}") - # --------------------------------------------------------------------------- # Step 5: Ask — Grounded AI Response with Memory Citations # --------------------------------------------------------------------------- @@ -260,26 +280,19 @@ def print_tier_badge(tier: str) -> str: This is unique to Epicode. Flat vector databases never self-organize. """) -# Trigger dream cycle via the underlying HTTP client (exposed via MCP tool) -# We use the raw request path since dream_cycle is an MCP tool print(" 🌙 Triggering dream cycle...") -try: - dream_data = client._request("POST", "/dream") - print(f" ✅ Dream cycle completed!") - print(f"\n 📊 Consolidation report:") - print(f" • Connections strengthened: {dream_data.get('strengthened', 'N/A')}") - print(f" • Pruned memories: {dream_data.get('pruned', 'N/A')}") - print(f" • Merged duplicates: {dream_data.get('merged', 'N/A')}") - print(f" • Cluster energy after: {dream_data.get('energy', 'N/A')}") -except Exception as e: - print(f" ⚠️ Dream cycle not available in this environment: {e}") - print(" (This is normal for local development without the scheduler enabled)") +dream = client.dream_cycle() +if dream.error: + print(f" ⚠️ Dream cycle failed: {dream.error}") +else: + print(" ✅ Dream cycle completed!") + print(json.dumps(dream.result, indent=2, ensure_ascii=False)) # --------------------------------------------------------------------------- -# Step 7: Identity Ritual — Establishing Self-Model +# Step 7: Identity Memories — Reinforcing Self-Model # --------------------------------------------------------------------------- -print_banner("Step 7: Identity Ritual — Establishing Self-Model", "🪞") +print_banner("Step 7: Identity Memories — Reinforcing Self-Model", "🪞") print(""" The central hollow cylinder in Epicode has four layers. The deepest layer is IDENTITY — where the agent's self-model, persistent preferences, and long- @@ -353,7 +366,7 @@ def print_tier_badge(tier: str) -> str: print_emotion(recall2.emotion) print(f"\n 📄 Aurora's internal memory file:") -print(f" {recall2.memory_file[:400]}...") +print(f" {json.dumps(recall2.memory_file or {}, ensure_ascii=False)[:400]}...") print(f"\n 💡 Aurora synthesizes:") print(f" 'You prefer quiet spaces and your cat Nebula would love a cozy")