oxide-agent 0.1.1__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 (52) hide show
  1. oxide/__init__.py +57 -0
  2. oxide/__main__.py +3 -0
  3. oxide/api.py +468 -0
  4. oxide/capture.py +231 -0
  5. oxide/cli.py +177 -0
  6. oxide/core/__init__.py +1 -0
  7. oxide/core/capsule.py +221 -0
  8. oxide/core/indexing.py +262 -0
  9. oxide/core/languages/__init__.py +0 -0
  10. oxide/core/languages/c.py +372 -0
  11. oxide/core/languages/c_resolve.py +170 -0
  12. oxide/core/languages/cpp.py +474 -0
  13. oxide/core/languages/cpp_facts.py +109 -0
  14. oxide/core/languages/cpp_macros.py +92 -0
  15. oxide/core/languages/cpp_resolve.py +312 -0
  16. oxide/core/languages/go.py +375 -0
  17. oxide/core/languages/go_resolve.py +239 -0
  18. oxide/core/languages/java.py +392 -0
  19. oxide/core/languages/java_facts.py +128 -0
  20. oxide/core/languages/java_resolve.py +423 -0
  21. oxide/core/languages/nesting.py +42 -0
  22. oxide/core/languages/python.py +487 -0
  23. oxide/core/languages/python_dispatch.py +173 -0
  24. oxide/core/languages/python_resolve.py +484 -0
  25. oxide/core/languages/rust.py +427 -0
  26. oxide/core/languages/rust_cargo.py +325 -0
  27. oxide/core/languages/rust_facts.py +251 -0
  28. oxide/core/languages/rust_macros.py +74 -0
  29. oxide/core/languages/rust_resolve.py +446 -0
  30. oxide/core/languages/tsconfig.py +227 -0
  31. oxide/core/languages/typescript.py +397 -0
  32. oxide/core/languages/typescript_resolve.py +447 -0
  33. oxide/core/languages/typescript_syntax.py +447 -0
  34. oxide/core/navigation.py +130 -0
  35. oxide/core/ordering.py +85 -0
  36. oxide/core/packing.py +356 -0
  37. oxide/core/retrieval.py +181 -0
  38. oxide/core/routing.py +123 -0
  39. oxide/core/structural.py +119 -0
  40. oxide/jev.py +295 -0
  41. oxide/mcp_server.py +230 -0
  42. oxide/models.py +338 -0
  43. oxide/py.typed +0 -0
  44. oxide/storage/__init__.py +1 -0
  45. oxide/storage/fts.py +107 -0
  46. oxide/storage/ladybug.py +441 -0
  47. oxide/storage/store.py +378 -0
  48. oxide_agent-0.1.1.dist-info/METADATA +144 -0
  49. oxide_agent-0.1.1.dist-info/RECORD +52 -0
  50. oxide_agent-0.1.1.dist-info/WHEEL +4 -0
  51. oxide_agent-0.1.1.dist-info/entry_points.txt +3 -0
  52. oxide_agent-0.1.1.dist-info/licenses/LICENSE +21 -0
oxide/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """OXIDE v2: a structural context engine for agents (docs/spec/SPEC.md, ADR-0011)."""
2
+
3
+ from oxide.api import Repository, index_repository, open_repository
4
+ from oxide.models import (
5
+ ChannelStatus,
6
+ ContextBundle,
7
+ ContextItem,
8
+ Entity,
9
+ EntryPoint,
10
+ FileInfo,
11
+ HintIssue,
12
+ Impact,
13
+ ImpactHop,
14
+ IndexReport,
15
+ ItemEntity,
16
+ JudgeStatus,
17
+ Omission,
18
+ OxideError,
19
+ Relation,
20
+ RouteSummary,
21
+ SearchResult,
22
+ Seed,
23
+ Skip,
24
+ Snapshot,
25
+ SourceRef,
26
+ StructuralMatch,
27
+ StructuralResult,
28
+ )
29
+
30
+ __all__ = [
31
+ "ChannelStatus",
32
+ "ContextBundle",
33
+ "ContextItem",
34
+ "Entity",
35
+ "EntryPoint",
36
+ "FileInfo",
37
+ "HintIssue",
38
+ "Impact",
39
+ "ImpactHop",
40
+ "IndexReport",
41
+ "ItemEntity",
42
+ "JudgeStatus",
43
+ "Omission",
44
+ "OxideError",
45
+ "Relation",
46
+ "Repository",
47
+ "RouteSummary",
48
+ "SearchResult",
49
+ "Seed",
50
+ "Skip",
51
+ "Snapshot",
52
+ "SourceRef",
53
+ "StructuralMatch",
54
+ "StructuralResult",
55
+ "index_repository",
56
+ "open_repository",
57
+ ]
oxide/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from oxide.cli import main
2
+
3
+ raise SystemExit(main())
oxide/api.py ADDED
@@ -0,0 +1,468 @@
1
+ """The public Python API: the stable product boundary of OXIDE v2 (ADR-0011).
2
+
3
+ ``index_repository -> search -> build_context`` over persisted repository state::
4
+
5
+ with oxide.open_repository("path/to/repo") as repo:
6
+ repo.index()
7
+ hits = repo.search("where failed requests are retried")
8
+ bundle = repo.build_context("where failed requests are retried", budget=1024)
9
+ print(bundle.payload)
10
+
11
+ Every result names the snapshot it was read from. A ``Repository`` owns its store
12
+ exclusively (a second owner, in any process, gets ``OwnershipConflict``) and is not
13
+ thread-safe.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import gc
19
+ import os
20
+ import time
21
+ from collections.abc import Callable, Iterable, Mapping
22
+ from pathlib import Path
23
+ from types import TracebackType
24
+ from typing import Any, Literal
25
+
26
+ from oxide import jev
27
+ from oxide.capture import capture
28
+ from oxide.core import navigation, ordering, packing, retrieval, routing, structural
29
+ from oxide.core.indexing import components, derive, source_dialect, validate
30
+ from oxide.core.retrieval import Hints
31
+ from oxide.models import (
32
+ Closed,
33
+ ContextBundle,
34
+ Entity,
35
+ EntryPoint,
36
+ FileInfo,
37
+ Impact,
38
+ ImpactHop,
39
+ IndexReport,
40
+ InvalidArgument,
41
+ InvalidPath,
42
+ JudgeStatus,
43
+ Relation,
44
+ RouteSummary,
45
+ SearchResult,
46
+ Snapshot,
47
+ StructuralResult,
48
+ )
49
+ from oxide.storage.fts import fts_location, fts_unavailable, prepare_fts
50
+ from oxide.storage.ladybug import View, default_data_dir, storage_components
51
+ from oxide.storage.store import Store
52
+
53
+ # Upper bounds of caller-chosen sizes, checked before any query is built.
54
+ MAX_LIMIT = 1000
55
+ MAX_BUDGET = 1 << 20
56
+ MAX_DEPTH = 8
57
+ _MAX_OFFSET = (1 << 63) - 1 # LadybugDB INT64
58
+
59
+
60
+ def _count(name: str, value: object, high: int) -> int:
61
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= high:
62
+ raise InvalidArgument(f"{name} must be an integer in [0, {high}], got {value!r}")
63
+ return value
64
+
65
+
66
+ def _texts(name: str, values: Iterable[str]) -> tuple[str, ...]:
67
+ try:
68
+ out = () if isinstance(values, str) else tuple(values)
69
+ except TypeError:
70
+ out = None
71
+ if out is None or isinstance(values, str) or not all(isinstance(v, str) for v in out):
72
+ raise InvalidArgument(f"{name} must be an iterable of strings, got {values!r}")
73
+ return out
74
+
75
+
76
+ def _hints(
77
+ query: str,
78
+ paths: Iterable[str],
79
+ symbols: Iterable[str],
80
+ selection: tuple[str, int, int] | None,
81
+ changed: Iterable[str],
82
+ ) -> Hints:
83
+ if not isinstance(query, str):
84
+ raise InvalidArgument(f"query must be a string, got {query!r}")
85
+ if selection is not None:
86
+ if not (isinstance(selection, tuple) and len(selection) == 3):
87
+ raise InvalidArgument(f"selection must be (path, start, end), got {selection!r}")
88
+ path, start, end = selection
89
+ _texts("selection path", [path])
90
+ if _count("selection start", start, _MAX_OFFSET) > _count(
91
+ "selection end", end, _MAX_OFFSET
92
+ ):
93
+ raise InvalidArgument(f"selection start {start} is after its end {end}")
94
+ return Hints(
95
+ selection, _texts("paths", paths), _texts("symbols", symbols), _texts("changed", changed)
96
+ )
97
+
98
+
99
+ class Repository:
100
+ def __init__(
101
+ self,
102
+ path: str | os.PathLike[str],
103
+ data_dir: str | os.PathLike[str] | None = None,
104
+ create: bool = True,
105
+ ) -> None:
106
+ try:
107
+ root = Path(path).resolve(strict=True)
108
+ except OSError as error:
109
+ raise InvalidPath(f"{path}: {error.strerror}") from error
110
+ if not root.is_dir():
111
+ raise InvalidPath(f"{root}: not a directory")
112
+ self.root = root
113
+ data = Path(data_dir or default_data_dir()).absolute()
114
+ self._store: Store | None = Store(root, data, create)
115
+
116
+ def __enter__(self) -> Repository:
117
+ return self
118
+
119
+ def __exit__(
120
+ self,
121
+ kind: type[BaseException] | None,
122
+ error: BaseException | None,
123
+ trace: TracebackType | None,
124
+ ) -> None:
125
+ self.close()
126
+
127
+ def close(self) -> None:
128
+ if self._store is not None:
129
+ self._store.close()
130
+ self._store = None
131
+
132
+ @property
133
+ def store(self) -> Store:
134
+ if self._store is None:
135
+ raise Closed(f"{self.root}: repository is closed")
136
+ return self._store
137
+
138
+ @property
139
+ def repo_id(self) -> str:
140
+ return self.store.repo_id
141
+
142
+ def index(self, *, progress: Callable[[str], None] | None = None) -> IndexReport:
143
+ """Captures the worktree and publishes a new generation, or reports ``unchanged``
144
+ when the capture and derivation equal the current one. Supported source
145
+ files whose bytes the current generation already parsed reuse its facts;
146
+ resolution and publication always cover the whole capture, so the result equals
147
+ a full rebuild. ``progress`` receives a line as each stage starts."""
148
+ started = time.perf_counter()
149
+ report = progress or (lambda line: None)
150
+ store = self.store
151
+ report(f"capturing {self.root}")
152
+ # The data location is never source, wherever it sits (compared by identity).
153
+ snapshot = capture(self.root, exclude=(store.data, store.data / "repos"))
154
+ lexical_unavailable = fts_unavailable(fts_location())
155
+ parts = components(storage_components(lexical_unavailable is None))
156
+ built = not store.is_current(snapshot.snapshot_id, parts)
157
+ previous: Snapshot | None = None
158
+ changes: tuple[tuple[str, ...], ...] = ((), (), ())
159
+ parsed = reused = 0
160
+ if built:
161
+ old = store.previous()
162
+ before = {f.path: f.digest for f in old.files.values()} if old else {}
163
+ after = {f.path: f.digest for f in snapshot.files}
164
+ changes = (
165
+ tuple(p for p in after if p not in before),
166
+ tuple(p for p in after if p in before and before[p] != after[p]),
167
+ tuple(sorted(p for p in before if p not in after)),
168
+ )
169
+ previous = old.snapshot if old else None
170
+ cached = old.facts() if old else {}
171
+ sources = [
172
+ (lang, f.digest)
173
+ for f in snapshot.files
174
+ if (lang := source_dialect(f.path, f.data)) is not None
175
+ ]
176
+ hits = sum(isinstance(s := cached.get(lang), dict) and d in s for lang, d in sources)
177
+ report(
178
+ f"deriving {len(after)} files: parsing {len(sources) - hits} source files, "
179
+ f"reusing {hits}"
180
+ )
181
+ # A build allocates millions of objects that hold no cycles; the cyclic GC only
182
+ # re-scans them (~8% of a build, indexing-throughput at 013e791). The switch
183
+ # is process-global, so the caller's state is restored on every exit.
184
+ enabled = gc.isenabled()
185
+ gc.disable()
186
+ try:
187
+ derived = derive(snapshot, store.repo_id, cached)
188
+ reused, parsed = derived.reused, len(sources) - derived.reused
189
+ if reused < hits:
190
+ report(f"{hits - reused} cached parse facts were unusable and parsed again")
191
+ validate(derived)
192
+ report(
193
+ f"publishing {len(derived.entities)} entities, {len(derived.edges)} relations"
194
+ )
195
+ store.publish(snapshot, derived, parts, lexical_unavailable)
196
+ finally:
197
+ if enabled:
198
+ gc.enable()
199
+ view = store.view()
200
+ files = view.files.values()
201
+ return IndexReport(
202
+ snapshot=view.snapshot,
203
+ status="built" if built else "unchanged",
204
+ files=len(view.files),
205
+ entities=view.counts[0],
206
+ relations=view.counts[1],
207
+ partial=tuple(f.path for f in files if f.coverage == "partial"),
208
+ unsupported=sum(f.coverage == "unsupported" for f in files),
209
+ skipped=snapshot.skipped,
210
+ case_collisions=snapshot.case_collisions,
211
+ lexical=view.lexical_unavailable or "available",
212
+ seconds=time.perf_counter() - started,
213
+ previous=previous,
214
+ added=changes[0],
215
+ modified=changes[1],
216
+ deleted=changes[2],
217
+ parsed=parsed,
218
+ reused=reused,
219
+ )
220
+
221
+ def _view(self) -> View:
222
+ """The current generation; its compatibility is checked before it is opened."""
223
+ return self.store.view()
224
+
225
+ @property
226
+ def snapshot(self) -> Snapshot:
227
+ return self._view().snapshot
228
+
229
+ def files(self) -> list[FileInfo]:
230
+ return list(self._view().files.values())
231
+
232
+ def entity(self, key: str) -> Entity | None:
233
+ return self._view().entities([key]).get(key)
234
+
235
+ def relations(self, key: str) -> list[Relation]:
236
+ return self._view().relations(key)
237
+
238
+ def impact(
239
+ self,
240
+ key: str,
241
+ *,
242
+ direction: Literal["upstream", "downstream"] = "upstream",
243
+ depth: int = 3,
244
+ limit: int = 200,
245
+ ) -> Impact:
246
+ """Bounded dependents (``upstream``) or dependencies (``downstream``) of the entity
247
+ ``key``, nearest first, each with its proven depth. Read-only; never ranked or
248
+ packed into context."""
249
+ if direction not in ("upstream", "downstream"):
250
+ raise InvalidArgument(f"direction must be upstream or downstream, got {direction!r}")
251
+ _texts("key", [key])
252
+ depth = _count("depth", depth, MAX_DEPTH)
253
+ limit = _count("limit", limit, MAX_LIMIT)
254
+ view = self._view()
255
+ root = view.entities([key]).get(key)
256
+ if root is None:
257
+ raise InvalidArgument(f"no entity {key!r} in this snapshot")
258
+ found = navigation.walk(view, key, direction, depth, limit)
259
+ known = view.entities(h.key for h in found.hops)
260
+ names = [e.name for e in view.entities(found.roots).values() if e.category == "symbol"]
261
+ return Impact(
262
+ view.snapshot,
263
+ root,
264
+ direction,
265
+ found.roots,
266
+ tuple(
267
+ ImpactHop(known[h.key], h.depth, h.proven_depth, h.via, h.cyclic)
268
+ for h in found.hops
269
+ ),
270
+ view.mentions(names, ("calls", "references")) if direction == "upstream" else 0,
271
+ found.stopped_by,
272
+ )
273
+
274
+ def search(
275
+ self,
276
+ query: str,
277
+ *,
278
+ limit: int = retrieval.LEXICAL_LIMIT,
279
+ paths: Iterable[str] = (),
280
+ symbols: Iterable[str] = (),
281
+ selection: tuple[str, int, int] | None = None,
282
+ changed: Iterable[str] = (),
283
+ ) -> SearchResult:
284
+ """Entry-point candidates for inspection; never the final context."""
285
+ hints = _hints(query, paths, symbols, selection, changed)
286
+ limit = _count("limit", limit, MAX_LIMIT)
287
+ view = self._view()
288
+ found = retrieval.retrieve(view, query, hints, lexical_limit=limit)
289
+ entities = view.entities(e.key for e in found.entries)
290
+ entries = tuple(
291
+ EntryPoint(entities[e.key], e.rank, e.score, tuple(e.seeds)) for e in found.entries
292
+ )
293
+ return SearchResult(view.snapshot, query, entries, found.lexical, tuple(found.issues))
294
+
295
+ def structural_search(
296
+ self,
297
+ rule: str | Mapping[str, Any],
298
+ *,
299
+ language: str,
300
+ paths: Iterable[str] = (),
301
+ limit: int = 100,
302
+ ) -> StructuralResult:
303
+ """Nodes of the published snapshot an ast-grep ``rule`` matches: a pattern string
304
+ or a rule config (``{"rule": ..., "constraints": ...}``). Explicit and read-only;
305
+ never ranked or packed. Needs the ``structural`` extra."""
306
+ if language not in structural.GRAMMARS:
307
+ raise InvalidArgument(f"language must be python or typescript, got {language!r}")
308
+ if not (isinstance(rule, Mapping) or (isinstance(rule, str) and rule.strip())):
309
+ raise InvalidArgument(f"rule must be a pattern string or a rule config, got {rule!r}")
310
+ scope = _texts("paths", paths)
311
+ return structural.search(
312
+ self._view(), rule, language, scope, _count("limit", limit, MAX_LIMIT)
313
+ )
314
+
315
+ def build_context(
316
+ self,
317
+ query: str,
318
+ budget: int,
319
+ *,
320
+ paths: Iterable[str] = (),
321
+ symbols: Iterable[str] = (),
322
+ selection: tuple[str, int, int] | None = None,
323
+ changed: Iterable[str] = (),
324
+ ) -> ContextBundle:
325
+ """A bounded, source-backed context bundle: ``used <= budget`` units. Ordered by
326
+ the optional judge only when this repository authorized it (``judge``)."""
327
+ by = jev.configured(self)
328
+ return self._context(query, budget, paths, symbols, selection, changed, by)
329
+
330
+ def judge(self, enable: bool | None = None) -> JudgeStatus:
331
+ """The optional judge's status for this repository (ADR-0009). ``True`` authorizes
332
+ ``build_context`` to send this repository's source to it, ``False`` revokes that.
333
+ The authorization lives in this repository's store, never in the repository."""
334
+ if enable is not None and not isinstance(enable, bool):
335
+ raise InvalidArgument(f"enable must be True, False or None, got {enable!r}")
336
+ if enable is not None:
337
+ (jev.authorize if enable else jev.revoke)(self)
338
+ return jev.status(self)
339
+
340
+ def _context(
341
+ self,
342
+ query: str,
343
+ budget: int,
344
+ paths: Iterable[str],
345
+ symbols: Iterable[str],
346
+ selection: tuple[str, int, int] | None,
347
+ changed: Iterable[str],
348
+ by: ordering.Order | None,
349
+ ) -> ContextBundle:
350
+ """``build_context``, optionally ordered ``by`` a judge. Internal (ADR-0009 § 5)."""
351
+ hints = _hints(query, paths, symbols, selection, changed)
352
+ budget = _count("budget", budget, MAX_BUDGET)
353
+ view = self._view()
354
+ found = retrieval.retrieve(view, query, hints)
355
+ entries = {e.key: e for e in found.entries}
356
+ route = routing.route(view, list(entries))
357
+ routed = {c.key: c for c in route.candidates}
358
+ later = sorted((k for k in routed if k not in entries), key=lambda k: (routed[k].depth, k))
359
+ order = [k for k in entries if k in routed] + later
360
+ beyond = max(0, len(order) - packing.GRAPH_NODES)
361
+ order = order[: packing.GRAPH_NODES]
362
+ known = view.entities([*order, *(a for k in order for a in packing.ancestors(k))])
363
+ hinted = {k[5:] for k, e in entries.items() if any(s.kind == "path" for s in e.seeds)}
364
+ pool = [
365
+ packing.Candidate(
366
+ known[k],
367
+ routed[k].depth,
368
+ entries[k].rank if k in entries else None,
369
+ bool(entries[k].seeds) if k in entries else False,
370
+ (source := known[k].source) is not None and source.path in hinted,
371
+ entries[k].score if k in entries else None,
372
+ tuple(f"{s.kind}:{s.hint}" for s in entries[k].seeds) if k in entries else (),
373
+ routed[k].origins,
374
+ )
375
+ for k in order
376
+ if k in known
377
+ ]
378
+ # Module-level views need the top-level declarations of every parsed file.
379
+ files = [
380
+ c.entity.key
381
+ for c in pool
382
+ if c.entity.category == "file"
383
+ and c.entity.source is not None
384
+ and view.files[c.entity.source.path].coverage != "unsupported"
385
+ ]
386
+ children = view.adjacency(files, kinds=("contains",))
387
+ tops = view.entities(
388
+ b for k in files for kind, b, *_ in children[k][0] if kind == "contains"
389
+ )
390
+ declarations = {
391
+ k: [
392
+ (s.start, s.end)
393
+ for kind, b, *_ in children[k][0]
394
+ if kind == "contains" and b in tops and (s := tops[b].source) is not None
395
+ ]
396
+ for k in files
397
+ }
398
+ judge = by(query, view.snapshot.derivation_id, view.source) if by else None
399
+ items, omissions = packing.pack(pool, known, budget, view.source, declarations, judge)
400
+ degradations = []
401
+ if found.lexical.state != "ran":
402
+ degradations.append(f"lexical channel {found.lexical.state}: {found.lexical.detail}")
403
+ if route.stopped_by is not None:
404
+ degradations.append(f"routing stopped by its {route.stopped_by} bound")
405
+ if beyond:
406
+ degradations.append(f"{beyond} routed candidates beyond the graph node limit")
407
+ incomplete = {f.path for f in view.files.values() if f.coverage != "complete"}
408
+ used_paths = {c.entity.source.path for c in pool if c.entity.source is not None}
409
+ degradations += [f"incomplete parse: {path}" for path in sorted(used_paths & incomplete)]
410
+ if judge is not None and judge.outcome not in (None, "judged"):
411
+ degradations.append(f"judge {judge.identity} fell back: {judge.outcome}")
412
+ summary = RouteSummary(
413
+ route.root_fallback,
414
+ route.regions,
415
+ len(route.candidates),
416
+ route.deferred,
417
+ route.fanout_truncations,
418
+ route.stopped_by,
419
+ )
420
+ versions = {
421
+ "retrieval": retrieval.RETRIEVAL_VERSION,
422
+ "router": routing.ROUTER_VERSION,
423
+ "selector": packing.SELECTOR_VERSION,
424
+ "payload": packing.PAYLOAD_VERSION,
425
+ "derivation": view.snapshot.derivation_id,
426
+ }
427
+ if judge is not None and judge.outcome == "judged":
428
+ versions |= judge.versions
429
+ return ContextBundle(
430
+ snapshot=view.snapshot,
431
+ query=query,
432
+ budget=budget,
433
+ used=sum(i.units for i in items),
434
+ units=packing.UNITS_VERSION,
435
+ items=tuple(items),
436
+ omissions=tuple(omissions),
437
+ lexical=found.lexical,
438
+ hints=tuple(found.issues),
439
+ route=summary,
440
+ degradations=tuple(degradations),
441
+ versions=versions,
442
+ )
443
+
444
+
445
+ def open_repository(
446
+ path: str | os.PathLike[str],
447
+ *,
448
+ data_dir: str | os.PathLike[str] | None = None,
449
+ create: bool = True,
450
+ ) -> Repository:
451
+ """Opens (and exclusively owns) the store of the repository at ``path``. Nothing is
452
+ indexed until ``index()``; reads before that raise ``NotIndexed``. With
453
+ ``create=False`` (read-only clients) a repository without a store raises
454
+ ``NotIndexed`` here, and nothing is written."""
455
+ return Repository(path, data_dir, create)
456
+
457
+
458
+ def index_repository(
459
+ path: str | os.PathLike[str], *, data_dir: str | os.PathLike[str] | None = None
460
+ ) -> IndexReport:
461
+ with Repository(path, data_dir) as repo:
462
+ return repo.index()
463
+
464
+
465
+ def prepare() -> Path:
466
+ """Fetches and verifies the pinned FTS extension (``oxide prepare``); returns its
467
+ path. The only network access OXIDE makes, and only when called."""
468
+ return prepare_fts()