smartmemory-client 0.5.0__py3-none-any.whl

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.
@@ -0,0 +1,3279 @@
1
+ """
2
+ SmartMemory HTTP Client
3
+
4
+ A clean, manually-maintained HTTP client for the SmartMemory Service API.
5
+
6
+ Features:
7
+ - JWT authentication with automatic token handling
8
+ - Type-safe operations with Pydantic models
9
+ - Comprehensive error handling
10
+ - Full API coverage (CRUD, search, ingestion, links, etc.)
11
+
12
+ For more information, see: https://github.com/smartmemory/smart-memory-client
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ from typing import Any, Dict, List, Optional, Union
18
+ import httpx
19
+
20
+ # Use local model instead of core dependency
21
+ from smartmemory_client.models.memory_item import MemoryItem
22
+ from smartmemory_client.models.conversation import ConversationContextModel
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class SmartMemoryClientError(Exception):
28
+ """Base exception for SmartMemory client errors"""
29
+
30
+ pass
31
+
32
+
33
+ class SmartMemoryClient:
34
+ """
35
+ SmartMemory HTTP Client
36
+
37
+ A clean, manually-maintained HTTP client for the SmartMemory Service API.
38
+
39
+ Features:
40
+ - JWT authentication with automatic token handling
41
+ - API key authentication for automation/scripts
42
+ - Type-safe operations with Pydantic models
43
+ - Comprehensive error handling
44
+ - Full API coverage
45
+
46
+ Usage:
47
+ ```python
48
+ from smartmemory_client import SmartMemoryClient
49
+
50
+ # With API key (for automation/scripts)
51
+ client = SmartMemoryClient(
52
+ base_url="http://localhost:9001",
53
+ api_key="sk_your_api_key"
54
+ )
55
+
56
+ # With JWT token (if you already have one)
57
+ client = SmartMemoryClient(
58
+ base_url="http://localhost:9001",
59
+ token="eyJ..."
60
+ )
61
+
62
+ # With login (interactive flow)
63
+ client = SmartMemoryClient(base_url="http://localhost:9001")
64
+ client.login(email="user@example.com", password="secret")
65
+
66
+ # Add memory
67
+ item_id = client.add("This is a test memory")
68
+
69
+ # Search
70
+ results = client.search("test", top_k=5)
71
+
72
+ # Ingest with full pipeline
73
+ result = client.ingest(
74
+ content="Complex content",
75
+ extractor_name="llm",
76
+ context={"key": "value"}
77
+ )
78
+ ```
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ base_url: Optional[str] = None,
84
+ api_key: Optional[str] = None,
85
+ token: Optional[str] = None,
86
+ timeout: float = 30.0,
87
+ verify_ssl: bool = True,
88
+ workspace_id: Optional[str] = None,
89
+ team_id: Optional[
90
+ str
91
+ ] = None, # deprecated alias for workspace_id, removed in v0.5.0
92
+ ):
93
+ """
94
+ Initialize SmartMemory client wrapper.
95
+
96
+ Args:
97
+ base_url: Base URL of the SmartMemory service
98
+ api_key: API key (sk_...) for automation (or set SMARTMEMORY_API_KEY env var)
99
+ token: JWT token for authentication (or set SMARTMEMORY_TOKEN env var)
100
+ timeout: Request timeout in seconds
101
+ verify_ssl: Whether to verify SSL certificates
102
+ workspace_id: Workspace ID for multi-tenant isolation (preferred)
103
+ team_id: Deprecated alias for workspace_id. Removed in v0.5.0.
104
+
105
+ Note:
106
+ Provide either api_key OR token, not both. If neither provided,
107
+ use login() method to authenticate.
108
+ """
109
+ # Determine base URL from parameter or environment
110
+ if base_url is None:
111
+ host = os.getenv("SMARTMEMORY_CLIENT_HOST") or os.getenv(
112
+ "SMARTMEMORY_SERVER_HOST", "localhost"
113
+ )
114
+ if host in ("0.0.0.0", "::"):
115
+ host = "localhost"
116
+ try:
117
+ port = int(os.getenv("SMARTMEMORY_SERVER_PORT", "9001"))
118
+ except Exception:
119
+ port = 9001
120
+ base_url = f"http://{host}:{port}"
121
+
122
+ self.base_url = base_url
123
+ self.timeout = timeout
124
+ self.verify_ssl = verify_ssl
125
+
126
+ # Store tokens separately for clarity
127
+ self._api_key: Optional[str] = None
128
+ self._token: Optional[str] = None
129
+ self._refresh_token: Optional[str] = None
130
+
131
+ # Resolve auth from parameters or environment
132
+ # Priority: explicit param > env var
133
+ resolved_api_key = api_key or os.getenv("SMARTMEMORY_API_KEY")
134
+ resolved_token = token or os.getenv("SMARTMEMORY_TOKEN")
135
+
136
+ # Use token if provided, otherwise api_key
137
+ if resolved_token:
138
+ self._token = resolved_token
139
+ logger.info(f"Using JWT token (length: {len(resolved_token)})")
140
+ elif resolved_api_key:
141
+ self._api_key = resolved_api_key
142
+ logger.info(f"Using API key: {resolved_api_key[:10]}...")
143
+ else:
144
+ logger.warning("No auth credentials provided. Use login() to authenticate.")
145
+
146
+ # Resolve workspace_id; team_id is a deprecated alias (removed in v0.5.0).
147
+ # Warn only when team_id is actually used as the fallback (workspace_id not provided).
148
+ self.team_id = (
149
+ workspace_id
150
+ or team_id
151
+ or os.getenv("SMARTMEMORY_WORKSPACE_ID")
152
+ or os.getenv("SMARTMEMORY_TEAM_ID")
153
+ or "team_default_demo"
154
+ )
155
+ if team_id is not None and not workspace_id:
156
+ import warnings
157
+
158
+ warnings.warn(
159
+ "The 'team_id' parameter is deprecated and will be removed in v0.5.0. "
160
+ "Use 'workspace_id' instead.",
161
+ DeprecationWarning,
162
+ stacklevel=2,
163
+ )
164
+
165
+ # Build default headers (auth header added dynamically)
166
+ self._base_headers = {
167
+ "Content-Type": "application/json",
168
+ "X-Workspace-Id": self.team_id,
169
+ }
170
+
171
+ if self.is_authenticated:
172
+ logger.info(
173
+ f"SmartMemoryClient initialized with authentication. "
174
+ f"Base URL: {self.base_url}, Team ID: {self.team_id}"
175
+ )
176
+ else:
177
+ logger.warning(
178
+ "SmartMemoryClient initialized WITHOUT authentication - "
179
+ "most endpoints will fail. Call login() or provide api_key/token."
180
+ )
181
+
182
+ @property
183
+ def is_authenticated(self) -> bool:
184
+ """Check if client has valid authentication credentials."""
185
+ return bool(self._token or self._api_key)
186
+
187
+ @property
188
+ def headers(self) -> Dict[str, str]:
189
+ """Get headers with current auth token."""
190
+ headers = self._base_headers.copy()
191
+ if self._token:
192
+ headers["Authorization"] = f"Bearer {self._token}"
193
+ elif self._api_key:
194
+ headers["Authorization"] = f"Bearer {self._api_key}"
195
+ return headers
196
+
197
+ # Legacy property for backwards compatibility
198
+ @property
199
+ def api_key(self) -> Optional[str]:
200
+ """Get current auth credential (token or api_key)."""
201
+ return self._token or self._api_key
202
+
203
+ def refresh_token(
204
+ self, refresh_token_value: Optional[str] = None
205
+ ) -> Dict[str, Any]:
206
+ """
207
+ Refresh the JWT access token using the refresh token.
208
+
209
+ Args:
210
+ refresh_token_value: Refresh token to use. If not provided, uses internally stored token.
211
+
212
+ Returns:
213
+ Dict with new tokens
214
+
215
+ Raises:
216
+ SmartMemoryClientError: If refresh fails or no refresh token available
217
+ """
218
+ token_to_use = refresh_token_value or self._refresh_token
219
+ if not token_to_use:
220
+ raise SmartMemoryClientError(
221
+ "No refresh token available. Call login() first or provide refresh_token."
222
+ )
223
+
224
+ body = {"refresh_token": token_to_use}
225
+ result = self._request("POST", "/auth/refresh", json_body=body)
226
+
227
+ if "access_token" in result:
228
+ self._token = result["access_token"]
229
+ if "refresh_token" in result:
230
+ self._refresh_token = result["refresh_token"]
231
+
232
+ logger.info("Token refreshed successfully")
233
+ return result
234
+
235
+ def logout(self) -> None:
236
+ """
237
+ Logout user and clear authentication tokens.
238
+
239
+ Calls the server logout endpoint and clears local state.
240
+ """
241
+ try:
242
+ self._request("POST", "/auth/logout")
243
+ except Exception:
244
+ pass # Ignore errors on logout - still clear local state
245
+
246
+ self._token = None
247
+ self._refresh_token = None
248
+ self._api_key = None
249
+ logger.info("Logged out - credentials cleared")
250
+
251
+ def health_check(self) -> Dict[str, Any]:
252
+ """
253
+ Check the health status of the SmartMemory service.
254
+
255
+ Returns:
256
+ Health status information
257
+
258
+ Example:
259
+ ```python
260
+ status = client.health_check()
261
+ print(status) # {'status': 'healthy'}
262
+ ```
263
+ """
264
+ try:
265
+ response = httpx.get(
266
+ f"{self.base_url}/health", headers=self.headers, timeout=self.timeout
267
+ )
268
+ response.raise_for_status()
269
+ return {"status": "healthy"}
270
+ except httpx.HTTPStatusError as e:
271
+ raise SmartMemoryClientError(f"Health check failed: {e}")
272
+ except Exception as e:
273
+ raise SmartMemoryClientError(f"Health check failed: {str(e)}")
274
+
275
+ def add(
276
+ self,
277
+ item: Union[str, MemoryItem, Dict[str, Any]],
278
+ memory_type: str = "semantic",
279
+ metadata: Optional[Dict[str, Any]] = None,
280
+ use_pipeline: bool = True,
281
+ conversation_context: Optional[
282
+ Union[ConversationContextModel, Dict[str, Any]]
283
+ ] = None,
284
+ ) -> str:
285
+ """
286
+ Add a memory item to the system.
287
+
288
+ Args:
289
+ item: Memory content (string) or MemoryItem object
290
+ memory_type: Type of memory (semantic, episodic, procedural, working)
291
+ metadata: Additional metadata for the memory
292
+ use_pipeline: Whether to run full extraction pipeline (default: True)
293
+
294
+ Returns:
295
+ Memory item ID
296
+
297
+ Example:
298
+ ```python
299
+ # Simple add
300
+ item_id = client.add("Remember this")
301
+
302
+ # With pipeline disabled (faster)
303
+ item_id = client.add("Quick note", use_pipeline=False)
304
+
305
+ # With metadata
306
+ item_id = client.add(
307
+ "Important fact",
308
+ metadata={"source": "user", "priority": "high"},
309
+ use_pipeline=True
310
+ )
311
+ ```
312
+ """
313
+ # Handle different input types
314
+ if isinstance(item, str):
315
+ content = item
316
+ elif isinstance(item, MemoryItem):
317
+ content = item.content
318
+ memory_type = item.memory_type or memory_type
319
+ metadata = metadata or item.metadata
320
+ elif isinstance(item, dict):
321
+ content = item.get("content", str(item))
322
+ memory_type = item.get("memory_type", memory_type)
323
+ metadata = metadata or item.get("metadata")
324
+ else:
325
+ content = str(item)
326
+
327
+ body_dict = {
328
+ "content": content,
329
+ "memory_type": memory_type,
330
+ "metadata": metadata or {},
331
+ "use_pipeline": use_pipeline,
332
+ }
333
+
334
+ if conversation_context:
335
+ import dataclasses
336
+
337
+ if isinstance(conversation_context, ConversationContextModel):
338
+ body_dict["conversation_context"] = dataclasses.asdict(
339
+ conversation_context
340
+ )
341
+ elif dataclasses.is_dataclass(conversation_context) and not isinstance(
342
+ conversation_context, type
343
+ ):
344
+ # Handle core ConversationContext or any other dataclass passed directly
345
+ body_dict["conversation_context"] = dataclasses.asdict(
346
+ conversation_context
347
+ )
348
+ else:
349
+ body_dict["conversation_context"] = conversation_context
350
+
351
+ try:
352
+ result = self._request("POST", "/memory/add", json_body=body_dict)
353
+ except SmartMemoryClientError as e:
354
+ if "401" in str(e):
355
+ logger.warning(
356
+ "Authentication required for add. Set SMARTMEMORY_API_KEY environment variable."
357
+ )
358
+ raise
359
+
360
+ if not result:
361
+ raise SmartMemoryClientError("Failed to add memory")
362
+
363
+ if isinstance(result, dict):
364
+ return result.get("id")
365
+ elif hasattr(result, "id"):
366
+ return result.id
367
+ else:
368
+ raise SmartMemoryClientError(f"Unexpected response format: {result}")
369
+
370
+ def get(self, item_id: str) -> Optional[MemoryItem]:
371
+ """
372
+ Retrieve a memory item by ID.
373
+
374
+ Args:
375
+ item_id: Memory item ID
376
+
377
+ Returns:
378
+ MemoryItem object or None if not found
379
+
380
+ Example:
381
+ ```python
382
+ memory = client.get("item_123")
383
+ if memory:
384
+ print(memory.content)
385
+ ```
386
+ """
387
+ try:
388
+ response = self._request("GET", f"/memory/{item_id}")
389
+
390
+ if response is None:
391
+ return None
392
+
393
+ # Convert response to MemoryItem using factory method
394
+ return MemoryItem.from_dict(response)
395
+ except Exception as e:
396
+ logger.error(f"Error getting memory {item_id}: {e}")
397
+ return None
398
+
399
+ def search(
400
+ self,
401
+ query: str,
402
+ top_k: int = 5,
403
+ memory_type: Optional[str] = None,
404
+ use_ssg: Optional[bool] = None,
405
+ enable_hybrid: Optional[bool] = True,
406
+ channel_weights: Optional[Dict[str, float]] = None,
407
+ multi_hop: bool = False,
408
+ max_hops: int = 3,
409
+ budget_ms: int = 1500,
410
+ ) -> List[MemoryItem]:
411
+ """
412
+ Search for memory items using semantic matching.
413
+
414
+ Args:
415
+ query: Search query
416
+ top_k: Maximum number of results
417
+ memory_type: Type of memory to search (optional)
418
+ use_ssg: Use Similarity Graph Traversal for better multi-hop reasoning (optional)
419
+ If None, uses config default. If True, uses SSG. If False, uses basic vector search.
420
+ enable_hybrid: Enable hybrid retrieval (vector + keyword search with RRF fusion).
421
+ Default: True. Set to False for vector-only search.
422
+ channel_weights: Per-channel weight multipliers for RRF fusion (CORE-SEARCH-2a).
423
+ Keys: entity-graph, ssg-traversal, semantic, regex-text, contains, keyword-bm25.
424
+ Values: float multipliers (default varies by channel).
425
+
426
+ Returns:
427
+ List of MemoryItem objects
428
+
429
+ Example:
430
+ ```python
431
+ # Simple search (hybrid enabled by default)
432
+ results = client.search("AI concepts", top_k=10)
433
+
434
+ # Search with SSG for better multi-hop reasoning
435
+ results = client.search("AI concepts", top_k=10, use_ssg=True)
436
+
437
+ # Vector-only search (disable hybrid)
438
+ results = client.search("AI concepts", enable_hybrid=False)
439
+
440
+ # Search specific memory type
441
+ results = client.search("conversation", memory_type="episodic")
442
+
443
+ for item in results:
444
+ print(f"{item.item_id}: {item.content}")
445
+ ```
446
+
447
+ Note:
448
+ user_id is automatically determined from the JWT token.
449
+ No need to pass it as a parameter.
450
+ """
451
+ # The FastAPI route expects a top-level SearchRequest body
452
+ body_dict: Dict[str, Any] = {
453
+ "query": query,
454
+ "top_k": top_k,
455
+ "enable_hybrid": enable_hybrid,
456
+ }
457
+ if memory_type is not None:
458
+ body_dict["memory_type"] = memory_type
459
+ if use_ssg is not None:
460
+ body_dict["use_ssg"] = use_ssg
461
+ if channel_weights is not None:
462
+ body_dict["channel_weights"] = channel_weights
463
+ if multi_hop:
464
+ body_dict["multi_hop"] = True
465
+ body_dict["max_hops"] = max_hops
466
+ body_dict["budget_ms"] = budget_ms
467
+
468
+ # SELF-IMPROVE-6: use _request_raw to capture X-Search-Session-Id header
469
+ import httpx
470
+
471
+ url = f"{self.base_url}/memory/search"
472
+ req_headers = {"X-Workspace-Id": self.team_id}
473
+ if self.api_key:
474
+ req_headers["Authorization"] = f"Bearer {self.api_key}"
475
+
476
+ try:
477
+ response = httpx.request(
478
+ "POST",
479
+ url,
480
+ json=body_dict,
481
+ headers=req_headers,
482
+ timeout=self.timeout,
483
+ )
484
+ response.raise_for_status()
485
+ self._last_search_session_id = response.headers.get("X-Search-Session-Id")
486
+ response_data = response.json()
487
+ except httpx.HTTPStatusError as e:
488
+ error_detail = e.response.text if hasattr(e, "response") else str(e)
489
+ raise SmartMemoryClientError(
490
+ f"Request failed: {e} - Detail: {error_detail}"
491
+ )
492
+ except Exception as e:
493
+ raise SmartMemoryClientError(f"Request failed: {str(e)}")
494
+
495
+ if not response_data:
496
+ return []
497
+
498
+ # Convert response to MemoryItem objects
499
+ results: List[MemoryItem] = []
500
+ response_list = (
501
+ response_data if isinstance(response_data, list) else [response_data]
502
+ )
503
+
504
+ for item_data in response_list:
505
+ if hasattr(item_data, "to_dict"):
506
+ item_dict = item_data.to_dict()
507
+ elif isinstance(item_data, dict):
508
+ item_dict = item_data
509
+ else:
510
+ continue
511
+
512
+ # Use factory method for consistent parsing
513
+ results.append(MemoryItem.from_dict(item_dict))
514
+
515
+ return results
516
+
517
+ @property
518
+ def last_search_session_id(self) -> Optional[str]:
519
+ """Return the search_session_id from the most recent search() call.
520
+
521
+ Use this with submit_result_feedback() to report which results were used.
522
+ """
523
+ return getattr(self, "_last_search_session_id", None)
524
+
525
+ def get_working_context(
526
+ self,
527
+ session_id: str,
528
+ query: str,
529
+ k: int = 20,
530
+ max_tokens: Optional[int] = None,
531
+ strategy: Optional[str] = None,
532
+ ) -> Dict[str, Any]:
533
+ """Request a surfacing response from ``POST /memory/context``.
534
+
535
+ Replacement for the legacy ``memory_recall`` surface (CORE-MEMORY-DYNAMICS-1 M1a).
536
+ Response shape matches ``context-api-contract.json``.
537
+
538
+ Args:
539
+ session_id: Session scope for anchor resolution.
540
+ query: Natural-language query for the surfacing turn.
541
+ k: Item-count cap on returned items (1-100).
542
+ max_tokens: Token budget cap; None disables.
543
+ strategy: Override surfacing strategy; None lets the router decide.
544
+
545
+ Returns:
546
+ Dict with ``decision_id``, ``items``, ``drift_warnings``,
547
+ ``strategy_used``, ``tokens_used``, ``tokens_budget``,
548
+ ``deprecation`` keys (see contract).
549
+
550
+ Raises:
551
+ SmartMemoryClientError: Server returned 4xx/5xx (including
552
+ ``budget_too_small``) or a transport error occurred.
553
+ """
554
+ body: Dict[str, Any] = {
555
+ "session_id": session_id,
556
+ "query": query,
557
+ "k": k,
558
+ }
559
+ if max_tokens is not None:
560
+ body["max_tokens"] = max_tokens
561
+ if strategy is not None:
562
+ body["strategy"] = strategy
563
+ return self._request("POST", "/memory/context", json_body=body)
564
+
565
+ def search_advanced(
566
+ self,
567
+ query: str,
568
+ algorithm: str = "query_traversal",
569
+ max_results: int = 15,
570
+ use_ssg: bool = True,
571
+ ) -> List[MemoryItem]:
572
+ """
573
+ Advanced search using Similarity Graph Traversal (SSG) algorithms.
574
+
575
+ SSG provides superior multi-hop reasoning and contextual retrieval compared to basic vector search.
576
+
577
+ Args:
578
+ query: Search query
579
+ algorithm: SSG algorithm to use:
580
+ - "query_traversal": Best for general queries (100% test pass, 0.91 precision/recall)
581
+ - "triangulation_fulldim": Best for high precision (highest faithfulness)
582
+ max_results: Maximum number of results to return
583
+ use_ssg: Enable SSG traversal (vs basic vector search)
584
+
585
+ Returns:
586
+ List of MemoryItem objects
587
+
588
+ Example:
589
+ ```python
590
+ # Best for general queries
591
+ results = client.search_advanced("AI concepts", algorithm="query_traversal")
592
+
593
+ # Best for high precision factual queries
594
+ results = client.search_advanced("specific fact", algorithm="triangulation_fulldim")
595
+
596
+ # Disable SSG (fallback to basic search)
597
+ results = client.search_advanced("query", use_ssg=False)
598
+
599
+ for item in results:
600
+ print(f"{item.item_id}: {item.content}")
601
+ ```
602
+
603
+ Note:
604
+ Based on research: github.com/glacier-creative-git/similarity-graph-traversal-semantic-rag-research
605
+ """
606
+ payload = {
607
+ "query": query,
608
+ "algorithm": algorithm,
609
+ "max_results": max_results,
610
+ "use_ssg": use_ssg,
611
+ }
612
+
613
+ data = self._request("POST", "/memory/search/advanced", json_body=payload)
614
+
615
+ # Parse response using factory method
616
+ results = []
617
+ for item_dict in data.get("results", []):
618
+ results.append(MemoryItem.from_dict(item_dict))
619
+
620
+ return results
621
+
622
+ def code_search(
623
+ self,
624
+ query: str,
625
+ entity_type: Optional[str] = None,
626
+ repo: Optional[str] = None,
627
+ limit: int = 20,
628
+ semantic: bool = False,
629
+ ) -> List[Dict[str, Any]]:
630
+ """Search for code entities (classes, functions, routes, tests).
631
+
632
+ Args:
633
+ query: Search string (partial name match, or natural language when semantic=True)
634
+ entity_type: Filter by type: module, class, function, route, test
635
+ repo: Filter by repository name
636
+ limit: Maximum results (default 20)
637
+ semantic: Use vector similarity instead of name substring match
638
+
639
+ Returns:
640
+ List of code entity dicts with item_id, name, entity_type,
641
+ file_path, line_number, docstring, repo, score (when semantic=True).
642
+
643
+ Example:
644
+ ```python
645
+ # Name substring search (default)
646
+ results = client.code_search("auth", entity_type="class")
647
+
648
+ # Semantic search — natural language
649
+ results = client.code_search("functions that handle payments", semantic=True)
650
+ ```
651
+ """
652
+ params: Dict[str, Any] = {"query": query, "limit": limit, "semantic": semantic}
653
+ if entity_type:
654
+ params["entity_type"] = entity_type
655
+ if repo:
656
+ params["repo"] = repo
657
+ return self._request("GET", "/memory/code/search", params=params)
658
+
659
+ def code_index(self, path: str, repo: Optional[str] = None, commit: Optional[str] = None) -> Dict[str, Any]:
660
+ """Index code entities from a file or directory."""
661
+ body: Dict[str, Any] = {"path": path}
662
+ if repo:
663
+ body["repo"] = repo
664
+ if commit:
665
+ body["commit"] = commit
666
+ return self._request("POST", "/memory/code/index", json_body=body)
667
+
668
+ def code_context(self, entity_name: str, repo: Optional[str] = None) -> Dict[str, Any]:
669
+ """Get rich context for a code entity."""
670
+ params: Dict[str, Any] = {"entity_name": entity_name}
671
+ if repo:
672
+ params["repo"] = repo
673
+ return self._request("GET", "/memory/code/context", params=params)
674
+
675
+ def code_dead_code(self, repo: str) -> Dict[str, Any]:
676
+ """Find unreferenced code entities in a repository."""
677
+ return self._request("GET", "/memory/code/dead-code", params={"repo": repo})
678
+
679
+ def code_dependencies(self, entity_name: str, direction: str = "both", repo: Optional[str] = None) -> Dict[str, Any]:
680
+ """Trace dependencies for a code entity."""
681
+ params: Dict[str, Any] = {"entity_name": entity_name, "direction": direction}
682
+ if repo:
683
+ params["repo"] = repo
684
+ return self._request("GET", "/memory/code/dependencies", params=params)
685
+
686
+ def get_plan(self, plan_id: str) -> Dict[str, Any]:
687
+ """Get a plan container with all its tasks."""
688
+ return self._request("GET", f"/memory/{plan_id}")
689
+
690
+ def delete_plan(self, plan_id: str) -> bool:
691
+ """Delete a plan."""
692
+ try:
693
+ self._request("DELETE", f"/memory/{plan_id}")
694
+ return True
695
+ except Exception:
696
+ return False
697
+
698
+ def update_plan_task(self, plan_id: str, task_id: str, status: str, outcome: Optional[str] = None) -> Dict[str, Any]:
699
+ """Update a task's status within a plan."""
700
+ body: Dict[str, Any] = {"task_id": task_id, "status": status}
701
+ if outcome:
702
+ body["outcome"] = outcome
703
+ return self._request("PATCH", f"/memory/{plan_id}/task", json_body=body)
704
+
705
+ def complete_plan(self, plan_id: str, summary: Optional[str] = None, graduate_to_decision: bool = False) -> Dict[str, Any]:
706
+ """Mark a plan as completed."""
707
+ body: Dict[str, Any] = {"graduate_to_decision": graduate_to_decision}
708
+ if summary:
709
+ body["summary"] = summary
710
+ return self._request("POST", f"/memory/{plan_id}/complete", json_body=body)
711
+
712
+ def fail_plan(self, plan_id: str, reason: str) -> Dict[str, Any]:
713
+ """Mark a plan as failed."""
714
+ return self._request("POST", f"/memory/{plan_id}/fail", json_body={"reason": reason})
715
+
716
+ def update(
717
+ self,
718
+ item_id: str,
719
+ content: Optional[str] = None,
720
+ metadata: Optional[Dict[str, Any]] = None,
721
+ properties: Optional[Dict[str, Any]] = None,
722
+ write_mode: Optional[str] = None,
723
+ ) -> bool:
724
+ """
725
+ Update a memory item (CORE-CRUD-UPDATE-1 contract).
726
+
727
+ Args:
728
+ item_id: Memory item ID
729
+ content: Convenience — folded into properties["content"]
730
+ metadata: Convenience — deep-merged with existing metadata
731
+ properties: Advanced — direct node-property dict. Takes precedence
732
+ over content/metadata when provided.
733
+ write_mode: "merge" (default) or "replace"
734
+
735
+ Returns:
736
+ True if successful, False on any HTTP error
737
+
738
+ Examples:
739
+ ```python
740
+ # Simple updates (convenience surface)
741
+ client.update("item_123", content="Updated content")
742
+ client.update("item_123", metadata={"updated": True})
743
+
744
+ # Advanced update (direct properties)
745
+ client.update("item_123",
746
+ properties={"importance_score": 0.9, "tags": ["v2"]})
747
+
748
+ # Replace all properties (preserves memory_type + node_category)
749
+ client.update("item_123",
750
+ properties={"content": "fresh", "tags": ["reset"]},
751
+ write_mode="replace")
752
+ ```
753
+ """
754
+ body: Dict[str, Any] = {}
755
+ if content is not None:
756
+ body["content"] = content
757
+ if metadata is not None:
758
+ body["metadata"] = metadata
759
+ if properties is not None:
760
+ body["properties"] = properties
761
+ if write_mode is not None:
762
+ body["write_mode"] = write_mode
763
+
764
+ try:
765
+ self._request("PATCH", f"/memory/{item_id}", json_body=body)
766
+ return True
767
+ except Exception:
768
+ return False
769
+
770
+ def delete(self, item_id: str) -> bool:
771
+ """
772
+ Delete a memory item.
773
+
774
+ Args:
775
+ item_id: Memory item ID
776
+
777
+ Returns:
778
+ True if successful
779
+
780
+ Example:
781
+ ```python
782
+ if client.delete("item_123"):
783
+ print("Memory deleted")
784
+ ```
785
+ """
786
+ try:
787
+ self._request("DELETE", f"/memory/{item_id}")
788
+ return True
789
+ except Exception:
790
+ return False
791
+
792
+ def ingest(
793
+ self,
794
+ content: str,
795
+ extractor_name: str = "llm",
796
+ context: Optional[Dict[str, Any]] = None,
797
+ ) -> Dict[str, Any]:
798
+ """
799
+ Ingest content with full extraction and enrichment pipeline.
800
+
801
+ Args:
802
+ content: Content to ingest
803
+ extractor_name: Name of the extractor to use (default: "llm")
804
+ context: Additional context information
805
+
806
+ Returns:
807
+ Ingestion result with item_id, user_id, tenant_id, queued status
808
+
809
+ Example:
810
+ ```python
811
+ # Ingest conversation
812
+ result = client.ingest(
813
+ content="User: Hello\nAssistant: Hi there!",
814
+ extractor_name="llm",
815
+ context={"conversation_id": "123", "timestamp": "2025-11-07"}
816
+ )
817
+
818
+ print(f"Ingested: {result['item_id']}")
819
+ ```
820
+
821
+ Note:
822
+ user_id is automatically determined from the JWT token.
823
+ """
824
+ body_dict = {
825
+ "content": content,
826
+ "extractor_name": extractor_name,
827
+ "context": context or {},
828
+ }
829
+
830
+ try:
831
+ return self._request("POST", "/memory/ingest", json_body=body_dict)
832
+ except Exception as e:
833
+ raise SmartMemoryClientError(f"Failed to ingest content: {str(e)}")
834
+
835
+ def ingest_conversation(
836
+ self,
837
+ turns: List[Dict[str, str]],
838
+ session_boundaries: Optional[List[int]] = None,
839
+ conversation_id: Optional[str] = None,
840
+ session_dates: Optional[List[str]] = None,
841
+ turns_per_chunk: int = 15,
842
+ max_chunk_chars: int = 12000,
843
+ max_concurrent: int = 4,
844
+ ) -> Dict[str, Any]:
845
+ """Ingest a conversation as session chunks through the full pipeline (RLM-1g).
846
+
847
+ Args:
848
+ turns: List of turn dicts with "role"/"speaker" and "content" keys.
849
+ session_boundaries: Turn indices where sessions start (e.g. [0, 50, 120]).
850
+ conversation_id: User-supplied ID (auto-generated if None).
851
+ session_dates: ISO date per session for metadata.
852
+ turns_per_chunk: Max turns per auto-chunk (default 15).
853
+ max_chunk_chars: Safety split for oversized chunks (default 12000).
854
+ max_concurrent: Semaphore limit for parallel chunk ingestion (default 4).
855
+
856
+ Returns:
857
+ Dict with conversation_id, conversation_node_id, chunks_ingested,
858
+ chunks_failed, total_turns, total_chunks, chunk_results, total_duration_ms.
859
+ """
860
+ body_dict: Dict[str, Any] = {"turns": turns}
861
+ if session_boundaries is not None:
862
+ body_dict["session_boundaries"] = session_boundaries
863
+ if conversation_id is not None:
864
+ body_dict["conversation_id"] = conversation_id
865
+ if session_dates is not None:
866
+ body_dict["session_dates"] = session_dates
867
+ if turns_per_chunk != 15:
868
+ body_dict["turns_per_chunk"] = turns_per_chunk
869
+ if max_chunk_chars != 12000:
870
+ body_dict["max_chunk_chars"] = max_chunk_chars
871
+ if max_concurrent != 4:
872
+ body_dict["max_concurrent"] = max_concurrent
873
+
874
+ try:
875
+ return self._request(
876
+ "POST", "/memory/ingest/conversation", json_body=body_dict
877
+ )
878
+ except Exception as e:
879
+ raise SmartMemoryClientError(f"Failed to ingest conversation: {str(e)}")
880
+
881
+ def link(self, source_id: str, target_id: str, link_type: str = "RELATED") -> bool:
882
+ """
883
+ Create a link between two memory items.
884
+
885
+ Args:
886
+ source_id: Source memory item ID
887
+ target_id: Target memory item ID
888
+ link_type: Type of link (RELATED, CAUSES, FOLLOWS, etc.)
889
+
890
+ Returns:
891
+ True if successful
892
+
893
+ Example:
894
+ ```python
895
+ # Create relationship
896
+ client.link("concept_1", "concept_2", link_type="RELATED")
897
+ client.link("cause_id", "effect_id", link_type="CAUSES")
898
+ ```
899
+ """
900
+ body_dict = {
901
+ "source_id": source_id,
902
+ "target_id": target_id,
903
+ "link_type": link_type,
904
+ }
905
+
906
+ try:
907
+ self._request("POST", "/memory/link", json_body=body_dict)
908
+ return True
909
+ except Exception as e:
910
+ # Link endpoint may not exist or be disabled - fail gracefully
911
+ logger.debug(f"Link operation not supported or failed: {e}")
912
+ return False
913
+
914
+ def add_edge(
915
+ self,
916
+ source_id: str,
917
+ target_id: str,
918
+ relation_type: str,
919
+ properties: Optional[Dict[str, Any]] = None,
920
+ ) -> Dict[str, Any]:
921
+ """
922
+ Add a direct edge between two nodes in the graph.
923
+
924
+ This is a lower-level operation than link() - it creates a raw edge
925
+ with custom properties. Use link() for standard memory linking.
926
+
927
+ Args:
928
+ source_id: Source node ID
929
+ target_id: Target node ID
930
+ relation_type: Type of relation/edge
931
+ properties: Optional edge properties
932
+
933
+ Returns:
934
+ Dict with edge creation result
935
+
936
+ Example:
937
+ ```python
938
+ result = client.add_edge(
939
+ source_id="node_1",
940
+ target_id="node_2",
941
+ relation_type="INFLUENCES",
942
+ properties={"weight": 0.8, "confidence": 0.95}
943
+ )
944
+ ```
945
+ """
946
+ body = {
947
+ "source_id": source_id,
948
+ "target_id": target_id,
949
+ "relation_type": relation_type,
950
+ "properties": properties or {},
951
+ }
952
+ return self._request("POST", "/memory/edge", json_body=body)
953
+
954
+ def get_neighbors(self, item_id: str) -> List[Dict[str, Any]]:
955
+ """
956
+ Get neighboring memory items (linked items).
957
+
958
+ Args:
959
+ item_id: Memory item ID
960
+
961
+ Returns:
962
+ List of neighbor information with item and link_type
963
+
964
+ Example:
965
+ ```python
966
+ neighbors = client.get_neighbors("item_123")
967
+ for neighbor in neighbors:
968
+ print(f"{neighbor['item_id']}: {neighbor['link_type']}")
969
+ ```
970
+ """
971
+ try:
972
+ result = self._request("GET", f"/memory/{item_id}/neighbors")
973
+ return result.get("neighbors", [])
974
+ except Exception as e:
975
+ logger.warning(f"Error getting neighbors: {e}")
976
+ return []
977
+
978
+ def get_lineage(self, item_id: str) -> Dict[str, Any]:
979
+ """Get the supersession lineage chain for a memory item."""
980
+ return self._request("GET", f"/memory/{item_id}/lineage")
981
+
982
+ def get_links(self, item_id: str) -> Dict[str, Any]:
983
+ """Get all edges (links) for a memory item."""
984
+ return self._request("GET", f"/memory/{item_id}/links")
985
+
986
+ def search_by_metadata(
987
+ self, filters: Dict[str, Any], limit: int = 50, offset: int = 0
988
+ ) -> List[Dict[str, Any]]:
989
+ """Search memory items by metadata key-value filters."""
990
+ params = {"limit": limit, "offset": offset, **filters}
991
+ return self._request("GET", "/memory/by-metadata", params=params)
992
+
993
+ def get_recall_profile(self, agent_id: str) -> Dict[str, Any]:
994
+ """Get an agent's recall profile for personality-aware retrieval."""
995
+ return self._request("GET", f"/memory/agents/{agent_id}/recall-profile")
996
+
997
+ def set_recall_profile(self, agent_id: str, recall_profile: Dict[str, Any]) -> Dict[str, Any]:
998
+ """Set an agent's recall profile. Send {} to clear."""
999
+ return self._request("PUT", f"/memory/agents/{agent_id}/recall-profile", json_body={"recall_profile": recall_profile})
1000
+
1001
+ def summary(self) -> Dict[str, Any]:
1002
+ """Get summary statistics about the memory system."""
1003
+ return self._request("GET", "/memory/summary")
1004
+
1005
+ def enrich(
1006
+ self, item_id: str, routines: Optional[List[str]] = None
1007
+ ) -> Dict[str, Any]:
1008
+ """
1009
+ Enrich a memory item with additional processing.
1010
+
1011
+ Args:
1012
+ item_id: Memory item ID
1013
+ routines: List of enrichment routines to run
1014
+
1015
+ Returns:
1016
+ Enrichment result
1017
+
1018
+ Example:
1019
+ ```python
1020
+ result = client.enrich("item_123", routines=["sentiment", "keywords"])
1021
+ ```
1022
+ """
1023
+ body = {"item_id": item_id, "routines": routines or []}
1024
+ return self._request("POST", f"/memory/{item_id}/enrich", json_body=body)
1025
+
1026
+ def personalize(
1027
+ self,
1028
+ traits: Optional[Dict[str, Any]] = None,
1029
+ preferences: Optional[Dict[str, Any]] = None,
1030
+ ) -> Dict[str, Any]:
1031
+ """
1032
+ Update personalization settings for the authenticated user.
1033
+
1034
+ Args:
1035
+ traits: User traits
1036
+ preferences: User preferences
1037
+
1038
+ Returns:
1039
+ Personalization result
1040
+
1041
+ Example:
1042
+ ```python
1043
+ result = client.personalize(
1044
+ traits={"learning_style": "visual"},
1045
+ preferences={"language": "en", "complexity": "advanced"}
1046
+ )
1047
+ ```
1048
+
1049
+ Note:
1050
+ user_id is automatically determined from the JWT token.
1051
+ """
1052
+ body = {"traits": traits or {}, "preferences": preferences or {}}
1053
+ return self._request("POST", "/memory/personalize", json_body=body)
1054
+
1055
+ def provide_feedback(
1056
+ self, feedback: Dict[str, Any], memory_type: str = "semantic"
1057
+ ) -> Dict[str, Any]:
1058
+ """
1059
+ Provide feedback to improve the memory system.
1060
+
1061
+ Args:
1062
+ feedback: Feedback information
1063
+ memory_type: Type of memory to update
1064
+
1065
+ Returns:
1066
+ Feedback processing result
1067
+
1068
+ Example:
1069
+ ```python
1070
+ client.provide_feedback(
1071
+ feedback={"rating": 5, "comment": "Great result"},
1072
+ memory_type="semantic"
1073
+ )
1074
+ ```
1075
+ """
1076
+ body = {"feedback": feedback, "memory_type": memory_type}
1077
+ return self._request("POST", "/memory/feedback", json_body=body)
1078
+
1079
+ def cluster(
1080
+ self, distance_threshold: float = 0.1, dry_run: bool = False
1081
+ ) -> Dict[str, Any]:
1082
+ """
1083
+ Run entity clustering/deduplication for the workspace.
1084
+
1085
+ Args:
1086
+ distance_threshold: Similarity threshold (0.0-1.0, default 0.1)
1087
+ dry_run: If true, preview clusters without merging
1088
+
1089
+ Returns:
1090
+ Clustering results (merged_count, clusters_found, etc.)
1091
+ """
1092
+ params = {"distance_threshold": distance_threshold, "dry_run": dry_run}
1093
+ return self._request("POST", "/memory/clustering/run", params=params)
1094
+
1095
+ def get_clustering_stats(self) -> Dict[str, Any]:
1096
+ """
1097
+ Get clustering statistics for the workspace.
1098
+
1099
+ Returns:
1100
+ Clustering statistics
1101
+ """
1102
+ return self._request("GET", "/memory/clustering/stats")
1103
+
1104
+ def ground(
1105
+ self, item_id: str, source_url: str, validation: Optional[Dict[str, Any]] = None
1106
+ ) -> Dict[str, Any]:
1107
+ """
1108
+ Ground a memory item to an external source for provenance.
1109
+
1110
+ Args:
1111
+ item_id: Memory item ID
1112
+ source_url: URL of the source
1113
+ validation: Optional validation data
1114
+
1115
+ Returns:
1116
+ Result message
1117
+ """
1118
+ body = {"item_id": item_id, "source_url": source_url, "validation": validation}
1119
+ return self._request("POST", f"/memory/{item_id}/ground", json_body=body)
1120
+
1121
+ def get_summarize_prompt(self, item_id: str) -> Dict[str, Any]:
1122
+ """
1123
+ Generate a prompt template for summarizing a memory item.
1124
+
1125
+ Args:
1126
+ item_id: Memory item ID
1127
+
1128
+ Returns:
1129
+ Prompt template and metadata
1130
+ """
1131
+ return self._request("GET", f"/memory/{item_id}/prompt/summarize")
1132
+
1133
+ def get_analyze_prompt(self, item_id: str) -> Dict[str, Any]:
1134
+ """
1135
+ Generate a prompt template for analyzing memory connections.
1136
+
1137
+ Args:
1138
+ item_id: Memory item ID
1139
+
1140
+ Returns:
1141
+ Prompt template and metadata
1142
+ """
1143
+ return self._request("GET", f"/memory/{item_id}/prompt/analyze")
1144
+
1145
+ def ingest_full(
1146
+ self,
1147
+ content: str,
1148
+ extractor_name: str = "llm",
1149
+ context: Optional[Dict[str, Any]] = None,
1150
+ ) -> Dict[str, Any]:
1151
+ """
1152
+ Ingest content with full synchronous entity/relation extraction pipeline.
1153
+
1154
+ Args:
1155
+ content: Content to ingest
1156
+ extractor_name: Name of the extractor to use (default: "llm")
1157
+ context: Additional context information
1158
+
1159
+ Returns:
1160
+ Full ingestion result with entities and relations
1161
+ """
1162
+ body = {
1163
+ "content": content,
1164
+ "extractor_name": extractor_name,
1165
+ "context": context or {},
1166
+ }
1167
+ return self._request("POST", "/memory/ingest/full", json_body=body)
1168
+
1169
+ # ============================================================================
1170
+ # Admin & Monitoring
1171
+ # ============================================================================
1172
+
1173
+ def orphaned_notes(self) -> Dict[str, Any]:
1174
+ """Find orphaned notes (notes with no connections)."""
1175
+ return self._request("GET", "/memory/admin/orphaned-notes")
1176
+
1177
+ def prune(
1178
+ self, strategy: str = "old", days: int = 365, dry_run: bool = True
1179
+ ) -> Dict[str, Any]:
1180
+ """Prune old or unused memories."""
1181
+ params = {"strategy": strategy, "days": days, "dry_run": dry_run}
1182
+ return self._request("POST", "/memory/admin/prune", params=params)
1183
+
1184
+ def find_old_notes(self, days: int = 365) -> Dict[str, Any]:
1185
+ """Find notes older than N days."""
1186
+ return self._request("GET", "/memory/admin/old-notes", params={"days": days})
1187
+
1188
+ def self_monitor(self) -> Dict[str, Any]:
1189
+ """Get self-monitoring metrics."""
1190
+ return self._request("GET", "/memory/admin/self-monitor")
1191
+
1192
+ def get_system_stats(self) -> Dict[str, Any]:
1193
+ """Get comprehensive system statistics."""
1194
+ return self._request("GET", "/memory/admin/stats")
1195
+
1196
+ def reflect(self, top_k: int = 5) -> Dict[str, Any]:
1197
+ """
1198
+ Reflect on memory patterns and insights.
1199
+
1200
+ Analyzes memory content to identify patterns, themes,
1201
+ and potential connections.
1202
+
1203
+ Args:
1204
+ top_k: Number of top items to reflect on
1205
+
1206
+ Returns:
1207
+ Dict with reflection results including themes, patterns, suggestions
1208
+
1209
+ Example:
1210
+ ```python
1211
+ reflection = client.reflect(top_k=10)
1212
+ print(reflection["reflection"]["themes"])
1213
+ ```
1214
+ """
1215
+ return self._request("GET", "/memory/admin/reflect", params={"top_k": top_k})
1216
+
1217
+ def summarize(self, max_items: int = 10) -> Dict[str, Any]:
1218
+ """
1219
+ Generate a summary of memory contents.
1220
+
1221
+ Creates a high-level overview of stored memories,
1222
+ including key topics, recent additions, and knowledge distribution.
1223
+
1224
+ Args:
1225
+ max_items: Maximum items to include in summary
1226
+
1227
+ Returns:
1228
+ Dict with summary including topic distribution, memory type breakdown
1229
+
1230
+ Example:
1231
+ ```python
1232
+ summary = client.summarize(max_items=20)
1233
+ print(summary["summary"]["topic_distribution"])
1234
+ ```
1235
+ """
1236
+ return self._request(
1237
+ "GET", "/memory/admin/summarize", params={"max_items": max_items}
1238
+ )
1239
+
1240
+ # ============================================================================
1241
+ # Agents
1242
+ # ============================================================================
1243
+
1244
+ def create_agent(
1245
+ self,
1246
+ name: str,
1247
+ description: Optional[str] = None,
1248
+ agent_config: Optional[Dict[str, Any]] = None,
1249
+ roles: Optional[List[str]] = None,
1250
+ ) -> Dict[str, Any]:
1251
+ """Create a new AI agent."""
1252
+ body = {
1253
+ "name": name,
1254
+ "description": description,
1255
+ "agent_config": agent_config or {},
1256
+ "roles": roles or ["user"],
1257
+ }
1258
+ return self._request("POST", "/memory/agents", json_body=body)
1259
+
1260
+ def list_agents(self) -> List[Dict[str, Any]]:
1261
+ """List all agents in the current tenant."""
1262
+ return self._request("GET", "/memory/agents")
1263
+
1264
+ def get_agent(self, agent_id: str) -> Dict[str, Any]:
1265
+ """Get details of a specific agent."""
1266
+ return self._request("GET", f"/memory/agents/{agent_id}")
1267
+
1268
+ def delete_agent(self, agent_id: str) -> None:
1269
+ """Delete (deactivate) an agent."""
1270
+ self._request("DELETE", f"/memory/agents/{agent_id}")
1271
+
1272
+ # ============================================================================
1273
+ # Analytics
1274
+ # ============================================================================
1275
+
1276
+ def get_analytics_status(self) -> Dict[str, Any]:
1277
+ """Return analytics feature status."""
1278
+ return self._request("GET", "/memory/analytics/status")
1279
+
1280
+ def detect_drift(self, time_window_days: int = 30) -> Dict[str, Any]:
1281
+ """Run concept drift detection."""
1282
+ return self._request(
1283
+ "GET",
1284
+ "/memory/analytics/drift",
1285
+ params={"time_window_days": time_window_days},
1286
+ )
1287
+
1288
+ def detect_bias(
1289
+ self,
1290
+ protected_attributes: Optional[List[str]] = None,
1291
+ sentiment_analysis: Optional[bool] = None,
1292
+ topic_analysis: Optional[bool] = None,
1293
+ ) -> Dict[str, Any]:
1294
+ """Run bias detection."""
1295
+ body = {
1296
+ "protected_attributes": protected_attributes,
1297
+ "sentiment_analysis": sentiment_analysis,
1298
+ "topic_analysis": topic_analysis,
1299
+ }
1300
+ return self._request("POST", "/memory/analytics/bias", json_body=body)
1301
+
1302
+ # ============================================================================
1303
+ # API Keys
1304
+ # ============================================================================
1305
+
1306
+ def create_api_key(
1307
+ self,
1308
+ name: str,
1309
+ scopes: Optional[List[str]] = None,
1310
+ expires_in_days: Optional[int] = None,
1311
+ ) -> Dict[str, Any]:
1312
+ """Create a new API key."""
1313
+ body = {
1314
+ "name": name,
1315
+ "scopes": scopes or ["read:memories"],
1316
+ "expires_in_days": expires_in_days,
1317
+ }
1318
+ return self._request("POST", "/memory/api-keys", json_body=body)
1319
+
1320
+ def list_api_keys(self) -> List[Dict[str, Any]]:
1321
+ """List all API keys."""
1322
+ return self._request("GET", "/memory/api-keys")
1323
+
1324
+ def revoke_api_key(self, key_id: str) -> None:
1325
+ """Revoke (delete) an API key."""
1326
+ self._request("DELETE", f"/memory/api-keys/{key_id}")
1327
+
1328
+ # ============================================================================
1329
+ # Auth
1330
+ # ============================================================================
1331
+
1332
+ def get_me(self) -> Dict[str, Any]:
1333
+ """Get current authenticated user info."""
1334
+ return self._request("GET", "/auth/me")
1335
+
1336
+ def logout_all(self) -> None:
1337
+ """Logout from all devices."""
1338
+ self._request("POST", "/auth/logout-all")
1339
+ self._token = None
1340
+ self._refresh_token = None
1341
+ self._api_key = None
1342
+
1343
+ def update_llm_keys(
1344
+ self,
1345
+ openai_key: Optional[str] = None,
1346
+ anthropic_key: Optional[str] = None,
1347
+ groq_key: Optional[str] = None,
1348
+ ) -> Dict[str, Any]:
1349
+ """Update user's LLM provider API keys."""
1350
+ body = {
1351
+ "openai_key": openai_key,
1352
+ "anthropic_key": anthropic_key,
1353
+ "groq_key": groq_key,
1354
+ }
1355
+ return self._request("PATCH", "/auth/llm-keys", json_body=body)
1356
+
1357
+ def get_llm_keys(self) -> Dict[str, Any]:
1358
+ """Get user's LLM provider API keys (masked)."""
1359
+ return self._request("GET", "/auth/llm-keys")
1360
+
1361
+ # ============================================================================
1362
+ # Evolve
1363
+ # ============================================================================
1364
+
1365
+ def trigger_evolution(self) -> Dict[str, Any]:
1366
+ """Manually trigger memory evolution processes."""
1367
+ return self._request("POST", "/memory/evolution/trigger")
1368
+
1369
+ def run_dream_phase(self) -> Dict[str, Any]:
1370
+ """Run a 'dream' phase: promote working memory to episodic/procedural."""
1371
+ return self._request("POST", "/memory/evolution/dream")
1372
+
1373
+ def get_evolution_status(self) -> Dict[str, Any]:
1374
+ """Get status of memory evolution processes."""
1375
+ return self._request("GET", "/memory/evolution/status")
1376
+
1377
+ # ============================================================================
1378
+ # Governance
1379
+ # ============================================================================
1380
+
1381
+ def run_governance_analysis(
1382
+ self,
1383
+ query: str = "*",
1384
+ top_k: int = 100,
1385
+ memory_items: Optional[List[Dict[str, Any]]] = None,
1386
+ ) -> Dict[str, Any]:
1387
+ """Run governance analysis."""
1388
+ body = {"query": query, "top_k": top_k, "memory_items": memory_items or []}
1389
+ return self._request("POST", "/memory/governance/run-analysis", json_body=body)
1390
+
1391
+ def list_violations(
1392
+ self, severity: Optional[str] = None, auto_fixable_only: bool = False
1393
+ ) -> Dict[str, Any]:
1394
+ """List violations available for review."""
1395
+ params = {"severity": severity, "auto_fixable_only": auto_fixable_only}
1396
+ return self._request("GET", "/memory/governance/violations", params=params)
1397
+
1398
+ def get_violation(self, violation_id: str) -> Dict[str, Any]:
1399
+ """Get a specific violation by ID."""
1400
+ return self._request("GET", f"/memory/governance/violations/{violation_id}")
1401
+
1402
+ def apply_governance_decision(
1403
+ self,
1404
+ violation_id: str,
1405
+ action: str = "approve",
1406
+ rationale: str = "",
1407
+ decided_by: str = "human",
1408
+ ) -> Dict[str, Any]:
1409
+ """Apply a governance decision for a violation."""
1410
+ body = {
1411
+ "violation_id": violation_id,
1412
+ "action": action,
1413
+ "rationale": rationale,
1414
+ "decided_by": decided_by,
1415
+ }
1416
+ return self._request(
1417
+ "POST", "/memory/governance/apply-decision", json_body=body
1418
+ )
1419
+
1420
+ def auto_fix_violations(self, confidence_threshold: float = 0.8) -> Dict[str, Any]:
1421
+ """Run auto-fix for high-confidence violations."""
1422
+ body = {"confidence_threshold": confidence_threshold}
1423
+ return self._request("POST", "/memory/governance/auto-fix", json_body=body)
1424
+
1425
+ def get_governance_summary(self) -> Dict[str, Any]:
1426
+ """Get a summary of governance state."""
1427
+ return self._request("GET", "/memory/governance/summary")
1428
+
1429
+ # ============================================================================
1430
+ # Ontology
1431
+ # ============================================================================
1432
+
1433
+ def run_inference(
1434
+ self,
1435
+ raw_chunks: List[Dict[str, str]],
1436
+ registry_id: str = "default",
1437
+ params: Optional[Dict[str, Any]] = None,
1438
+ ) -> Dict[str, Any]:
1439
+ """Run ontology inference over provided raw text chunks."""
1440
+ body = {
1441
+ "registry_id": registry_id,
1442
+ "raw_chunks": raw_chunks,
1443
+ "params": params or {},
1444
+ }
1445
+ return self._request("POST", "/memory/ontology/inference/run", json_body=body)
1446
+
1447
+ def list_registries(self) -> Dict[str, Any]:
1448
+ """List all ontology registries."""
1449
+ return self._request("GET", "/memory/ontology/registries")
1450
+
1451
+ def create_registry(
1452
+ self, name: str, description: str = "", domain: str = "general"
1453
+ ) -> Dict[str, Any]:
1454
+ """Create a new ontology registry."""
1455
+ body = {"name": name, "description": description, "domain": domain}
1456
+ return self._request("POST", "/memory/ontology/registries", json_body=body)
1457
+
1458
+ def get_registry_snapshot(
1459
+ self, registry_id: str, version: Optional[str] = None
1460
+ ) -> Dict[str, Any]:
1461
+ """Get a snapshot of a registry (current or specific version)."""
1462
+ params = {"version": version} if version else None
1463
+ return self._request(
1464
+ "GET", f"/memory/ontology/registry/{registry_id}/snapshot", params=params
1465
+ )
1466
+
1467
+ def apply_changeset(
1468
+ self,
1469
+ registry_id: str,
1470
+ changeset: Dict[str, Any],
1471
+ base_version: str = "",
1472
+ message: str = "",
1473
+ ) -> Dict[str, Any]:
1474
+ """Apply a changeset to create a new version of the registry."""
1475
+ body = {
1476
+ "base_version": base_version,
1477
+ "changeset": changeset,
1478
+ "message": message,
1479
+ }
1480
+ return self._request(
1481
+ "POST", f"/memory/ontology/registry/{registry_id}/apply", json_body=body
1482
+ )
1483
+
1484
+ def list_registry_snapshots(
1485
+ self, registry_id: str, limit: int = 50
1486
+ ) -> Dict[str, Any]:
1487
+ """List snapshots for a registry."""
1488
+ return self._request(
1489
+ "GET",
1490
+ f"/memory/ontology/registry/{registry_id}/snapshots",
1491
+ params={"limit": limit},
1492
+ )
1493
+
1494
+ def get_registry_changelog(
1495
+ self, registry_id: str, limit: int = 50
1496
+ ) -> Dict[str, Any]:
1497
+ """Get change history for a registry."""
1498
+ return self._request(
1499
+ "GET",
1500
+ f"/memory/ontology/registry/{registry_id}/changelog",
1501
+ params={"limit": limit},
1502
+ )
1503
+
1504
+ def rollback_registry(
1505
+ self, registry_id: str, target_version: str = "", message: str = ""
1506
+ ) -> Dict[str, Any]:
1507
+ """Rollback registry to a previous version."""
1508
+ body = {"target_version": target_version, "message": message}
1509
+ return self._request(
1510
+ "POST", f"/memory/ontology/registry/{registry_id}/rollback", json_body=body
1511
+ )
1512
+
1513
+ def export_registry(
1514
+ self,
1515
+ registry_id: str,
1516
+ version: Optional[str] = None,
1517
+ user_id: Optional[str] = None,
1518
+ ) -> Dict[str, Any]:
1519
+ """Export a registry snapshot."""
1520
+ params = {}
1521
+ if version:
1522
+ params["version"] = version
1523
+ if user_id:
1524
+ params["user_id"] = user_id
1525
+ return self._request(
1526
+ "GET", f"/memory/ontology/registry/{registry_id}/export", params=params
1527
+ )
1528
+
1529
+ def import_registry(
1530
+ self, registry_id: str, data: Dict[str, Any], message: str = ""
1531
+ ) -> Dict[str, Any]:
1532
+ """Import data into a registry."""
1533
+ body = {"data": data, "message": message}
1534
+ return self._request(
1535
+ "POST", f"/memory/ontology/registry/{registry_id}/import", json_body=body
1536
+ )
1537
+
1538
+ def list_enrichment_providers(self) -> Dict[str, Any]:
1539
+ """List available enrichment providers."""
1540
+ return self._request("GET", "/memory/ontology/enrichment/providers")
1541
+
1542
+ def run_enrichment(
1543
+ self,
1544
+ entities: List[str],
1545
+ provider: str = "wikipedia",
1546
+ user_id: Optional[str] = None,
1547
+ ) -> Dict[str, Any]:
1548
+ """Run enrichment operation using specified provider."""
1549
+ body = {"provider": provider, "entities": entities, "user_id": user_id}
1550
+ return self._request("POST", "/memory/ontology/enrichment/run", json_body=body)
1551
+
1552
+ def run_grounding_ontology(
1553
+ self,
1554
+ item_id: str,
1555
+ candidates: List[str],
1556
+ grounder: str = "wikipedia",
1557
+ user_id: Optional[str] = None,
1558
+ ) -> Dict[str, Any]:
1559
+ """Run grounding operation using specified grounder."""
1560
+ body = {
1561
+ "grounder": grounder,
1562
+ "item_id": item_id,
1563
+ "candidates": candidates,
1564
+ "user_id": user_id,
1565
+ }
1566
+ return self._request("POST", "/memory/ontology/grounding/run", json_body=body)
1567
+
1568
+ # ============================================================================
1569
+ # Pipeline
1570
+ # ============================================================================
1571
+
1572
+ def run_extraction_stage(
1573
+ self, content: str, extractor_name: str = "llm"
1574
+ ) -> Dict[str, Any]:
1575
+ """Run extraction pipeline stage."""
1576
+ body = {"content": content, "extractor_name": extractor_name}
1577
+ return self._request("POST", "/memory/pipeline/extraction", json_body=body)
1578
+
1579
+ def run_storage_stage(
1580
+ self, extracted_data: Dict[str, Any], storage_strategy: str = "standard"
1581
+ ) -> Dict[str, Any]:
1582
+ """Run storage pipeline stage."""
1583
+ body = {"extracted_data": extracted_data, "storage_strategy": storage_strategy}
1584
+ return self._request("POST", "/memory/pipeline/storage", json_body=body)
1585
+
1586
+ def run_linking_stage(
1587
+ self, stored_entities: List[Dict[str, Any]], linking_algorithm: str = "exact"
1588
+ ) -> Dict[str, Any]:
1589
+ """Run linking pipeline stage."""
1590
+ body = {
1591
+ "stored_entities": stored_entities,
1592
+ "linking_algorithm": linking_algorithm,
1593
+ }
1594
+ return self._request("POST", "/memory/pipeline/linking", json_body=body)
1595
+
1596
+ def run_enrichment_stage(
1597
+ self,
1598
+ linked_entities: List[Dict[str, Any]],
1599
+ enrichment_types: Optional[List[str]] = None,
1600
+ ) -> Dict[str, Any]:
1601
+ """Run enrichment pipeline stage."""
1602
+ body = {
1603
+ "linked_entities": linked_entities,
1604
+ "enrichment_types": enrichment_types or ["sentiment", "topics"],
1605
+ }
1606
+ return self._request("POST", "/memory/pipeline/enrichment", json_body=body)
1607
+
1608
+ def run_grounding_stage(
1609
+ self,
1610
+ enriched_entities: List[Dict[str, Any]],
1611
+ grounding_sources: Optional[List[str]] = None,
1612
+ ) -> Dict[str, Any]:
1613
+ """Run grounding pipeline stage."""
1614
+ body = {
1615
+ "enriched_entities": enriched_entities,
1616
+ "grounding_sources": grounding_sources or ["wikipedia"],
1617
+ }
1618
+ return self._request("POST", "/memory/pipeline/grounding", json_body=body)
1619
+
1620
+ def get_pipeline_state(
1621
+ self, pipeline_id: str, run_id: Optional[str] = None
1622
+ ) -> Dict[str, Any]:
1623
+ """Get pipeline state for a specific pipeline and run."""
1624
+ params = {"run_id": run_id} if run_id else None
1625
+ return self._request(
1626
+ "GET", f"/memory/pipeline/{pipeline_id}/state", params=params
1627
+ )
1628
+
1629
+ def reset_pipeline(self, pipeline_id: str) -> Dict[str, Any]:
1630
+ """Reset a pipeline, clearing its state."""
1631
+ return self._request("DELETE", f"/memory/pipeline/{pipeline_id}")
1632
+
1633
+ def clear_run_state(self, pipeline_id: str, run_id: str) -> Dict[str, Any]:
1634
+ """Clear all stage states for a specific pipeline run."""
1635
+ return self._request("DELETE", f"/memory/pipeline/{pipeline_id}/run/{run_id}")
1636
+
1637
+ # ============================================================================
1638
+ # Subscription
1639
+ # ============================================================================
1640
+
1641
+ def create_checkout_session(
1642
+ self,
1643
+ tier: str,
1644
+ billing_period: str = "monthly",
1645
+ trial_days: Optional[int] = None,
1646
+ ) -> Dict[str, Any]:
1647
+ """Create a Stripe Checkout session for subscription upgrade."""
1648
+ body = {
1649
+ "tier": tier,
1650
+ "billing_period": billing_period,
1651
+ "trial_days": trial_days,
1652
+ }
1653
+ return self._request("POST", "/subscription/checkout", json_body=body)
1654
+
1655
+ def upgrade_subscription(
1656
+ self,
1657
+ tier: str,
1658
+ billing_period: str = "monthly",
1659
+ payment_method_id: Optional[str] = None,
1660
+ use_checkout: bool = False,
1661
+ ) -> Dict[str, Any]:
1662
+ """Upgrade subscription tier."""
1663
+ body = {
1664
+ "tier": tier,
1665
+ "billing_period": billing_period,
1666
+ "payment_method_id": payment_method_id,
1667
+ "use_checkout": use_checkout,
1668
+ }
1669
+ return self._request("POST", "/subscription/upgrade", json_body=body)
1670
+
1671
+ def get_subscription(self) -> Dict[str, Any]:
1672
+ """Get current subscription details."""
1673
+ return self._request("GET", "/subscription/current")
1674
+
1675
+ def cancel_subscription(self, immediately: bool = False) -> Dict[str, Any]:
1676
+ """Cancel subscription."""
1677
+ return self._request(
1678
+ "POST", "/subscription/cancel", params={"immediately": immediately}
1679
+ )
1680
+
1681
+ # ============================================================================
1682
+ # Teams
1683
+ # ============================================================================
1684
+
1685
+ def create_team(
1686
+ self,
1687
+ name: str,
1688
+ description: Optional[str] = None,
1689
+ data_classification: str = "internal",
1690
+ cost_center: Optional[str] = None,
1691
+ ) -> Dict[str, Any]:
1692
+ """Create a new team."""
1693
+ body = {
1694
+ "name": name,
1695
+ "description": description,
1696
+ "data_classification": data_classification,
1697
+ "cost_center": cost_center,
1698
+ }
1699
+ return self._request("POST", "/memory/teams", json_body=body)
1700
+
1701
+ def list_teams(self) -> List[Dict[str, Any]]:
1702
+ """List all teams the user has access to."""
1703
+ return self._request("GET", "/memory/teams")
1704
+
1705
+ def get_team(self, team_id: str) -> Dict[str, Any]:
1706
+ """Get details of a specific team."""
1707
+ return self._request("GET", f"/memory/teams/{team_id}")
1708
+
1709
+ def update_team(
1710
+ self,
1711
+ team_id: str,
1712
+ name: Optional[str] = None,
1713
+ description: Optional[str] = None,
1714
+ data_classification: Optional[str] = None,
1715
+ cost_center: Optional[str] = None,
1716
+ ) -> Dict[str, Any]:
1717
+ """Update team details."""
1718
+ body = {}
1719
+ if name:
1720
+ body["name"] = name
1721
+ if description:
1722
+ body["description"] = description
1723
+ if data_classification:
1724
+ body["data_classification"] = data_classification
1725
+ if cost_center:
1726
+ body["cost_center"] = cost_center
1727
+ return self._request("PATCH", f"/memory/teams/{team_id}", json_body=body)
1728
+
1729
+ def delete_team(self, team_id: str) -> Dict[str, Any]:
1730
+ """Delete a team."""
1731
+ return self._request("DELETE", f"/memory/teams/{team_id}")
1732
+
1733
+ def list_team_members(self, team_id: str) -> Dict[str, Any]:
1734
+ """List all members of a team."""
1735
+ return self._request("GET", f"/memory/teams/{team_id}/members")
1736
+
1737
+ def add_team_member(
1738
+ self, team_id: str, user_id: str, role: str = "member"
1739
+ ) -> Dict[str, Any]:
1740
+ """Add a user to a team."""
1741
+ body = {"user_id": user_id, "role": role}
1742
+ return self._request("POST", f"/memory/teams/{team_id}/members", json_body=body)
1743
+
1744
+ def update_team_member(
1745
+ self, team_id: str, member_user_id: str, role: str
1746
+ ) -> Dict[str, Any]:
1747
+ """Update a team member's role."""
1748
+ body = {"role": role}
1749
+ return self._request(
1750
+ "PATCH", f"/memory/teams/{team_id}/members/{member_user_id}", json_body=body
1751
+ )
1752
+
1753
+ def remove_team_member(self, team_id: str, member_user_id: str) -> Dict[str, Any]:
1754
+ """Remove a user from a team."""
1755
+ return self._request(
1756
+ "DELETE", f"/memory/teams/{team_id}/members/{member_user_id}"
1757
+ )
1758
+
1759
+ def get_team_permissions(self, team_id: str) -> Dict[str, Any]:
1760
+ """Get available permissions for a team."""
1761
+ return self._request("GET", f"/memory/teams/{team_id}/permissions")
1762
+
1763
+ # ============================================================================
1764
+ # Temporal
1765
+ # ============================================================================
1766
+
1767
+ def get_history(
1768
+ self,
1769
+ item_id: str,
1770
+ start_time: Optional[str] = None,
1771
+ end_time: Optional[str] = None,
1772
+ limit: int = 100,
1773
+ ) -> Dict[str, Any]:
1774
+ """Get complete version history of a memory item."""
1775
+ params = {"limit": limit}
1776
+ if start_time:
1777
+ params["start_time"] = start_time
1778
+ if end_time:
1779
+ params["end_time"] = end_time
1780
+ return self._request(
1781
+ "GET", f"/memory/temporal/{item_id}/history", params=params
1782
+ )
1783
+
1784
+ def time_travel(
1785
+ self, timestamp: str, query: Optional[str] = None, limit: int = 100
1786
+ ) -> Dict[str, Any]:
1787
+ """Time-travel query - get state at specific time."""
1788
+ params = {"limit": limit}
1789
+ if query:
1790
+ params["query"] = query
1791
+ return self._request("GET", f"/memory/temporal/at/{timestamp}", params=params)
1792
+
1793
+ def get_item_at_time(self, item_id: str, timestamp: str) -> Dict[str, Any]:
1794
+ """Get specific item as it existed at timestamp."""
1795
+ return self._request("GET", f"/memory/temporal/{item_id}/at/{timestamp}")
1796
+
1797
+ def get_changes(
1798
+ self,
1799
+ item_id: str,
1800
+ since: Optional[str] = None,
1801
+ until: Optional[str] = None,
1802
+ change_type: Optional[str] = None,
1803
+ ) -> Dict[str, Any]:
1804
+ """Get all changes to an item in time range."""
1805
+ params = {}
1806
+ if since:
1807
+ params["since"] = since
1808
+ if until:
1809
+ params["until"] = until
1810
+ if change_type:
1811
+ params["change_type"] = change_type
1812
+ return self._request(
1813
+ "GET", f"/memory/temporal/{item_id}/changes", params=params
1814
+ )
1815
+
1816
+ def compare_versions(self, item_id: str, v1: int, v2: int) -> Dict[str, Any]:
1817
+ """Compare two versions of an item."""
1818
+ params = {"v1": v1, "v2": v2}
1819
+ return self._request(
1820
+ "POST", f"/memory/temporal/{item_id}/compare", params=params
1821
+ )
1822
+
1823
+ def rollback(
1824
+ self,
1825
+ item_id: str,
1826
+ to_version: Optional[int] = None,
1827
+ to_time: Optional[str] = None,
1828
+ ) -> Dict[str, Any]:
1829
+ """Rollback item to previous version."""
1830
+ params = {}
1831
+ if to_version:
1832
+ params["to_version"] = to_version
1833
+ if to_time:
1834
+ params["to_time"] = to_time
1835
+ return self._request(
1836
+ "POST", f"/memory/temporal/{item_id}/rollback", params=params
1837
+ )
1838
+
1839
+ def get_audit_trail(
1840
+ self,
1841
+ item_id: str,
1842
+ change_type: Optional[str] = None,
1843
+ user_id: Optional[str] = None,
1844
+ start_time: Optional[str] = None,
1845
+ end_time: Optional[str] = None,
1846
+ ) -> Dict[str, Any]:
1847
+ """Get complete audit trail for an item."""
1848
+ params = {}
1849
+ if change_type:
1850
+ params["change_type"] = change_type
1851
+ if user_id:
1852
+ params["user_id"] = user_id
1853
+ if start_time:
1854
+ params["start_time"] = start_time
1855
+ if end_time:
1856
+ params["end_time"] = end_time
1857
+ return self._request("GET", f"/memory/temporal/{item_id}/audit", params=params)
1858
+
1859
+ def search_during_range(
1860
+ self, query: str, start_time: str, end_time: str, limit: int = 100
1861
+ ) -> Dict[str, Any]:
1862
+ """Search memories that existed during time range."""
1863
+ params = {
1864
+ "query": query,
1865
+ "start_time": start_time,
1866
+ "end_time": end_time,
1867
+ "limit": limit,
1868
+ }
1869
+ return self._request("GET", "/memory/temporal/search/during", params=params)
1870
+
1871
+ def generate_compliance_report(
1872
+ self,
1873
+ start_date: str,
1874
+ end_date: str,
1875
+ report_type: str = "HIPAA",
1876
+ item_ids: Optional[List[str]] = None,
1877
+ ) -> Dict[str, Any]:
1878
+ """Generate compliance report (HIPAA, GDPR, SOC2)."""
1879
+ params = {
1880
+ "start_date": start_date,
1881
+ "end_date": end_date,
1882
+ "report_type": report_type,
1883
+ }
1884
+ if item_ids:
1885
+ params["item_ids"] = item_ids
1886
+ return self._request("GET", "/memory/temporal/compliance/report", params=params)
1887
+
1888
+ def get_relationship_history(self, rel_id: str) -> Dict[str, Any]:
1889
+ """Get history of a relationship."""
1890
+ return self._request("GET", f"/memory/temporal/relationships/{rel_id}/history")
1891
+
1892
+ def get_relationships_at_time(
1893
+ self, timestamp: str, limit: int = 100
1894
+ ) -> Dict[str, Any]:
1895
+ """Get all relationships that existed at specific time."""
1896
+ params = {"limit": limit}
1897
+ return self._request(
1898
+ "GET", f"/memory/temporal/relationships/at/{timestamp}", params=params
1899
+ )
1900
+
1901
+ def get_relationship_valid_periods(self, rel_id: str) -> Dict[str, Any]:
1902
+ """Get valid time periods for a relationship."""
1903
+ return self._request(
1904
+ "GET", f"/memory/temporal/relationships/{rel_id}/valid-periods"
1905
+ )
1906
+
1907
+ # ============================================================================
1908
+ # Usage
1909
+ # ============================================================================
1910
+
1911
+ def get_usage_dashboard(self) -> Dict[str, Any]:
1912
+ """Get usage dashboard with current quotas and limits."""
1913
+ return self._request("GET", "/usage/dashboard")
1914
+
1915
+ def get_usage_limits(self) -> Dict[str, Any]:
1916
+ """Get quota limits for current subscription tier."""
1917
+ return self._request("GET", "/usage/limits")
1918
+
1919
+ def get_current_usage(self) -> Dict[str, Any]:
1920
+ """Get current usage statistics."""
1921
+ return self._request("GET", "/usage/current")
1922
+
1923
+ def get_available_tiers(self) -> Dict[str, Any]:
1924
+ """Get available subscription tiers."""
1925
+ return self._request("GET", "/usage/tiers")
1926
+
1927
+ # ============================================================================
1928
+ # Token Usage (CFS-1)
1929
+ # ============================================================================
1930
+
1931
+ def get_token_usage(
1932
+ self,
1933
+ start_date: Optional[str] = None,
1934
+ end_date: Optional[str] = None,
1935
+ group_by: Optional[str] = None,
1936
+ limit: int = 100,
1937
+ ) -> Dict[str, Any]:
1938
+ """Get aggregated token usage history for the current workspace.
1939
+
1940
+ Args:
1941
+ start_date: ISO date string (inclusive), e.g. "2026-02-01"
1942
+ end_date: ISO date string (inclusive), e.g. "2026-02-11"
1943
+ group_by: Group results by "stage", "profile", or "day"
1944
+ limit: Max records to return (1-1000, default 100)
1945
+
1946
+ Returns:
1947
+ Dict with workspace_id, record_count, total_spent, total_avoided,
1948
+ savings_pct, records list, and optional grouping data.
1949
+ """
1950
+ params: Dict[str, Any] = {"limit": limit}
1951
+ if start_date:
1952
+ params["start_date"] = start_date
1953
+ if end_date:
1954
+ params["end_date"] = end_date
1955
+ if group_by:
1956
+ params["group_by"] = group_by
1957
+ return self._request("GET", "/memory/token-usage", params=params)
1958
+
1959
+ def get_token_usage_current(self) -> Dict[str, Any]:
1960
+ """Get real-time token usage: cache stats + last 10 pipeline runs.
1961
+
1962
+ Returns:
1963
+ Dict with workspace_id, cache_stats, and recent_runs list.
1964
+ """
1965
+ return self._request("GET", "/memory/token-usage/current")
1966
+
1967
+ # ============================================================================
1968
+ # Procedure Matches (CFS-2)
1969
+ # ============================================================================
1970
+
1971
+ def list_procedure_matches(
1972
+ self,
1973
+ start_date: Optional[str] = None,
1974
+ end_date: Optional[str] = None,
1975
+ procedure_id: Optional[str] = None,
1976
+ feedback: Optional[str] = None,
1977
+ limit: int = 100,
1978
+ ) -> Dict[str, Any]:
1979
+ """List procedure match history for the current workspace.
1980
+
1981
+ Args:
1982
+ start_date: ISO date string (inclusive), e.g. "2026-02-01"
1983
+ end_date: ISO date string (inclusive), e.g. "2026-02-12"
1984
+ procedure_id: Filter by matched procedure ID
1985
+ feedback: Filter by feedback value: "success", "failure", or "neutral"
1986
+ limit: Max records to return (1-1000, default 100)
1987
+
1988
+ Returns:
1989
+ Dict with workspace_id, record_count, and records list.
1990
+ """
1991
+ params: Dict[str, Any] = {"limit": limit}
1992
+ if start_date:
1993
+ params["start_date"] = start_date
1994
+ if end_date:
1995
+ params["end_date"] = end_date
1996
+ if procedure_id:
1997
+ params["procedure_id"] = procedure_id
1998
+ if feedback:
1999
+ params["feedback"] = feedback
2000
+ return self._request("GET", "/memory/procedures/matches", params=params)
2001
+
2002
+ def submit_procedure_match_feedback(
2003
+ self,
2004
+ match_id: str,
2005
+ feedback: str,
2006
+ note: Optional[str] = None,
2007
+ ) -> Dict[str, Any]:
2008
+ """Submit feedback for a procedure match.
2009
+
2010
+ Args:
2011
+ match_id: The match ID (uuid) from ingest response or match list.
2012
+ feedback: One of "success", "failure", "neutral".
2013
+ note: Optional explanation (max 500 chars).
2014
+
2015
+ Returns:
2016
+ Dict with status, match_id, and feedback.
2017
+ """
2018
+ body: Dict[str, Any] = {"feedback": feedback}
2019
+ if note:
2020
+ body["note"] = note
2021
+ return self._request(
2022
+ "POST", f"/memory/procedures/matches/{match_id}/feedback", json_body=body
2023
+ )
2024
+
2025
+ def submit_result_feedback(
2026
+ self,
2027
+ session_id: str,
2028
+ result_used: List[str],
2029
+ ) -> Dict[str, Any]:
2030
+ """Submit result-selection feedback for a completed search session (SELF-IMPROVE-6).
2031
+
2032
+ Call this after using results from ``search()`` to tell SmartMemory which
2033
+ results were actually useful. The ``session_id`` is available in the
2034
+ ``X-Search-Session-Id`` response header from ``POST /memory/search``.
2035
+
2036
+ Args:
2037
+ session_id: Server-generated session ID from the search response
2038
+ (``X-Search-Session-Id`` header).
2039
+ result_used: Item IDs from the search results that you incorporated.
2040
+ Pass an empty list if none of the results were useful.
2041
+
2042
+ Returns:
2043
+ Dict with ``status``, ``search_session_id``, ``result_used_count``,
2044
+ ``result_shown_count``.
2045
+
2046
+ Raises:
2047
+ HTTPError 404: Session not found or expired (> 1 hour since search).
2048
+ HTTPError 400: result_used contains IDs not in the original result set.
2049
+ HTTPError 409: Feedback already submitted for this session.
2050
+ """
2051
+ return self._request(
2052
+ "POST",
2053
+ "/memory/result-feedback",
2054
+ json_body={"search_session_id": session_id, "result_used": result_used},
2055
+ )
2056
+
2057
+ def get_procedure_match_stats(self) -> Dict[str, Any]:
2058
+ """Get aggregated procedure match statistics for the current workspace.
2059
+
2060
+ Returns:
2061
+ Dict with total_matches, successful, failed, neutral, no_feedback,
2062
+ avg_confidence, and by_procedure breakdown.
2063
+ """
2064
+ return self._request("GET", "/memory/procedures/matches/stats")
2065
+
2066
+ # ============================================================================
2067
+ # Procedure Catalog (CFS-3)
2068
+ # ============================================================================
2069
+
2070
+ def list_procedures(
2071
+ self,
2072
+ limit: int = 50,
2073
+ offset: int = 0,
2074
+ sort_by: Optional[str] = None,
2075
+ sort_order: str = "desc",
2076
+ ) -> Dict[str, Any]:
2077
+ """List all procedural memories with aggregated match statistics.
2078
+
2079
+ Args:
2080
+ limit: Max procedures to return (1-200, default 50)
2081
+ offset: Pagination offset (default 0)
2082
+ sort_by: Sort field - "match_count", "success_rate", "created_at", or "name"
2083
+ sort_order: Sort direction - "asc" or "desc" (default "desc")
2084
+
2085
+ Returns:
2086
+ Dict with workspace_id, total_count, and procedures list.
2087
+ Each procedure has id, name, description, created_at, metadata, and match_stats.
2088
+ """
2089
+ params: Dict[str, Any] = {
2090
+ "limit": limit,
2091
+ "offset": offset,
2092
+ "sort_order": sort_order,
2093
+ }
2094
+ if sort_by:
2095
+ params["sort_by"] = sort_by
2096
+ return self._request("GET", "/memory/procedures", params=params)
2097
+
2098
+ def get_procedure(
2099
+ self,
2100
+ procedure_id: str,
2101
+ include_matches: bool = True,
2102
+ match_limit: int = 20,
2103
+ ) -> Dict[str, Any]:
2104
+ """Get full procedure detail with recent match history.
2105
+
2106
+ Args:
2107
+ procedure_id: The procedure ID to retrieve.
2108
+ include_matches: Whether to include recent match history (default True).
2109
+ match_limit: Max matches to include (1-100, default 20).
2110
+
2111
+ Returns:
2112
+ Dict with id, name, description, content, created_at, updated_at,
2113
+ metadata, match_stats, and optionally recent_matches list.
2114
+
2115
+ Raises:
2116
+ SmartMemoryClientError: If procedure not found (404).
2117
+ """
2118
+ params: Dict[str, Any] = {
2119
+ "include_matches": include_matches,
2120
+ "match_limit": match_limit,
2121
+ }
2122
+ return self._request("GET", f"/memory/procedures/{procedure_id}", params=params)
2123
+
2124
+ # ============================================================================
2125
+ # Webhooks
2126
+ # ============================================================================
2127
+
2128
+ def trigger_stripe_webhook(
2129
+ self, payload: Dict[str, Any], signature: str
2130
+ ) -> Dict[str, Any]:
2131
+ """Trigger a Stripe webhook event (mostly for testing)."""
2132
+ # Note: This sends raw body, but our _request helper assumes JSON or params.
2133
+ # For webhooks, we might need to send raw bytes if we were simulating real Stripe calls.
2134
+ # However, the client is typically used to INTERACT with the API, not send webhooks TO it.
2135
+ # But if we need to simulate it:
2136
+ headers = {"stripe-signature": signature}
2137
+ # We'll use json_body for convenience, but real Stripe sends raw bytes.
2138
+ # This method might be limited by _request implementation if strict raw body is needed.
2139
+ return self._request(
2140
+ "POST", "/webhooks/stripe", json_body=payload, headers=headers
2141
+ )
2142
+
2143
+ # ============================================================================
2144
+ # Zettelkasten
2145
+ # ============================================================================
2146
+
2147
+ def get_backlinks(self, note_id: str) -> Dict[str, Any]:
2148
+ """Get notes that link TO this note (backlinks)."""
2149
+ return self._request("GET", f"/memory/zettel/{note_id}/backlinks")
2150
+
2151
+ def get_forward_links(self, note_id: str) -> Dict[str, Any]:
2152
+ """Get notes this note links TO (forward links)."""
2153
+ return self._request("GET", f"/memory/zettel/{note_id}/forward-links")
2154
+
2155
+ def get_connections(self, note_id: str) -> Dict[str, Any]:
2156
+ """Get all connections (backlinks + forward links)."""
2157
+ return self._request("GET", f"/memory/zettel/{note_id}/connections")
2158
+
2159
+ def get_clusters(
2160
+ self, min_size: int = 3, algorithm: str = "louvain"
2161
+ ) -> Dict[str, Any]:
2162
+ """Detect knowledge clusters in your Zettelkasten."""
2163
+ params = {"min_size": min_size, "algorithm": algorithm}
2164
+ return self._request("GET", "/memory/zettel/clusters", params=params)
2165
+
2166
+ def get_hubs(self, min_connections: int = 5, limit: int = 20) -> Dict[str, Any]:
2167
+ """Find hub notes (highly connected notes)."""
2168
+ params = {"min_connections": min_connections, "limit": limit}
2169
+ return self._request("GET", "/memory/zettel/hubs", params=params)
2170
+
2171
+ def get_bridges(self, limit: int = 20) -> Dict[str, Any]:
2172
+ """Find bridge notes (notes connecting different clusters)."""
2173
+ params = {"limit": limit}
2174
+ return self._request("GET", "/memory/zettel/bridges", params=params)
2175
+
2176
+ def get_discoveries(
2177
+ self, note_id: str, max_distance: int = 3, min_surprise: float = 0.5
2178
+ ) -> Dict[str, Any]:
2179
+ """Find unexpected connections (serendipitous discovery)."""
2180
+ params = {"max_distance": max_distance, "min_surprise": min_surprise}
2181
+ return self._request(
2182
+ "GET", f"/memory/zettel/{note_id}/discoveries", params=params
2183
+ )
2184
+
2185
+ def get_path(
2186
+ self, note_id: str, target_id: str, max_paths: int = 5
2187
+ ) -> Dict[str, Any]:
2188
+ """Find paths between two notes."""
2189
+ params = {"max_paths": max_paths}
2190
+ return self._request(
2191
+ "GET", f"/memory/zettel/{note_id}/path/{target_id}", params=params
2192
+ )
2193
+
2194
+ def parse_wikilinks(self, content: str, auto_create: bool = True) -> Dict[str, Any]:
2195
+ """Parse [[wikilinks]] in content."""
2196
+ params = {"content": content, "auto_create": auto_create}
2197
+ return self._request("POST", "/memory/zettel/wikilink/parse", params=params)
2198
+
2199
+ def resolve_wikilink(self, link: str) -> Dict[str, Any]:
2200
+ """Resolve a wikilink to a note."""
2201
+ params = {"link": link}
2202
+ return self._request("GET", "/memory/zettel/wikilink/resolve", params=params)
2203
+
2204
+ def get_subgraph(
2205
+ self, note_id: str, depth: int = 2, include_metadata: bool = True
2206
+ ) -> Dict[str, Any]:
2207
+ """Get subgraph around a note (for visualization)."""
2208
+ params = {"depth": depth, "include_metadata": include_metadata}
2209
+ return self._request("GET", f"/memory/zettel/{note_id}/graph", params=params)
2210
+
2211
+ def detect_concept_emergence(self, limit: int = 20) -> Dict[str, Any]:
2212
+ """Detect emerging concepts from connection patterns."""
2213
+ params = {"limit": limit}
2214
+ return self._request("GET", "/memory/zettel/concept-emergence", params=params)
2215
+
2216
+ def suggest_related_notes(self, note_id: str, count: int = 5) -> Dict[str, Any]:
2217
+ """Suggest related notes for serendipitous discovery."""
2218
+ params = {"count": count}
2219
+ return self._request(
2220
+ "GET", f"/memory/zettel/{note_id}/suggestions", params=params
2221
+ )
2222
+
2223
+ def random_walk_discovery(self, note_id: str, length: int = 5) -> Dict[str, Any]:
2224
+ """Perform random walk for serendipitous discovery."""
2225
+ params = {"length": length}
2226
+ return self._request(
2227
+ "GET", f"/memory/zettel/{note_id}/random-walk", params=params
2228
+ )
2229
+
2230
+ def find_notes_by_tag(self, tag: str, limit: int = 100) -> Dict[str, Any]:
2231
+ """Find notes by tag."""
2232
+ params = {"limit": limit}
2233
+ return self._request("GET", f"/memory/zettel/by-tag/{tag}", params=params)
2234
+
2235
+ def find_notes_by_property(
2236
+ self, key: str, value: str, limit: int = 100
2237
+ ) -> Dict[str, Any]:
2238
+ """Find notes by property."""
2239
+ params = {"key": key, "value": value, "limit": limit}
2240
+ return self._request("GET", "/memory/zettel/by-property", params=params)
2241
+
2242
+ def find_notes_mentioning(self, entity_id: str, limit: int = 100) -> Dict[str, Any]:
2243
+ """Find notes mentioning an entity."""
2244
+ params = {"limit": limit}
2245
+ return self._request(
2246
+ "GET", f"/memory/zettel/mentioning/{entity_id}", params=params
2247
+ )
2248
+
2249
+ def query_by_dynamic_relation(
2250
+ self, source_id: str, relation_type: str, limit: int = 100
2251
+ ) -> Dict[str, Any]:
2252
+ """Query notes by dynamic relation type."""
2253
+ params = {"limit": limit}
2254
+ return self._request(
2255
+ "GET",
2256
+ f"/memory/zettel/by-relation/{source_id}/{relation_type}",
2257
+ params=params,
2258
+ )
2259
+
2260
+ # =========================================================================
2261
+ # Reasoning / Assertion Challenging
2262
+ # =========================================================================
2263
+
2264
+ def challenge(
2265
+ self, assertion: str, memory_type: str = "semantic", use_llm: bool = True
2266
+ ) -> Dict[str, Any]:
2267
+ """
2268
+ Challenge an assertion against existing knowledge to detect contradictions.
2269
+
2270
+ Uses a multi-method detection cascade:
2271
+ 1. LLM-based (if enabled) - most accurate
2272
+ 2. Graph-based - structural analysis
2273
+ 3. Embedding-based - semantic similarity + polarity
2274
+ 4. Heuristic - pattern matching fallback
2275
+
2276
+ Args:
2277
+ assertion: The assertion to challenge
2278
+ memory_type: Type of memory to search (default: "semantic")
2279
+ use_llm: Use LLM for deep contradiction analysis
2280
+
2281
+ Returns:
2282
+ Challenge result with conflicts, confidence, etc.
2283
+
2284
+ Example:
2285
+ ```python
2286
+ result = client.challenge("Paris is the capital of Germany")
2287
+ if result["has_conflicts"]:
2288
+ for conflict in result["conflicts"]:
2289
+ print(f"Contradicts: {conflict['existing_fact']}")
2290
+ ```
2291
+ """
2292
+ body = {"assertion": assertion, "memory_type": memory_type, "use_llm": use_llm}
2293
+ return self._request("POST", "/memory/reasoning/challenge", json_body=body)
2294
+
2295
+ def resolve_conflict(
2296
+ self,
2297
+ existing_item_id: str,
2298
+ new_fact: str,
2299
+ auto_resolve: bool = True,
2300
+ strategy: Optional[str] = None,
2301
+ use_wikipedia: bool = True,
2302
+ use_llm: bool = True,
2303
+ ) -> Dict[str, Any]:
2304
+ """
2305
+ Resolve a conflict between assertions.
2306
+
2307
+ Auto-resolution cascade (if enabled):
2308
+ 1. Wikipedia lookup - verify against Wikipedia
2309
+ 2. LLM reasoning - ask GPT to fact-check
2310
+ 3. Grounding check - check existing provenance
2311
+ 4. Recency heuristic - prefer recent info for temporal conflicts
2312
+
2313
+ Args:
2314
+ existing_item_id: ID of the existing memory item in conflict
2315
+ new_fact: The new fact that conflicts
2316
+ auto_resolve: Attempt auto-resolution before manual strategy
2317
+ strategy: Manual resolution strategy if auto fails
2318
+ ("keep_existing", "accept_new", "keep_both", "defer")
2319
+ use_wikipedia: Use Wikipedia for verification
2320
+ use_llm: Use LLM for reasoning
2321
+
2322
+ Returns:
2323
+ Resolution result with method, evidence, confidence
2324
+
2325
+ Example:
2326
+ ```python
2327
+ result = client.resolve_conflict(
2328
+ existing_item_id="item_123",
2329
+ new_fact="Paris is the capital of Germany",
2330
+ auto_resolve=True
2331
+ )
2332
+ if result["auto_resolved"]:
2333
+ print(f"Resolved via {result['method']}: {result['evidence']}")
2334
+ ```
2335
+ """
2336
+ body = {
2337
+ "existing_item_id": existing_item_id,
2338
+ "new_fact": new_fact,
2339
+ "auto_resolve": auto_resolve,
2340
+ "strategy": strategy,
2341
+ "use_wikipedia": use_wikipedia,
2342
+ "use_llm": use_llm,
2343
+ }
2344
+ return self._request("POST", "/memory/reasoning/resolve", json_body=body)
2345
+
2346
+ def list_conflicts(
2347
+ self, needs_review: bool = True, limit: int = 50
2348
+ ) -> Dict[str, Any]:
2349
+ """
2350
+ List memory items that have unresolved conflicts.
2351
+
2352
+ Args:
2353
+ needs_review: Filter to items needing review
2354
+ limit: Maximum number of items to return
2355
+
2356
+ Returns:
2357
+ List of conflicting items with details
2358
+
2359
+ Example:
2360
+ ```python
2361
+ conflicts = client.list_conflicts()
2362
+ for item in conflicts["conflicts"]:
2363
+ print(f"{item['item_id']}: {item['review_reason']}")
2364
+ ```
2365
+ """
2366
+ params = {"needs_review": needs_review, "limit": limit}
2367
+ return self._request("GET", "/memory/reasoning/conflicts", params=params)
2368
+
2369
+ def get_low_confidence_items(
2370
+ self, threshold: float = 0.5, limit: int = 50
2371
+ ) -> Dict[str, Any]:
2372
+ """
2373
+ Get items with confidence below threshold.
2374
+
2375
+ Useful for finding facts that have been challenged multiple times
2376
+ and may need review or removal.
2377
+
2378
+ Args:
2379
+ threshold: Confidence threshold (0.0-1.0)
2380
+ limit: Maximum items to return
2381
+
2382
+ Returns:
2383
+ Items sorted by confidence (lowest first)
2384
+
2385
+ Example:
2386
+ ```python
2387
+ low_conf = client.get_low_confidence_items(threshold=0.3)
2388
+ for item in low_conf["items"]:
2389
+ print(f"{item['item_id']}: {item['confidence']:.2f} ({item['challenge_count']} challenges)")
2390
+ ```
2391
+ """
2392
+ params = {"threshold": threshold, "limit": limit}
2393
+ return self._request("GET", "/memory/reasoning/low-confidence", params=params)
2394
+
2395
+ def get_confidence_history(self, item_id: str) -> Dict[str, Any]:
2396
+ """
2397
+ Get the confidence decay history for a specific item.
2398
+
2399
+ Args:
2400
+ item_id: Memory item ID
2401
+
2402
+ Returns:
2403
+ Confidence history with timestamps, reasons, and conflicting facts
2404
+
2405
+ Example:
2406
+ ```python
2407
+ history = client.get_confidence_history("item_123")
2408
+ print(f"Current confidence: {history['current_confidence']}")
2409
+ for event in history["history"]:
2410
+ print(f" {event['timestamp']}: {event['old_confidence']:.2f} -> {event['new_confidence']:.2f}")
2411
+ print(f" Reason: {event['reason']}")
2412
+ ```
2413
+ """
2414
+ return self._request("GET", f"/memory/reasoning/confidence-history/{item_id}")
2415
+
2416
+ # =========================================================================
2417
+ # Reasoning Traces (System 2 Memory)
2418
+ # =========================================================================
2419
+
2420
+ def extract_reasoning(
2421
+ self,
2422
+ content: str,
2423
+ min_steps: int = 2,
2424
+ min_quality_score: float = 0.4,
2425
+ use_llm_detection: bool = True,
2426
+ ) -> Dict[str, Any]:
2427
+ """
2428
+ Extract reasoning traces from content.
2429
+
2430
+ Detects chain-of-thought reasoning patterns (Thought:/Action:/Observation:).
2431
+
2432
+ Args:
2433
+ content: Content to extract reasoning from
2434
+ min_steps: Minimum steps required for a valid trace
2435
+ min_quality_score: Minimum quality score threshold
2436
+ use_llm_detection: Use LLM for implicit reasoning detection
2437
+
2438
+ Returns:
2439
+ Extraction result with trace, has_reasoning, quality_score, step_count
2440
+
2441
+ Example:
2442
+ ```python
2443
+ result = client.extract_reasoning('''
2444
+ Thought: I need to analyze this bug.
2445
+ Action: Let me search for the function.
2446
+ Observation: Found the issue in line 42.
2447
+ Conclusion: The fix is to add a null check.
2448
+ ''')
2449
+ if result['has_reasoning']:
2450
+ print(f"Found {result['step_count']} reasoning steps")
2451
+ ```
2452
+ """
2453
+ body = {
2454
+ "content": content,
2455
+ "min_steps": min_steps,
2456
+ "min_quality_score": min_quality_score,
2457
+ "use_llm_detection": use_llm_detection,
2458
+ }
2459
+ return self._request("POST", "/memory/reasoning-traces/extract", json_body=body)
2460
+
2461
+ def store_reasoning_trace(
2462
+ self, trace: Dict[str, Any], artifact_ids: Optional[List[str]] = None
2463
+ ) -> Dict[str, Any]:
2464
+ """
2465
+ Store a reasoning trace as a memory item.
2466
+
2467
+ Creates a 'reasoning' type memory with CAUSES relations to artifacts.
2468
+
2469
+ Args:
2470
+ trace: Reasoning trace dict with trace_id, steps, task_context
2471
+ artifact_ids: IDs of artifacts this reasoning produced
2472
+
2473
+ Returns:
2474
+ Storage result with trace_id, step_count, artifact_links
2475
+
2476
+ Example:
2477
+ ```python
2478
+ result = client.store_reasoning_trace(
2479
+ trace={
2480
+ "trace_id": "trace_123",
2481
+ "steps": [
2482
+ {"type": "thought", "content": "Analyzing the problem"},
2483
+ {"type": "conclusion", "content": "Found the solution"},
2484
+ ],
2485
+ "task_context": {"goal": "Fix bug", "domain": "python"},
2486
+ },
2487
+ artifact_ids=["code_fix_456"]
2488
+ )
2489
+ ```
2490
+ """
2491
+ body = {
2492
+ "trace": trace,
2493
+ "artifact_ids": artifact_ids,
2494
+ }
2495
+ return self._request("POST", "/memory/reasoning-traces/store", json_body=body)
2496
+
2497
+ def query_reasoning(
2498
+ self, query: str, artifact_id: Optional[str] = None, limit: int = 10
2499
+ ) -> Dict[str, Any]:
2500
+ """
2501
+ Query reasoning traces.
2502
+
2503
+ Use cases:
2504
+ - "Why did I choose Python?" → finds reasoning traces about Python decisions
2505
+ - artifact_id → finds reasoning that led to this artifact
2506
+
2507
+ Args:
2508
+ query: Query like "why did I choose X?"
2509
+ artifact_id: Find reasoning that led to this artifact
2510
+ limit: Maximum traces to return
2511
+
2512
+ Returns:
2513
+ Query result with traces list and count
2514
+
2515
+ Example:
2516
+ ```python
2517
+ # Find reasoning about a decision
2518
+ result = client.query_reasoning("why did I use async/await?")
2519
+ for trace in result['traces']:
2520
+ print(f"Trace {trace['trace_id']}: {trace['content'][:100]}...")
2521
+
2522
+ # Find reasoning that led to an artifact
2523
+ result = client.query_reasoning("", artifact_id="code_123")
2524
+ ```
2525
+ """
2526
+ body = {
2527
+ "query": query,
2528
+ "artifact_id": artifact_id,
2529
+ "limit": limit,
2530
+ }
2531
+ return self._request("POST", "/memory/reasoning-traces/query", json_body=body)
2532
+
2533
+ def get_reasoning_trace(self, trace_id: str) -> Dict[str, Any]:
2534
+ """
2535
+ Get a specific reasoning trace by ID.
2536
+
2537
+ Args:
2538
+ trace_id: Reasoning trace ID
2539
+
2540
+ Returns:
2541
+ Full reasoning trace with steps, task_context, artifact_ids
2542
+ """
2543
+ return self._request("GET", f"/memory/reasoning-traces/{trace_id}")
2544
+
2545
+ # =========================================================================
2546
+ # Synthesis Evolution (Opinions & Observations)
2547
+ # =========================================================================
2548
+
2549
+ def synthesize_opinions(self) -> Dict[str, Any]:
2550
+ """
2551
+ Run opinion synthesis: detect patterns in episodic memories and form opinions.
2552
+
2553
+ Creates 'opinion' type memories with confidence scores based on recurring patterns.
2554
+
2555
+ Returns:
2556
+ Synthesis result with status, message, timestamp
2557
+
2558
+ Example:
2559
+ ```python
2560
+ result = client.synthesize_opinions()
2561
+ print(f"Status: {result['status']}")
2562
+ ```
2563
+ """
2564
+ return self._request("POST", "/memory/evolution/synthesize/opinions")
2565
+
2566
+ def synthesize_observations(self) -> Dict[str, Any]:
2567
+ """
2568
+ Run observation synthesis: create entity summaries from scattered facts.
2569
+
2570
+ Creates 'observation' type memories that summarize what we know about entities.
2571
+
2572
+ Returns:
2573
+ Synthesis result with status, message, timestamp
2574
+
2575
+ Example:
2576
+ ```python
2577
+ result = client.synthesize_observations()
2578
+ print(f"Status: {result['status']}")
2579
+ ```
2580
+ """
2581
+ return self._request("POST", "/memory/evolution/synthesize/observations")
2582
+
2583
+ def reinforce_opinions(self) -> Dict[str, Any]:
2584
+ """
2585
+ Run opinion reinforcement: update confidence scores based on new evidence.
2586
+
2587
+ Reinforces or contradicts existing opinions based on recent episodic memories.
2588
+ Archives opinions that fall below confidence threshold.
2589
+
2590
+ Returns:
2591
+ Reinforcement result with status, message, timestamp
2592
+
2593
+ Example:
2594
+ ```python
2595
+ result = client.reinforce_opinions()
2596
+ print(f"Status: {result['status']}")
2597
+ ```
2598
+ """
2599
+ return self._request("POST", "/memory/evolution/reinforce/opinions")
2600
+
2601
+ # =========================================================================
2602
+ # Decision Memory
2603
+ # =========================================================================
2604
+
2605
+ def create_decision(
2606
+ self,
2607
+ content: str,
2608
+ decision_type: str = "inference",
2609
+ confidence: float = 0.8,
2610
+ evidence_ids: Optional[List[str]] = None,
2611
+ domain: Optional[str] = None,
2612
+ tags: Optional[List[str]] = None,
2613
+ source_trace_id: Optional[str] = None,
2614
+ source_session_id: Optional[str] = None,
2615
+ ) -> Dict[str, Any]:
2616
+ """Create a new decision with provenance tracking.
2617
+
2618
+ Args:
2619
+ content: The decision statement.
2620
+ decision_type: One of inference, preference, classification, choice, belief, policy.
2621
+ confidence: Initial confidence score (0.0-1.0).
2622
+ evidence_ids: Memory IDs supporting this decision.
2623
+ domain: Domain tag for filtered retrieval.
2624
+ tags: Additional tags.
2625
+ source_trace_id: ReasoningTrace ID that produced this decision.
2626
+ source_session_id: Conversation session ID.
2627
+
2628
+ Returns:
2629
+ Created decision dict with decision_id, content, confidence, status.
2630
+ """
2631
+ body: Dict[str, Any] = {
2632
+ "content": content,
2633
+ "decision_type": decision_type,
2634
+ "confidence": confidence,
2635
+ }
2636
+ if evidence_ids:
2637
+ body["evidence_ids"] = evidence_ids
2638
+ if domain:
2639
+ body["domain"] = domain
2640
+ if tags:
2641
+ body["tags"] = tags
2642
+ if source_trace_id:
2643
+ body["source_trace_id"] = source_trace_id
2644
+ if source_session_id:
2645
+ body["source_session_id"] = source_session_id
2646
+ return self._request("POST", "/memory/decisions/create", json_body=body)
2647
+
2648
+ def get_decision(self, decision_id: str) -> Dict[str, Any]:
2649
+ """Retrieve a decision by ID.
2650
+
2651
+ Args:
2652
+ decision_id: The decision ID to retrieve.
2653
+
2654
+ Returns:
2655
+ Decision dict with all fields.
2656
+ """
2657
+ return self._request("GET", f"/memory/decisions/{decision_id}")
2658
+
2659
+ def list_decisions(
2660
+ self,
2661
+ domain: Optional[str] = None,
2662
+ decision_type: Optional[str] = None,
2663
+ min_confidence: float = 0.0,
2664
+ limit: int = 50,
2665
+ ) -> List[Dict[str, Any]]:
2666
+ """List active decisions with optional filters.
2667
+
2668
+ Args:
2669
+ domain: Filter by domain.
2670
+ decision_type: Filter by type (inference, preference, etc.).
2671
+ min_confidence: Minimum confidence threshold.
2672
+ limit: Maximum results.
2673
+
2674
+ Returns:
2675
+ List of decision dicts.
2676
+ """
2677
+ params: Dict[str, Any] = {"min_confidence": min_confidence, "limit": limit}
2678
+ if domain:
2679
+ params["domain"] = domain
2680
+ if decision_type:
2681
+ params["decision_type"] = decision_type
2682
+ result = self._request("GET", "/memory/decisions", params=params)
2683
+ return result.get("decisions", [])
2684
+
2685
+ def supersede_decision(
2686
+ self,
2687
+ decision_id: str,
2688
+ new_content: str,
2689
+ reason: str,
2690
+ new_decision_type: str = "inference",
2691
+ new_confidence: float = 0.8,
2692
+ ) -> Dict[str, Any]:
2693
+ """Replace a decision with a new one.
2694
+
2695
+ Args:
2696
+ decision_id: ID of the decision to supersede.
2697
+ new_content: Content of the replacement decision.
2698
+ reason: Why the old decision is being superseded.
2699
+ new_decision_type: Type of the new decision.
2700
+ new_confidence: Confidence of the new decision.
2701
+
2702
+ Returns:
2703
+ Dict with old_decision_id, new_decision_id, status.
2704
+ """
2705
+ body = {
2706
+ "new_content": new_content,
2707
+ "reason": reason,
2708
+ "new_decision_type": new_decision_type,
2709
+ "new_confidence": new_confidence,
2710
+ }
2711
+ return self._request(
2712
+ "POST", f"/memory/decisions/{decision_id}/supersede", json_body=body
2713
+ )
2714
+
2715
+ def retract_decision(self, decision_id: str, reason: str) -> Dict[str, Any]:
2716
+ """Retract a decision (mark as no longer valid).
2717
+
2718
+ Args:
2719
+ decision_id: ID of the decision to retract.
2720
+ reason: Why the decision is being retracted.
2721
+
2722
+ Returns:
2723
+ Dict with decision_id and status.
2724
+ """
2725
+ return self._request(
2726
+ "POST",
2727
+ f"/memory/decisions/{decision_id}/retract",
2728
+ json_body={"reason": reason},
2729
+ )
2730
+
2731
+ def reinforce_decision(self, decision_id: str, evidence_id: str) -> Dict[str, Any]:
2732
+ """Record supporting evidence for a decision.
2733
+
2734
+ Args:
2735
+ decision_id: ID of the decision to reinforce.
2736
+ evidence_id: Memory ID of the supporting evidence.
2737
+
2738
+ Returns:
2739
+ Dict with decision_id, confidence, reinforcement_count.
2740
+ """
2741
+ return self._request(
2742
+ "POST",
2743
+ f"/memory/decisions/{decision_id}/reinforce",
2744
+ json_body={"evidence_id": evidence_id},
2745
+ )
2746
+
2747
+ def get_provenance_chain(self, decision_id: str) -> Dict[str, Any]:
2748
+ """Get full provenance chain for a decision.
2749
+
2750
+ Args:
2751
+ decision_id: The decision ID.
2752
+
2753
+ Returns:
2754
+ Dict with decision, reasoning_trace, evidence, superseded.
2755
+ """
2756
+ return self._request("GET", f"/memory/decisions/{decision_id}/provenance")
2757
+
2758
+ def get_causal_chain(
2759
+ self,
2760
+ decision_id: str,
2761
+ direction: str = "both",
2762
+ max_depth: int = 3,
2763
+ ) -> Dict[str, Any]:
2764
+ """Trace causal chain from a decision.
2765
+
2766
+ Args:
2767
+ decision_id: The decision ID.
2768
+ direction: 'causes', 'effects', or 'both'.
2769
+ max_depth: Maximum traversal depth (1-10).
2770
+
2771
+ Returns:
2772
+ Dict with decision, causes, effects.
2773
+ """
2774
+ params = {"direction": direction, "max_depth": max_depth}
2775
+ return self._request(
2776
+ "GET", f"/memory/decisions/{decision_id}/causal-chain", params=params
2777
+ )
2778
+
2779
+ # =========================================================================
2780
+ # Procedure Evolution (CFS-3b)
2781
+ # =========================================================================
2782
+
2783
+ def get_procedure_evolution(
2784
+ self,
2785
+ procedure_id: str,
2786
+ limit: int = 20,
2787
+ offset: int = 0,
2788
+ ) -> Dict[str, Any]:
2789
+ """Get evolution history for a procedure.
2790
+
2791
+ Returns a list of evolution events showing how the procedure was
2792
+ discovered and refined over time.
2793
+
2794
+ Args:
2795
+ procedure_id: The procedure ID to get history for
2796
+ limit: Maximum number of events to return (default 20)
2797
+ offset: Number of events to skip (default 0)
2798
+
2799
+ Returns:
2800
+ Dict with procedure_id, current_version, total_events, and events list
2801
+
2802
+ Example:
2803
+ ```python
2804
+ history = client.get_procedure_evolution("proc_123")
2805
+ print(f"Current version: {history['current_version']}")
2806
+ for event in history['events']:
2807
+ print(f" v{event['version']}: {event['event_type']} - {event['summary']}")
2808
+ ```
2809
+ """
2810
+ params = {"limit": limit, "offset": offset}
2811
+ return self._request(
2812
+ "GET", f"/memory/procedures/{procedure_id}/evolution", params=params
2813
+ )
2814
+
2815
+ def get_procedure_evolution_event(
2816
+ self,
2817
+ procedure_id: str,
2818
+ event_id: str,
2819
+ ) -> Dict[str, Any]:
2820
+ """Get detailed information about a specific evolution event.
2821
+
2822
+ Returns the full content snapshot and diff for a single evolution event.
2823
+
2824
+ Args:
2825
+ procedure_id: The procedure ID
2826
+ event_id: The event ID to retrieve
2827
+
2828
+ Returns:
2829
+ Full event detail including content_snapshot and changes_from_previous
2830
+
2831
+ Example:
2832
+ ```python
2833
+ event = client.get_procedure_evolution_event("proc_123", "evt_456")
2834
+ print(f"Content at v{event['version']}:")
2835
+ print(event['content_snapshot']['content'])
2836
+ if event['changes_from_previous']['has_changes']:
2837
+ print(f"Changes: {event['changes_from_previous']['summary']}")
2838
+ ```
2839
+ """
2840
+ return self._request(
2841
+ "GET", f"/memory/procedures/{procedure_id}/evolution/{event_id}"
2842
+ )
2843
+
2844
+ def get_procedure_confidence_trajectory(
2845
+ self,
2846
+ procedure_id: str,
2847
+ ) -> Dict[str, Any]:
2848
+ """Get confidence trajectory data for charting.
2849
+
2850
+ Returns time-series data showing how confidence has changed over the
2851
+ procedure's lifecycle, suitable for rendering in a line chart.
2852
+
2853
+ Args:
2854
+ procedure_id: The procedure ID
2855
+
2856
+ Returns:
2857
+ Dict with procedure_id and data_points list containing timestamp,
2858
+ confidence, matches, and success_rate for each point
2859
+
2860
+ Example:
2861
+ ```python
2862
+ trajectory = client.get_procedure_confidence_trajectory("proc_123")
2863
+ for point in trajectory['data_points']:
2864
+ print(f"{point['timestamp']}: confidence={point['confidence']:.2f}")
2865
+ ```
2866
+ """
2867
+ return self._request(
2868
+ "GET", f"/memory/procedures/{procedure_id}/confidence-trajectory"
2869
+ )
2870
+
2871
+ # ============================================================================
2872
+ # Procedure Candidates (CFS-3b Recommendation Engine)
2873
+ # ============================================================================
2874
+
2875
+ def list_procedure_candidates(
2876
+ self,
2877
+ min_score: float = 0.6,
2878
+ min_cluster_size: int = 3,
2879
+ days_back: int = 30,
2880
+ limit: int = 20,
2881
+ ) -> Dict[str, Any]:
2882
+ """
2883
+ List procedure promotion candidates from working memory patterns.
2884
+
2885
+ Analyzes working memory items to find repeated patterns that could be
2886
+ promoted to stored procedures for reuse.
2887
+
2888
+ Args:
2889
+ min_score: Minimum recommendation score (0.0-1.0, default: 0.6)
2890
+ min_cluster_size: Minimum items in cluster (default: 3)
2891
+ days_back: Look back period in days (default: 30)
2892
+ limit: Maximum candidates to return (default: 20)
2893
+
2894
+ Returns:
2895
+ Dict with workspace_id, candidate_count, total_working_items, and candidates list.
2896
+ Each candidate contains cluster_id, suggested_name, suggested_description,
2897
+ representative_content, item_count, scores, common_skills, common_tools,
2898
+ sample_item_ids, and date_range.
2899
+
2900
+ Example:
2901
+ ```python
2902
+ result = client.list_procedure_candidates(min_score=0.7)
2903
+ for candidate in result['candidates']:
2904
+ print(f"{candidate['suggested_name']}: {candidate['scores']['recommendation_score']:.2f}")
2905
+ ```
2906
+ """
2907
+ params = {
2908
+ "min_score": min_score,
2909
+ "min_cluster_size": min_cluster_size,
2910
+ "days_back": days_back,
2911
+ "limit": limit,
2912
+ }
2913
+ return self._request("GET", "/memory/procedures/candidates", params=params)
2914
+
2915
+ def promote_procedure_candidate(
2916
+ self,
2917
+ cluster_id: str,
2918
+ name: Optional[str] = None,
2919
+ description: Optional[str] = None,
2920
+ procedure_type: str = "extraction",
2921
+ preferred_profile: str = "quick_extract",
2922
+ remove_working_items: bool = False,
2923
+ ) -> Dict[str, Any]:
2924
+ """
2925
+ Promote a candidate cluster to a stored procedure.
2926
+
2927
+ Creates a new procedural memory item from the candidate cluster's
2928
+ representative content and metadata.
2929
+
2930
+ Args:
2931
+ cluster_id: The cluster ID from list_procedure_candidates
2932
+ name: Optional name for the procedure (uses suggested_name if omitted)
2933
+ description: Optional description for the procedure
2934
+ procedure_type: Type of procedure (default: "extraction")
2935
+ preferred_profile: Preferred pipeline profile (default: "quick_extract")
2936
+ remove_working_items: Remove working items after promotion (default: False)
2937
+
2938
+ Returns:
2939
+ Dict with status, procedure_id, name, items_promoted, and items_removed
2940
+
2941
+ Example:
2942
+ ```python
2943
+ result = client.promote_procedure_candidate(
2944
+ cluster_id="abc-123",
2945
+ name="API Error Handler",
2946
+ description="Handles 4xx errors from external APIs"
2947
+ )
2948
+ print(f"Created procedure: {result['procedure_id']}")
2949
+ ```
2950
+ """
2951
+ body = {
2952
+ "name": name,
2953
+ "description": description,
2954
+ "procedure_type": procedure_type,
2955
+ "preferred_profile": preferred_profile,
2956
+ "remove_working_items": remove_working_items,
2957
+ }
2958
+ return self._request(
2959
+ "POST",
2960
+ f"/memory/procedures/candidates/{cluster_id}/promote",
2961
+ json_body=body,
2962
+ )
2963
+
2964
+ def dismiss_procedure_candidate(self, cluster_id: str) -> Dict[str, Any]:
2965
+ """
2966
+ Dismiss a candidate cluster from future recommendations.
2967
+
2968
+ The candidate will be excluded from future recommendation lists
2969
+ for this workspace.
2970
+
2971
+ Args:
2972
+ cluster_id: The cluster ID to dismiss
2973
+
2974
+ Returns:
2975
+ Dict with status, cluster_id, and message
2976
+
2977
+ Example:
2978
+ ```python
2979
+ result = client.dismiss_procedure_candidate("abc-123")
2980
+ print(result['message']) # "Candidate dismissed from future recommendations"
2981
+ ```
2982
+ """
2983
+ return self._request(
2984
+ "DELETE", f"/memory/procedures/candidates/{cluster_id}/dismiss"
2985
+ )
2986
+
2987
+ # ============================================================================
2988
+ # Procedure Schema Drift Detection (CFS-4)
2989
+ # ============================================================================
2990
+
2991
+ def list_drift_events(
2992
+ self,
2993
+ procedure_id: Optional[str] = None,
2994
+ resolved: Optional[bool] = None,
2995
+ breaking_only: Optional[bool] = None,
2996
+ start_date: Optional[str] = None,
2997
+ end_date: Optional[str] = None,
2998
+ limit: int = 100,
2999
+ ) -> Dict[str, Any]:
3000
+ """
3001
+ List schema drift events for the current workspace.
3002
+
3003
+ Args:
3004
+ procedure_id: Filter by procedure ID.
3005
+ resolved: Filter by resolution status.
3006
+ breaking_only: If True, only return events with breaking changes.
3007
+ start_date: ISO 8601 date string for range start.
3008
+ end_date: ISO 8601 date string for range end.
3009
+ limit: Maximum number of events to return (1-1000, default 100).
3010
+
3011
+ Returns:
3012
+ Dict with workspace_id, record_count, and records list of DriftEventSummary dicts.
3013
+
3014
+ Example:
3015
+ ```python
3016
+ events = client.list_drift_events(resolved=False, breaking_only=True)
3017
+ for event in events["records"]:
3018
+ print(f"{event['procedure_id']}: {event['diff_summary']}")
3019
+ ```
3020
+ """
3021
+ params: Dict[str, Any] = {"limit": limit}
3022
+ if procedure_id is not None:
3023
+ params["procedure_id"] = procedure_id
3024
+ if resolved is not None:
3025
+ params["resolved"] = resolved
3026
+ if breaking_only is not None:
3027
+ params["breaking_only"] = breaking_only
3028
+ if start_date is not None:
3029
+ params["start_date"] = start_date
3030
+ if end_date is not None:
3031
+ params["end_date"] = end_date
3032
+ return self._request("GET", "/memory/procedures/drift", params=params)
3033
+
3034
+ def get_drift_event(self, event_id: str) -> Dict[str, Any]:
3035
+ """
3036
+ Get a single drift event with full change details.
3037
+
3038
+ Args:
3039
+ event_id: The drift event ID.
3040
+
3041
+ Returns:
3042
+ DriftEventDetail dict with changes list, resolution info, and full metadata.
3043
+
3044
+ Raises:
3045
+ SmartMemoryClientError: If event not found (404).
3046
+
3047
+ Example:
3048
+ ```python
3049
+ event = client.get_drift_event("evt-abc-123")
3050
+ for change in event["changes"]:
3051
+ print(f" {change['path']}: {change['change_type']} (breaking={change['breaking']})")
3052
+ ```
3053
+ """
3054
+ return self._request("GET", f"/memory/procedures/drift/{event_id}")
3055
+
3056
+ def resolve_drift_event(
3057
+ self, event_id: str, note: Optional[str] = None
3058
+ ) -> Dict[str, Any]:
3059
+ """
3060
+ Mark a drift event as resolved.
3061
+
3062
+ Args:
3063
+ event_id: The drift event ID to resolve.
3064
+ note: Optional resolution note (max 500 chars).
3065
+
3066
+ Returns:
3067
+ Dict with status, event_id, and resolved=True.
3068
+
3069
+ Raises:
3070
+ SmartMemoryClientError: If event not found (404).
3071
+
3072
+ Example:
3073
+ ```python
3074
+ result = client.resolve_drift_event("evt-abc-123", note="Schema updated intentionally")
3075
+ ```
3076
+ """
3077
+ body: Dict[str, Any] = {}
3078
+ if note is not None:
3079
+ body["note"] = note
3080
+ return self._request(
3081
+ "POST", f"/memory/procedures/drift/{event_id}/resolve", json_body=body
3082
+ )
3083
+
3084
+ def sweep_drift(self) -> Dict[str, Any]:
3085
+ """
3086
+ Trigger a drift sweep across all procedures in the workspace.
3087
+
3088
+ Checks all procedures for schema drift against their stored snapshots.
3089
+
3090
+ Returns:
3091
+ SweepResult dict with workspace_id, procedures_checked, drift_detected,
3092
+ and events_created counts.
3093
+
3094
+ Example:
3095
+ ```python
3096
+ result = client.sweep_drift()
3097
+ print(f"Checked {result['procedures_checked']} procedures, "
3098
+ f"found {result['drift_detected']} with drift")
3099
+ ```
3100
+ """
3101
+ return self._request("POST", "/memory/procedures/drift/sweep", json_body={})
3102
+
3103
+ def list_schema_snapshots(self, procedure_id: str) -> Dict[str, Any]:
3104
+ """
3105
+ List schema snapshots for a procedure.
3106
+
3107
+ Args:
3108
+ procedure_id: The procedure ID to get snapshots for.
3109
+
3110
+ Returns:
3111
+ Dict with workspace_id, procedure_id, record_count, and snapshots list
3112
+ of SchemaSnapshotSummary dicts.
3113
+
3114
+ Example:
3115
+ ```python
3116
+ result = client.list_schema_snapshots("proc-abc-123")
3117
+ for snap in result["snapshots"]:
3118
+ print(f"{snap['captured_at']}: {snap['tool_count']} tools, hash={snap['schema_hash']}")
3119
+ ```
3120
+ """
3121
+ return self._request("GET", f"/memory/procedures/schemas/{procedure_id}")
3122
+
3123
+ # ------------------------------------------------------------------
3124
+ # Memory Snapshots (CORE-SUMMARY-1, E1)
3125
+ # ------------------------------------------------------------------
3126
+
3127
+ def summary_generate(
3128
+ self,
3129
+ window_start: Optional[str] = None,
3130
+ include_markdown: bool = True,
3131
+ ) -> Dict[str, Any]:
3132
+ """Fire a manual snapshot for the current workspace.
3133
+
3134
+ Returns the full snapshot payload. Raises ``SmartMemoryClientError``
3135
+ on conflict (HTTP 409 ``{reason: lock_held}``).
3136
+ """
3137
+ body: Dict[str, Any] = {"include_markdown": include_markdown}
3138
+ if window_start is not None:
3139
+ body["window_start"] = window_start
3140
+ return self._request("POST", "/memory/summary/generate", json_body=body)
3141
+
3142
+ def summary_latest(self) -> Optional[Dict[str, Any]]:
3143
+ """Return the most recent snapshot for the current workspace."""
3144
+ try:
3145
+ return self._request("GET", "/memory/summary/latest")
3146
+ except SmartMemoryClientError as e:
3147
+ if "404" in str(e):
3148
+ return None
3149
+ raise
3150
+
3151
+ def summary_get(self, snapshot_id: str) -> Optional[Dict[str, Any]]:
3152
+ """Return a specific snapshot by id, or ``None`` if not found."""
3153
+ try:
3154
+ return self._request("GET", f"/memory/summary/{snapshot_id}")
3155
+ except SmartMemoryClientError as e:
3156
+ if "404" in str(e):
3157
+ return None
3158
+ raise
3159
+
3160
+ def summary_list(
3161
+ self,
3162
+ is_heartbeat: Optional[bool] = None,
3163
+ limit: int = 20,
3164
+ before: Optional[str] = None,
3165
+ ) -> List[Dict[str, Any]]:
3166
+ """List snapshots for the current workspace, newest first."""
3167
+ params: Dict[str, Any] = {"limit": limit}
3168
+ if is_heartbeat is not None:
3169
+ params["is_heartbeat"] = str(is_heartbeat).lower()
3170
+ if before is not None:
3171
+ params["before"] = before
3172
+ return self._request("GET", "/memory/summary/list", params=params) or []
3173
+
3174
+ def summary_delta(
3175
+ self,
3176
+ from_snapshot_id: str,
3177
+ to_snapshot_id: str,
3178
+ ) -> Optional[Dict[str, Any]]:
3179
+ """Return the SnapshotDelta between two snapshots."""
3180
+ try:
3181
+ return self._request(
3182
+ "GET",
3183
+ "/memory/summary/delta",
3184
+ params={"from": from_snapshot_id, "to": to_snapshot_id},
3185
+ )
3186
+ except SmartMemoryClientError as e:
3187
+ if "404" in str(e):
3188
+ return None
3189
+ raise
3190
+
3191
+ def summary_delete(self, snapshot_id: str) -> None:
3192
+ """Admin-only. Delete a snapshot. Raises on 403/404/500."""
3193
+ self._request("DELETE", f"/memory/summary/{snapshot_id}")
3194
+
3195
+ def _request(
3196
+ self,
3197
+ method: str,
3198
+ endpoint: str,
3199
+ params: Optional[Dict[str, Any]] = None,
3200
+ json_body: Optional[Dict[str, Any]] = None,
3201
+ data: Optional[Dict[str, Any]] = None,
3202
+ headers: Optional[Dict[str, str]] = None,
3203
+ ) -> Any:
3204
+ """Internal helper for making HTTP requests."""
3205
+ import httpx
3206
+
3207
+ url = f"{self.base_url}{endpoint}"
3208
+ req_headers = {"X-Workspace-Id": self.team_id}
3209
+ if headers:
3210
+ req_headers.update(headers)
3211
+
3212
+ if self.api_key:
3213
+ req_headers["Authorization"] = f"Bearer {self.api_key}"
3214
+
3215
+ try:
3216
+ response = httpx.request(
3217
+ method,
3218
+ url,
3219
+ params=params,
3220
+ json=json_body,
3221
+ data=data,
3222
+ headers=req_headers,
3223
+ timeout=self.timeout,
3224
+ )
3225
+ response.raise_for_status()
3226
+ if response.status_code == 204:
3227
+ return None
3228
+ return response.json()
3229
+ except httpx.HTTPStatusError as e:
3230
+ error_detail = e.response.text if hasattr(e, "response") else str(e)
3231
+ raise SmartMemoryClientError(
3232
+ f"Request failed: {e} - Detail: {error_detail}"
3233
+ )
3234
+ except Exception as e:
3235
+ raise SmartMemoryClientError(f"Request failed: {str(e)}")
3236
+
3237
+ def feedback(
3238
+ self,
3239
+ item_ids: List[str],
3240
+ outcome: str,
3241
+ query: Optional[str] = None,
3242
+ ) -> Dict[str, Any]:
3243
+ """Submit explicit feedback on recalled memory items.
3244
+
3245
+ Adjusts ``retention_score`` immediately and, for ``helpful`` feedback with
3246
+ multiple items, strengthens ``CO_RETRIEVED`` edges between them — feeding
3247
+ directly into the Hebbian co-retrieval evolver.
3248
+
3249
+ Args:
3250
+ item_ids: IDs of items returned by a prior ``search()`` call.
3251
+ outcome: ``"helpful"``, ``"misleading"``, or ``"neutral"``.
3252
+ query: Optional original search query (for context/logging).
3253
+
3254
+ Returns:
3255
+ Dict with keys: ``updated`` (int), ``edges_strengthened`` (int), ``outcome`` (str).
3256
+
3257
+ Example:
3258
+ ```python
3259
+ results = client.search("what did we decide about auth?")
3260
+ client.feedback([r.item_id for r in results], outcome="helpful")
3261
+ ```
3262
+ """
3263
+ body: Dict[str, Any] = {"item_ids": item_ids, "outcome": outcome}
3264
+ if query is not None:
3265
+ body["query"] = query
3266
+ return self._request("POST", "/memory/feedback", json_body=body)
3267
+
3268
+ def __repr__(self) -> str:
3269
+ auth_status = "authenticated" if self.api_key else "unauthenticated"
3270
+ return f"SmartMemoryClient(base_url='{self.base_url}', {auth_status})"
3271
+
3272
+ def __enter__(self):
3273
+ """Context manager entry"""
3274
+ return self
3275
+
3276
+ def __exit__(self, exc_type, exc_val, exc_tb):
3277
+ """Context manager exit"""
3278
+ # Clean up underlying client if needed
3279
+ pass