generic-ml-wrapper 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.
Files changed (107) hide show
  1. generic_ml_wrapper/__init__.py +13 -0
  2. generic_ml_wrapper/adapter/__init__.py +3 -0
  3. generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
  4. generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
  5. generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
  6. generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
  7. generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
  8. generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
  9. generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
  10. generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
  11. generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
  12. generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
  13. generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
  14. generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
  15. generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
  16. generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
  17. generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
  18. generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
  19. generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
  20. generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
  21. generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
  22. generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
  23. generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
  24. generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
  25. generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
  26. generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
  27. generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
  28. generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
  29. generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
  30. generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
  31. generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
  32. generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
  33. generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
  34. generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
  35. generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
  36. generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
  37. generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
  38. generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
  39. generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
  40. generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
  41. generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
  42. generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
  43. generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
  44. generic_ml_wrapper/application/__init__.py +3 -0
  45. generic_ml_wrapper/application/domain/__init__.py +3 -0
  46. generic_ml_wrapper/application/domain/model/__init__.py +3 -0
  47. generic_ml_wrapper/application/domain/model/client_status.py +44 -0
  48. generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
  49. generic_ml_wrapper/application/domain/model/run.py +35 -0
  50. generic_ml_wrapper/application/domain/model/session.py +24 -0
  51. generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
  52. generic_ml_wrapper/application/domain/model/workspace.py +31 -0
  53. generic_ml_wrapper/application/domain/service/__init__.py +3 -0
  54. generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
  55. generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
  56. generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
  57. generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
  58. generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
  59. generic_ml_wrapper/application/port/__init__.py +3 -0
  60. generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
  61. generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
  62. generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
  63. generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
  64. generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
  65. generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
  66. generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
  67. generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
  68. generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
  69. generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
  70. generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
  71. generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
  72. generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
  73. generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
  74. generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
  75. generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
  76. generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
  77. generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
  78. generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
  79. generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
  80. generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
  81. generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
  82. generic_ml_wrapper/application/usecase/__init__.py +3 -0
  83. generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
  84. generic_ml_wrapper/application/usecase/export_usage.py +93 -0
  85. generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
  86. generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
  87. generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
  88. generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
  89. generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
  90. generic_ml_wrapper/application/usecase/set_credential.py +27 -0
  91. generic_ml_wrapper/application/usecase/start_job.py +125 -0
  92. generic_ml_wrapper/application/wiring/__init__.py +3 -0
  93. generic_ml_wrapper/application/wiring/composition.py +230 -0
  94. generic_ml_wrapper/common/__init__.py +3 -0
  95. generic_ml_wrapper/common/config.py +179 -0
  96. generic_ml_wrapper/common/log.py +83 -0
  97. generic_ml_wrapper/common/paths.py +25 -0
  98. generic_ml_wrapper/common/spec_loader.py +67 -0
  99. generic_ml_wrapper/py.typed +0 -0
  100. generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
  101. generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
  102. generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
  103. generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
  104. generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
  105. generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
  106. generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
  107. generic_ml_wrapper-0.1.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The application layer: domain, ports, use cases, wiring."""
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pure domain: entities and services, no I/O."""
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Domain value objects."""
@@ -0,0 +1,44 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The ClientStatus value object: what a client reports to its status line."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from dataclasses import dataclass
9
+
10
+ _MAX_CONTEXT_PCT = 100
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ClientStatus:
15
+ """A client's live status, parsed from the payload it pipes to the status line.
16
+
17
+ Each field is optional because a client may not report it (or not yet).
18
+
19
+ Attributes:
20
+ model: The active model's display name, or ``None``.
21
+ context_pct: The context-window fill percentage, or ``None``.
22
+ session_cost_usd: The session's cumulative cost in USD, or ``None``.
23
+ extras: Client-specific, already-formatted status blocks (e.g. Claude's
24
+ quota) placed after the common fields; empty when the client reports
25
+ none. Each block carries its own label because its shape and name vary
26
+ by client, so the parser owns the formatting and the renderer only
27
+ places them.
28
+ """
29
+
30
+ model: str | None
31
+ context_pct: int | None
32
+ session_cost_usd: float | None
33
+ extras: tuple[str, ...]
34
+
35
+ def __post_init__(self) -> None:
36
+ """Reject an out-of-range context percentage or a negative cost."""
37
+ if self.context_pct is not None and not 0 <= self.context_pct <= _MAX_CONTEXT_PCT:
38
+ message = f"context_pct must be within 0..100, got {self.context_pct}"
39
+ raise ValueError(message)
40
+ if self.session_cost_usd is not None and (
41
+ self.session_cost_usd < 0 or not math.isfinite(self.session_cost_usd)
42
+ ):
43
+ message = f"session_cost_usd must be >= 0 and finite, got {self.session_cost_usd}"
44
+ raise ValueError(message)
@@ -0,0 +1,76 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Validated identifier value objects.
4
+
5
+ Each is a ``str`` subclass validated on construction, so an invalid identifier can
6
+ never be built -- and, being a ``str``, it drops in wherever the raw value flowed
7
+ before. Validation happens at the boundary (the CLI constructs them from argv),
8
+ so bad input fails early with a clear message rather than deep in a filesystem path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ # A job id is a single safe path segment: letters, digits, '-' and '_', starting
16
+ # with a letter or digit, at most 64 chars. No '.', '/', '\\', or NUL -- so no
17
+ # '..' traversal, no absolute path, no separator can reach a filesystem path.
18
+ _JOB_ID = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
19
+
20
+ # A workflow name is lowercase kebab: the same rule new_workflow used, now shared.
21
+ _WORKFLOW_NAME = re.compile(r"\A[a-z0-9][a-z0-9-]*\Z")
22
+
23
+ # An environment-variable name: POSIX portable (letters, digits, '_'; not a leading digit).
24
+ _ENV_VAR_NAME = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z")
25
+
26
+
27
+ class IdentifierError(ValueError):
28
+ """Raised when a string is not a valid identifier of its kind."""
29
+
30
+
31
+ class JobId(str):
32
+ """A validated job identifier (a safe single path segment)."""
33
+
34
+ __slots__ = ()
35
+
36
+ def __new__(cls, value: str) -> JobId:
37
+ """Return the validated job id, or raise :class:`IdentifierError`."""
38
+ if not _JOB_ID.match(value):
39
+ message = (
40
+ f"invalid job id {value!r}: allowed characters are letters, digits, "
41
+ "'-' and '_'; it must start with a letter or digit and be at most 64 characters"
42
+ )
43
+ raise IdentifierError(message)
44
+ return super().__new__(cls, value)
45
+
46
+
47
+ class WorkflowName(str):
48
+ """A validated workflow name (lowercase letters/digits and ``-``)."""
49
+
50
+ __slots__ = ()
51
+
52
+ def __new__(cls, value: str) -> WorkflowName:
53
+ """Return the validated workflow name, or raise :class:`IdentifierError`."""
54
+ if not _WORKFLOW_NAME.match(value):
55
+ message = (
56
+ f"invalid workflow name {value!r}: allowed characters are lowercase "
57
+ "letters, digits and '-'; it must start with a letter or digit"
58
+ )
59
+ raise IdentifierError(message)
60
+ return super().__new__(cls, value)
61
+
62
+
63
+ class EnvVarName(str):
64
+ """A validated environment-variable name (POSIX portable)."""
65
+
66
+ __slots__ = ()
67
+
68
+ def __new__(cls, value: str) -> EnvVarName:
69
+ """Return the validated env-var name, or raise :class:`IdentifierError`."""
70
+ if not _ENV_VAR_NAME.match(value):
71
+ message = (
72
+ f"invalid environment-variable name {value!r}: allowed characters are "
73
+ "letters, digits and '_'; it must not start with a digit"
74
+ )
75
+ raise IdentifierError(message)
76
+ return super().__new__(cls, value)
@@ -0,0 +1,35 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The RunContext handed to a client caller."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RunContext:
12
+ """Everything a caller needs to launch and meter one run.
13
+
14
+ Attributes:
15
+ job: The job identifier.
16
+ session_id: The session's human-readable id.
17
+ client: The client to launch.
18
+ uuid: The client-side session id, or ``None``.
19
+ resume: Whether this run resumes an existing session.
20
+ cwd: The working directory to launch in, or ``None`` for the current one.
21
+ context: Operating context to inject into the session, or ``None``.
22
+ kickoff: An opening message to start the session on, or ``None``.
23
+ env: Extra environment variables to export for the run (name/value pairs),
24
+ e.g. a workflow's resolved credentials.
25
+ """
26
+
27
+ job: str
28
+ session_id: str
29
+ client: str
30
+ uuid: str | None
31
+ resume: bool
32
+ cwd: str | None = None
33
+ context: str | None = None
34
+ kickoff: str | None = None
35
+ env: tuple[tuple[str, str], ...] = ()
@@ -0,0 +1,24 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The Session value object."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Session:
12
+ """A named, resumable client conversation belonging to a job.
13
+
14
+ Attributes:
15
+ session_id: The human-readable id, ``<job>_NNN``.
16
+ job: The job this session belongs to.
17
+ client: The client it runs on (e.g. ``"claude"``).
18
+ uuid: The client-side session id (Claude's ``--session-id``), or ``None``.
19
+ """
20
+
21
+ session_id: str
22
+ job: str
23
+ client: str
24
+ uuid: str | None
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The TurnUsage value object: one metered request/response round."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TurnUsage:
13
+ """The token (and optional cost) usage of a single client turn.
14
+
15
+ A turn is one request/response round a metering gateway observed on the wire.
16
+
17
+ Attributes:
18
+ session_id: The session the turn belongs to.
19
+ input_tokens: Prompt tokens sent upstream.
20
+ output_tokens: Completion tokens received.
21
+ cost_usd: The turn's cost in USD when the gateway can compute it, else
22
+ ``None`` (e.g. no price is known for the model).
23
+ model: The model that served the turn, or ``None`` if unreported.
24
+ cache_creation_tokens: Prompt tokens written to the cache this turn.
25
+ cache_read_tokens: Prompt tokens served from the cache this turn.
26
+ timestamp: The turn's wall-clock time (epoch seconds), or ``0.0``.
27
+ duration_s: How long the turn took, in seconds, or ``0.0``.
28
+ turn_id: The provider's id for this turn, or ``None``.
29
+ """
30
+
31
+ session_id: str
32
+ input_tokens: int
33
+ output_tokens: int
34
+ cost_usd: float | None
35
+ model: str | None
36
+ cache_creation_tokens: int = 0
37
+ cache_read_tokens: int = 0
38
+ timestamp: float = 0.0
39
+ duration_s: float = 0.0
40
+ turn_id: str | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ """Reject impossible usage: negative counts, or non-finite/negative amounts."""
44
+ for name in (
45
+ "input_tokens",
46
+ "output_tokens",
47
+ "cache_creation_tokens",
48
+ "cache_read_tokens",
49
+ ):
50
+ value: int = getattr(self, name)
51
+ if value < 0:
52
+ message = f"{name} must be non-negative, got {value}"
53
+ raise ValueError(message)
54
+ if self.cost_usd is not None and (self.cost_usd < 0 or not math.isfinite(self.cost_usd)):
55
+ message = f"cost_usd must be a non-negative finite number, got {self.cost_usd}"
56
+ raise ValueError(message)
57
+ if self.timestamp < 0 or not math.isfinite(self.timestamp):
58
+ message = f"timestamp must be a non-negative finite number, got {self.timestamp}"
59
+ raise ValueError(message)
60
+ if self.duration_s < 0 or not math.isfinite(self.duration_s):
61
+ message = f"duration_s must be a non-negative finite number, got {self.duration_s}"
62
+ raise ValueError(message)
@@ -0,0 +1,31 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The Workspace value object: environment facts the wrapper reports itself."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Workspace:
12
+ """The working environment a run executes in, computed by the wrapper.
13
+
14
+ These facts are client-agnostic: the wrapper derives them from the working
15
+ directory and git, so they enrich the status line the same way for every
16
+ client. Each field is optional because the run may not be in a git repository
17
+ (or in any resolvable directory at all).
18
+
19
+ Attributes:
20
+ folder: The working directory (home abbreviated to ``~``), or ``None``.
21
+ repo: The git repository's name, or ``None`` outside a repository.
22
+ branch: The checked-out branch, or ``None`` outside a repository.
23
+ short_sha: The current commit's short hash, or ``None``.
24
+ dirty: The count of uncommitted changes (``0`` when clean).
25
+ """
26
+
27
+ folder: str | None
28
+ repo: str | None
29
+ branch: str | None
30
+ short_sha: str | None
31
+ dirty: int
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pure domain services."""
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The ``Interceptor`` abstraction: a text transform applied at a named target.
4
+
5
+ This is the domain-owned contract the :class:`InterceptorChain` sequences. The
6
+ outbound :class:`~generic_ml_wrapper.application.port.outbound.interceptor.InterceptorPort`
7
+ extends it, so the dependency points inward (port -> domain) and the domain never
8
+ reaches out to a port.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+
15
+
16
+ class Interceptor(ABC):
17
+ """Transform a piece of text flowing through gmlw at a named target."""
18
+
19
+ @abstractmethod
20
+ def intercept(self, text: str, target: str) -> str:
21
+ """Return the transformed text (or the input unchanged).
22
+
23
+ Args:
24
+ text: The text to transform.
25
+ target: The target it is running for (e.g. ``context``, ``request``,
26
+ ``response``), so one interceptor can behave per target.
27
+
28
+ Returns:
29
+ The transformed text.
30
+ """
@@ -0,0 +1,57 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The InterceptorChain: apply per-target interceptors in order."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+
12
+ from generic_ml_wrapper.application.domain.service.interceptor import Interceptor
13
+
14
+
15
+ class InterceptorChain:
16
+ """An ordered set of ``(target, interceptor)`` pairs applied at named targets.
17
+
18
+ Each interceptor targets a name; :meth:`apply` runs, in declared order, those
19
+ whose target matches (a target may have 0..N, and one interceptor may appear
20
+ under several targets). The compile applies the context targets
21
+ (``profile``/``rules``/``workflow``/``context``); the metering relay applies the
22
+ wire targets (``request``/``response``). An empty chain is the identity.
23
+ """
24
+
25
+ def __init__(self, interceptors: Sequence[tuple[str, Interceptor]]) -> None:
26
+ """Bind the chain to its ordered interceptors.
27
+
28
+ Args:
29
+ interceptors: The ``(target, interceptor)`` pairs, in application order.
30
+ """
31
+ self._interceptors = tuple(interceptors)
32
+
33
+ def has(self, target: str) -> bool:
34
+ """Whether any interceptor is bound to ``target``.
35
+
36
+ Args:
37
+ target: The target name to check.
38
+
39
+ Returns:
40
+ ``True`` if at least one interceptor targets it.
41
+ """
42
+ return any(interceptor_target == target for interceptor_target, _ in self._interceptors)
43
+
44
+ def apply(self, target: str, text: str) -> str:
45
+ """Apply every interceptor bound to ``target``, in order.
46
+
47
+ Args:
48
+ target: The target name to run interceptors for.
49
+ text: The text to transform.
50
+
51
+ Returns:
52
+ The text after the matching interceptors have run.
53
+ """
54
+ for interceptor_target, interceptor in self._interceptors:
55
+ if interceptor_target == target:
56
+ text = interceptor.intercept(text, target)
57
+ return text
@@ -0,0 +1,35 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pure rule cleaning: drop bookkeeping and human-only notes before the model sees it."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from collections.abc import Sequence
9
+
10
+ _FRONTMATTER = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
11
+ _BLANKS = re.compile(r"\n{3,}")
12
+
13
+
14
+ def clean_rule(text: str, sections: Sequence[str]) -> str:
15
+ """Strip a rule's frontmatter and any human-only sections, losslessly for the model.
16
+
17
+ Removes the leading YAML frontmatter (bookkeeping such as name/status) and each
18
+ ``**Name:**`` section listed in ``sections`` (running until the next ``**Header:**``
19
+ marker, a markdown ``# `` heading, or end of text). Blank runs are collapsed. The
20
+ ``# `` stop is load-bearing: without it a trailing block would swallow the next
21
+ rule's title. Idempotent.
22
+
23
+ Args:
24
+ text: The raw rule text.
25
+ sections: Bold-header section names to drop (case-sensitive on the name).
26
+
27
+ Returns:
28
+ The cleaned rule text.
29
+ """
30
+ text = _FRONTMATTER.sub("", text, count=1)
31
+ if sections:
32
+ names = "|".join(re.escape(name) for name in sections)
33
+ stop = r"(?:^\*\*[^*:\n]+:\*\*|^#\s)"
34
+ text = re.compile(rf"(?ms)^\*\*(?:{names}):\*\*.*?(?={stop}|\Z)").sub("", text)
35
+ return _BLANKS.sub("\n\n", text).strip()
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pure session naming: mint the next ``<job>_NNN`` id."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ _SUFFIX = re.compile(r"_(\d+)$")
10
+
11
+
12
+ def next_session_id(job: str, existing_ids: list[str]) -> str:
13
+ """Return the next sequential session id for a job.
14
+
15
+ Ids are ``<job>_NNN`` (three-digit, 1-based). The next number is one past the
16
+ highest existing suffix, so gaps never cause a collision.
17
+
18
+ Args:
19
+ job: The job identifier.
20
+ existing_ids: The session ids already recorded for the job.
21
+
22
+ Returns:
23
+ The next session id, e.g. ``"JOB-1_001"``.
24
+ """
25
+ highest = 0
26
+ for session_id in existing_ids:
27
+ match = _SUFFIX.search(session_id)
28
+ if match:
29
+ highest = max(highest, int(match.group(1)))
30
+ return f"{job}_{highest + 1:03d}"
@@ -0,0 +1,81 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pure rendering of the working environment and client status into one line."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from generic_ml_wrapper.application.domain.model.client_status import ClientStatus
11
+ from generic_ml_wrapper.application.domain.model.workspace import Workspace
12
+
13
+ _SEPARATOR = " · "
14
+
15
+
16
+ def render_statusline(status: ClientStatus, workspace: Workspace) -> str:
17
+ """Render the working environment and client status as a single line.
18
+
19
+ The wrapper's own facts (git, folder) come first, then the common client
20
+ fields (model, context), then any client-specific ``extras`` (already
21
+ formatted, e.g. Claude's quota), then cost. Blocks the environment or client
22
+ did not provide are omitted, so a bare environment with an empty status yields
23
+ an empty line.
24
+
25
+ Args:
26
+ status: The parsed client status.
27
+ workspace: The working environment the wrapper computed.
28
+
29
+ Returns:
30
+ The status line (no trailing newline).
31
+ """
32
+ blocks = [
33
+ _git(workspace),
34
+ _folder(workspace),
35
+ status.model,
36
+ None if status.context_pct is None else f"ctx {status.context_pct}%",
37
+ *status.extras,
38
+ None if status.session_cost_usd is None else f"${status.session_cost_usd:.2f}",
39
+ ]
40
+ return _SEPARATOR.join(block for block in blocks if block)
41
+
42
+
43
+ def render_usage_row(label: str, name: str, turns: int, tokens: int, cost_usd: float) -> str:
44
+ """Render one usage footer row -- ``<label> <name> · N turns · tok · $`` -- indented.
45
+
46
+ Used for both the current-session row (``label="session"``) and the whole-job
47
+ total (``label="job"``). Turns/tokens show only when deep-metered (``turns`` > 0);
48
+ the cost always shows. The leading indent sets the footer apart from the live line.
49
+
50
+ Args:
51
+ label: The scope label, ``"session"`` or ``"job"``.
52
+ name: The session id or job id.
53
+ turns: The recorded turn count for this scope (``0`` if unmetered).
54
+ tokens: The total tokens across those turns (input + output + cache).
55
+ cost_usd: The scope's cumulative cost.
56
+
57
+ Returns:
58
+ The footer row (no trailing newline).
59
+ """
60
+ parts = [f"{label} {name}"]
61
+ if turns:
62
+ parts.append(f"{turns} turns")
63
+ parts.append(f"{tokens} tok")
64
+ parts.append(f"${cost_usd:.2f}")
65
+ return " " + " · ".join(parts)
66
+
67
+
68
+ def _git(workspace: Workspace) -> str | None:
69
+ if workspace.branch is None:
70
+ return None
71
+ head = f"{workspace.repo}/{workspace.branch}" if workspace.repo else workspace.branch
72
+ parts = [f"git {head}"]
73
+ if workspace.short_sha:
74
+ parts.append(workspace.short_sha)
75
+ if workspace.dirty:
76
+ parts.append(f"dirty:{workspace.dirty}")
77
+ return " ".join(parts)
78
+
79
+
80
+ def _folder(workspace: Workspace) -> str | None:
81
+ return None if workspace.folder is None else f"📁 {workspace.folder}"
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The ports: the application's boundary."""
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Inbound ports: the use cases the app offers."""
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The inbound port for first-run self-initialization."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+
10
+ class Bootstrap(ABC):
11
+ """Ensure the wrapper's runtime layout exists, creating only what is missing."""
12
+
13
+ @abstractmethod
14
+ def execute(self) -> None:
15
+ """Create any missing parts of the ``~/.gmlw`` layout (idempotent)."""
@@ -0,0 +1,109 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The inbound port for reporting a job's recorded usage."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TurnRow:
13
+ """One metered turn, for the per-turn table.
14
+
15
+ Attributes:
16
+ timestamp: The turn's wall-clock time (epoch seconds), or ``0.0``.
17
+ model: The model that served the turn.
18
+ duration_s: How long the turn took, in seconds.
19
+ input_tokens: Fresh prompt tokens.
20
+ output_tokens: Completion tokens.
21
+ cache_tokens: Cache creation + read prompt tokens.
22
+ turn_id: The provider's id for the turn, or ``None``.
23
+ """
24
+
25
+ timestamp: float
26
+ model: str
27
+ duration_s: float
28
+ input_tokens: int
29
+ output_tokens: int
30
+ cache_tokens: int
31
+ turn_id: str | None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ModelTotal:
36
+ """A model's totals across the job.
37
+
38
+ Attributes:
39
+ model: The model's name.
40
+ calls: How many turns this model served.
41
+ input_tokens: Total fresh prompt tokens.
42
+ output_tokens: Total completion tokens.
43
+ cache_tokens: Total cache prompt tokens.
44
+ duration_s: Total duration, in seconds.
45
+ """
46
+
47
+ model: str
48
+ calls: int
49
+ input_tokens: int
50
+ output_tokens: int
51
+ cache_tokens: int
52
+ duration_s: float
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SessionCost:
57
+ """A session's recorded cost.
58
+
59
+ Attributes:
60
+ session_id: The session's human-readable id.
61
+ cost_usd: The session's cumulative cost in USD.
62
+ """
63
+
64
+ session_id: str
65
+ cost_usd: float
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class UsageReport:
70
+ """A job's recorded usage: per-turn rows, totals by model, cost by session, totals.
71
+
72
+ Attributes:
73
+ job: The job identifier.
74
+ turns: Every metered turn, chronological.
75
+ models: Totals by model, sorted by model name.
76
+ session_costs: Recorded cost per session, sorted by session id.
77
+ turn_count: The number of metered turns.
78
+ input_tokens: The job's total fresh prompt tokens.
79
+ output_tokens: The job's total completion tokens.
80
+ cache_tokens: The job's total cache prompt tokens.
81
+ duration_s: The job's total metered duration, in seconds.
82
+ total_usd: The job's total cost across its sessions.
83
+ """
84
+
85
+ job: str
86
+ turns: tuple[TurnRow, ...] = ()
87
+ models: tuple[ModelTotal, ...] = ()
88
+ session_costs: tuple[SessionCost, ...] = ()
89
+ turn_count: int = 0
90
+ input_tokens: int = 0
91
+ output_tokens: int = 0
92
+ cache_tokens: int = 0
93
+ duration_s: float = 0.0
94
+ total_usd: float = 0.0
95
+
96
+
97
+ class ExportUsage(ABC):
98
+ """Report the usage recorded for a job."""
99
+
100
+ @abstractmethod
101
+ def execute(self, job: str) -> UsageReport:
102
+ """Build a job's usage report.
103
+
104
+ Args:
105
+ job: The job identifier.
106
+
107
+ Returns:
108
+ The job's per-turn rows, per-model totals, per-session cost, and totals.
109
+ """