contextos-memory-runtime 1.0.0rc2__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 (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,125 @@
1
+ """Strict generic JSON/JSONL import connector."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from contextos.connectors.models import ConnectorItem
10
+
11
+
12
+ class JsonImportConnector:
13
+ source_type = "json_import"
14
+
15
+ def __init__(self, connector_id: str, path: Path, max_bytes: int = 1_000_000) -> None:
16
+ self.connector_id = connector_id
17
+ self._path = path.resolve()
18
+ self._max_bytes = max_bytes
19
+
20
+ async def health(self) -> bool:
21
+ return self._path.exists() and self._path.is_file()
22
+
23
+ async def close(self) -> None:
24
+ pass
25
+
26
+ async def scan(self, cursor: str | None) -> tuple[list[ConnectorItem], str | None]:
27
+ if self._path.stat().st_size > self._max_bytes:
28
+ raise ValueError("ITEM_TOO_LARGE")
29
+
30
+ text = self._path.read_text(encoding="utf-8", errors="strict")
31
+ if self._path.suffix.lower() == ".jsonl":
32
+ raw = [json.loads(line) for line in text.splitlines() if line.strip()]
33
+ else:
34
+ raw = json.loads(text)
35
+
36
+ if isinstance(raw, dict):
37
+ raw = [raw]
38
+ if not isinstance(raw, list):
39
+ raise ValueError("invalid import root")
40
+
41
+ ids = set()
42
+ items = []
43
+ for value in raw:
44
+ if not isinstance(value, dict) or set(value) - {
45
+ "id",
46
+ "content",
47
+ "timestamp",
48
+ "updated_at",
49
+ "source",
50
+ "title",
51
+ "metadata",
52
+ "revision",
53
+ }:
54
+ raise ValueError("invalid item schema")
55
+
56
+ external_id = value.get("id")
57
+ content = value.get("content")
58
+ if not isinstance(external_id, str) or not isinstance(content, str) or not external_id.strip() or not content.strip():
59
+ raise ValueError("missing or invalid id or content")
60
+ if external_id in ids:
61
+ raise ValueError("duplicate id")
62
+ ids.add(external_id)
63
+
64
+ if len(content) > 100_000:
65
+ raise ValueError("ITEM_TOO_LARGE")
66
+
67
+ # Check for control characters
68
+ if any(ord(c) < 32 for c in external_id) or any(ord(c) < 32 and c not in "\n\r\t" for c in content):
69
+ raise ValueError("control characters not allowed")
70
+
71
+ metadata = value.get("metadata", {})
72
+ if not isinstance(metadata, dict) or any(isinstance(v, (dict, list)) for v in metadata.values()):
73
+ raise ValueError("invalid metadata: nested structures rejected")
74
+
75
+ # Check for secret metadata
76
+ secret_tokens = ("secret", "password", "api_key", "token", "sk-", "bearer")
77
+ for k, v in metadata.items():
78
+ k_lower = str(k).lower()
79
+ v_lower = str(v).lower()
80
+ if any(sec in k_lower or sec in v_lower for sec in secret_tokens):
81
+ raise ValueError("secret detected in metadata")
82
+
83
+ # Timestamp parsing
84
+ created_at = None
85
+ if "timestamp" in value and value["timestamp"] is not None:
86
+ ts = value["timestamp"]
87
+ try:
88
+ if isinstance(ts, (int, float)):
89
+ created_at = datetime.fromtimestamp(ts, tz=timezone.utc)
90
+ elif isinstance(ts, str):
91
+ created_at = datetime.fromisoformat(ts)
92
+ else:
93
+ raise ValueError("invalid timestamp")
94
+ except Exception as exc:
95
+ raise ValueError(f"invalid timestamp: {exc}") from exc
96
+
97
+ updated_at = None
98
+ if "updated_at" in value and value["updated_at"] is not None:
99
+ ts = value["updated_at"]
100
+ try:
101
+ if isinstance(ts, (int, float)):
102
+ updated_at = datetime.fromtimestamp(ts, tz=timezone.utc)
103
+ elif isinstance(ts, str):
104
+ updated_at = datetime.fromisoformat(ts)
105
+ else:
106
+ raise ValueError("invalid timestamp")
107
+ except Exception as exc:
108
+ raise ValueError(f"invalid timestamp: {exc}") from exc
109
+
110
+ revision = value.get("revision") or hashlib.sha256(content.encode()).hexdigest()
111
+ items.append(
112
+ ConnectorItem(
113
+ external_id=external_id,
114
+ source_type=self.source_type,
115
+ source_uri=f"json://{self.connector_id}/{external_id}",
116
+ content=content,
117
+ revision=revision,
118
+ title=value.get("title"),
119
+ metadata=metadata,
120
+ created_at=created_at,
121
+ updated_at=updated_at,
122
+ )
123
+ )
124
+
125
+ return items, str(len(items))
@@ -0,0 +1,102 @@
1
+ """Explicit-root UTF-8 local file connector."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ from pathlib import Path
5
+ from contextos.connectors.models import ConnectorItem
6
+
7
+ import os
8
+
9
+ def _is_reserved(path: Path) -> bool:
10
+ if hasattr(os.path, "isreserved"):
11
+ return os.path.isreserved(str(path))
12
+ name = path.name.upper()
13
+ stem = path.stem.upper()
14
+ reserved = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
15
+ return name in reserved or stem in reserved
16
+
17
+ class LocalFileConnector:
18
+ source_type = "local_file"
19
+
20
+ def __init__(self, connector_id: str, roots: list[Path], max_bytes: int = 1_000_000) -> None:
21
+ self.connector_id = connector_id
22
+ resolved_roots = []
23
+ for r in roots:
24
+ s = str(r)
25
+ if s.startswith(r"\\") or s.startswith("//"):
26
+ raise ValueError(f"UNC and network paths are not allowed: {s}")
27
+ resolved = r.resolve()
28
+ if str(resolved).startswith(r"\\") or str(resolved).startswith("//"):
29
+ raise ValueError(f"UNC and network paths are not allowed: {resolved}")
30
+ resolved_roots.append(resolved)
31
+ self._roots = resolved_roots
32
+ self._max_bytes = max_bytes
33
+
34
+ async def health(self) -> bool:
35
+ return all(root.exists() and root.is_dir() for root in self._roots)
36
+
37
+ async def close(self) -> None:
38
+ pass
39
+
40
+ def _allowed(self, path: Path) -> bool:
41
+ try:
42
+ s = str(path)
43
+ if s.startswith(r"\\") or s.startswith("//") or _is_reserved(path):
44
+ return False
45
+ resolved = path.resolve(strict=True)
46
+ res_str = str(resolved)
47
+ if res_str.startswith(r"\\") or res_str.startswith("//") or _is_reserved(resolved):
48
+ return False
49
+ return any(resolved.is_relative_to(root) for root in self._roots)
50
+ except (OSError, RuntimeError, ValueError):
51
+ return False
52
+
53
+ async def scan(self, cursor: str | None) -> tuple[list[ConnectorItem], str | None]:
54
+ from datetime import datetime, timezone
55
+
56
+ paths = []
57
+ for root in self._roots:
58
+ if not root.exists():
59
+ continue
60
+ for path in root.rglob("*"):
61
+ try:
62
+ if path.is_file() and path.suffix.lower() in {".txt", ".md", ".json", ".jsonl"} and self._allowed(path):
63
+ paths.append(path.resolve())
64
+ except (OSError, UnicodeError):
65
+ continue
66
+ items = []
67
+ for path in sorted(set(paths), key=lambda item: str(item).casefold()):
68
+ try:
69
+ # TOCTOU re-check before opening
70
+ if not self._allowed(path):
71
+ continue
72
+ st = path.stat()
73
+ size = st.st_size
74
+ if size > self._max_bytes or size == 0:
75
+ continue
76
+ # Bounded read to prevent memory exhaustion
77
+ with open(path, "r", encoding="utf-8", errors="strict") as f:
78
+ content = f.read(self._max_bytes + 1)
79
+ if len(content) > self._max_bytes or not content.strip():
80
+ continue
81
+ except (OSError, UnicodeError):
82
+ continue
83
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
84
+ try:
85
+ relative = next(
86
+ path.relative_to(root).as_posix()
87
+ for root in self._roots
88
+ if path.is_relative_to(root)
89
+ )
90
+ except Exception:
91
+ continue
92
+ items.append(
93
+ ConnectorItem(
94
+ external_id=relative,
95
+ source_type=self.source_type,
96
+ source_uri=f"file:///{relative}",
97
+ content=content,
98
+ revision=digest,
99
+ updated_at=datetime.fromtimestamp(st.st_mtime, tz=timezone.utc),
100
+ )
101
+ )
102
+ return items, str(len(items))
@@ -0,0 +1,293 @@
1
+ """Connector orchestration that preserves ContextOS ingestion invariants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import logging
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from contextos.connectors.models import (
13
+ ConnectorSyncResult,
14
+ ConnectorSyncState,
15
+ RetentionPolicy,
16
+ )
17
+ from contextos.connectors.protocols import Connector
18
+ from contextos.core.enums import MemoryStatus, SourceRole
19
+ from contextos.core.exceptions import IngestionError, SecretDetectedError
20
+ from contextos.core.models import IngestRequest
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ import sqlite3
26
+
27
+ def is_transient_error(exc: BaseException) -> bool:
28
+ """Classify whether an error during scan or ingestion is transient and eligible for retry."""
29
+ if isinstance(exc, (ConnectionError, TimeoutError, OSError)):
30
+ return True
31
+ if isinstance(exc, sqlite3.OperationalError):
32
+ msg = str(exc).lower()
33
+ if "locked" in msg or "busy" in msg or "cannot start a transaction" in msg:
34
+ return True
35
+ if isinstance(exc, RuntimeError) and "transient" in str(exc).lower():
36
+ return True
37
+ if getattr(exc, "is_transient", False):
38
+ return True
39
+ return False
40
+
41
+
42
+ class ConnectorManager:
43
+ """Orchestrates credential-free connector syncs while preserving ingestion invariants."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ state_repo: Any,
49
+ ingestion: Any,
50
+ temporal: Any,
51
+ retention_policy: RetentionPolicy = RetentionPolicy.KEEP_DERIVED_MEMORY,
52
+ memory_repo: Any = None,
53
+ max_retries: int = 3,
54
+ backoff_fn: Any = None,
55
+ ) -> None:
56
+ self._state_repo = state_repo
57
+ self._ingestion = ingestion
58
+ self._temporal = temporal
59
+ self._retention_policy = retention_policy
60
+ self._memory_repo = memory_repo
61
+ self._max_retries = max_retries
62
+ self._backoff_fn = backoff_fn
63
+ self._connectors: dict[str, Connector] = {}
64
+ self._locks: dict[str, asyncio.Lock] = {}
65
+
66
+ def register(self, connector: Connector) -> None:
67
+ if connector.connector_id in self._connectors:
68
+ raise ValueError(f"duplicate connector id: {connector.connector_id}")
69
+ self._connectors[connector.connector_id] = connector
70
+ self._locks[connector.connector_id] = asyncio.Lock()
71
+
72
+ def unregister(self, connector_id: str) -> None:
73
+ """Safely release connector and lock to prevent unbounded growth."""
74
+ if connector_id in self._connectors:
75
+ del self._connectors[connector_id]
76
+ if connector_id in self._locks:
77
+ del self._locks[connector_id]
78
+
79
+ def list_connectors(self) -> list[str]:
80
+ return sorted(self._connectors)
81
+
82
+
83
+ async def get_status(self, connector_id: str) -> ConnectorSyncState | None:
84
+ if connector_id not in self._connectors:
85
+ return None
86
+ return await self._state_repo.state(connector_id)
87
+
88
+ async def set_enabled(self, connector_id: str, enabled: bool) -> None:
89
+ if connector_id not in self._connectors:
90
+ raise KeyError(f"connector not found: {connector_id}")
91
+ connector = self._connectors[connector_id]
92
+ state = await self._state_repo.state(connector_id) or ConnectorSyncState(
93
+ connector_id=connector_id, connector_type=connector.source_type
94
+ )
95
+ state.enabled = enabled
96
+ await self._state_repo.save_state(state)
97
+
98
+ async def sync(self, connector_id: str) -> ConnectorSyncResult:
99
+ if connector_id not in self._connectors:
100
+ raise KeyError(f"connector not found: {connector_id}")
101
+
102
+ connector = self._connectors[connector_id]
103
+ started = time.perf_counter()
104
+
105
+ async with self._locks[connector_id]:
106
+ state = await self._state_repo.state(connector_id) or ConnectorSyncState(
107
+ connector_id=connector_id, connector_type=connector.source_type
108
+ )
109
+ if not state.enabled:
110
+ return ConnectorSyncResult(
111
+ connector_id=connector_id,
112
+ status="disabled",
113
+ duration_ms=(time.perf_counter() - started) * 1000,
114
+ )
115
+
116
+ state.last_attempt_at = datetime.now(timezone.utc)
117
+ state.status = "syncing"
118
+ await self._state_repo.save_state(state)
119
+
120
+ # --- Step 1: Scan with retry policy for transient errors ---
121
+ items = None
122
+ next_cursor = None
123
+ scan_attempts = 0
124
+
125
+ while scan_attempts < self._max_retries:
126
+ scan_attempts += 1
127
+ try:
128
+ items, next_cursor = await connector.scan(state.cursor)
129
+ break
130
+ except asyncio.CancelledError:
131
+ state.status = "cancelled"
132
+ state.error_code = "CANCELLED"
133
+ await self._state_repo.save_state(state)
134
+ raise
135
+ except (ValueError, TypeError):
136
+ state.status = "failed"
137
+ state.error_code = "VALIDATION_ERROR"
138
+ await self._state_repo.save_state(state)
139
+ return ConnectorSyncResult(
140
+ connector_id=connector_id,
141
+ status="failed",
142
+ failed=1,
143
+ error_code="VALIDATION_ERROR",
144
+ duration_ms=(time.perf_counter() - started) * 1000,
145
+ )
146
+ except Exception as exc:
147
+ if not is_transient_error(exc) or scan_attempts >= self._max_retries:
148
+ state.status = "failed"
149
+ state.error_code = "SOURCE_FAILURE"
150
+ await self._state_repo.save_state(state)
151
+ return ConnectorSyncResult(
152
+ connector_id=connector_id,
153
+ status="failed",
154
+ failed=1,
155
+ error_code="SOURCE_FAILURE",
156
+ duration_ms=(time.perf_counter() - started) * 1000,
157
+ )
158
+ if self._backoff_fn is not None:
159
+ res = self._backoff_fn(scan_attempts)
160
+ if asyncio.iscoroutine(res):
161
+ await res
162
+ else:
163
+ await asyncio.sleep(0.01 * (2 ** (scan_attempts - 1)))
164
+
165
+ assert items is not None
166
+ result = ConnectorSyncResult(
167
+ connector_id=connector_id,
168
+ status="success",
169
+ scanned=len(items),
170
+ next_cursor=next_cursor,
171
+ )
172
+
173
+ # --- Step 2: Item loop ---
174
+ for item in items:
175
+ digest = hashlib.sha256(item.content.encode("utf-8")).hexdigest()
176
+
177
+ # Unchanged skip check (before privacy / extraction / temporal!)
178
+ if await self._state_repo.item_is_current(connector_id, item, digest):
179
+ result.unchanged += 1
180
+ continue
181
+
182
+ # Deletion handling
183
+ if item.deleted:
184
+ old_mids = await self._state_repo.get_item_memory_ids(connector_id, item.external_id)
185
+ await self._state_repo.save_item(connector_id, item, digest, [], deleted=True)
186
+ result.deleted += 1
187
+
188
+ if (
189
+ self._retention_policy == RetentionPolicy.EXPIRE_ON_SOURCE_DELETE
190
+ and self._memory_repo is not None
191
+ ):
192
+ for mid in old_mids:
193
+ # Multi-source provenance check: only expire if no other active item references it!
194
+ active_refs = await self._state_repo.count_active_references(mid)
195
+ if active_refs == 0:
196
+ try:
197
+ existing = await self._memory_repo.get(mid)
198
+ if (
199
+ existing is not None
200
+ and existing.status != MemoryStatus.EXPIRED
201
+ and existing.source_type.startswith("connector:")
202
+ ):
203
+ await self._memory_repo.update_status(
204
+ mid, MemoryStatus.EXPIRED, expected_version=existing.version
205
+ )
206
+ except Exception:
207
+ pass
208
+ continue
209
+
210
+ # Process new / changed item with retries for transient errors
211
+ item_success = False
212
+ item_attempts = 0
213
+
214
+ while item_attempts < self._max_retries:
215
+ item_attempts += 1
216
+ try:
217
+ ingest_res = await self._ingestion.ingest(
218
+ IngestRequest(
219
+ content=item.content,
220
+ source_type=f"connector:{connector.source_type}",
221
+ source_uri=item.source_uri,
222
+ source_role=SourceRole.USER,
223
+ )
224
+ )
225
+ ids = []
226
+ for candidate in ingest_res.candidates:
227
+ resolution = await self._temporal.accept(
228
+ candidate, provenance_event_id=ingest_res.event_id
229
+ )
230
+ ids.append(resolution.memory.id)
231
+
232
+ await self._state_repo.save_item(
233
+ connector_id, item, digest, ids, deleted=False
234
+ )
235
+ result.accepted += len(ids)
236
+ if ids:
237
+ result.updated += 1
238
+ item_success = True
239
+ break
240
+
241
+ except asyncio.CancelledError:
242
+ state.status = "cancelled"
243
+ state.error_code = "CANCELLED"
244
+ await self._state_repo.save_state(state)
245
+ raise
246
+
247
+ except SecretDetectedError:
248
+ # Privacy rejection is permanent — do NOT retry!
249
+ await self._state_repo.save_item(
250
+ connector_id, item, digest, [], deleted=False
251
+ )
252
+ result.rejected += 1
253
+ item_success = True
254
+ break
255
+
256
+ except (ValueError, TypeError, IngestionError):
257
+ # Permanent validation error / item too large — do NOT retry!
258
+ result.rejected += 1
259
+ item_success = True
260
+ break
261
+
262
+ except Exception as exc:
263
+ if not is_transient_error(exc) or item_attempts >= self._max_retries:
264
+ # Non-transient errors or exhausted retries fail without retrying further
265
+ break
266
+ if self._backoff_fn is not None:
267
+ res = self._backoff_fn(item_attempts)
268
+ if asyncio.iscoroutine(res):
269
+ await res
270
+ else:
271
+ await asyncio.sleep(0.01 * (2 ** (item_attempts - 1)))
272
+
273
+
274
+
275
+ if not item_success:
276
+ result.failed += 1
277
+ result.status = "partial"
278
+ result.error_code = "ITEM_FAILURE"
279
+ break # Stop processing further items to preserve cursor safety!
280
+
281
+ # --- Step 3: Finalize state and cursor ---
282
+ if result.failed == 0:
283
+ state.cursor = next_cursor
284
+ state.last_success_at = datetime.now(timezone.utc)
285
+ state.status = "success"
286
+ state.error_code = None
287
+ else:
288
+ state.status = "partial" if (result.accepted > 0 or result.unchanged > 0 or result.deleted > 0) else "failed"
289
+ state.error_code = result.error_code or "ITEM_FAILURE"
290
+
291
+ await self._state_repo.save_state(state)
292
+ result.duration_ms = (time.perf_counter() - started) * 1000
293
+ return result
@@ -0,0 +1,62 @@
1
+ """Bounded provider-neutral connector contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
10
+
11
+
12
+ class RetentionPolicy(StrEnum):
13
+ KEEP_DERIVED_MEMORY = "keep_derived_memory"
14
+ EXPIRE_ON_SOURCE_DELETE = "expire_on_source_delete"
15
+
16
+
17
+ class ConnectorItem(BaseModel):
18
+ """Normalized untrusted input. It is never a final memory."""
19
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
20
+ external_id: str = Field(min_length=1, max_length=512)
21
+ source_type: str = Field(min_length=1, max_length=64)
22
+ source_uri: str = Field(min_length=1, max_length=2048)
23
+ content: str = Field(min_length=1, max_length=100_000, repr=False)
24
+ revision: str = Field(min_length=1, max_length=256)
25
+ created_at: datetime | None = None
26
+ updated_at: datetime | None = None
27
+ title: str | None = Field(default=None, max_length=512)
28
+ metadata: dict[str, str | int | float | bool | None] = Field(default_factory=dict, max_length=32)
29
+ deleted: bool = False
30
+
31
+ @field_validator("external_id", "source_type", "source_uri", "revision")
32
+ @classmethod
33
+ def safe_identifier(cls, value: str) -> str:
34
+ if "\x00" in value or any(ord(char) < 32 for char in value):
35
+ raise ValueError("control characters are not allowed")
36
+ return value.strip()
37
+
38
+
39
+ class ConnectorSyncState(BaseModel):
40
+ connector_id: str
41
+ connector_type: str
42
+ cursor: str | None = None
43
+ last_success_at: datetime | None = None
44
+ last_attempt_at: datetime | None = None
45
+ enabled: bool = True
46
+ status: str = "idle"
47
+ error_code: str | None = None
48
+
49
+
50
+ class ConnectorSyncResult(BaseModel):
51
+ connector_id: str
52
+ status: str
53
+ scanned: int = 0
54
+ accepted: int = 0
55
+ rejected: int = 0
56
+ unchanged: int = 0
57
+ updated: int = 0
58
+ deleted: int = 0
59
+ failed: int = 0
60
+ next_cursor: str | None = None
61
+ duration_ms: float = 0.0
62
+ error_code: str | None = None
@@ -0,0 +1,11 @@
1
+ """Small connector protocol; connectors return normalized untrusted items."""
2
+ from __future__ import annotations
3
+ from typing import Protocol
4
+ from contextos.connectors.models import ConnectorItem
5
+
6
+ class Connector(Protocol):
7
+ connector_id: str
8
+ source_type: str
9
+ async def health(self) -> bool: ...
10
+ async def scan(self, cursor: str | None) -> tuple[list[ConnectorItem], str | None]: ...
11
+ async def close(self) -> None: ...