runtime-memory 3.0.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.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1577 @@
1
+ """Python SDK for Runtime Memory.
2
+
3
+ This module provides a unified client interface for accessing Runtime Memory
4
+ functionality in both local (direct engine access) and remote (REST API) modes.
5
+
6
+ Example usage:
7
+ ```python
8
+ from runtime_memory.sdk import MemoryClient
9
+
10
+ # Local mode (direct engine access)
11
+ async with MemoryClient(mode="local") as client:
12
+ memory = await client.add("Use async/await for I/O", category="pattern")
13
+ results = await client.search("async patterns")
14
+
15
+ # Remote mode (REST API)
16
+ async with MemoryClient(mode="remote", base_url="http://localhost:8080") as client:
17
+ memory = await client.add("Use async/await for I/O", category="pattern")
18
+ results = await client.search("async patterns")
19
+
20
+ # Synchronous wrapper
21
+ from runtime_memory.sdk import SyncMemoryClient
22
+ with SyncMemoryClient(mode="local") as client:
23
+ memory = client.add("Use async/await for I/O", category="pattern")
24
+ ```
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime
33
+ from enum import Enum
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, Any, Literal
36
+
37
+ import httpx
38
+
39
+ from runtime_memory.core.paths import default_db_path
40
+ from runtime_memory.core.models import (
41
+ ContextResponse,
42
+ Memory,
43
+ MemoryCategory,
44
+ MemoryScope,
45
+ MemorySource,
46
+ Outcome,
47
+ SearchResult,
48
+ )
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Sequence
52
+
53
+ from runtime_memory.core.engine import EngineStats, MemoryEngine
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # =============================================================================
59
+ # Configuration
60
+ # =============================================================================
61
+
62
+
63
+ class ClientMode(str, Enum):
64
+ """SDK client operating mode."""
65
+
66
+ LOCAL = "local"
67
+ """Direct access to MemoryEngine (no network)."""
68
+
69
+ REMOTE = "remote"
70
+ """Access via REST API."""
71
+
72
+
73
+ @dataclass
74
+ class ClientConfig:
75
+ """Configuration for the Memory Client."""
76
+
77
+ # Mode selection
78
+ mode: ClientMode = ClientMode.LOCAL
79
+ """Operating mode: local or remote."""
80
+
81
+ # Local mode settings
82
+ db_path: str | Path = field(default_factory=lambda: str(default_db_path()))
83
+ """Path to SQLite database file (local mode only)."""
84
+
85
+ embedding_provider: str = "local"
86
+ """Embedding provider type (local mode only)."""
87
+
88
+ # Remote mode settings
89
+ base_url: str = "http://127.0.0.1:8080"
90
+ """Base URL for REST API (remote mode only)."""
91
+
92
+ api_key: str | None = None
93
+ """Optional API key for authentication."""
94
+
95
+ # HTTP client settings (remote mode)
96
+ timeout: float = 30.0
97
+ """Request timeout in seconds."""
98
+
99
+ max_retries: int = 3
100
+ """Maximum retry attempts for failed requests."""
101
+
102
+ retry_delay: float = 1.0
103
+ """Initial delay between retries (exponential backoff)."""
104
+
105
+ # Connection pooling
106
+ max_connections: int = 10
107
+ """Maximum number of concurrent connections."""
108
+
109
+ def __post_init__(self) -> None:
110
+ """Normalize configuration values."""
111
+ if isinstance(self.mode, str):
112
+ self.mode = ClientMode(self.mode)
113
+ if isinstance(self.db_path, str):
114
+ self.db_path = Path(self.db_path).expanduser()
115
+ # Ensure base_url doesn't have trailing slash
116
+ self.base_url = self.base_url.rstrip("/")
117
+
118
+
119
+ # =============================================================================
120
+ # Exceptions
121
+ # =============================================================================
122
+
123
+
124
+ class SDKError(Exception):
125
+ """Base exception for SDK errors."""
126
+
127
+ pass
128
+
129
+
130
+ class ConnectionError(SDKError):
131
+ """Error connecting to remote server."""
132
+
133
+ pass
134
+
135
+
136
+ class AuthenticationError(SDKError):
137
+ """Authentication failed."""
138
+
139
+ pass
140
+
141
+
142
+ class NotFoundError(SDKError):
143
+ """Resource not found."""
144
+
145
+ pass
146
+
147
+
148
+ class ValidationError(SDKError):
149
+ """Request validation failed."""
150
+
151
+ pass
152
+
153
+
154
+ class RateLimitError(SDKError):
155
+ """Rate limit exceeded."""
156
+
157
+ pass
158
+
159
+
160
+ # =============================================================================
161
+ # Response Types (for remote mode)
162
+ # =============================================================================
163
+
164
+
165
+ @dataclass
166
+ class StatsDict:
167
+ """Statistics response from the API."""
168
+
169
+ total_memories: int
170
+ active_memories: int
171
+ archived_memories: int
172
+ by_category: dict[str, int]
173
+ by_scope: dict[str, int]
174
+ by_source: dict[str, int]
175
+ avg_outcome_score: float
176
+ total_uses: int
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: dict[str, Any]) -> StatsDict:
180
+ """Create from API response."""
181
+ return cls(
182
+ total_memories=data.get("total_memories", 0),
183
+ active_memories=data.get("active_memories", 0),
184
+ archived_memories=data.get("archived_memories", 0),
185
+ by_category=data.get("by_category", {}),
186
+ by_scope=data.get("by_scope", {}),
187
+ by_source=data.get("by_source", {}),
188
+ avg_outcome_score=data.get("avg_outcome_score", 0.0),
189
+ total_uses=data.get("total_uses", 0),
190
+ )
191
+
192
+
193
+ # =============================================================================
194
+ # Memory Client (Async)
195
+ # =============================================================================
196
+
197
+
198
+ class MemoryClient:
199
+ """Async client for Runtime Memory.
200
+
201
+ Provides a unified interface for memory operations in both local
202
+ (direct engine access) and remote (REST API) modes.
203
+
204
+ Example:
205
+ ```python
206
+ # Local mode
207
+ async with MemoryClient(mode="local") as client:
208
+ memory = await client.add(
209
+ content="Use async/await for I/O",
210
+ category="pattern",
211
+ )
212
+
213
+ # Remote mode
214
+ async with MemoryClient(
215
+ mode="remote",
216
+ base_url="http://localhost:8080",
217
+ api_key="secret",
218
+ ) as client:
219
+ results = await client.search("async patterns")
220
+ ```
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ config: ClientConfig | None = None,
226
+ *,
227
+ mode: str | ClientMode = ClientMode.LOCAL,
228
+ db_path: str | Path | None = None,
229
+ embedding_provider: str = "local",
230
+ base_url: str | None = None,
231
+ api_key: str | None = None,
232
+ timeout: float = 30.0,
233
+ max_retries: int = 3,
234
+ ) -> None:
235
+ """Initialize the Memory Client.
236
+
237
+ Args:
238
+ config: Full configuration object (overrides other params).
239
+ mode: Operating mode: "local" or "remote".
240
+ db_path: Database path for local mode.
241
+ embedding_provider: Embedding provider for local mode.
242
+ base_url: API base URL for remote mode.
243
+ api_key: API key for authentication.
244
+ timeout: Request timeout in seconds.
245
+ max_retries: Maximum retry attempts.
246
+ """
247
+ if config:
248
+ self.config = config
249
+ else:
250
+ self.config = ClientConfig(
251
+ mode=ClientMode(mode) if isinstance(mode, str) else mode,
252
+ db_path=db_path or str(default_db_path()),
253
+ embedding_provider=embedding_provider,
254
+ base_url=base_url or "http://127.0.0.1:8080",
255
+ api_key=api_key,
256
+ timeout=timeout,
257
+ max_retries=max_retries,
258
+ )
259
+
260
+ # Internal state
261
+ self._engine: MemoryEngine | None = None
262
+ self._http_client: httpx.AsyncClient | None = None
263
+ self._initialized = False
264
+
265
+ async def initialize(self) -> None:
266
+ """Initialize the client.
267
+
268
+ For local mode, creates and initializes the MemoryEngine.
269
+ For remote mode, creates the HTTP client with connection pooling.
270
+ """
271
+ if self._initialized:
272
+ return
273
+
274
+ if self.config.mode == ClientMode.LOCAL:
275
+ await self._init_local()
276
+ else:
277
+ await self._init_remote()
278
+
279
+ self._initialized = True
280
+ logger.info(f"MemoryClient initialized in {self.config.mode.value} mode")
281
+
282
+ async def close(self) -> None:
283
+ """Close the client and release resources."""
284
+ if self.config.mode == ClientMode.LOCAL and self._engine:
285
+ await self._engine.close()
286
+ self._engine = None
287
+ elif self.config.mode == ClientMode.REMOTE and self._http_client:
288
+ await self._http_client.aclose()
289
+ self._http_client = None
290
+
291
+ self._initialized = False
292
+ logger.info("MemoryClient closed")
293
+
294
+ async def __aenter__(self) -> MemoryClient:
295
+ """Async context manager entry."""
296
+ await self.initialize()
297
+ return self
298
+
299
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
300
+ """Async context manager exit."""
301
+ await self.close()
302
+
303
+ def _ensure_initialized(self) -> None:
304
+ """Ensure the client is initialized."""
305
+ if not self._initialized:
306
+ raise SDKError(
307
+ "Client not initialized. Call initialize() first or use async context manager."
308
+ )
309
+
310
+ # =========================================================================
311
+ # Local Mode Initialization
312
+ # =========================================================================
313
+
314
+ async def _init_local(self) -> None:
315
+ """Initialize local mode with MemoryEngine."""
316
+ from runtime_memory.core.engine import EngineConfig, MemoryEngine
317
+
318
+ engine_config = EngineConfig(
319
+ db_path=self.config.db_path,
320
+ embedding_provider=self.config.embedding_provider,
321
+ )
322
+ self._engine = MemoryEngine(config=engine_config)
323
+ await self._engine.initialize()
324
+
325
+ # =========================================================================
326
+ # Remote Mode Initialization and Helpers
327
+ # =========================================================================
328
+
329
+ async def _init_remote(self) -> None:
330
+ """Initialize remote mode with HTTP client."""
331
+ headers = {}
332
+ if self.config.api_key:
333
+ headers["X-API-Key"] = self.config.api_key
334
+
335
+ limits = httpx.Limits(
336
+ max_connections=self.config.max_connections,
337
+ max_keepalive_connections=self.config.max_connections,
338
+ )
339
+
340
+ self._http_client = httpx.AsyncClient(
341
+ base_url=self.config.base_url,
342
+ headers=headers,
343
+ timeout=httpx.Timeout(self.config.timeout),
344
+ limits=limits,
345
+ )
346
+
347
+ # Verify connection with health check
348
+ try:
349
+ response = await self._http_client.get("/health")
350
+ response.raise_for_status()
351
+ except httpx.ConnectError as e:
352
+ await self._http_client.aclose()
353
+ self._http_client = None
354
+ raise ConnectionError(f"Failed to connect to {self.config.base_url}: {e}") from e
355
+
356
+ async def _request(
357
+ self,
358
+ method: str,
359
+ path: str,
360
+ *,
361
+ json: dict[str, Any] | None = None,
362
+ params: dict[str, Any] | None = None,
363
+ ) -> dict[str, Any]:
364
+ """Make an HTTP request with retry logic.
365
+
366
+ Args:
367
+ method: HTTP method (GET, POST, etc.).
368
+ path: API path.
369
+ json: JSON body for POST/PATCH requests.
370
+ params: Query parameters.
371
+
372
+ Returns:
373
+ Response JSON as dictionary.
374
+
375
+ Raises:
376
+ Various SDKError subclasses for different error conditions.
377
+ """
378
+ assert self._http_client is not None
379
+
380
+ last_error: Exception | None = None
381
+ delay = self.config.retry_delay
382
+
383
+ for attempt in range(self.config.max_retries):
384
+ try:
385
+ response = await self._http_client.request(
386
+ method,
387
+ path,
388
+ json=json,
389
+ params=params,
390
+ )
391
+
392
+ # Handle different status codes
393
+ if response.status_code == 200:
394
+ return response.json()
395
+ elif response.status_code == 201:
396
+ return response.json()
397
+ elif response.status_code == 204:
398
+ return {}
399
+ elif response.status_code == 401:
400
+ raise AuthenticationError("Invalid API key")
401
+ elif response.status_code == 404:
402
+ raise NotFoundError(response.json().get("detail", "Not found"))
403
+ elif response.status_code == 422:
404
+ raise ValidationError(response.json().get("detail", "Validation error"))
405
+ elif response.status_code == 429:
406
+ raise RateLimitError("Rate limit exceeded")
407
+ else:
408
+ response.raise_for_status()
409
+
410
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
411
+ last_error = e
412
+ if attempt < self.config.max_retries - 1:
413
+ logger.warning(f"Request failed (attempt {attempt + 1}), retrying in {delay}s")
414
+ await asyncio.sleep(delay)
415
+ delay *= 2 # Exponential backoff
416
+ continue
417
+
418
+ except (AuthenticationError, NotFoundError, ValidationError, RateLimitError):
419
+ raise
420
+
421
+ except httpx.HTTPStatusError as e:
422
+ raise SDKError(f"HTTP error: {e}") from e
423
+
424
+ raise ConnectionError(f"Failed after {self.config.max_retries} attempts: {last_error}")
425
+
426
+ # =========================================================================
427
+ # CRUD Operations
428
+ # =========================================================================
429
+
430
+ async def add(
431
+ self,
432
+ content: str,
433
+ category: str | MemoryCategory,
434
+ project: str | None = None,
435
+ scope: str | MemoryScope = MemoryScope.PROJECT,
436
+ source: str | MemorySource = MemorySource.EXPLICIT,
437
+ confidence: float = 1.0,
438
+ importance: float = 0.5,
439
+ tags: list[str] | None = None,
440
+ entities: list[str] | None = None,
441
+ supersedes: str | None = None,
442
+ metadata: dict[str, Any] | None = None,
443
+ ) -> Memory:
444
+ """Add a new memory.
445
+
446
+ Args:
447
+ content: The memory content text.
448
+ category: Classification category (string or enum).
449
+ project: Project scope (None for global).
450
+ scope: Visibility scope.
451
+ source: How the memory was created.
452
+ confidence: Source reliability (0.0-1.0).
453
+ importance: Importance weight (0.0-1.0).
454
+ tags: Optional tags for categorization.
455
+ entities: Detected entities (files, functions, etc.).
456
+ supersedes: ID of memory this one replaces.
457
+ metadata: Additional metadata.
458
+
459
+ Returns:
460
+ The created Memory.
461
+ """
462
+ self._ensure_initialized()
463
+
464
+ # Normalize category to enum
465
+ if isinstance(category, str):
466
+ category = MemoryCategory(category)
467
+ if isinstance(scope, str):
468
+ scope = MemoryScope(scope)
469
+ if isinstance(source, str):
470
+ source = MemorySource(source)
471
+
472
+ if self.config.mode == ClientMode.LOCAL:
473
+ assert self._engine is not None
474
+ return await self._engine.add(
475
+ content=content,
476
+ category=category,
477
+ project=project,
478
+ scope=scope,
479
+ source=source,
480
+ confidence=confidence,
481
+ importance=importance,
482
+ tags=tags,
483
+ entities=entities,
484
+ supersedes=supersedes,
485
+ metadata=metadata,
486
+ )
487
+ else:
488
+ # Remote mode
489
+ response = await self._request(
490
+ "POST",
491
+ "/memories",
492
+ json={
493
+ "content": content,
494
+ "category": category.value,
495
+ "project": project,
496
+ "tags": tags or [],
497
+ "importance": importance,
498
+ "entities": entities or [],
499
+ },
500
+ )
501
+ return Memory.from_dict(response)
502
+
503
+ async def get(self, memory_id: str) -> Memory:
504
+ """Get a memory by ID.
505
+
506
+ Args:
507
+ memory_id: The memory ID.
508
+
509
+ Returns:
510
+ The Memory.
511
+
512
+ Raises:
513
+ NotFoundError: If memory not found.
514
+ """
515
+ self._ensure_initialized()
516
+
517
+ if self.config.mode == ClientMode.LOCAL:
518
+ assert self._engine is not None
519
+ try:
520
+ return await self._engine.get(memory_id)
521
+ except Exception as e:
522
+ if "not found" in str(e).lower():
523
+ raise NotFoundError(f"Memory not found: {memory_id}") from e
524
+ raise
525
+ else:
526
+ response = await self._request("GET", f"/memories/{memory_id}")
527
+ return Memory.from_dict(response)
528
+
529
+ async def update(
530
+ self,
531
+ memory_id: str,
532
+ content: str | None = None,
533
+ category: str | MemoryCategory | None = None,
534
+ confidence: float | None = None,
535
+ importance: float | None = None,
536
+ tags: list[str] | None = None,
537
+ entities: list[str] | None = None,
538
+ metadata: dict[str, Any] | None = None,
539
+ ) -> Memory:
540
+ """Update an existing memory.
541
+
542
+ Only provided fields are updated.
543
+
544
+ Args:
545
+ memory_id: ID of memory to update.
546
+ content: New content (optional).
547
+ category: New category (optional).
548
+ confidence: New confidence (optional).
549
+ importance: New importance (optional).
550
+ tags: New tags (optional).
551
+ entities: New entities (optional).
552
+ metadata: New metadata (optional).
553
+
554
+ Returns:
555
+ The updated Memory.
556
+
557
+ Raises:
558
+ NotFoundError: If memory not found.
559
+ """
560
+ self._ensure_initialized()
561
+
562
+ # Normalize category to enum
563
+ if isinstance(category, str):
564
+ category = MemoryCategory(category)
565
+
566
+ if self.config.mode == ClientMode.LOCAL:
567
+ assert self._engine is not None
568
+ return await self._engine.update(
569
+ memory_id=memory_id,
570
+ content=content,
571
+ category=category,
572
+ confidence=confidence,
573
+ importance=importance,
574
+ tags=tags,
575
+ entities=entities,
576
+ metadata=metadata,
577
+ )
578
+ else:
579
+ payload: dict[str, Any] = {}
580
+ if content is not None:
581
+ payload["content"] = content
582
+ if category is not None:
583
+ payload["category"] = category.value
584
+ if tags is not None:
585
+ payload["tags"] = tags
586
+ if importance is not None:
587
+ payload["importance"] = importance
588
+
589
+ response = await self._request("PATCH", f"/memories/{memory_id}", json=payload)
590
+ return Memory.from_dict(response)
591
+
592
+ async def delete(self, memory_id: str, hard_delete: bool = False) -> None:
593
+ """Delete a memory.
594
+
595
+ By default, performs a soft delete (archive).
596
+
597
+ Args:
598
+ memory_id: ID of memory to delete.
599
+ hard_delete: If True, permanently delete.
600
+
601
+ Raises:
602
+ NotFoundError: If memory not found.
603
+ """
604
+ self._ensure_initialized()
605
+
606
+ if self.config.mode == ClientMode.LOCAL:
607
+ assert self._engine is not None
608
+ await self._engine.delete(memory_id, hard_delete=hard_delete)
609
+ else:
610
+ await self._request("DELETE", f"/memories/{memory_id}")
611
+
612
+ # =========================================================================
613
+ # Search and List
614
+ # =========================================================================
615
+
616
+ async def search(
617
+ self,
618
+ query: str,
619
+ limit: int = 10,
620
+ categories: list[str | MemoryCategory] | None = None,
621
+ project: str | None = None,
622
+ min_score: float = -1.0,
623
+ include_archived: bool = False,
624
+ ) -> list[SearchResult]:
625
+ """Search for relevant memories.
626
+
627
+ Uses hybrid retrieval combining BM25 text search and vector
628
+ similarity for intelligent ranking.
629
+
630
+ Args:
631
+ query: Search query text.
632
+ limit: Maximum number of results.
633
+ categories: Filter by categories.
634
+ project: Filter by project.
635
+ min_score: Minimum outcome score threshold.
636
+ include_archived: Whether to include archived memories.
637
+
638
+ Returns:
639
+ List of SearchResults sorted by relevance.
640
+ """
641
+ self._ensure_initialized()
642
+
643
+ # Normalize categories
644
+ if categories:
645
+ categories = [
646
+ MemoryCategory(c) if isinstance(c, str) else c
647
+ for c in categories
648
+ ]
649
+
650
+ if self.config.mode == ClientMode.LOCAL:
651
+ assert self._engine is not None
652
+ # Note: Local mode only supports single category filter
653
+ category = categories[0] if categories and len(categories) == 1 else None
654
+ return await self._engine.search(
655
+ query=query,
656
+ limit=limit,
657
+ category=category,
658
+ project=project,
659
+ min_score=min_score,
660
+ include_archived=include_archived,
661
+ )
662
+ else:
663
+ payload: dict[str, Any] = {
664
+ "query": query,
665
+ "limit": limit,
666
+ "min_score": min_score,
667
+ }
668
+ if categories:
669
+ payload["categories"] = [c.value for c in categories]
670
+ if project:
671
+ payload["project"] = project
672
+
673
+ response = await self._request("POST", "/memories/search", json=payload)
674
+ results = []
675
+ for item in response.get("results", []):
676
+ memory = Memory.from_dict(item["memory"])
677
+ results.append(SearchResult(
678
+ memory=memory,
679
+ score=item.get("score", 0.0),
680
+ semantic_score=item.get("semantic_score", 0.0),
681
+ recency_score=item.get("recency_score", 0.0),
682
+ frequency_score=item.get("frequency_score", 0.0),
683
+ ))
684
+ return results
685
+
686
+ async def list(
687
+ self,
688
+ project: str | None = None,
689
+ category: str | MemoryCategory | None = None,
690
+ limit: int = 100,
691
+ include_archived: bool = False,
692
+ ) -> list[Memory]:
693
+ """List memories with filtering.
694
+
695
+ Args:
696
+ project: Filter by project.
697
+ category: Filter by category.
698
+ limit: Maximum results to return.
699
+ include_archived: Whether to include archived memories.
700
+
701
+ Returns:
702
+ List of memories matching filters.
703
+ """
704
+ self._ensure_initialized()
705
+
706
+ # Normalize category
707
+ if isinstance(category, str):
708
+ category = MemoryCategory(category)
709
+
710
+ if self.config.mode == ClientMode.LOCAL:
711
+ assert self._engine is not None
712
+ return await self._engine.list(
713
+ project=project,
714
+ category=category,
715
+ limit=limit,
716
+ include_archived=include_archived,
717
+ )
718
+ else:
719
+ params: dict[str, Any] = {
720
+ "limit": limit,
721
+ "include_archived": include_archived,
722
+ }
723
+ if project:
724
+ params["project"] = project
725
+ if category:
726
+ params["category"] = category.value
727
+
728
+ response = await self._request("GET", "/memories", params=params)
729
+ return [Memory.from_dict(m) for m in response.get("memories", [])]
730
+
731
+ # =========================================================================
732
+ # Outcome Recording
733
+ # =========================================================================
734
+
735
+ async def record_outcome(
736
+ self,
737
+ memory_ids: list[str] | str,
738
+ outcome: str | Outcome,
739
+ ) -> list[Memory]:
740
+ """Record outcome feedback for memories.
741
+
742
+ Updates outcome scores for specified memories based on feedback.
743
+
744
+ Args:
745
+ memory_ids: Memory ID(s) to update.
746
+ outcome: The outcome (WORKED, FAILED, PARTIAL).
747
+
748
+ Returns:
749
+ List of updated memories (local mode only, empty list for remote).
750
+ """
751
+ self._ensure_initialized()
752
+
753
+ # Normalize inputs
754
+ if isinstance(memory_ids, str):
755
+ memory_ids = [memory_ids]
756
+ if isinstance(outcome, str):
757
+ outcome = Outcome(outcome)
758
+
759
+ if self.config.mode == ClientMode.LOCAL:
760
+ assert self._engine is not None
761
+ return await self._engine.record_outcome(memory_ids, outcome)
762
+ else:
763
+ await self._request(
764
+ "POST",
765
+ "/memories/outcome",
766
+ json={
767
+ "memory_ids": memory_ids,
768
+ "outcome": outcome.value,
769
+ },
770
+ )
771
+ # Remote mode doesn't return updated memories
772
+ return []
773
+
774
+ # =========================================================================
775
+ # Context
776
+ # =========================================================================
777
+
778
+ async def get_context(
779
+ self,
780
+ project: str | None = None,
781
+ query: str | None = None,
782
+ limit: int = 10,
783
+ format: str = "markdown",
784
+ ) -> ContextResponse:
785
+ """Get formatted context for injection into prompts.
786
+
787
+ Args:
788
+ project: Project filter.
789
+ query: Optional query to find relevant memories.
790
+ limit: Maximum memories to include.
791
+ format: Output format (markdown, brief, detailed).
792
+
793
+ Returns:
794
+ ContextResponse with formatted context.
795
+ """
796
+ self._ensure_initialized()
797
+
798
+ if self.config.mode == ClientMode.LOCAL:
799
+ assert self._engine is not None
800
+ return await self._engine.get_context(
801
+ query=query,
802
+ project=project,
803
+ max_memories=limit,
804
+ )
805
+ else:
806
+ params: dict[str, Any] = {
807
+ "limit": limit,
808
+ "format": format,
809
+ }
810
+ if project:
811
+ params["project"] = project
812
+
813
+ response = await self._request("GET", "/context", params=params)
814
+
815
+ memories = [Memory.from_dict(m) for m in response.get("memories", [])]
816
+ return ContextResponse(
817
+ memories=memories,
818
+ project=response.get("project"),
819
+ total_count=response.get("total_count", 0),
820
+ included_count=response.get("included_count", len(memories)),
821
+ formatted=response.get("formatted", ""),
822
+ categories={},
823
+ )
824
+
825
+ # =========================================================================
826
+ # Statistics
827
+ # =========================================================================
828
+
829
+ async def stats(self, project: str | None = None) -> StatsDict | Any:
830
+ """Get memory statistics.
831
+
832
+ Args:
833
+ project: Optional project filter.
834
+
835
+ Returns:
836
+ Statistics dictionary (StatsDict for remote, EngineStats for local).
837
+ """
838
+ self._ensure_initialized()
839
+
840
+ if self.config.mode == ClientMode.LOCAL:
841
+ assert self._engine is not None
842
+ return await self._engine.stats(project=project)
843
+ else:
844
+ params: dict[str, Any] = {}
845
+ if project:
846
+ params["project"] = project
847
+
848
+ response = await self._request("GET", "/stats", params=params)
849
+ return StatsDict.from_dict(response)
850
+
851
+ # =========================================================================
852
+ # Health Check
853
+ # =========================================================================
854
+
855
+ async def health(self) -> dict[str, Any]:
856
+ """Check client health.
857
+
858
+ Returns:
859
+ Health status dictionary.
860
+ """
861
+ self._ensure_initialized()
862
+
863
+ if self.config.mode == ClientMode.LOCAL:
864
+ assert self._engine is not None
865
+ return await self._engine.health_check()
866
+ else:
867
+ response = await self._request("GET", "/health")
868
+ return response
869
+
870
+ # =========================================================================
871
+ # Beads Integration
872
+ # =========================================================================
873
+
874
+ async def beads_sync(self, task_id: str | None = None) -> dict[str, Any]:
875
+ """Sync outcomes for completed Beads tasks.
876
+
877
+ When a task completes, memories that helped solve it get their
878
+ outcome scores boosted.
879
+
880
+ Args:
881
+ task_id: Optional specific task ID to sync (syncs all if None).
882
+
883
+ Returns:
884
+ Sync result dictionary with tasks_found, outcomes_recorded, etc.
885
+ """
886
+ self._ensure_initialized()
887
+
888
+ if self.config.mode == ClientMode.LOCAL:
889
+ from runtime_memory.tasks import BeadsAdapter, BeadsTaskStatus
890
+
891
+ assert self._engine is not None
892
+ adapter = BeadsAdapter(self._engine)
893
+ await adapter.initialize()
894
+
895
+ if not adapter.is_available:
896
+ return {"success": False, "error": "Beads not available"}
897
+
898
+ if task_id:
899
+ task = adapter.get_task(task_id)
900
+ if not task:
901
+ return {"success": False, "error": f"Task {task_id} not found"}
902
+
903
+ if task.status == BeadsTaskStatus.DONE:
904
+ count = await adapter.on_task_done(task_id)
905
+ elif task.status == BeadsTaskStatus.CANCELLED:
906
+ count = await adapter.on_task_cancelled(task_id)
907
+ elif task.status == BeadsTaskStatus.BLOCKED:
908
+ count = await adapter.on_task_blocked(task_id)
909
+ else:
910
+ count = 0
911
+
912
+ return {
913
+ "success": True,
914
+ "task_id": task_id,
915
+ "outcomes_recorded": count,
916
+ }
917
+ else:
918
+ result = await adapter.sync()
919
+ return result.to_dict()
920
+ else:
921
+ data = {}
922
+ if task_id:
923
+ data["task_id"] = task_id
924
+ response = await self._request("POST", "/beads/sync", json=data)
925
+ return response
926
+
927
+ async def beads_context(
928
+ self,
929
+ task_id: str | None = None,
930
+ limit: int = 10,
931
+ ) -> dict[str, Any]:
932
+ """Get unified context for a Beads task.
933
+
934
+ Combines task info with relevant memories.
935
+
936
+ Args:
937
+ task_id: Task ID (uses current task if None).
938
+ limit: Maximum memories to include.
939
+
940
+ Returns:
941
+ Context dictionary with task info and memories.
942
+ """
943
+ self._ensure_initialized()
944
+
945
+ if self.config.mode == ClientMode.LOCAL:
946
+ from runtime_memory.tasks import BeadsAdapter
947
+
948
+ assert self._engine is not None
949
+ adapter = BeadsAdapter(self._engine)
950
+ await adapter.initialize()
951
+
952
+ if not adapter.is_available:
953
+ return {"success": False, "error": "Beads not available"}
954
+
955
+ context = await adapter.get_unified_context(task_id, limit)
956
+ if not context:
957
+ return {"success": False, "error": "No task found"}
958
+
959
+ return {
960
+ "success": True,
961
+ "task_id": context.task.id,
962
+ "task_title": context.task.title,
963
+ "task_status": context.task.status.value,
964
+ "memories_count": len(context.memories),
965
+ "formatted": context.formatted,
966
+ }
967
+ else:
968
+ params: dict[str, Any] = {"limit": limit}
969
+ if task_id:
970
+ params["task_id"] = task_id
971
+ response = await self._request("GET", "/beads/context", params=params)
972
+ return response
973
+
974
+ async def beads_link(
975
+ self,
976
+ memory_id: str,
977
+ task_id: str | None = None,
978
+ context: str | None = None,
979
+ ) -> dict[str, Any]:
980
+ """Link a memory to a Beads task.
981
+
982
+ When the task completes, the memory's outcome will be recorded.
983
+
984
+ Args:
985
+ memory_id: Memory ID to link.
986
+ task_id: Task ID (uses current task if None).
987
+ context: Optional context about how memory is used.
988
+
989
+ Returns:
990
+ Link result dictionary.
991
+ """
992
+ self._ensure_initialized()
993
+
994
+ if self.config.mode == ClientMode.LOCAL:
995
+ from runtime_memory.tasks import BeadsAdapter
996
+
997
+ assert self._engine is not None
998
+ adapter = BeadsAdapter(self._engine)
999
+ await adapter.initialize()
1000
+
1001
+ if not adapter.is_available:
1002
+ return {"success": False, "error": "Beads not available"}
1003
+
1004
+ if task_id:
1005
+ task = adapter.get_task(task_id)
1006
+ if not task:
1007
+ return {"success": False, "error": f"Task {task_id} not found"}
1008
+ else:
1009
+ task = adapter.get_current_task()
1010
+ if not task:
1011
+ return {"success": False, "error": "No current task found"}
1012
+ task_id = task.id
1013
+
1014
+ try:
1015
+ await self._engine.get(memory_id)
1016
+ except Exception:
1017
+ return {"success": False, "error": f"Memory {memory_id} not found"}
1018
+
1019
+ await adapter.link_memory_to_task(task_id, memory_id, context)
1020
+ return {"success": True, "memory_id": memory_id, "task_id": task_id}
1021
+ else:
1022
+ data: dict[str, Any] = {"memory_id": memory_id}
1023
+ if task_id:
1024
+ data["task_id"] = task_id
1025
+ if context:
1026
+ data["context"] = context
1027
+ response = await self._request("POST", "/beads/link", json=data)
1028
+ return response
1029
+
1030
+ async def beads_tasks(
1031
+ self,
1032
+ status: str | None = None,
1033
+ limit: int = 20,
1034
+ ) -> dict[str, Any]:
1035
+ """List Beads tasks.
1036
+
1037
+ Args:
1038
+ status: Optional status filter (pending, in_progress, done, etc).
1039
+ limit: Maximum tasks to return.
1040
+
1041
+ Returns:
1042
+ Dictionary with task list.
1043
+ """
1044
+ self._ensure_initialized()
1045
+
1046
+ if self.config.mode == ClientMode.LOCAL:
1047
+ from runtime_memory.tasks import BeadsAdapter, BeadsTaskStatus
1048
+
1049
+ assert self._engine is not None
1050
+ adapter = BeadsAdapter(self._engine)
1051
+ await adapter.initialize()
1052
+
1053
+ if not adapter.is_available:
1054
+ return {"success": False, "error": "Beads not available"}
1055
+
1056
+ status_enum = None
1057
+ if status:
1058
+ try:
1059
+ status_enum = BeadsTaskStatus(status)
1060
+ except ValueError:
1061
+ return {"success": False, "error": f"Invalid status: {status}"}
1062
+
1063
+ tasks = adapter.list_tasks(status=status_enum)[:limit]
1064
+ return {
1065
+ "success": True,
1066
+ "count": len(tasks),
1067
+ "tasks": [
1068
+ {
1069
+ "id": t.id,
1070
+ "title": t.title,
1071
+ "status": t.status.value,
1072
+ "is_ready": t.is_ready,
1073
+ "is_completed": t.is_completed,
1074
+ }
1075
+ for t in tasks
1076
+ ],
1077
+ }
1078
+ else:
1079
+ params: dict[str, Any] = {"limit": limit}
1080
+ if status:
1081
+ params["status"] = status
1082
+ response = await self._request("GET", "/beads/tasks", params=params)
1083
+ return response
1084
+
1085
+ async def beads_stats(self) -> dict[str, Any]:
1086
+ """Get Beads integration statistics.
1087
+
1088
+ Returns:
1089
+ Statistics dictionary.
1090
+ """
1091
+ self._ensure_initialized()
1092
+
1093
+ if self.config.mode == ClientMode.LOCAL:
1094
+ from runtime_memory.tasks import BeadsAdapter
1095
+
1096
+ assert self._engine is not None
1097
+ adapter = BeadsAdapter(self._engine)
1098
+ await adapter.initialize()
1099
+
1100
+ return await adapter.get_stats()
1101
+ else:
1102
+ response = await self._request("GET", "/beads/stats")
1103
+ return response
1104
+
1105
+
1106
+ # =============================================================================
1107
+ # Synchronous Wrapper
1108
+ # =============================================================================
1109
+
1110
+
1111
+ class SyncMemoryClient:
1112
+ """Synchronous wrapper for MemoryClient.
1113
+
1114
+ Provides a sync interface by running async operations in an event loop.
1115
+
1116
+ Example:
1117
+ ```python
1118
+ with SyncMemoryClient(mode="local") as client:
1119
+ memory = client.add(
1120
+ content="Use async/await for I/O",
1121
+ category="pattern",
1122
+ )
1123
+ results = client.search("async patterns")
1124
+ ```
1125
+ """
1126
+
1127
+ def __init__(
1128
+ self,
1129
+ config: ClientConfig | None = None,
1130
+ *,
1131
+ mode: str | ClientMode = ClientMode.LOCAL,
1132
+ db_path: str | Path | None = None,
1133
+ embedding_provider: str = "local",
1134
+ base_url: str | None = None,
1135
+ api_key: str | None = None,
1136
+ timeout: float = 30.0,
1137
+ max_retries: int = 3,
1138
+ ) -> None:
1139
+ """Initialize the Sync Memory Client.
1140
+
1141
+ Args:
1142
+ config: Full configuration object.
1143
+ mode: Operating mode: "local" or "remote".
1144
+ db_path: Database path for local mode.
1145
+ embedding_provider: Embedding provider for local mode.
1146
+ base_url: API base URL for remote mode.
1147
+ api_key: API key for authentication.
1148
+ timeout: Request timeout in seconds.
1149
+ max_retries: Maximum retry attempts.
1150
+ """
1151
+ self._async_client = MemoryClient(
1152
+ config=config,
1153
+ mode=mode,
1154
+ db_path=db_path,
1155
+ embedding_provider=embedding_provider,
1156
+ base_url=base_url,
1157
+ api_key=api_key,
1158
+ timeout=timeout,
1159
+ max_retries=max_retries,
1160
+ )
1161
+ self._loop: asyncio.AbstractEventLoop | None = None
1162
+ self._owns_loop = False
1163
+
1164
+ def _get_loop(self) -> asyncio.AbstractEventLoop:
1165
+ """Get or create event loop."""
1166
+ if self._loop is None or self._loop.is_closed():
1167
+ try:
1168
+ self._loop = asyncio.get_running_loop()
1169
+ self._owns_loop = False
1170
+ except RuntimeError:
1171
+ self._loop = asyncio.new_event_loop()
1172
+ self._owns_loop = True
1173
+ return self._loop
1174
+
1175
+ def _run(self, coro: Any) -> Any:
1176
+ """Run a coroutine synchronously."""
1177
+ loop = self._get_loop()
1178
+ if loop.is_running():
1179
+ # If loop is running, we need to use run_coroutine_threadsafe
1180
+ import concurrent.futures
1181
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
1182
+ return future.result()
1183
+ else:
1184
+ return loop.run_until_complete(coro)
1185
+
1186
+ def initialize(self) -> None:
1187
+ """Initialize the client."""
1188
+ self._run(self._async_client.initialize())
1189
+
1190
+ def close(self) -> None:
1191
+ """Close the client."""
1192
+ self._run(self._async_client.close())
1193
+ if self._owns_loop and self._loop:
1194
+ self._loop.close()
1195
+ self._loop = None
1196
+
1197
+ def __enter__(self) -> SyncMemoryClient:
1198
+ """Context manager entry."""
1199
+ self.initialize()
1200
+ return self
1201
+
1202
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
1203
+ """Context manager exit."""
1204
+ self.close()
1205
+
1206
+ # =========================================================================
1207
+ # Sync Methods (wrap async methods)
1208
+ # =========================================================================
1209
+
1210
+ def add(
1211
+ self,
1212
+ content: str,
1213
+ category: str | MemoryCategory,
1214
+ project: str | None = None,
1215
+ scope: str | MemoryScope = MemoryScope.PROJECT,
1216
+ source: str | MemorySource = MemorySource.EXPLICIT,
1217
+ confidence: float = 1.0,
1218
+ importance: float = 0.5,
1219
+ tags: list[str] | None = None,
1220
+ entities: list[str] | None = None,
1221
+ supersedes: str | None = None,
1222
+ metadata: dict[str, Any] | None = None,
1223
+ ) -> Memory:
1224
+ """Add a new memory (sync)."""
1225
+ return self._run(self._async_client.add(
1226
+ content=content,
1227
+ category=category,
1228
+ project=project,
1229
+ scope=scope,
1230
+ source=source,
1231
+ confidence=confidence,
1232
+ importance=importance,
1233
+ tags=tags,
1234
+ entities=entities,
1235
+ supersedes=supersedes,
1236
+ metadata=metadata,
1237
+ ))
1238
+
1239
+ def get(self, memory_id: str) -> Memory:
1240
+ """Get a memory by ID (sync)."""
1241
+ return self._run(self._async_client.get(memory_id))
1242
+
1243
+ def update(
1244
+ self,
1245
+ memory_id: str,
1246
+ content: str | None = None,
1247
+ category: str | MemoryCategory | None = None,
1248
+ confidence: float | None = None,
1249
+ importance: float | None = None,
1250
+ tags: list[str] | None = None,
1251
+ entities: list[str] | None = None,
1252
+ metadata: dict[str, Any] | None = None,
1253
+ ) -> Memory:
1254
+ """Update an existing memory (sync)."""
1255
+ return self._run(self._async_client.update(
1256
+ memory_id=memory_id,
1257
+ content=content,
1258
+ category=category,
1259
+ confidence=confidence,
1260
+ importance=importance,
1261
+ tags=tags,
1262
+ entities=entities,
1263
+ metadata=metadata,
1264
+ ))
1265
+
1266
+ def delete(self, memory_id: str, hard_delete: bool = False) -> None:
1267
+ """Delete a memory (sync)."""
1268
+ return self._run(self._async_client.delete(memory_id, hard_delete=hard_delete))
1269
+
1270
+ def search(
1271
+ self,
1272
+ query: str,
1273
+ limit: int = 10,
1274
+ categories: list[str | MemoryCategory] | None = None,
1275
+ project: str | None = None,
1276
+ min_score: float = -1.0,
1277
+ include_archived: bool = False,
1278
+ ) -> list[SearchResult]:
1279
+ """Search for relevant memories (sync)."""
1280
+ return self._run(self._async_client.search(
1281
+ query=query,
1282
+ limit=limit,
1283
+ categories=categories,
1284
+ project=project,
1285
+ min_score=min_score,
1286
+ include_archived=include_archived,
1287
+ ))
1288
+
1289
+ def list(
1290
+ self,
1291
+ project: str | None = None,
1292
+ category: str | MemoryCategory | None = None,
1293
+ limit: int = 100,
1294
+ include_archived: bool = False,
1295
+ ) -> list[Memory]:
1296
+ """List memories with filtering (sync)."""
1297
+ return self._run(self._async_client.list(
1298
+ project=project,
1299
+ category=category,
1300
+ limit=limit,
1301
+ include_archived=include_archived,
1302
+ ))
1303
+
1304
+ def record_outcome(
1305
+ self,
1306
+ memory_ids: list[str] | str,
1307
+ outcome: str | Outcome,
1308
+ ) -> list[Memory]:
1309
+ """Record outcome feedback for memories (sync)."""
1310
+ return self._run(self._async_client.record_outcome(memory_ids, outcome))
1311
+
1312
+ def get_context(
1313
+ self,
1314
+ project: str | None = None,
1315
+ query: str | None = None,
1316
+ limit: int = 10,
1317
+ format: str = "markdown",
1318
+ ) -> ContextResponse:
1319
+ """Get formatted context (sync)."""
1320
+ return self._run(self._async_client.get_context(
1321
+ project=project,
1322
+ query=query,
1323
+ limit=limit,
1324
+ format=format,
1325
+ ))
1326
+
1327
+ def stats(self, project: str | None = None) -> StatsDict | Any:
1328
+ """Get memory statistics (sync)."""
1329
+ return self._run(self._async_client.stats(project=project))
1330
+
1331
+ def health(self) -> dict[str, Any]:
1332
+ """Check client health (sync)."""
1333
+ return self._run(self._async_client.health())
1334
+
1335
+ # Beads integration methods
1336
+
1337
+ def beads_sync(self, task_id: str | None = None) -> dict[str, Any]:
1338
+ """Sync outcomes for completed Beads tasks (sync)."""
1339
+ return self._run(self._async_client.beads_sync(task_id=task_id))
1340
+
1341
+ def beads_context(
1342
+ self,
1343
+ task_id: str | None = None,
1344
+ limit: int = 10,
1345
+ ) -> dict[str, Any]:
1346
+ """Get unified context for a Beads task (sync)."""
1347
+ return self._run(self._async_client.beads_context(task_id=task_id, limit=limit))
1348
+
1349
+ def beads_link(
1350
+ self,
1351
+ memory_id: str,
1352
+ task_id: str | None = None,
1353
+ context: str | None = None,
1354
+ ) -> dict[str, Any]:
1355
+ """Link a memory to a Beads task (sync)."""
1356
+ return self._run(self._async_client.beads_link(
1357
+ memory_id=memory_id,
1358
+ task_id=task_id,
1359
+ context=context,
1360
+ ))
1361
+
1362
+ def beads_tasks(
1363
+ self,
1364
+ status: str | None = None,
1365
+ limit: int = 20,
1366
+ ) -> dict[str, Any]:
1367
+ """List Beads tasks (sync)."""
1368
+ return self._run(self._async_client.beads_tasks(status=status, limit=limit))
1369
+
1370
+ def beads_stats(self) -> dict[str, Any]:
1371
+ """Get Beads integration statistics (sync)."""
1372
+ return self._run(self._async_client.beads_stats())
1373
+
1374
+
1375
+ # =============================================================================
1376
+ # Module-Level Convenience Functions
1377
+ # =============================================================================
1378
+
1379
+ # Global default client (lazily initialized)
1380
+ _default_client: MemoryClient | None = None
1381
+ _default_config: ClientConfig | None = None
1382
+
1383
+
1384
+ def configure(
1385
+ mode: str | ClientMode = ClientMode.LOCAL,
1386
+ db_path: str | Path | None = None,
1387
+ base_url: str | None = None,
1388
+ api_key: str | None = None,
1389
+ **kwargs: Any,
1390
+ ) -> None:
1391
+ """Configure the default client.
1392
+
1393
+ Call this before using module-level convenience functions.
1394
+
1395
+ Args:
1396
+ mode: Operating mode: "local" or "remote".
1397
+ db_path: Database path for local mode.
1398
+ base_url: API base URL for remote mode.
1399
+ api_key: API key for authentication.
1400
+ **kwargs: Additional ClientConfig parameters.
1401
+ """
1402
+ global _default_config, _default_client
1403
+
1404
+ _default_config = ClientConfig(
1405
+ mode=ClientMode(mode) if isinstance(mode, str) else mode,
1406
+ db_path=db_path or str(default_db_path()),
1407
+ base_url=base_url or "http://127.0.0.1:8080",
1408
+ api_key=api_key,
1409
+ **kwargs,
1410
+ )
1411
+ _default_client = None # Reset client to use new config
1412
+
1413
+
1414
+ async def _get_client() -> MemoryClient:
1415
+ """Get the default client, initializing if needed."""
1416
+ global _default_client
1417
+
1418
+ if _default_client is None:
1419
+ _default_client = MemoryClient(config=_default_config or ClientConfig())
1420
+ await _default_client.initialize()
1421
+
1422
+ return _default_client
1423
+
1424
+
1425
+ async def add(
1426
+ content: str,
1427
+ category: str | MemoryCategory,
1428
+ project: str | None = None,
1429
+ **kwargs: Any,
1430
+ ) -> Memory:
1431
+ """Add a new memory using the default client.
1432
+
1433
+ Args:
1434
+ content: The memory content text.
1435
+ category: Classification category.
1436
+ project: Project scope.
1437
+ **kwargs: Additional memory parameters.
1438
+
1439
+ Returns:
1440
+ The created Memory.
1441
+
1442
+ Example:
1443
+ ```python
1444
+ from runtime_memory.sdk import add, configure
1445
+
1446
+ configure(mode="local")
1447
+ memory = await add("Use async/await for I/O", category="pattern")
1448
+ ```
1449
+ """
1450
+ client = await _get_client()
1451
+ return await client.add(content=content, category=category, project=project, **kwargs)
1452
+
1453
+
1454
+ async def search(
1455
+ query: str,
1456
+ limit: int = 10,
1457
+ project: str | None = None,
1458
+ **kwargs: Any,
1459
+ ) -> list[SearchResult]:
1460
+ """Search for relevant memories using the default client.
1461
+
1462
+ Args:
1463
+ query: Search query text.
1464
+ limit: Maximum number of results.
1465
+ project: Filter by project.
1466
+ **kwargs: Additional search parameters.
1467
+
1468
+ Returns:
1469
+ List of SearchResults.
1470
+
1471
+ Example:
1472
+ ```python
1473
+ from runtime_memory.sdk import search, configure
1474
+
1475
+ configure(mode="local")
1476
+ results = await search("async patterns")
1477
+ ```
1478
+ """
1479
+ client = await _get_client()
1480
+ return await client.search(query=query, limit=limit, project=project, **kwargs)
1481
+
1482
+
1483
+ async def get_context(
1484
+ project: str | None = None,
1485
+ limit: int = 10,
1486
+ **kwargs: Any,
1487
+ ) -> ContextResponse:
1488
+ """Get formatted context using the default client.
1489
+
1490
+ Args:
1491
+ project: Project filter.
1492
+ limit: Maximum memories to include.
1493
+ **kwargs: Additional context parameters.
1494
+
1495
+ Returns:
1496
+ ContextResponse with formatted context.
1497
+ """
1498
+ client = await _get_client()
1499
+ return await client.get_context(project=project, limit=limit, **kwargs)
1500
+
1501
+
1502
+ async def record_outcome(
1503
+ memory_ids: list[str] | str,
1504
+ outcome: str | Outcome,
1505
+ ) -> list[Memory]:
1506
+ """Record outcome feedback using the default client.
1507
+
1508
+ Args:
1509
+ memory_ids: Memory ID(s) to update.
1510
+ outcome: The outcome (worked, failed, partial).
1511
+
1512
+ Returns:
1513
+ List of updated memories.
1514
+ """
1515
+ client = await _get_client()
1516
+ return await client.record_outcome(memory_ids=memory_ids, outcome=outcome)
1517
+
1518
+
1519
+ async def close_default_client() -> None:
1520
+ """Close the default client if initialized."""
1521
+ global _default_client
1522
+
1523
+ if _default_client is not None:
1524
+ await _default_client.close()
1525
+ _default_client = None
1526
+
1527
+
1528
+ # =============================================================================
1529
+ # Beads Integration Convenience Functions
1530
+ # =============================================================================
1531
+
1532
+
1533
+ async def beads_sync(task_id: str | None = None) -> dict[str, Any]:
1534
+ """Sync outcomes for completed Beads tasks.
1535
+
1536
+ Args:
1537
+ task_id: Optional specific task ID to sync.
1538
+
1539
+ Returns:
1540
+ Sync result dictionary.
1541
+ """
1542
+ client = await _get_client()
1543
+ return await client.beads_sync(task_id=task_id)
1544
+
1545
+
1546
+ async def beads_context(
1547
+ task_id: str | None = None,
1548
+ limit: int = 10,
1549
+ ) -> dict[str, Any]:
1550
+ """Get unified context for a Beads task.
1551
+
1552
+ Args:
1553
+ task_id: Task ID (uses current task if None).
1554
+ limit: Maximum memories to include.
1555
+
1556
+ Returns:
1557
+ Context dictionary.
1558
+ """
1559
+ client = await _get_client()
1560
+ return await client.beads_context(task_id=task_id, limit=limit)
1561
+
1562
+
1563
+ async def beads_tasks(
1564
+ status: str | None = None,
1565
+ limit: int = 20,
1566
+ ) -> dict[str, Any]:
1567
+ """List Beads tasks.
1568
+
1569
+ Args:
1570
+ status: Optional status filter.
1571
+ limit: Maximum tasks to return.
1572
+
1573
+ Returns:
1574
+ Dictionary with task list.
1575
+ """
1576
+ client = await _get_client()
1577
+ return await client.beads_tasks(status=status, limit=limit)