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.
- generic_ml_wrapper/__init__.py +13 -0
- generic_ml_wrapper/adapter/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
- generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
- generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
- generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
- generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
- generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
- generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
- generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
- generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
- generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
- generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
- generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
- generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
- generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
- generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
- generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
- generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
- generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
- generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
- generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
- generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
- generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
- generic_ml_wrapper/application/__init__.py +3 -0
- generic_ml_wrapper/application/domain/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/client_status.py +44 -0
- generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
- generic_ml_wrapper/application/domain/model/run.py +35 -0
- generic_ml_wrapper/application/domain/model/session.py +24 -0
- generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
- generic_ml_wrapper/application/domain/model/workspace.py +31 -0
- generic_ml_wrapper/application/domain/service/__init__.py +3 -0
- generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
- generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
- generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
- generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
- generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
- generic_ml_wrapper/application/port/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
- generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
- generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
- generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
- generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
- generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
- generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
- generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
- generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
- generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
- generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
- generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
- generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
- generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
- generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
- generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
- generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
- generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
- generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
- generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
- generic_ml_wrapper/application/usecase/__init__.py +3 -0
- generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
- generic_ml_wrapper/application/usecase/export_usage.py +93 -0
- generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
- generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
- generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
- generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
- generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
- generic_ml_wrapper/application/usecase/set_credential.py +27 -0
- generic_ml_wrapper/application/usecase/start_job.py +125 -0
- generic_ml_wrapper/application/wiring/__init__.py +3 -0
- generic_ml_wrapper/application/wiring/composition.py +230 -0
- generic_ml_wrapper/common/__init__.py +3 -0
- generic_ml_wrapper/common/config.py +179 -0
- generic_ml_wrapper/common/log.py +83 -0
- generic_ml_wrapper/common/paths.py +25 -0
- generic_ml_wrapper/common/spec_loader.py +67 -0
- generic_ml_wrapper/py.typed +0 -0
- generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
- generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
- generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
- generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
- generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
- generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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,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,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,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
|