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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- 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: ...
|