opencontextengine-client 0.1.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.
oce_client/context.py ADDED
@@ -0,0 +1,478 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from .filesystem import FileAdmissionError, LocalFileSource
9
+ from .http import OceApiError
10
+ from .identity import Sha256BlobIdentity
11
+ from .ignore import LayeredIgnoreMatcher
12
+ from .models import (
13
+ BlobDelta,
14
+ BlobUpload,
15
+ FileRecord,
16
+ FileStatus,
17
+ RetrievalResult,
18
+ SyncResult,
19
+ UploadPlan,
20
+ WorkspaceSnapshot,
21
+ )
22
+ from .ports import BlobApi, BlobIdentity
23
+ from .state import SQLiteStateStore
24
+ from .watcher import WatchHandle
25
+
26
+
27
+ class CheckpointResetRequired(RuntimeError):
28
+ pass
29
+
30
+
31
+ class BlobCompatibilityError(RuntimeError):
32
+ pass
33
+
34
+
35
+ class WorkspaceContext:
36
+ """Synchronous workspace inventory, blob synchronization, and retrieval facade."""
37
+
38
+ def __init__(
39
+ self,
40
+ root: Path,
41
+ api: BlobApi,
42
+ state: SQLiteStateStore,
43
+ *,
44
+ identity: BlobIdentity | None = None,
45
+ file_source: LocalFileSource | None = None,
46
+ runtime_patterns: Iterable[str] = (),
47
+ ready_poll_attempts: int = 20,
48
+ ready_poll_seconds: float = 0.25,
49
+ max_find_missing: int = 1000,
50
+ max_upload_blobs: int = 1000,
51
+ max_upload_bytes: int = 2 * 1024 * 1024,
52
+ ) -> None:
53
+ self.root = root.resolve()
54
+ self.api = api
55
+ self.state = state
56
+ self.identity = identity or Sha256BlobIdentity()
57
+ self.file_source = file_source or LocalFileSource()
58
+ self.runtime_patterns = tuple(runtime_patterns)
59
+ self.ready_poll_attempts = ready_poll_attempts
60
+ self.ready_poll_seconds = ready_poll_seconds
61
+ if max_find_missing < 1 or max_upload_blobs < 1 or max_upload_bytes < 1:
62
+ raise ValueError("sync batch limits must be positive")
63
+ self.max_find_missing = max_find_missing
64
+ self.max_upload_blobs = max_upload_blobs
65
+ self.max_upload_bytes = max_upload_bytes
66
+ self._watch: WatchHandle | None = None
67
+
68
+ @classmethod
69
+ def open(
70
+ cls,
71
+ root: str | os.PathLike[str],
72
+ api: BlobApi,
73
+ *,
74
+ state_path: str | os.PathLike[str] | None = None,
75
+ identity: BlobIdentity | None = None,
76
+ file_source: LocalFileSource | None = None,
77
+ runtime_patterns: Iterable[str] = (),
78
+ **kwargs: object,
79
+ ) -> "WorkspaceContext":
80
+ root_path = Path(root).resolve()
81
+ if not root_path.is_dir():
82
+ raise NotADirectoryError(root_path)
83
+ db_path = Path(state_path) if state_path else root_path / ".oce-client" / "state.sqlite3"
84
+ return cls(
85
+ root_path,
86
+ api,
87
+ SQLiteStateStore(db_path),
88
+ identity=identity,
89
+ file_source=file_source,
90
+ runtime_patterns=runtime_patterns,
91
+ **kwargs,
92
+ )
93
+
94
+ def close(self) -> None:
95
+ if self._watch is not None:
96
+ self._watch.stop()
97
+ self._watch.join(2.0)
98
+ self._watch = None
99
+ self.state.close()
100
+ close = getattr(self.api, "close", None)
101
+ if close is not None:
102
+ close()
103
+
104
+ def _matcher(self) -> LayeredIgnoreMatcher:
105
+ return LayeredIgnoreMatcher(self.root, self.runtime_patterns)
106
+
107
+ def _next_generation(self) -> int:
108
+ return self.state.load_snapshot().generation + 1
109
+
110
+ def _normalize_path(self, path: str | os.PathLike[str]) -> str:
111
+ candidate = Path(path)
112
+ if candidate.is_absolute():
113
+ try:
114
+ candidate = candidate.resolve().relative_to(self.root)
115
+ except ValueError as exc:
116
+ raise ValueError(f"path is outside workspace: {path}") from exc
117
+ normalized = candidate.as_posix()
118
+ while normalized.startswith("./"):
119
+ normalized = normalized[2:]
120
+ if not normalized or normalized == "." or normalized.startswith("../"):
121
+ raise ValueError(f"invalid workspace-relative path: {path}")
122
+ return normalized
123
+
124
+ def observe_file(self, path: str, content: str) -> None:
125
+ normalized = self._normalize_path(path)
126
+ if self._matcher().ignores(normalized):
127
+ raise ValueError(f"path is ignored: {normalized}")
128
+ if not isinstance(content, str):
129
+ raise TypeError("content must be text")
130
+ if len(content.encode("utf-8")) > self.file_source.max_file_size:
131
+ raise FileAdmissionError(f"file exceeds {self.file_source.max_file_size} bytes")
132
+ if "\x00" in content:
133
+ raise FileAdmissionError("binary content is not supported")
134
+ generation = self._next_generation()
135
+ record = FileRecord(
136
+ normalized,
137
+ self.identity.calculate(normalized, content),
138
+ FileStatus.PRESENT,
139
+ content,
140
+ len(content.encode("utf-8")),
141
+ None,
142
+ "explicit",
143
+ generation,
144
+ )
145
+ self.state.upsert_file(record)
146
+ self.state.set_meta("generation", str(generation))
147
+
148
+ def remove_file(self, path: str) -> None:
149
+ normalized = self._normalize_path(path)
150
+ generation = self._next_generation()
151
+ self.state.mark_deleted_paths([normalized], generation)
152
+ self.state.set_meta("generation", str(generation))
153
+
154
+ def reconcile(self) -> WorkspaceSnapshot:
155
+ generation = self._next_generation()
156
+ current = self.state.load_snapshot()
157
+ explicit = {
158
+ path: record
159
+ for path, record in current.files.items()
160
+ if record.source == "explicit" and record.status == FileStatus.PRESENT
161
+ }
162
+ records: list[FileRecord] = []
163
+ known_paths: set[str] = set()
164
+ scanned_paths: set[str] = set()
165
+ matcher = self._matcher()
166
+
167
+ def flush_records() -> None:
168
+ if records:
169
+ self.state.apply_file_changes(records, [], generation)
170
+ records.clear()
171
+
172
+ for path, source_path, stat in self.file_source.iter_files(self.root, matcher):
173
+ scanned_paths.add(path)
174
+ known_paths.add(path)
175
+ existing = current.files.get(path)
176
+ if (
177
+ existing is not None
178
+ and existing.source == "filesystem"
179
+ and existing.status == FileStatus.PRESENT
180
+ and existing.blob_name
181
+ and existing.mtime_ns == stat.st_mtime_ns
182
+ and existing.size == stat.st_size
183
+ ):
184
+ records.append(
185
+ FileRecord(
186
+ path,
187
+ existing.blob_name,
188
+ FileStatus.PRESENT,
189
+ None,
190
+ existing.size,
191
+ existing.mtime_ns,
192
+ "filesystem",
193
+ generation,
194
+ )
195
+ )
196
+ if len(records) >= 256:
197
+ flush_records()
198
+ continue
199
+ try:
200
+ content = self.file_source.read(source_path)
201
+ stat = source_path.stat()
202
+ except (FileAdmissionError, OSError):
203
+ continue
204
+ blob_name = self.identity.calculate(path, content)
205
+ overlay = explicit.get(path)
206
+ if overlay is not None and blob_name != overlay.blob_name:
207
+ records.append(overlay)
208
+ if len(records) >= 256:
209
+ flush_records()
210
+ continue
211
+ records.append(
212
+ FileRecord(
213
+ path,
214
+ blob_name,
215
+ FileStatus.PRESENT,
216
+ content,
217
+ stat.st_size,
218
+ stat.st_mtime_ns,
219
+ "filesystem",
220
+ generation,
221
+ )
222
+ )
223
+ if len(records) >= 256:
224
+ flush_records()
225
+ for path, record in explicit.items():
226
+ if path not in scanned_paths:
227
+ records.append(record)
228
+ known_paths.add(path)
229
+ if len(records) >= 256:
230
+ flush_records()
231
+ flush_records()
232
+ self.state.set_meta("generation", str(generation))
233
+ missing_paths = [path for path in current.files if path not in known_paths and path not in explicit]
234
+ self.state.mark_missing_paths(missing_paths, generation)
235
+ return self.state.load_snapshot()
236
+
237
+ def reconcile_paths(self, changed_paths: Iterable[str | os.PathLike[str]]) -> WorkspaceSnapshot:
238
+ resolved_paths: set[Path] = set()
239
+ for path in changed_paths:
240
+ candidate = Path(path)
241
+ if not candidate.is_absolute():
242
+ candidate = self.root / candidate
243
+ resolved_paths.add(candidate.resolve())
244
+ if not resolved_paths:
245
+ return self.snapshot()
246
+ for path in resolved_paths:
247
+ if path.name in {".gitignore", ".oceignore"} or path.is_dir():
248
+ return self.reconcile()
249
+
250
+ generation = self._next_generation()
251
+ current = self.state.load_snapshot()
252
+ matcher = self._matcher()
253
+ records: list[FileRecord] = []
254
+ deleted: set[str] = set()
255
+ for source_path in resolved_paths:
256
+ try:
257
+ relative = source_path.relative_to(self.root).as_posix()
258
+ except ValueError:
259
+ continue
260
+ tracked = {
261
+ path
262
+ for path in current.files
263
+ if path == relative or path.startswith(relative.rstrip("/") + "/")
264
+ }
265
+ if not source_path.is_file() or matcher.ignores(relative):
266
+ deleted.update(
267
+ path
268
+ for path in tracked
269
+ if current.files[path].source != "explicit"
270
+ )
271
+ continue
272
+ try:
273
+ stat = source_path.stat()
274
+ if stat.st_size > self.file_source.max_file_size:
275
+ deleted.update(tracked)
276
+ continue
277
+ content = self.file_source.read(source_path)
278
+ stat = source_path.stat()
279
+ except (FileAdmissionError, OSError):
280
+ deleted.update(tracked)
281
+ continue
282
+ blob_name = self.identity.calculate(relative, content)
283
+ existing = current.files.get(relative)
284
+ if (
285
+ existing is not None
286
+ and existing.source == "explicit"
287
+ and existing.status == FileStatus.PRESENT
288
+ and existing.blob_name != blob_name
289
+ ):
290
+ records.append(existing)
291
+ continue
292
+ records.append(
293
+ FileRecord(
294
+ relative,
295
+ blob_name,
296
+ FileStatus.PRESENT,
297
+ content,
298
+ stat.st_size,
299
+ stat.st_mtime_ns,
300
+ "filesystem",
301
+ generation,
302
+ )
303
+ )
304
+ deleted.discard(relative)
305
+ self.state.apply_file_changes(records, sorted(deleted), generation)
306
+ return self.state.load_snapshot()
307
+
308
+ def snapshot(self) -> WorkspaceSnapshot:
309
+ return self.state.load_snapshot()
310
+
311
+ def plan_sync(self) -> UploadPlan:
312
+ current_names: set[str] = set()
313
+ committed_names: set[str] = set()
314
+ skipped: list[str] = []
315
+ for row in self.state.load_file_rows():
316
+ if row["status"] == FileStatus.PRESENT.value and row["blob_name"]:
317
+ current_names.add(str(row["blob_name"]))
318
+ elif row["status"] == FileStatus.SKIPPED.value:
319
+ skipped.append(str(row["path"]))
320
+ if row["committed_blob_name"]:
321
+ committed_names.add(str(row["committed_blob_name"]))
322
+ added = tuple(sorted(current_names - committed_names))
323
+ deleted = tuple(sorted(committed_names - current_names))
324
+ return UploadPlan(
325
+ (),
326
+ BlobDelta(self.state.get_meta("checkpoint_id"), added, deleted),
327
+ tuple(sorted(skipped)),
328
+ )
329
+
330
+ def _wait_ready(self, names: tuple[str, ...], checkpoint_id: str | None) -> None:
331
+ if not names:
332
+ return
333
+ pending = set(names)
334
+ for attempt in range(self.ready_poll_attempts):
335
+ status = self.api.blob_status(tuple(sorted(pending)), checkpoint_id)
336
+ if status.checkpoint_not_found:
337
+ raise CheckpointResetRequired("server checkpoint no longer exists")
338
+ if status.unknown_blob_names:
339
+ raise OceApiError(404, f"unknown blobs: {status.unknown_blob_names}")
340
+ pending = set(status.nonindexed_blob_names)
341
+ if not pending:
342
+ return
343
+ if attempt + 1 < self.ready_poll_attempts:
344
+ time.sleep(self.ready_poll_seconds)
345
+ raise TimeoutError(f"blobs did not become ready: {sorted(pending)}")
346
+
347
+ def sync(self) -> SyncResult:
348
+ self.reconcile()
349
+ return self._sync_reconciled()
350
+
351
+ def sync_paths(
352
+ self,
353
+ changed_paths: Iterable[str | os.PathLike[str]],
354
+ ) -> SyncResult:
355
+ self.reconcile_paths(changed_paths)
356
+ return self._sync_reconciled()
357
+
358
+ def _sync_reconciled(self) -> SyncResult:
359
+ plan = self.plan_sync()
360
+ payload = {
361
+ "delta": plan.delta.to_api_dict(),
362
+ "uploads": [
363
+ {"path": upload.path, "blob_name": upload.blob_name}
364
+ for upload in plan.uploads
365
+ ],
366
+ }
367
+ # A new sync is a durable retry of any prior failed attempt. The current
368
+ # inventory is authoritative, so retrying from a fresh plan is idempotent.
369
+ self.state.supersede_pending()
370
+ operation_id = self.state.add_outbox("sync", payload)
371
+ try:
372
+ rows = self.state.load_file_rows()
373
+ current_records = {
374
+ str(row["blob_name"]): row
375
+ for row in rows
376
+ if row["status"] == FileStatus.PRESENT.value and row["blob_name"]
377
+ }
378
+ current_names = tuple(sorted(current_records))
379
+ names_to_check = (
380
+ current_names
381
+ if plan.delta.checkpoint_id is None
382
+ else plan.delta.added_blobs
383
+ )
384
+ unknown: set[str] = set()
385
+ nonindexed: set[str] = set()
386
+ for offset in range(0, len(names_to_check), self.max_find_missing):
387
+ missing = self.api.find_missing(
388
+ names_to_check[offset : offset + self.max_find_missing]
389
+ )
390
+ unknown.update(missing.unknown_blob_names)
391
+ nonindexed.update(missing.nonindexed_blob_names)
392
+ to_upload = unknown | nonindexed
393
+ uploaded_names: set[str] = set()
394
+ batch: list[BlobUpload] = []
395
+ batch_bytes = 0
396
+ for name in sorted(to_upload):
397
+ record = current_records.get(name)
398
+ if record is None:
399
+ raise BlobCompatibilityError(f"missing local record for blob {name}")
400
+ path = str(record["path"])
401
+ content = self.state.load_file_content(path)
402
+ if content is None:
403
+ content = self.file_source.read(self.root / Path(path))
404
+ actual_name = self.identity.calculate(path, content)
405
+ if actual_name != name:
406
+ raise BlobCompatibilityError(f"file changed during sync: {path}")
407
+ upload = BlobUpload(path, content, name)
408
+ upload_bytes = len(upload.path.encode("utf-8")) + len(upload.content.encode("utf-8"))
409
+ if batch and (
410
+ len(batch) >= self.max_upload_blobs
411
+ or batch_bytes + upload_bytes > self.max_upload_bytes
412
+ ):
413
+ result = self.api.batch_upload(batch)
414
+ expected = {item.blob_name for item in batch}
415
+ received = set(result.blob_names)
416
+ if received != expected:
417
+ raise BlobCompatibilityError(
418
+ f"batch-upload returned {sorted(received)}; expected {sorted(expected)}"
419
+ )
420
+ uploaded_names.update(received)
421
+ batch = []
422
+ batch_bytes = 0
423
+ batch.append(upload)
424
+ batch_bytes += upload_bytes
425
+ if batch:
426
+ result = self.api.batch_upload(batch)
427
+ expected = {item.blob_name for item in batch}
428
+ received = set(result.blob_names)
429
+ if received != expected:
430
+ raise BlobCompatibilityError(
431
+ f"batch-upload returned {sorted(received)}; expected {sorted(expected)}"
432
+ )
433
+ uploaded_names.update(received)
434
+ uploaded = tuple(sorted(uploaded_names))
435
+ self._wait_ready(tuple(sorted(to_upload)), None)
436
+ checkpoint_id = plan.delta.checkpoint_id
437
+ try:
438
+ if plan.delta.added_blobs or plan.delta.deleted_blobs or checkpoint_id is None:
439
+ result = self.api.checkpoint(
440
+ checkpoint_id,
441
+ plan.delta.added_blobs,
442
+ plan.delta.deleted_blobs,
443
+ )
444
+ checkpoint_id = result.new_checkpoint_id
445
+ except OceApiError as exc:
446
+ if exc.status_code != 404:
447
+ raise
448
+ # The server lost the chain. Rebuild the current workspace from scratch.
449
+ self.state.set_meta("checkpoint_id", None)
450
+ result = self.api.checkpoint(None, current_names, ())
451
+ checkpoint_id = result.new_checkpoint_id
452
+ deleted_paths = [
453
+ row["path"]
454
+ for row in self.state.load_file_rows()
455
+ if row["status"] != FileStatus.PRESENT
456
+ and row["committed_blob_name"] in set(plan.delta.deleted_blobs)
457
+ ]
458
+ self.state.commit_sync(checkpoint_id or "", deleted_paths, self.snapshot().generation)
459
+ self.state.update_outbox(operation_id, "complete")
460
+ return SyncResult(uploaded, checkpoint_id, plan.delta.added_blobs, plan.delta.deleted_blobs)
461
+ except Exception as exc:
462
+ self.state.update_outbox(operation_id, "failed", str(exc))
463
+ raise
464
+
465
+ def retrieve(self, query: str, *, scope: str = "workspace") -> RetrievalResult:
466
+ if scope not in {"workspace", "working_set"}:
467
+ raise ValueError(f"unknown scope: {scope}")
468
+ if self.state.get_meta("synced_generation") != self.state.get_meta("generation"):
469
+ self.sync()
470
+ snapshot = self.snapshot()
471
+ names = tuple(sorted(record.blob_name for record in snapshot.files.values() if record.status == FileStatus.PRESENT and record.blob_name))
472
+ return self.api.retrieve(query, snapshot.checkpoint_id, names if snapshot.checkpoint_id is None else (), ())
473
+
474
+ def start_watching(self, *, debounce_ms: int = 300) -> WatchHandle:
475
+ if self._watch is not None:
476
+ return self._watch
477
+ self._watch = WatchHandle(self.root, self.sync_paths, debounce_ms)
478
+ return self._watch
oce_client/defaults.py ADDED
@@ -0,0 +1,2 @@
1
+ DEFAULT_API_URL = "http://127.0.0.1:8986"
2
+ DEFAULT_API_KEY = "sk-opencontextengine"
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ from .ignore import LayeredIgnoreMatcher
8
+
9
+
10
+ class FileAdmissionError(ValueError):
11
+ pass
12
+
13
+
14
+ class LocalFileSource:
15
+ def __init__(self, *, max_file_size: int = 1_048_576) -> None:
16
+ self.max_file_size = max_file_size
17
+
18
+ def iter_files(
19
+ self,
20
+ root: Path,
21
+ matcher: LayeredIgnoreMatcher,
22
+ ) -> Iterator[tuple[str, Path, os.stat_result]]:
23
+ root = root.resolve()
24
+ for directory, dirnames, filenames in os.walk(root, followlinks=False):
25
+ directory_path = Path(directory)
26
+ kept_dirs: list[str] = []
27
+ for name in dirnames:
28
+ path = directory_path / name
29
+ relative = path.relative_to(root).as_posix()
30
+ if not matcher.ignores(relative, is_dir=True):
31
+ kept_dirs.append(name)
32
+ dirnames[:] = kept_dirs
33
+ for name in filenames:
34
+ path = directory_path / name
35
+ try:
36
+ relative = path.relative_to(root).as_posix()
37
+ stat = path.stat()
38
+ except (OSError, ValueError):
39
+ continue
40
+ if matcher.ignores(relative) or stat.st_size > self.max_file_size:
41
+ continue
42
+ yield relative, path, stat
43
+
44
+ def scan(self, root: Path, matcher: LayeredIgnoreMatcher) -> dict[str, str]:
45
+ files: dict[str, str] = {}
46
+ for relative, path, _stat in self.iter_files(root, matcher):
47
+ try:
48
+ files[relative] = self.read(path)
49
+ except (FileAdmissionError, OSError):
50
+ continue
51
+ return files
52
+
53
+ def read(self, path: Path) -> str:
54
+ data = path.read_bytes()
55
+ if len(data) > self.max_file_size:
56
+ raise FileAdmissionError(f"file exceeds {self.max_file_size} bytes: {path}")
57
+ if b"\x00" in data:
58
+ raise FileAdmissionError(f"binary file is not supported: {path}")
59
+ try:
60
+ return data.decode("utf-8", errors="strict")
61
+ except UnicodeDecodeError as exc:
62
+ raise FileAdmissionError(f"file is not valid UTF-8: {path}") from exc
oce_client/http.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Sequence
5
+
6
+ import httpx
7
+
8
+ from . import __version__
9
+ from .defaults import DEFAULT_API_KEY, DEFAULT_API_URL
10
+ from .models import (
11
+ BlobStatusResult,
12
+ BlobUpload,
13
+ CheckpointResult,
14
+ MissingResult,
15
+ RetrievalResult,
16
+ UploadResult,
17
+ )
18
+
19
+
20
+ class OceApiError(RuntimeError):
21
+ def __init__(self, status_code: int, detail: str) -> None:
22
+ super().__init__(f"OCE API request failed ({status_code}): {detail}")
23
+ self.status_code = status_code
24
+ self.detail = detail
25
+
26
+
27
+ class OceHttpClient:
28
+ """Synchronous adapter for the public ACE-compatible OCE endpoints."""
29
+
30
+ def __init__(
31
+ self,
32
+ api_url: str = DEFAULT_API_URL,
33
+ api_key: str = DEFAULT_API_KEY,
34
+ *,
35
+ timeout: float | httpx.Timeout = 60.0,
36
+ client: httpx.Client | None = None,
37
+ ) -> None:
38
+ self.api_url = api_url.rstrip("/")
39
+ self._owns_client = client is None
40
+ self._client = client or httpx.Client(timeout=timeout)
41
+ self._headers = {
42
+ "Authorization": f"Bearer {api_key}",
43
+ "Content-Type": "application/json",
44
+ "User-Agent": f"oce-client/{__version__}",
45
+ }
46
+
47
+ def close(self) -> None:
48
+ if self._owns_client:
49
+ self._client.close()
50
+
51
+ def _post(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]:
52
+ response = self._client.post(
53
+ f"{self.api_url}/{endpoint}", json=payload, headers=self._headers
54
+ )
55
+ if response.is_error:
56
+ try:
57
+ detail = str(response.json().get("detail", response.text))
58
+ except ValueError:
59
+ detail = response.text
60
+ raise OceApiError(response.status_code, detail)
61
+ data = response.json()
62
+ if not isinstance(data, dict):
63
+ raise OceApiError(response.status_code, "response must be a JSON object")
64
+ return data
65
+
66
+ def find_missing(self, blob_names: Sequence[str]) -> MissingResult:
67
+ data = self._post("find-missing", {"mem_object_names": list(blob_names)})
68
+ return MissingResult(
69
+ tuple(str(v) for v in data.get("unknown_memory_names", [])),
70
+ tuple(str(v) for v in data.get("nonindexed_blob_names", [])),
71
+ )
72
+
73
+ def batch_upload(self, blobs: Sequence[BlobUpload]) -> UploadResult:
74
+ data = self._post(
75
+ "batch-upload",
76
+ {"blobs": [{"path": b.path, "content": b.content} for b in blobs]},
77
+ )
78
+ return UploadResult(tuple(str(v) for v in data.get("blob_names", [])))
79
+
80
+ @staticmethod
81
+ def _blobs_payload(
82
+ checkpoint_id: str | None,
83
+ added_blobs: Sequence[str],
84
+ deleted_blobs: Sequence[str],
85
+ ) -> dict[str, object]:
86
+ return {
87
+ "checkpoint_id": checkpoint_id or "",
88
+ "added_blobs": list(added_blobs),
89
+ "deleted_blobs": list(deleted_blobs),
90
+ }
91
+
92
+ def blob_status(
93
+ self, blob_names: Sequence[str], checkpoint_id: str | None = None
94
+ ) -> BlobStatusResult:
95
+ data = self._post(
96
+ "agents/blob-status",
97
+ {"blobs": self._blobs_payload(checkpoint_id, blob_names, ())},
98
+ )
99
+ return BlobStatusResult(
100
+ tuple(str(v) for v in data.get("unknown_blob_names", [])),
101
+ tuple(str(v) for v in data.get("nonindexed_blob_names", [])),
102
+ bool(data.get("checkpoint_not_found", False)),
103
+ )
104
+
105
+ def checkpoint(
106
+ self,
107
+ checkpoint_id: str | None,
108
+ added_blobs: Sequence[str],
109
+ deleted_blobs: Sequence[str],
110
+ ) -> CheckpointResult:
111
+ data = self._post(
112
+ "checkpoint-blobs",
113
+ {"blobs": self._blobs_payload(checkpoint_id, added_blobs, deleted_blobs)},
114
+ )
115
+ return CheckpointResult(str(data["new_checkpoint_id"]))
116
+
117
+ def retrieve(
118
+ self,
119
+ query: str,
120
+ checkpoint_id: str | None,
121
+ added_blobs: Sequence[str],
122
+ deleted_blobs: Sequence[str],
123
+ ) -> RetrievalResult:
124
+ started = time.perf_counter()
125
+ data = self._post(
126
+ "agents/codebase-retrieval",
127
+ {
128
+ "information_request": query,
129
+ "blobs": self._blobs_payload(checkpoint_id, added_blobs, deleted_blobs),
130
+ "chat_history": [],
131
+ },
132
+ )
133
+ elapsed = int((time.perf_counter() - started) * 1000)
134
+ return RetrievalResult(str(data.get("formatted_retrieval", "")), elapsed)
oce_client/identity.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+
5
+
6
+ class Sha256BlobIdentity:
7
+ """OCE-compatible content address: SHA-256 of normalized path + UTF-8 text."""
8
+
9
+ def calculate(self, path: str, content: str) -> str:
10
+ if not isinstance(path, str) or not path:
11
+ raise ValueError("path must be a non-empty string")
12
+ if not isinstance(content, str):
13
+ raise TypeError("content must be text")
14
+ digest = hashlib.sha256()
15
+ digest.update(path.encode("utf-8"))
16
+ digest.update(content.encode("utf-8"))
17
+ return digest.hexdigest()