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/__init__.py +33 -0
- oce_client/cli.py +250 -0
- oce_client/context.py +478 -0
- oce_client/defaults.py +2 -0
- oce_client/filesystem.py +62 -0
- oce_client/http.py +134 -0
- oce_client/identity.py +17 -0
- oce_client/ignore.py +84 -0
- oce_client/indexer.py +265 -0
- oce_client/mcp_server.py +309 -0
- oce_client/models.py +102 -0
- oce_client/ports.py +67 -0
- oce_client/runtime.py +245 -0
- oce_client/skill/SKILL.md +136 -0
- oce_client/skill/agents/openai.yaml +4 -0
- oce_client/state.py +288 -0
- oce_client/watcher.py +57 -0
- opencontextengine_client-0.1.0.dist-info/METADATA +162 -0
- opencontextengine_client-0.1.0.dist-info/RECORD +22 -0
- opencontextengine_client-0.1.0.dist-info/WHEEL +4 -0
- opencontextengine_client-0.1.0.dist-info/entry_points.txt +3 -0
- opencontextengine_client-0.1.0.dist-info/licenses/LICENSE +202 -0
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
oce_client/filesystem.py
ADDED
|
@@ -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()
|