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,71 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Parse cursor-agent's status-line payload into a ``ClientStatus``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import cast
8
+
9
+ from generic_ml_wrapper.application.domain.model.client_status import ClientStatus
10
+ from generic_ml_wrapper.application.port.outbound.client_status import ClientStatusParserPort
11
+
12
+
13
+ def _dig(payload: object, *keys: str) -> object:
14
+ current: object = payload
15
+ for key in keys:
16
+ if not isinstance(current, dict):
17
+ return None
18
+ current = cast("dict[str, object]", current).get(key)
19
+ return current
20
+
21
+
22
+ class CursorStatusParser(ClientStatusParserPort):
23
+ """Read model and context fill from cursor-agent's status payload.
24
+
25
+ cursor-agent pipes a Claude-Code-compatible payload, so ``model.display_name``
26
+ and ``context_window.used_percentage`` parse just like Claude's. cursor is
27
+ subscription-metered, so there is no per-session cost on the wire (``None``).
28
+
29
+ Its allowance block -- the plan pools (auto/api %) -- is NOT in the status
30
+ payload; cursor exposes that only via its dashboard API. ``extras`` therefore
31
+ carries the plan block only if a payload happens to include a ``plan`` table;
32
+ otherwise it is omitted (the dashboard-API fetch is a separate concern).
33
+ """
34
+
35
+ def parse(self, payload: dict[str, object]) -> ClientStatus:
36
+ """Parse cursor-agent's status payload.
37
+
38
+ Args:
39
+ payload: The decoded JSON cursor-agent pipes to the status-line command
40
+ (``model.display_name``, ``context_window.used_percentage``, and --
41
+ if present -- a ``plan`` table).
42
+
43
+ Returns:
44
+ The parsed status.
45
+ """
46
+ return ClientStatus(
47
+ model=_as_str(_dig(payload, "model", "display_name")),
48
+ context_pct=_as_pct(_dig(payload, "context_window", "used_percentage")),
49
+ session_cost_usd=None,
50
+ extras=_plan_extras(_dig(payload, "plan")),
51
+ )
52
+
53
+
54
+ def _plan_extras(plan: object) -> tuple[str, ...]:
55
+ """Cursor's plan-pool allowance block, if the payload carries one (auto/api %)."""
56
+ parts: list[str] = []
57
+ for label, key in (("auto", "auto_pct"), ("api", "api_pct")):
58
+ pct = _as_pct(_dig(plan, key))
59
+ if pct is not None:
60
+ parts.append(f"{label} {pct}%")
61
+ return (f"plan {' · '.join(parts)}",) if parts else ()
62
+
63
+
64
+ def _as_str(value: object) -> str | None:
65
+ return value if isinstance(value, str) and value else None
66
+
67
+
68
+ def _as_pct(value: object) -> int | None:
69
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
70
+ return None
71
+ return round(value)
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Filesystem persistence adapters."""
@@ -0,0 +1,65 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Filesystem ``TranscriptPort``: the self-contained per-call in/out/usage trio.
4
+
5
+ Each metered call writes three files under ``<root>/<job>/<session>/``::
6
+
7
+ call_001.in.json the request body forwarded upstream
8
+ call_001.out.sse the raw response body
9
+ call_001.usage.json tokens, cost, model, and timing
10
+
11
+ The folder is self-identifying, so it can be copied out and analysed with no
12
+ knowledge of the ledger. ``usage.json`` deliberately duplicates the metering row --
13
+ that is what makes the folder portable.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import TYPE_CHECKING
20
+
21
+ from generic_ml_wrapper.application.port.outbound.transcript import TranscriptCall, TranscriptPort
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+ from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
27
+
28
+
29
+ class FilesystemTranscriptStore(TranscriptPort):
30
+ """Persist each call's in/out/usage trio under a per-session folder."""
31
+
32
+ def __init__(self, root: Path) -> None:
33
+ """Bind the store to its transcript root.
34
+
35
+ Args:
36
+ root: The directory under which ``<job>/<session>/`` folders are written.
37
+ """
38
+ self._root = root
39
+
40
+ def record(self, call: TranscriptCall) -> None:
41
+ """Write the call's request, response, and usage as the three trio files."""
42
+ directory = self._root / call.job / call.session
43
+ directory.mkdir(parents=True, exist_ok=True)
44
+ stem = f"call_{call.call_seq:03d}"
45
+ (directory / f"{stem}.in.json").write_bytes(call.request)
46
+ (directory / f"{stem}.out.sse").write_bytes(call.response)
47
+ (directory / f"{stem}.usage.json").write_text(
48
+ json.dumps(_usage_dict(call.usage), indent=2), encoding="utf-8"
49
+ )
50
+
51
+
52
+ def _usage_dict(usage: TurnUsage | None) -> dict[str, object]:
53
+ if usage is None:
54
+ return {}
55
+ return {
56
+ "input_tokens": usage.input_tokens,
57
+ "output_tokens": usage.output_tokens,
58
+ "cache_creation_tokens": usage.cache_creation_tokens,
59
+ "cache_read_tokens": usage.cache_read_tokens,
60
+ "cost_usd": usage.cost_usd,
61
+ "model": usage.model,
62
+ "timestamp": usage.timestamp,
63
+ "duration_s": usage.duration_s,
64
+ "turn_id": usage.turn_id,
65
+ }
@@ -0,0 +1,104 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The SQLite ledger: the one ``~/.gmlw/ledger.db`` behind the store adapters.
4
+
5
+ A connection is opened per operation: WAL mode lets concurrent sessions -- and the
6
+ metering relay's threads -- read and write without a shared, thread-bound
7
+ connection, and transactions give crash-consistency for free (no temp-file dance).
8
+ Pre-1.0 the schema is a single create-from-final-state file; a schema change is a
9
+ full store reset (one user, resets freely), so real per-version migrations wait for
10
+ the 1.0 backwards-compatibility promise. See docs/storage-design.md.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+ from contextlib import contextmanager
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Generator
21
+ from pathlib import Path
22
+
23
+ SCHEMA_VERSION = 1
24
+
25
+ _SCHEMA = """
26
+ CREATE TABLE jobs (
27
+ job TEXT PRIMARY KEY,
28
+ kind TEXT NOT NULL DEFAULT 'work', -- 'work' | 'authoring'
29
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
30
+ );
31
+
32
+ CREATE TABLE sessions (
33
+ id INTEGER PRIMARY KEY,
34
+ session_id TEXT NOT NULL UNIQUE, -- <job>_NNN
35
+ job TEXT NOT NULL,
36
+ client TEXT NOT NULL,
37
+ uuid TEXT,
38
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
39
+ );
40
+ CREATE INDEX idx_sessions_job ON sessions(job);
41
+
42
+ CREATE TABLE turns (
43
+ id INTEGER PRIMARY KEY,
44
+ job TEXT NOT NULL,
45
+ session_id TEXT NOT NULL,
46
+ turn_id TEXT,
47
+ input_tokens INTEGER NOT NULL,
48
+ output_tokens INTEGER NOT NULL,
49
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
50
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
51
+ cost_usd REAL,
52
+ model TEXT,
53
+ timestamp REAL NOT NULL DEFAULT 0,
54
+ duration_s REAL NOT NULL DEFAULT 0
55
+ );
56
+ CREATE INDEX idx_turns_job ON turns(job);
57
+
58
+ CREATE TABLE session_costs (
59
+ session_id TEXT PRIMARY KEY,
60
+ job TEXT NOT NULL,
61
+ cost_usd REAL NOT NULL,
62
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
63
+ );
64
+ CREATE INDEX idx_session_costs_job ON session_costs(job);
65
+ """
66
+
67
+
68
+ class Ledger:
69
+ """Owns the SQLite database file and hands out ready-to-use connections."""
70
+
71
+ def __init__(self, path: Path) -> None:
72
+ """Bind the ledger to its database file.
73
+
74
+ Args:
75
+ path: The ``ledger.db`` file (created, with its parent, on first use).
76
+ """
77
+ self._path = path
78
+
79
+ @contextmanager
80
+ def connect(self) -> Generator[sqlite3.Connection]:
81
+ """Yield a WAL connection with the schema ensured, committing on success.
82
+
83
+ Yields:
84
+ An open connection whose row factory is :class:`sqlite3.Row`.
85
+ """
86
+ self._path.parent.mkdir(parents=True, exist_ok=True)
87
+ connection = sqlite3.connect(self._path, timeout=5.0)
88
+ try:
89
+ connection.row_factory = sqlite3.Row
90
+ connection.execute("PRAGMA journal_mode=WAL")
91
+ connection.execute("PRAGMA foreign_keys=ON")
92
+ _ensure_schema(connection)
93
+ yield connection
94
+ connection.commit()
95
+ finally:
96
+ connection.close()
97
+
98
+
99
+ def _ensure_schema(connection: sqlite3.Connection) -> None:
100
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
101
+ if version >= SCHEMA_VERSION:
102
+ return
103
+ connection.executescript(_SCHEMA)
104
+ connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
@@ -0,0 +1,68 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """SQLite ``PerTurnMeteringPort``: metered turns in the shared ``ledger.db``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
10
+ from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
11
+
12
+ if TYPE_CHECKING:
13
+ from generic_ml_wrapper.adapter.outbound.store.ledger import Ledger
14
+
15
+
16
+ class SqlitePerTurnStore(PerTurnMeteringPort):
17
+ """Append and read metered turns, keyed by job, in the ledger."""
18
+
19
+ def __init__(self, ledger: Ledger) -> None:
20
+ """Bind the store to the shared SQLite ledger."""
21
+ self._ledger = ledger
22
+
23
+ def record(self, job: str, turn: TurnUsage) -> None:
24
+ """Append one metered turn for a job."""
25
+ with self._ledger.connect() as connection:
26
+ connection.execute(
27
+ "INSERT INTO turns (job, session_id, turn_id, input_tokens, output_tokens, "
28
+ "cache_creation_tokens, cache_read_tokens, cost_usd, model, timestamp, duration_s) "
29
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
30
+ (
31
+ job,
32
+ turn.session_id,
33
+ turn.turn_id,
34
+ turn.input_tokens,
35
+ turn.output_tokens,
36
+ turn.cache_creation_tokens,
37
+ turn.cache_read_tokens,
38
+ turn.cost_usd,
39
+ turn.model,
40
+ turn.timestamp,
41
+ turn.duration_s,
42
+ ),
43
+ )
44
+
45
+ def turns_for_job(self, job: str) -> list[TurnUsage]:
46
+ """Return every recorded turn for a job, in the order recorded."""
47
+ with self._ledger.connect() as connection:
48
+ rows = connection.execute(
49
+ "SELECT session_id, turn_id, input_tokens, output_tokens, cache_creation_tokens, "
50
+ "cache_read_tokens, cost_usd, model, timestamp, duration_s "
51
+ "FROM turns WHERE job = ? ORDER BY id",
52
+ (job,),
53
+ ).fetchall()
54
+ return [
55
+ TurnUsage(
56
+ row["session_id"],
57
+ row["input_tokens"],
58
+ row["output_tokens"],
59
+ row["cost_usd"],
60
+ row["model"],
61
+ cache_creation_tokens=row["cache_creation_tokens"],
62
+ cache_read_tokens=row["cache_read_tokens"],
63
+ timestamp=row["timestamp"],
64
+ duration_s=row["duration_s"],
65
+ turn_id=row["turn_id"],
66
+ )
67
+ for row in rows
68
+ ]
@@ -0,0 +1,73 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """SQLite ``SessionStorePort``: sessions in the shared ``ledger.db``.
4
+
5
+ The store is scoped to a ``kind`` (``work`` or ``authoring``): recording tags the
6
+ job with it and :meth:`jobs` filters by it, so authoring sessions stay out of
7
+ ``gmlw jobs`` -- the same separation the old parallel filesystem root gave, without
8
+ a second store.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ from generic_ml_wrapper.application.domain.model.session import Session
16
+ from generic_ml_wrapper.application.port.outbound.session_store import SessionStorePort
17
+
18
+ if TYPE_CHECKING:
19
+ from generic_ml_wrapper.adapter.outbound.store.ledger import Ledger
20
+
21
+
22
+ class SqliteSessionStore(SessionStorePort):
23
+ """Persist and read sessions in the ledger, scoped to a job ``kind``."""
24
+
25
+ def __init__(self, ledger: Ledger, kind: str = "work") -> None:
26
+ """Bind the store to the ledger and the job kind it owns.
27
+
28
+ Args:
29
+ ledger: The shared SQLite ledger.
30
+ kind: The job kind this store records and lists (``work`` | ``authoring``).
31
+ """
32
+ self._ledger = ledger
33
+ self._kind = kind
34
+
35
+ def jobs(self) -> list[str]:
36
+ """Return the ids of this kind's jobs that have recorded sessions, sorted."""
37
+ with self._ledger.connect() as connection:
38
+ rows = connection.execute(
39
+ "SELECT DISTINCT s.job FROM sessions s JOIN jobs j ON s.job = j.job "
40
+ "WHERE j.kind = ? ORDER BY s.job",
41
+ (self._kind,),
42
+ ).fetchall()
43
+ return [row["job"] for row in rows]
44
+
45
+ def record(self, session: Session) -> None:
46
+ """Persist a session, creating its job (tagged with this store's kind) if new."""
47
+ with self._ledger.connect() as connection:
48
+ connection.execute(
49
+ "INSERT OR IGNORE INTO jobs (job, kind) VALUES (?, ?)",
50
+ (session.job, self._kind),
51
+ )
52
+ connection.execute(
53
+ "INSERT INTO sessions (session_id, job, client, uuid) VALUES (?, ?, ?, ?)",
54
+ (session.session_id, session.job, session.client, session.uuid),
55
+ )
56
+
57
+ def sessions_for_job(self, job: str) -> list[Session]:
58
+ """Return the sessions recorded for a job, oldest first."""
59
+ with self._ledger.connect() as connection:
60
+ rows = connection.execute(
61
+ "SELECT session_id, job, client, uuid FROM sessions WHERE job = ? ORDER BY id",
62
+ (job,),
63
+ ).fetchall()
64
+ return [Session(row["session_id"], row["job"], row["client"], row["uuid"]) for row in rows]
65
+
66
+ def ids_for_job(self, job: str) -> list[str]:
67
+ """Return the session ids recorded for a job, oldest first."""
68
+ return [session.session_id for session in self.sessions_for_job(job)]
69
+
70
+ def latest_for_job(self, job: str) -> Session | None:
71
+ """Return the most recently recorded session for a job, or ``None``."""
72
+ sessions = self.sessions_for_job(job)
73
+ return sessions[-1] if sessions else None
@@ -0,0 +1,44 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """SQLite ``UsageStorePort``: per-session cumulative cost in the shared ``ledger.db``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from generic_ml_wrapper.application.port.outbound.usage_store import UsageStorePort
10
+
11
+ if TYPE_CHECKING:
12
+ from generic_ml_wrapper.adapter.outbound.store.ledger import Ledger
13
+
14
+
15
+ class SqliteUsageStore(UsageStorePort):
16
+ """Persist and read per-session cumulative cost (monotonic) in the ledger.
17
+
18
+ Each session owns its own row, so two sessions of one job recording concurrently
19
+ update different rows -- no read-modify-write of a shared map, no lost update.
20
+ """
21
+
22
+ def __init__(self, ledger: Ledger) -> None:
23
+ """Bind the store to the shared SQLite ledger."""
24
+ self._ledger = ledger
25
+
26
+ def record_session_cost(self, job: str, session: str, cost_usd: float) -> None:
27
+ """Record a session's cumulative cost, keeping the highest value seen."""
28
+ with self._ledger.connect() as connection:
29
+ connection.execute(
30
+ "INSERT INTO session_costs (session_id, job, cost_usd) VALUES (?, ?, ?) "
31
+ "ON CONFLICT(session_id) DO UPDATE SET "
32
+ "cost_usd = excluded.cost_usd, updated_at = datetime('now') "
33
+ "WHERE excluded.cost_usd > session_costs.cost_usd",
34
+ (session, job, cost_usd),
35
+ )
36
+
37
+ def session_costs(self, job: str) -> dict[str, float]:
38
+ """Return the recorded cost per session for a job."""
39
+ with self._ledger.connect() as connection:
40
+ rows = connection.execute(
41
+ "SELECT session_id, cost_usd FROM session_costs WHERE job = ?",
42
+ (job,),
43
+ ).fetchall()
44
+ return {row["session_id"]: float(row["cost_usd"]) for row in rows}
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Workflow source adapters."""
@@ -0,0 +1,165 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Filesystem ``WorkflowSourcePort``: workflows under ``~/.gmlw/workflows``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from importlib import resources
8
+ from importlib.resources.abc import Traversable
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
15
+ from generic_ml_wrapper.application.domain.service.rule_cleaner import clean_rule
16
+ from generic_ml_wrapper.application.port.outbound.workflow_source import WorkflowSourcePort
17
+
18
+ _COMMON = "_common"
19
+ _META = "create-workflow"
20
+ _HIDDEN = frozenset({_COMMON, _META})
21
+ _PROFILE_SUBDIRS = ("me", "company")
22
+ _STRIP_SECTIONS = ("Origin", "Notes")
23
+
24
+
25
+ class FilesystemWorkflowSource(WorkflowSourcePort):
26
+ """Read workflows from ``<root>/<name>/workflow.md``; seed packaged defaults.
27
+
28
+ ``compile`` assembles a workflow's operating context from several locations —
29
+ the shared base, the user's profile, the global and per-workflow rules, and the
30
+ workflow's steps.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ root: Path,
36
+ profile_root: Path | None = None,
37
+ rules_root: Path | None = None,
38
+ interceptors: InterceptorChain | None = None,
39
+ ) -> None:
40
+ """Bind the source to its roots.
41
+
42
+ Args:
43
+ root: The directory holding one folder per workflow.
44
+ profile_root: The user's profile directory, or ``None`` to omit it.
45
+ rules_root: The global rules directory, or ``None`` to omit it.
46
+ interceptors: The per-section interceptor chain, or ``None`` for none.
47
+ """
48
+ self._root = root
49
+ self._profile_root = profile_root
50
+ self._rules_root = rules_root
51
+ self._interceptors = interceptors or InterceptorChain(())
52
+
53
+ def seed(self) -> None:
54
+ """Copy the packaged default workflows into ``root``, never overwriting."""
55
+ packaged = resources.files("generic_ml_wrapper").joinpath("resources", "workflows")
56
+ self._copy_tree(packaged, self._root)
57
+
58
+ def _copy_tree(self, source: Traversable, destination: Path) -> None:
59
+ destination.mkdir(parents=True, exist_ok=True)
60
+ for entry in source.iterdir():
61
+ target = destination / entry.name
62
+ if entry.is_dir():
63
+ self._copy_tree(entry, target)
64
+ elif not target.exists():
65
+ target.write_bytes(entry.read_bytes())
66
+
67
+ def names(self) -> list[str]:
68
+ """Return the runnable workflow names, sorted.
69
+
70
+ A runnable workflow is a folder with a ``workflow.md`` that is neither the
71
+ shared base nor the create-workflow meta-workflow.
72
+
73
+ Returns:
74
+ The runnable workflow names (empty if none exist).
75
+ """
76
+ if not self._root.is_dir():
77
+ return []
78
+ return sorted(
79
+ child.name
80
+ for child in self._root.iterdir()
81
+ if child.name not in _HIDDEN and (child / "workflow.md").is_file()
82
+ )
83
+
84
+ def exists(self, name: str) -> bool:
85
+ """Return whether ``<root>/<name>/workflow.md`` exists.
86
+
87
+ Args:
88
+ name: The workflow name.
89
+
90
+ Returns:
91
+ ``True`` if the workflow has a ``workflow.md``.
92
+ """
93
+ return (self._root / name / "workflow.md").is_file()
94
+
95
+ def create(self, name: str) -> str:
96
+ """Create ``<root>/<name>/`` (and its ``rules/`` folder).
97
+
98
+ Args:
99
+ name: The workflow name.
100
+
101
+ Returns:
102
+ The absolute path to the created folder.
103
+ """
104
+ folder = self._root / name
105
+ (folder / "rules").mkdir(parents=True, exist_ok=True)
106
+ return str(folder)
107
+
108
+ def compile(self, name: str) -> str:
109
+ """Compile a workflow's operating context, in a fixed order.
110
+
111
+ The order is: shared base, the user's profile, the global rules, the
112
+ workflow's own rules, then the workflow's steps. Missing sections are
113
+ skipped; rules are cleaned (frontmatter and human-only notes dropped). Each
114
+ section passes through the interceptor chain for its target, and the joined
115
+ result through the ``context`` target.
116
+
117
+ Args:
118
+ name: The workflow name.
119
+
120
+ Returns:
121
+ The concatenated context (sections that exist, joined by blank lines).
122
+ """
123
+ profile = self._interceptors.apply("profile", self._profile())
124
+ rules = self._interceptors.apply("rules", self._combined_rules(name))
125
+ workflow = self._interceptors.apply(
126
+ "workflow", self._read(self._root / name / "workflow.md")
127
+ )
128
+ parts = [self._read(self._root / _COMMON / "base.md"), profile, rules, workflow]
129
+ context = "\n\n\n".join(part for part in parts if part)
130
+ return self._interceptors.apply("context", context)
131
+
132
+ def _combined_rules(self, name: str) -> str:
133
+ parts = [self._rules(self._rules_root), self._rules(self._root / name / "rules")]
134
+ return "\n\n\n".join(part for part in parts if part)
135
+
136
+ def _profile(self) -> str:
137
+ if self._profile_root is None:
138
+ return ""
139
+ pieces = [self._concat_dir(self._profile_root / subdir) for subdir in _PROFILE_SUBDIRS]
140
+ return "\n\n".join(piece for piece in pieces if piece)
141
+
142
+ def _concat_dir(self, directory: Path) -> str:
143
+ """Concatenate every ``*.md`` in a profile subfolder, sorted by filename."""
144
+ if not directory.is_dir():
145
+ return ""
146
+ files = [self._read(path) for path in sorted(directory.glob("*.md"))]
147
+ return "\n\n".join(text for text in files if text)
148
+
149
+ def _rules(self, directory: Path | None) -> str:
150
+ if directory is None or not directory.is_dir():
151
+ return ""
152
+ cleaned: list[str] = []
153
+ for path in sorted(directory.glob("*.rule.md")):
154
+ raw = path.read_text(encoding="utf-8").strip()
155
+ if raw and "status: draft" not in raw:
156
+ rule = clean_rule(raw, _STRIP_SECTIONS)
157
+ if rule:
158
+ cleaned.append(rule)
159
+ return "\n\n---\n\n".join(cleaned)
160
+
161
+ @staticmethod
162
+ def _read(path: Path) -> str:
163
+ if not path.is_file():
164
+ return ""
165
+ return path.read_text(encoding="utf-8").strip()
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Working-environment inspection adapters."""
@@ -0,0 +1,75 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``LocalGitWorkspaceInspector``: read the folder and git state from the cwd."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from generic_ml_wrapper.application.domain.model.workspace import Workspace
11
+ from generic_ml_wrapper.application.port.outbound.workspace import WorkspaceInspectorPort
12
+
13
+ _GIT_TIMEOUT_SECONDS = 2
14
+
15
+
16
+ class LocalGitWorkspaceInspector(WorkspaceInspectorPort):
17
+ """Inspect the working directory and, when it is a git repository, git state.
18
+
19
+ The status-line command runs in the client's working directory, so the folder
20
+ comes from :func:`Path.cwd` and the git facts from ``git`` invoked there. Git
21
+ calls are best-effort: any failure (not a repository, git absent, a slow call)
22
+ leaves that field empty rather than raising.
23
+ """
24
+
25
+ def inspect(self) -> Workspace:
26
+ """Inspect the current working directory and its git state.
27
+
28
+ Returns:
29
+ The folder (home abbreviated) and git state; git fields are ``None``
30
+ outside a repository and ``dirty`` is ``0`` when clean or unknown.
31
+ """
32
+ branch = _git("branch", "--show-current")
33
+ if branch is None:
34
+ return Workspace(folder=_folder(), repo=None, branch=None, short_sha=None, dirty=0)
35
+ toplevel = _git("rev-parse", "--show-toplevel")
36
+ porcelain = _git("status", "--porcelain")
37
+ return Workspace(
38
+ folder=_folder(),
39
+ repo=Path(toplevel).name if toplevel else None,
40
+ branch=branch,
41
+ short_sha=_git("rev-parse", "--short", "HEAD"),
42
+ dirty=len(porcelain.splitlines()) if porcelain else 0,
43
+ )
44
+
45
+
46
+ def _folder() -> str | None:
47
+ try:
48
+ cwd = Path.cwd()
49
+ except OSError:
50
+ return None
51
+ home = Path.home()
52
+ if cwd == home:
53
+ return "~"
54
+ try:
55
+ return "~/" + cwd.relative_to(home).as_posix()
56
+ except ValueError:
57
+ return str(cwd)
58
+
59
+
60
+ def _git(*args: str) -> str | None:
61
+ try:
62
+ # Trusted argv: a fixed ``git`` program with our own literal arguments.
63
+ completed = subprocess.run( # noqa: S603
64
+ ["git", *args], # noqa: S607
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=_GIT_TIMEOUT_SECONDS,
68
+ check=False,
69
+ )
70
+ except (OSError, subprocess.SubprocessError):
71
+ return None
72
+ if completed.returncode != 0:
73
+ return None
74
+ stripped = completed.stdout.strip()
75
+ return stripped or None