generic-ml-cache-core 0.2.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.
Files changed (99) hide show
  1. generic_ml_cache_core/__init__.py +64 -0
  2. generic_ml_cache_core/adapter/__init__.py +1 -0
  3. generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
  4. generic_ml_cache_core/adapter/inbound/composition.py +96 -0
  5. generic_ml_cache_core/adapter/out/__init__.py +1 -0
  6. generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
  7. generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
  8. generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
  9. generic_ml_cache_core/adapter/out/client/claude.py +214 -0
  10. generic_ml_cache_core/adapter/out/client/codex.py +171 -0
  11. generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
  12. generic_ml_cache_core/adapter/out/client/discover.py +121 -0
  13. generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
  14. generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
  15. generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
  16. generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
  17. generic_ml_cache_core/adapter/out/client/registry.py +34 -0
  18. generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
  19. generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
  20. generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
  21. generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
  22. generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
  23. generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
  24. generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
  25. generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
  26. generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
  27. generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
  28. generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
  29. generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
  30. generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
  31. generic_ml_cache_core/application/__init__.py +1 -0
  32. generic_ml_cache_core/application/domain/__init__.py +1 -0
  33. generic_ml_cache_core/application/domain/model/__init__.py +1 -0
  34. generic_ml_cache_core/application/domain/model/client_status.py +17 -0
  35. generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
  36. generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
  37. generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
  38. generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
  39. generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
  40. generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
  41. generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
  42. generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
  43. generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
  44. generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
  45. generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
  46. generic_ml_cache_core/application/domain/model/model_info.py +20 -0
  47. generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
  48. generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
  49. generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
  50. generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
  51. generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
  52. generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
  53. generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
  54. generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
  55. generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
  56. generic_ml_cache_core/application/domain/model/run/message.py +20 -0
  57. generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
  58. generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
  59. generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
  60. generic_ml_cache_core/application/domain/service/__init__.py +1 -0
  61. generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
  62. generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
  63. generic_ml_cache_core/application/port/__init__.py +1 -0
  64. generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
  65. generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
  66. generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
  67. generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
  68. generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
  69. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
  70. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
  71. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
  72. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
  73. generic_ml_cache_core/application/port/out/__init__.py +1 -0
  74. generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
  75. generic_ml_cache_core/application/port/out/base.py +272 -0
  76. generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
  77. generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
  78. generic_ml_cache_core/application/port/out/clock_port.py +22 -0
  79. generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
  80. generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
  81. generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
  82. generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
  83. generic_ml_cache_core/application/usecase/__init__.py +1 -0
  84. generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
  85. generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
  86. generic_ml_cache_core/application/usecase/journal_events.py +19 -0
  87. generic_ml_cache_core/application/usecase/probe_service.py +44 -0
  88. generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
  89. generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
  90. generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
  91. generic_ml_cache_core/common/__init__.py +1 -0
  92. generic_ml_cache_core/common/checksum.py +82 -0
  93. generic_ml_cache_core/common/errors.py +76 -0
  94. generic_ml_cache_core/stream.py +65 -0
  95. generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
  96. generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
  97. generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
  98. generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
  99. generic_ml_cache_core-0.2.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,35 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ProbeCommand."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List
9
+
10
+ from generic_ml_cache_core.application.domain.service.cacheability import is_call_uncacheable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ProbeCommand:
15
+ """The input to the probe use case: the key-determining inputs only.
16
+
17
+ A probe is a read-only forecast, so it carries no run policy (no cache mode,
18
+ no persist/record flags, no system prompt). It exposes the same keyed fields a
19
+ run does, so both derive the same key from the shared builder.
20
+ """
21
+
22
+ client: str
23
+ model: str
24
+ effort: str
25
+ context: str
26
+ prompt: str
27
+ input_file_paths: List[str] = field(default_factory=list)
28
+ allow_paths: List[str] = field(default_factory=list)
29
+ scan_trust: bool = False
30
+ client_args: List[str] = field(default_factory=list)
31
+ grants: List[str] = field(default_factory=list)
32
+
33
+ @property
34
+ def is_uncacheable(self) -> bool:
35
+ return is_call_uncacheable(self.allow_paths, self.scan_trust)
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ProbeUseCase (inbound port)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from generic_ml_cache_core.application.domain.model.probe.probe_report import ProbeReport
10
+ from generic_ml_cache_core.application.port.inbound.probe_command import ProbeCommand
11
+
12
+
13
+ class ProbeUseCase(ABC):
14
+ """Inbound port for the read-only cache probe — a forecast of what a run would
15
+ do, with no side effects (no client launch, no store, no journal event)."""
16
+
17
+ @abstractmethod
18
+ def execute(self, command: ProbeCommand) -> ProbeReport:
19
+ """Forecast the verdict for ``command`` without running or recording."""
@@ -0,0 +1,40 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RunApiExecutionCommand."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List
9
+
10
+ from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
11
+ from generic_ml_cache_core.application.domain.model.run.message import Message
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RunApiExecutionCommand:
16
+ """The input to the API use case.
17
+
18
+ The caller builds the full message list (there is no local client to read
19
+ files or scan folders), so there are no input-file, allow-path, grant, or
20
+ scan-trust fields. An API call is always cacheable.
21
+
22
+ Note (future): ``persist_output = False`` will be incompatible with async
23
+ execution — an async call must store its output so the caller can retrieve it
24
+ by id later. Async is not built yet, so nothing enforces it here.
25
+ """
26
+
27
+ provider: str
28
+ model: str
29
+ messages: List[Message] = field(default_factory=list)
30
+ cache_mode: CacheMode = CacheMode.CACHE
31
+ persist_output: bool = True
32
+ record_on_error: bool = False
33
+
34
+ def should_persist(self, succeeded: bool) -> bool:
35
+ """Whether this command's policy stores an output for a run that ended
36
+ with ``succeeded``: never without ``persist_output``; a failure only with
37
+ ``record_on_error``."""
38
+ if not self.persist_output:
39
+ return False
40
+ return succeeded or self.record_on_error
@@ -0,0 +1,20 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RunApiExecutionUseCase (inbound port)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
10
+ from generic_ml_cache_core.application.port.inbound.run_api_execution_command import (
11
+ RunApiExecutionCommand,
12
+ )
13
+
14
+
15
+ class RunApiExecutionUseCase(ABC):
16
+ """Inbound port for record-or-replay of a direct ML provider API call."""
17
+
18
+ @abstractmethod
19
+ def execute(self, command: RunApiExecutionCommand) -> MlExecution:
20
+ """Resolve the API command and return the resulting execution."""
@@ -0,0 +1,48 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RunManagedLocalExecutionCommand."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional
9
+
10
+ from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
11
+ from generic_ml_cache_core.application.domain.service.cacheability import is_call_uncacheable
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RunManagedLocalExecutionCommand:
16
+ """The input to the managed-local use case: raw user intent only.
17
+
18
+ It carries file *paths* and raw text, never fingerprints — the use case
19
+ reads the files and computes the fingerprints. The use case builds the
20
+ CallIdentity from this command; the command never builds it itself.
21
+ """
22
+
23
+ client: str
24
+ model: str
25
+ effort: str
26
+ context: str
27
+ prompt: str
28
+ user_system_prompt: Optional[str] = None
29
+ input_file_paths: List[str] = field(default_factory=list)
30
+ allow_paths: List[str] = field(default_factory=list)
31
+ scan_trust: bool = False
32
+ client_args: List[str] = field(default_factory=list)
33
+ grants: List[str] = field(default_factory=list)
34
+ cache_mode: CacheMode = CacheMode.CACHE
35
+ persist_output: bool = True
36
+ record_on_error: bool = False
37
+
38
+ @property
39
+ def is_uncacheable(self) -> bool:
40
+ return is_call_uncacheable(self.allow_paths, self.scan_trust)
41
+
42
+ def should_persist(self, succeeded: bool) -> bool:
43
+ """Whether this command's policy stores an output for a run that ended
44
+ with ``succeeded``: never without ``persist_output``; a failure only with
45
+ ``record_on_error``."""
46
+ if not self.persist_output:
47
+ return False
48
+ return succeeded or self.record_on_error
@@ -0,0 +1,25 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RunManagedLocalExecutionUseCase (inbound port)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
10
+ from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import (
11
+ RunManagedLocalExecutionCommand,
12
+ )
13
+
14
+
15
+ class RunManagedLocalExecutionUseCase(ABC):
16
+ """Inbound port for record-or-replay of a fully managed local client call.
17
+
18
+ The driving adapter (CLI, daemon, library consumer) depends on this contract
19
+ and never on the implementation. The composition root wires the concrete
20
+ service in.
21
+ """
22
+
23
+ @abstractmethod
24
+ def execute(self, command: RunManagedLocalExecutionCommand) -> MlExecution:
25
+ """Resolve the command and return the resulting execution."""
@@ -0,0 +1,35 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RunPassthroughExecutionCommand."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List
9
+
10
+ from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RunPassthroughExecutionCommand:
15
+ """The input to the passthrough use case.
16
+
17
+ Everything after the client name is opaque: ``native_args`` is forwarded to
18
+ the client verbatim and enters the key as-is (by fingerprint). A passthrough
19
+ is always cacheable (it declares no scan folders), so there is no allow-path
20
+ or scan-trust here.
21
+ """
22
+
23
+ client: str
24
+ native_args: List[str] = field(default_factory=list)
25
+ cache_mode: CacheMode = CacheMode.CACHE
26
+ persist_output: bool = True
27
+ record_on_error: bool = False
28
+
29
+ def should_persist(self, succeeded: bool) -> bool:
30
+ """Whether this command's policy stores an output for a run that ended
31
+ with ``succeeded``: never without ``persist_output``; a failure only with
32
+ ``record_on_error``."""
33
+ if not self.persist_output:
34
+ return False
35
+ return succeeded or self.record_on_error
@@ -0,0 +1,20 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RunPassthroughExecutionUseCase (inbound port)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
10
+ from generic_ml_cache_core.application.port.inbound.run_passthrough_execution_command import (
11
+ RunPassthroughExecutionCommand,
12
+ )
13
+
14
+
15
+ class RunPassthroughExecutionUseCase(ABC):
16
+ """Inbound port for record-or-replay of a passthrough (alias) client call."""
17
+
18
+ @abstractmethod
19
+ def execute(self, command: RunPassthroughExecutionCommand) -> MlExecution:
20
+ """Resolve the passthrough command and return the resulting execution."""
@@ -0,0 +1 @@
1
+ """Hexagonal layer package."""
@@ -0,0 +1,26 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ApiClientPort."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import List
9
+
10
+ from generic_ml_cache_core.application.domain.model.run.client_run_result import ClientRunResult
11
+ from generic_ml_cache_core.application.domain.model.run.message import Message
12
+
13
+
14
+ class ApiClientPort(ABC):
15
+ """Outbound port for calling an ML provider API directly.
16
+
17
+ Distinct from the local runner ports: there is no subprocess, no filesystem,
18
+ and no grants — the caller has already built the full message list. The
19
+ adapter returns a raw ClientRunResult with the response text as stdout, an
20
+ empty file list, and the provider-reported token usage when available.
21
+ """
22
+
23
+ @abstractmethod
24
+ def run(self, provider: str, model: str, messages: List[Message]) -> ClientRunResult:
25
+ """Call ``provider``'s ``model`` with ``messages`` and return the raw
26
+ result. Raises on an unrecoverable transport failure."""
@@ -0,0 +1,272 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Client adapters: how to launch one agentic CLI and read its output.
4
+
5
+ An adapter is the *only* place that knows a specific CLI's flags. Everything
6
+ else in the cache works in terms of the neutral quartet
7
+ ``(model, effort, prompt, context)`` plus a system prompt.
8
+
9
+ The v0.0.1 adapters below encode each CLI's own launch conventions (Claude
10
+ ``--effort``; Codex ``model_reasoning_effort``; Cursor bakes effort into the
11
+ model id). Flags can drift, so every adapter:
12
+
13
+ * accepts an explicit ``executable`` override (the *executable seam*), and
14
+ * keeps launch wiring small and obvious so it is cheap to correct.
15
+
16
+ Adapters never decide caching policy and never read the caller's ambient files.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import shutil
22
+ from abc import ABC, abstractmethod
23
+ from pathlib import Path
24
+ from typing import ClassVar, List, Optional, Sequence
25
+
26
+ from generic_ml_cache_core.application.domain.model.model_info import ModelInfo as ModelInfo
27
+ from generic_ml_cache_core.application.domain.model.parsed_output import ParsedOutput
28
+ from generic_ml_cache_core.common.errors import ClientNotFound
29
+
30
+
31
+ class ClientAdapter(ABC):
32
+ """Translate the neutral request into a concrete subprocess invocation."""
33
+
34
+ #: short client name used in stored records and on the CLI (e.g. "claude")
35
+ name: ClassVar[str]
36
+ #: default executable looked up on PATH when no override is given
37
+ default_executable: ClassVar[str]
38
+
39
+ def resolve_executable(self, override: Optional[str]) -> str:
40
+ """Return an absolute path to the executable, honoring the seam."""
41
+ candidate = override or self.default_executable
42
+ # An explicit path (contains a separator) is used verbatim if it exists.
43
+ if any(sep in candidate for sep in ("/", "\\")):
44
+ p = Path(candidate)
45
+ if p.exists():
46
+ return str(p)
47
+ raise ClientNotFound(f"executable not found at {candidate!r}")
48
+ found = shutil.which(candidate)
49
+ if not found:
50
+ raise ClientNotFound(
51
+ f"could not find {candidate!r} on PATH; pass --executable to override"
52
+ )
53
+ return found
54
+
55
+ def version_argv(self, executable: str) -> List[str]:
56
+ """Argv that prints the client's version. Default: ``<exe> --version``.
57
+
58
+ Override only if a client uses a different flag. Advisory: used by the
59
+ ``doctor`` command for discovery; it never affects caching.
60
+ """
61
+ return [executable, "--version"]
62
+
63
+ def models_argv(self, executable: str) -> Optional[List[str]]:
64
+ """Argv that makes the client list the models it can use, or ``None``.
65
+
66
+ Return ``None`` when the client has no scriptable way to enumerate its
67
+ models -- discovery then reports "not supported" for this client rather
68
+ than inventing or substituting a list. When non-``None``, the output is
69
+ relayed through :meth:`parse_model_list`. Because the client is the one
70
+ already authenticated, a relayed list reflects what *that account* can
71
+ actually reach. Advisory: never selects, restricts, or gates a run.
72
+ """
73
+ return None
74
+
75
+ def parse_model_list(self, stdout: str) -> List[ModelInfo]:
76
+ """Structure the client's raw model-list output into ``ModelInfo``.
77
+
78
+ Only called when :meth:`models_argv` returns a command; override the two
79
+ together. Keep parsing to plain structuring of what the client printed.
80
+ """
81
+ raise NotImplementedError
82
+
83
+ def prepare(self, run_dir: Path, context: str, prompt: str, system_prompt: str) -> None:
84
+ """Write any input files the client needs into its isolated folder.
85
+
86
+ Called *before* the pre-run snapshot, so anything written here is part of
87
+ the baseline and is therefore not mistaken for client output. Default:
88
+ no-op (the client receives everything via argv/stdin).
89
+ """
90
+
91
+ @abstractmethod
92
+ def build_argv(
93
+ self,
94
+ executable: str,
95
+ run_dir: Path,
96
+ model: str,
97
+ effort: str,
98
+ context: str,
99
+ prompt: str,
100
+ system_prompt: str,
101
+ client_args: List[str],
102
+ grants: Sequence[str] = (),
103
+ ) -> List[str]:
104
+ """Return the full argv to launch the client in ``run_dir``.
105
+
106
+ ``client_args`` are passthrough arguments the caller wants appended to the
107
+ launch verbatim -- the cache never interprets them. The adapter places
108
+ them as late as its CLI allows while they are still read as flags: at the
109
+ very end for clients whose prompt arrives on stdin, but **before the
110
+ trailing prompt positional** for a client that takes the prompt in argv
111
+ (otherwise they would be swallowed as prompt text rather than applied).
112
+
113
+ ``grants`` are declared capabilities to *open* for this run (e.g. ``"net"``
114
+ for network access). The adapter opens the matching door via
115
+ :meth:`grant_setup` (a config-file mechanism) when granted. Grants enable;
116
+ they never restrict (see ``docs/reference/grants.md``).
117
+ """
118
+
119
+ def stdin_payload(self, context: str, prompt: str, system_prompt: str) -> Optional[str]:
120
+ """Optional text to feed on stdin. Default: nothing."""
121
+ return None
122
+
123
+ def parse_output(self, stdout: str) -> ParsedOutput:
124
+ """Lift the clean answer text and the usage envelope out of the client's
125
+ raw stdout.
126
+
127
+ The cache runs each client in its **structured (JSON) output mode** so it
128
+ can read usage -- which means raw stdout is no longer the bare answer but a
129
+ JSON object (or JSON-lines stream) with the answer as one field and the
130
+ token counts beside it. The adapter is the only place that knows its own
131
+ client's structure, so it does the extraction here: it returns the answer
132
+ text (which the cache then hands the caller on stdout, exactly as a plain
133
+ client would) and the normalized :class:`~..usage.Usage` it read.
134
+
135
+ Default: the client was *not* run in a structured mode, so stdout already
136
+ *is* the answer and there is no usage to read. Adapters that switch their
137
+ client to JSON override this.
138
+
139
+ An override MUST degrade rather than raise: if the output cannot be parsed
140
+ (an unexpected shape, a truncated stream), return ``ParsedOutput(stdout,
141
+ None)`` so a parsing surprise never breaks the core call -- the caller
142
+ still gets the client's output, just without a usage envelope.
143
+ """
144
+ return ParsedOutput(text=stdout, usage=None)
145
+
146
+ def read_access_argv(self, paths: List[str]) -> List[str]:
147
+ """Extra argv granting the client read access to ``paths`` (directories).
148
+
149
+ Default: none -- the client relies on the soft prime-directive door only.
150
+ Adapters with a real per-client read mechanism override this (Claude:
151
+ ``--add-dir``). Codex and Cursor have one too but it is heterogeneous and
152
+ currently unverified, so they stay on the directive until adapter
153
+ hardening verifies them against the live CLIs.
154
+ """
155
+ return []
156
+
157
+ def write_access_argv(self, run_dir: Path) -> List[str]:
158
+ """Extra argv opening the client's WRITE/TRUST door for its own ``run_dir``.
159
+
160
+ Headless clients refuse to write by default: they pause on a permission
161
+ prompt, or decline a workspace they have not been told to trust. Without
162
+ this the client only *narrates* the file it was asked to produce, the
163
+ before/after diff captures nothing, and a file-producing call records an
164
+ empty ``response.files`` -- the v0.0.5 record-path bug this fixes.
165
+
166
+ The run folder is the client's own isolated, ephemeral sandbox, so writing
167
+ into it is the normal case and the grant is **on by default**. It is scoped
168
+ to that folder: reads *outside* it are unaffected and remain gated by the
169
+ prime directive and :meth:`read_access_argv`. Unlike ``read_access_argv``
170
+ (appended after :meth:`build_argv`), each adapter splices this into
171
+ ``build_argv`` itself, because some CLIs take the prompt as a trailing
172
+ positional and reject flags placed after it.
173
+
174
+ Default: none. The per-client flags below are verified against the live
175
+ CLIs (see ``docs/client-mapping.md``); adapter hardening keeps them small
176
+ and correctable should a CLI change.
177
+ """
178
+ return []
179
+
180
+ #: capabilities the cache can OPEN via the uniform config-file mechanism.
181
+ GRANTS: ClassVar[tuple] = ("net", "read", "write", "shell", "web-search")
182
+
183
+ def grant_setup(self, run_dir: Path, config_home: Path, grants: Sequence[str]) -> dict:
184
+ """Render this client's own config file into ``config_home`` so the file --
185
+ not a flag -- enables the granted capabilities, and return the environment
186
+ the run needs (the client's config-home variable pointed at ``config_home``,
187
+ e.g. ``{"CODEX_HOME": ...}``), seeding any credentials the relocated home
188
+ needs along the way.
189
+
190
+ This is the uniform door (v0.0.16): every client is driven the same way --
191
+ a private config home, its home variable pointed at it, the settings file
192
+ written inside. Writing into the run folder is always on (the client cannot
193
+ produce output otherwise); the named grants open capability *beyond* that.
194
+ Grants ENABLE, never restrict (``docs/reference/grants.md``); where a client has no
195
+ file-level way to *close* a capability, that is a documented limit, not a
196
+ door this method tries to shut.
197
+
198
+ ``config_home`` is separate from ``run_dir``, so nothing written here is
199
+ ever mistaken for client output or captured into a stored record. Default: no
200
+ config home, empty env (adapters override).
201
+ """
202
+ return {}
203
+
204
+ def grant_argv(self, grants: Sequence[str]) -> List[str]:
205
+ """Operational flags a client FORCES for a grant its config file cannot
206
+ express -- not a capability door, a transport necessity (Cursor's external
207
+ network egress is ignored in its sandbox file headless, so ``net`` still
208
+ needs ``--force``). Default: none.
209
+ """
210
+ return []
211
+
212
+ def stream_event(self, raw_line: str) -> Optional[dict]:
213
+ """Map ONE raw stdout line of this client's streaming output into a small
214
+ normalized progress event, e.g. ``{"kind": "thinking"}`` or
215
+ ``{"kind": "tool", "name": "web_search"}`` -- or ``None`` to ignore the
216
+ line. Used only to feed the opt-in live stream (``--stream``; see
217
+ ``stream.py``); it never affects what is recorded. Default: ignore every
218
+ line (a client whose stream we do not normalize still shows the cache's own
219
+ ``run.start`` / ``run.end`` events).
220
+ """
221
+ return None
222
+
223
+
224
+ def final_result_object(stdout: str):
225
+ """Return the client's final result object, whether its output arrived as a
226
+ single JSON object (``--output-format json``) or as the last ``type:result``
227
+ line of an NDJSON stream (``--output-format stream-json``).
228
+
229
+ Claude and Cursor emit *the same* result object in both forms, so the recorded
230
+ answer and usage are identical either way -- this is what lets the live stream
231
+ switch the client to streaming mode without changing the stored record. Returns
232
+ ``None`` if nothing parseable is present (the adapter then degrades to raw
233
+ stdout with no usage).
234
+ """
235
+ import json
236
+
237
+ text = stdout.strip()
238
+ if not text:
239
+ return None
240
+ # Single object (today's --output-format json): parses whole, in one shot.
241
+ try:
242
+ doc = json.loads(text)
243
+ if isinstance(doc, dict):
244
+ return doc
245
+ except (json.JSONDecodeError, ValueError):
246
+ pass # not a single object -> it is an NDJSON stream; scan for the result
247
+ last = None
248
+ for line in stdout.splitlines():
249
+ line = line.strip()
250
+ if not line:
251
+ continue
252
+ try:
253
+ event = json.loads(line)
254
+ except (json.JSONDecodeError, ValueError):
255
+ continue
256
+ if isinstance(event, dict) and event.get("type") == "result":
257
+ last = event
258
+ return last
259
+
260
+
261
+ def ensure_trailing_newline(text: str) -> str:
262
+ """Append a newline to a client's answer when it lacks one.
263
+
264
+ A client's structured (JSON) ``result`` carries the bare answer text, without
265
+ the trailing newline a real CLI prints when it shows that answer. Without this
266
+ the replayed answer butts against the next shell prompt, and a piped capture
267
+ (``gmlcache run ... > file``) lacks the conventional final newline. Normalizing
268
+ here -- at the adapter boundary -- keeps record and replay byte-identical (the
269
+ recorded form simply includes the newline). Empty text is left untouched."""
270
+ if text and not text.endswith("\n"):
271
+ return text + "\n"
272
+ return text
@@ -0,0 +1,37 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """BlobStorePort."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Optional
9
+
10
+
11
+ class BlobStorePort(ABC):
12
+ """Outbound port for storing and retrieving opaque execution output bytes.
13
+
14
+ The store is intentionally dumb: it translates a key to its own address and
15
+ reads/writes bytes. It never parses a payload, never computes a key, and
16
+ never interprets content. This is what makes it swappable (filesystem, S3,
17
+ in-memory) without touching the core.
18
+
19
+ The key is always produced by CallIdentity.generate_key().
20
+ """
21
+
22
+ @abstractmethod
23
+ def get(self, key: str) -> Optional[bytes]:
24
+ """Return the stored bytes for ``key``, or None if not present."""
25
+
26
+ @abstractmethod
27
+ def put(self, key: str, output: bytes) -> None:
28
+ """Persist ``output`` under ``key``, overwriting any prior value."""
29
+
30
+ @abstractmethod
31
+ def remove(self, key: str) -> None:
32
+ """Delete the bytes stored under ``key``; a no-op if nothing is stored.
33
+
34
+ Removal is driven by a reference-counted prune (a blob is content-
35
+ addressed and may be shared by many executions), so a caller removes a
36
+ key only after confirming no execution still references it.
37
+ """
@@ -0,0 +1,26 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ClientRunnerPort."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from generic_ml_cache_core.application.domain.model.run.client_run_request import ClientRunRequest
10
+ from generic_ml_cache_core.application.domain.model.run.client_run_result import ClientRunResult
11
+
12
+
13
+ class ClientRunnerPort(ABC):
14
+ """Outbound port for launching a local ML client and capturing its output.
15
+
16
+ The adapter is the only place that knows a specific client's CLI flags,
17
+ isolation mechanism, and output format. The core names only this contract.
18
+ The runner returns a raw ``ClientRunResult`` — it never hashes, never
19
+ computes a key, never stores; turning the result into stored artifacts is
20
+ the use case's job.
21
+ """
22
+
23
+ @abstractmethod
24
+ def run(self, client_run_request: ClientRunRequest) -> ClientRunResult:
25
+ """Launch the client described by ``client_run_request`` and return its
26
+ raw captured result. Raises on unrecoverable launch failure."""
@@ -0,0 +1,22 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ClockPort."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from datetime import datetime
9
+
10
+
11
+ class ClockPort(ABC):
12
+ """Outbound port for reading the current time.
13
+
14
+ Time is ambient I/O, so the core never calls ``datetime.now()`` directly —
15
+ it reads the clock through this port. The composition root injects a real
16
+ clock; a test injects a fixed one. This keeps the engine deterministic and
17
+ free of hidden wall-clock dependencies.
18
+ """
19
+
20
+ @abstractmethod
21
+ def now(self) -> datetime:
22
+ """Return the current instant as a timezone-aware UTC datetime."""