gooseloop 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.
gooseloop/phase.py ADDED
@@ -0,0 +1,146 @@
1
+ """Core data types: Phase, Pipeline, Context.
2
+
3
+ See ADRs 0001, 0006, 0007 and PROTOCOL.md for the contracts these
4
+ types implement. The Pipeline is the bookend dataclass with named review /
5
+ body / summary slots; Context carries typed methods for body phases to
6
+ extend the session ledger.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, Callable, Optional
12
+
13
+ from .protocol import OperatorAction, RoutingEntry
14
+
15
+ if TYPE_CHECKING:
16
+ from .environment import Environment
17
+
18
+
19
+ @dataclass
20
+ class Context:
21
+ """Carried through a single begin_loop() pass.
22
+
23
+ `session_dir` is None when the Looper was constructed with save=False.
24
+ `artifacts` is the engine's scratchpad; engines store extension data
25
+ here and read each other's contributions across phases. The framework
26
+ reserves a few well-known keys:
27
+
28
+ artifacts["review_output"] -> dict (the parsed ReviewOutput payload)
29
+ artifacts["operator_actions"] -> list[OperatorAction]
30
+ artifacts["outputs_written"] -> list[str]
31
+
32
+ Body phases should mutate these via the typed methods below
33
+ (add_operator_action / record_output / session_log) rather than poking
34
+ artifacts directly; the methods enforce the schema.
35
+ """
36
+ model: str
37
+ session_dir: Optional[Path]
38
+ base_env: dict[str, str]
39
+ artifacts: dict[str, Any] = field(default_factory=dict)
40
+ environment: Optional["Environment"] = None
41
+
42
+ def add_operator_action(self, action: str, why: str = "", **extras: Any) -> None:
43
+ """Append an operator action to the session ledger.
44
+
45
+ Dedup is by (action, why); calling twice with the same pair is a
46
+ no-op so cadence phases that re-detect the same condition do not
47
+ flood the ledger. Extras are stored alongside.
48
+
49
+ `action` must be a non-empty string. `why` is optional and may
50
+ be empty — some actions don't need a stated reason; the action
51
+ itself is the operator-facing artifact.
52
+ """
53
+ if not isinstance(action, str) or not action:
54
+ raise TypeError("add_operator_action: 'action' must be a non-empty str")
55
+ if not isinstance(why, str):
56
+ raise TypeError("add_operator_action: 'why' must be a str (may be empty)")
57
+ ledger: list[OperatorAction] = self.artifacts.setdefault("operator_actions", [])
58
+ for existing in ledger:
59
+ if existing.get("action") == action and existing.get("why") == why:
60
+ return
61
+ entry: OperatorAction = {"action": action, "why": why}
62
+ entry.update(extras) # type: ignore[typeddict-item]
63
+ ledger.append(entry)
64
+
65
+ @property
66
+ def operator_actions(self) -> list[OperatorAction]:
67
+ """Read-only view of the current ledger. Mutate via add_operator_action."""
68
+ return list(self.artifacts.get("operator_actions", []))
69
+
70
+ def record_output(self, path: Path | str) -> None:
71
+ """Track a file body phases produced. Summary and footer render these."""
72
+ outputs: list[str] = self.artifacts.setdefault("outputs_written", [])
73
+ as_str = str(path)
74
+ if as_str not in outputs:
75
+ outputs.append(as_str)
76
+
77
+ def session_log(self, message: str) -> None:
78
+ """Append a timestamped line to the session log, if a session is open."""
79
+ if self.session_dir is None:
80
+ return
81
+ from .session import log_step
82
+ log_step(self.session_dir, message)
83
+
84
+ @property
85
+ def review_routing(self) -> list[RoutingEntry]:
86
+ """Routing entries the review emitted (frozen at bookend)."""
87
+ return list(self.artifacts.get("review_routing", []))
88
+
89
+
90
+ # Callable type aliases for Phase fields.
91
+ BuildEnv = Callable[[Context], dict[str, str]]
92
+ SuccessPredicate = Callable[[str], bool]
93
+ PostProcess = Callable[[str, Context], Optional[list["Phase"]]]
94
+ # SkipIf returns falsy to run, True for a generic skip, or a str carrying
95
+ # the reason. The reason lands in the session log.
96
+ SkipIf = Callable[[Context], "bool | str | None"]
97
+
98
+
99
+ def _empty_env(_ctx: Context) -> dict[str, str]:
100
+ return {}
101
+
102
+
103
+ @dataclass
104
+ class Phase:
105
+ """A single recipe invocation, with optional pre/post hooks.
106
+
107
+ Attributes:
108
+ name: shown in banners and logs.
109
+ recipe_path: relative path to the recipe yaml.
110
+ build_env: returns env vars merged with base_env for this call.
111
+ success_predicate: optional override for "did this attempt succeed?".
112
+ None falls through to the looper's transient-error check.
113
+ post_process: called after a successful run. May return a list of
114
+ child Phases for the Looper to enqueue.
115
+ skip_if: called before the recipe runs. Truthy return skips the
116
+ Phase. A string return is used as the skip reason in the log.
117
+ label: optional override for the per-call footer label. Useful when
118
+ multiple phases reuse the same recipe (review-spawned branches).
119
+ """
120
+ name: str
121
+ recipe_path: str
122
+ build_env: BuildEnv = _empty_env
123
+ success_predicate: Optional[SuccessPredicate] = None
124
+ post_process: Optional[PostProcess] = None
125
+ skip_if: Optional[SkipIf] = None
126
+ label: Optional[str] = None
127
+
128
+
129
+ @dataclass
130
+ class Pipeline:
131
+ """Named-slot pipeline. Framework owns review-first / summary-last ordering.
132
+
133
+ Engines return this from Engine.pipeline(). Per ADR 0006:
134
+
135
+ - review runs first; its post_process and the framework parse the
136
+ review output and spawn child phases (via the BranchPolicy
137
+ registry) that go to the HEAD of the body queue.
138
+ - body runs after, in queue order. May be empty.
139
+ - summary runs last, with access to the final operator_actions
140
+ ledger and outputs list.
141
+
142
+ `--review-only` runs review and stops; body and summary skip.
143
+ """
144
+ review: Phase
145
+ body: list[Phase] = field(default_factory=list)
146
+ summary: Optional[Phase] = None
@@ -0,0 +1,69 @@
1
+ """Reusable success-predicate factories.
2
+
3
+ A Phase's success_predicate decides whether a recipe attempt succeeded.
4
+ Three named shapes cover the common cases:
5
+
6
+ file_freshly_touched(path, pre_mtime) - strict: exists, non-empty, newer than pre_mtime
7
+ file_nonempty(path) - loose: exists and non-empty
8
+ json_in_stdout(required_keys) - sentinel JSON parses and has keys
9
+
10
+ Phase(success_predicate=None) falls through to the looper's transient-error
11
+ check; that is the explicit "no extra validation" option. No no-op
12
+ predicate factory is shipped (per YAGNI; six characters of lambda if a
13
+ recipe ever genuinely needs one).
14
+ """
15
+
16
+ from pathlib import Path
17
+ from typing import Callable, Iterable
18
+
19
+ from .extract import extract_json
20
+
21
+
22
+ SuccessPredicate = Callable[[str], bool]
23
+
24
+
25
+ def file_freshly_touched(path: Path, pre_mtime: float = 0.0) -> SuccessPredicate:
26
+ """Strict: `path` exists, is non-empty, AND its mtime is strictly newer
27
+ than `pre_mtime`.
28
+
29
+ Snapshot pre_mtime BEFORE the recipe runs; if the file didn't exist,
30
+ pass 0.0. Use when the recipe writes to a deterministic path and you
31
+ need to detect both "model didn't write" and "model wrote a different
32
+ file so this one is stale."
33
+ """
34
+ def predicate(_output: str) -> bool:
35
+ if not path.exists():
36
+ return False
37
+ if path.stat().st_size == 0:
38
+ return False
39
+ return path.stat().st_mtime > pre_mtime
40
+ return predicate
41
+
42
+
43
+ def file_nonempty(path: Path) -> SuccessPredicate:
44
+ """Loose: `path` exists and is non-empty.
45
+
46
+ Safe only when you already know the file didn't exist before the
47
+ recipe ran (otherwise a pre-existing file passes without proof the
48
+ recipe wrote anything).
49
+ """
50
+ def predicate(_output: str) -> bool:
51
+ return path.exists() and path.stat().st_size > 0
52
+ return predicate
53
+
54
+
55
+ def json_in_stdout(required_keys: Iterable[str] | None = None) -> SuccessPredicate:
56
+ """The recipe's stdout contains sentinel-wrapped JSON with all required_keys.
57
+
58
+ Use for stdout-deliverable recipes (the JSON IS the artifact). Catches
59
+ the "model emitted a thin stub instead of the full schema" failure
60
+ mode.
61
+ """
62
+ keys = list(required_keys or [])
63
+
64
+ def predicate(output: str) -> bool:
65
+ data = extract_json(output)
66
+ if data is None:
67
+ return False
68
+ return all(k in data for k in keys)
69
+ return predicate
gooseloop/protocol.py ADDED
@@ -0,0 +1,197 @@
1
+ """TypedDicts and validation for the gooseloop review protocol.
2
+
3
+ See PROTOCOL.md for the canonical contract and ADR 0007 for
4
+ the rationale. The framework reads these keys; engines may add arbitrary
5
+ extension keys to any of these structures.
6
+
7
+ Validation is **liberal in what it accepts, strict in what the schema
8
+ guarantees on the way out**:
9
+
10
+ - Status synonyms (success/ok/complete/failed/incomplete/...) canonicalise
11
+ to the three-value enum. Unknown synonyms raise.
12
+ - Non-load-bearing keys (insights, operator_actions) default to [] when
13
+ missing. They're informational; absence is "model had nothing to say"
14
+ not "model broke the contract."
15
+ - Load-bearing keys (protocol_version, status, summary, routing) are
16
+ strictly required. Absence means the review can't drive the framework.
17
+ - protocol_version major must match what we ship.
18
+
19
+ This is the validate-side companion to the parser-side leniency in
20
+ gooseloop.extract: accept what's obviously the same intent in a slightly
21
+ different shape, fail loud on actual contract violations.
22
+ """
23
+
24
+ from typing import Any, Literal, TypedDict
25
+
26
+
27
+ PROTOCOL_VERSION = "1.0"
28
+ PROTOCOL_MAJOR = 1
29
+
30
+
31
+ class RoutingEntry(TypedDict, total=False):
32
+ """One entry in the review's routing[] list."""
33
+ recipe: str
34
+ params: dict[str, Any]
35
+ reason: str
36
+
37
+
38
+ class OperatorAction(TypedDict, total=False):
39
+ """One entry in the session's operator_actions ledger."""
40
+ action: str
41
+ why: str
42
+
43
+
44
+ ReviewStatus = Literal["done", "partial", "error"]
45
+
46
+
47
+ class ReviewOutput(TypedDict, total=False):
48
+ """Canonical review payload after validation."""
49
+ protocol_version: str
50
+ status: ReviewStatus
51
+ summary: str
52
+ insights: list[str]
53
+ routing: list[RoutingEntry]
54
+ operator_actions: list[OperatorAction]
55
+
56
+
57
+ # Keys the framework can't function without. A review missing any of
58
+ # these can't drive routing or the session ledger; refuse loud.
59
+ REQUIRED_KEYS = ("protocol_version", "status", "summary", "routing")
60
+
61
+ # Keys the framework reads but treats as optional in the schema. Missing
62
+ # = "model had nothing to add"; default to empty list and proceed.
63
+ DEFAULTED_LIST_KEYS = ("insights", "operator_actions")
64
+
65
+ # Status synonyms. Models drift on the status enum more than on any
66
+ # other field; canonicalise here so downstream code only sees the three
67
+ # canonical values. Unknown values raise (a fresh synonym should land
68
+ # in this table, not slip past the validator).
69
+ _STATUS_SYNONYMS: dict[str, ReviewStatus] = {
70
+ "done": "done",
71
+ "success": "done",
72
+ "ok": "done",
73
+ "complete": "done",
74
+ "completed": "done",
75
+ "finished": "done",
76
+ "partial": "partial",
77
+ "incomplete": "partial",
78
+ "in_progress": "partial",
79
+ "in-progress": "partial",
80
+ "pending": "partial",
81
+ "error": "error",
82
+ "errored": "error",
83
+ "failed": "error",
84
+ "failure": "error",
85
+ "broken": "error",
86
+ }
87
+
88
+
89
+ class ProtocolVersionError(RuntimeError):
90
+ """Raised when a review declares a major version the framework does not support."""
91
+
92
+
93
+ def validate_review(payload: dict) -> ReviewOutput:
94
+ """Canonicalise + validate a review payload.
95
+
96
+ Returns a NEW dict with synonyms canonicalised, defaulted lists
97
+ filled in, and entry shapes normalised. The original payload is
98
+ not mutated.
99
+
100
+ Normalisations performed (liberal in what we accept):
101
+
102
+ - status: synonyms ("success", "ok", ...) collapse to the
103
+ three-value enum.
104
+ - insights/operator_actions: missing or None becomes [].
105
+ - operator_actions entries: a bare string becomes
106
+ {"action": <string>, "why": ""}. A dict missing "why" gets
107
+ why="". Malformed entries (non-string, non-dict, or dict with
108
+ no "action") are dropped.
109
+ - routing entries: a dict missing optional fields gets default
110
+ params={} and reason="". Entries without a "recipe" string
111
+ are dropped.
112
+ """
113
+ missing = [k for k in REQUIRED_KEYS if k not in payload]
114
+ if missing:
115
+ raise ValueError(f"review missing required keys: {missing}")
116
+
117
+ _check_protocol_version(str(payload["protocol_version"]))
118
+
119
+ raw_status = str(payload["status"]).strip().lower()
120
+ canonical_status = _STATUS_SYNONYMS.get(raw_status)
121
+ if canonical_status is None:
122
+ raise ValueError(
123
+ f"review status {payload['status']!r} not recognised "
124
+ f"(known: {sorted(set(_STATUS_SYNONYMS))})"
125
+ )
126
+
127
+ out: dict[str, Any] = dict(payload)
128
+ out["status"] = canonical_status
129
+ for key in DEFAULTED_LIST_KEYS:
130
+ if key not in out or out[key] is None:
131
+ out[key] = []
132
+ out["operator_actions"] = _normalise_operator_actions(out["operator_actions"])
133
+ out["routing"] = _normalise_routing(out["routing"])
134
+ return out # type: ignore[return-value]
135
+
136
+
137
+ def _normalise_operator_actions(entries: Any) -> list[OperatorAction]:
138
+ """Coerce loose shapes into well-formed OperatorAction dicts."""
139
+ if not isinstance(entries, list):
140
+ return []
141
+ out: list[OperatorAction] = []
142
+ for entry in entries:
143
+ if isinstance(entry, str):
144
+ action = entry.strip()
145
+ if action:
146
+ out.append({"action": action, "why": ""}) # type: ignore[typeddict-item]
147
+ continue
148
+ if isinstance(entry, dict):
149
+ action = str(entry.get("action", "")).strip()
150
+ if not action:
151
+ continue
152
+ normalised: OperatorAction = { # type: ignore[typeddict-item]
153
+ "action": action,
154
+ "why": str(entry.get("why", "")),
155
+ }
156
+ for k, v in entry.items():
157
+ if k not in ("action", "why"):
158
+ normalised[k] = v # type: ignore[literal-required]
159
+ out.append(normalised)
160
+ return out
161
+
162
+
163
+ def _normalise_routing(entries: Any) -> list[RoutingEntry]:
164
+ """Coerce loose shapes into well-formed RoutingEntry dicts."""
165
+ if not isinstance(entries, list):
166
+ return []
167
+ out: list[RoutingEntry] = []
168
+ for entry in entries:
169
+ if not isinstance(entry, dict):
170
+ continue
171
+ recipe = entry.get("recipe")
172
+ if not isinstance(recipe, str) or not recipe.strip():
173
+ continue
174
+ normalised: RoutingEntry = { # type: ignore[typeddict-item]
175
+ "recipe": recipe.strip(),
176
+ "params": entry.get("params") if isinstance(entry.get("params"), dict) else {},
177
+ "reason": str(entry.get("reason", "")),
178
+ }
179
+ out.append(normalised)
180
+ return out
181
+
182
+
183
+ def _check_protocol_version(declared: str) -> None:
184
+ try:
185
+ major = int(declared.split(".", 1)[0])
186
+ except (ValueError, IndexError):
187
+ raise ProtocolVersionError(
188
+ f"protocol_version {declared!r} is not parseable; "
189
+ f"framework supports major {PROTOCOL_MAJOR}"
190
+ )
191
+ if major != PROTOCOL_MAJOR:
192
+ raise ProtocolVersionError(
193
+ f"review declares protocol_version {declared!r} (major {major}); "
194
+ f"framework supports major {PROTOCOL_MAJOR} only"
195
+ )
196
+
197
+
@@ -0,0 +1,152 @@
1
+ """Compose-style recipe overlay merge.
2
+
3
+ Per ADR 0008 and PROTOCOL.md §6. Two YAML documents merge according to a
4
+ type-and-name-dispatched rule table:
5
+
6
+ scalars - later wins (full replace)
7
+ dicts - deep-merge; scalar leaves: later wins
8
+ context list - keyed by 'label'; same label overrides, new appends
9
+ extensions list - keyed by (type, name); same key overrides, new appends
10
+ plain lists - later replaces fully
11
+
12
+ A keyed-list overlay entry with `source: REMOVE` (the removal sentinel)
13
+ deletes that entry from the merge result.
14
+
15
+ Layer order is `merge_recipes(base, *overlays)`; later overlays override
16
+ earlier ones. The looper composes layers in the order: base file ->
17
+ <name>.local.yaml -> --review-overlay X1 -> --review-overlay X2 -> ...
18
+ """
19
+
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import yaml
24
+
25
+
26
+ REMOVE_SENTINEL = "REMOVE"
27
+
28
+
29
+ def merge_recipes(base: dict, *overlays: dict) -> dict:
30
+ """Merge `base` with successive overlays, returning a new dict.
31
+
32
+ The merge is pure: input dicts are never mutated. Per ADR 0008 the
33
+ merge rules dispatch on field name (for keyed lists) and value type
34
+ (everything else).
35
+ """
36
+ out = _copy(base)
37
+ for overlay in overlays:
38
+ out = _merge_dict(out, overlay, path=())
39
+ return out
40
+
41
+
42
+ def load_layered_recipe(
43
+ base_path: Path,
44
+ *,
45
+ local_path: Path | None = None,
46
+ overlay_paths: list[Path] | None = None,
47
+ ) -> dict:
48
+ """Resolve a recipe by loading base + local + CLI overlays.
49
+
50
+ Convention: `<name>.local.yaml` sits next to the base recipe in the
51
+ same directory; the caller computes the local path and passes it.
52
+ Missing local file is fine (skipped).
53
+ """
54
+ base = _read_yaml(base_path)
55
+ overlays: list[dict] = []
56
+ if local_path is not None and local_path.exists():
57
+ overlays.append(_read_yaml(local_path))
58
+ for p in overlay_paths or []:
59
+ overlays.append(_read_yaml(p))
60
+ return merge_recipes(base, *overlays)
61
+
62
+
63
+ def resolved_recipe_yaml(merged: dict) -> str:
64
+ """Render the merged recipe back to YAML (for --resolve debug output)."""
65
+ return yaml.safe_dump(merged, sort_keys=False, default_flow_style=False)
66
+
67
+
68
+ # ---- internals ------------------------------------------------------
69
+
70
+ # Keyed-list rules: list-field name -> the key function on each entry.
71
+ _KEYED_LISTS: dict[str, Any] = {
72
+ "context": lambda entry: entry.get("label"),
73
+ "extensions": lambda entry: (entry.get("type"), entry.get("name")),
74
+ }
75
+
76
+
77
+ def _read_yaml(path: Path) -> dict:
78
+ text = path.read_text()
79
+ data = yaml.safe_load(text)
80
+ if data is None:
81
+ return {}
82
+ if not isinstance(data, dict):
83
+ raise ValueError(f"recipe at {path} must be a YAML mapping at top level")
84
+ return data
85
+
86
+
87
+ def _copy(value: Any) -> Any:
88
+ if isinstance(value, dict):
89
+ return {k: _copy(v) for k, v in value.items()}
90
+ if isinstance(value, list):
91
+ return [_copy(v) for v in value]
92
+ return value
93
+
94
+
95
+ def _merge_dict(base: dict, overlay: dict, *, path: tuple[str, ...]) -> dict:
96
+ out = _copy(base)
97
+ for k, ov in overlay.items():
98
+ if k not in out:
99
+ out[k] = _copy(ov)
100
+ continue
101
+ bv = out[k]
102
+ if isinstance(bv, dict) and isinstance(ov, dict):
103
+ out[k] = _merge_dict(bv, ov, path=path + (k,))
104
+ elif isinstance(bv, list) and isinstance(ov, list):
105
+ out[k] = _merge_list(bv, ov, list_name=k)
106
+ else:
107
+ out[k] = _copy(ov)
108
+ return out
109
+
110
+
111
+ def _merge_list(base: list, overlay: list, *, list_name: str) -> list:
112
+ keyer = _KEYED_LISTS.get(list_name)
113
+ if keyer is None:
114
+ # Plain list: later replaces fully.
115
+ return _copy(overlay)
116
+ return _merge_keyed_list(base, overlay, keyer=keyer)
117
+
118
+
119
+ def _merge_keyed_list(base: list, overlay: list, *, keyer) -> list:
120
+ """Merge two keyed lists by item identity.
121
+
122
+ Walk overlay in order. For each entry:
123
+ - If key matches an existing base entry: merge fields (overlay wins).
124
+ REMOVE sentinel on `source` field deletes the base entry.
125
+ - If key is new: append to the result.
126
+
127
+ Preserves base order for retained entries; new entries land at the
128
+ end in overlay order.
129
+ """
130
+ result: list = [_copy(b) for b in base]
131
+ by_key: dict[Any, int] = {}
132
+ for i, entry in enumerate(result):
133
+ if isinstance(entry, dict):
134
+ by_key[keyer(entry)] = i
135
+
136
+ for entry in overlay:
137
+ if not isinstance(entry, dict):
138
+ result.append(_copy(entry))
139
+ continue
140
+ key = keyer(entry)
141
+ if key in by_key:
142
+ if entry.get("source") == REMOVE_SENTINEL:
143
+ idx = by_key.pop(key)
144
+ result[idx] = None # tombstone
145
+ continue
146
+ idx = by_key[key]
147
+ merged = _merge_dict(result[idx], entry, path=())
148
+ result[idx] = merged
149
+ else:
150
+ result.append(_copy(entry))
151
+
152
+ return [r for r in result if r is not None]
gooseloop/session.py ADDED
@@ -0,0 +1,41 @@
1
+ """Session folder management: create timestamped dirs, log steps.
2
+
3
+ The Looper owns the session layout:
4
+
5
+ <sessions_dir>/<UTC-timestamp>/
6
+ session.meta.json - model, engine, timestamps
7
+ session.log - append-only event log
8
+ actions/ - Phase outputs land here
9
+ """
10
+
11
+ import json
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from .text import Color, banner
16
+
17
+
18
+ def new_session(sessions_dir: Path, model: str, engine_name: str) -> Path:
19
+ """Create a timestamped session directory and print a banner."""
20
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
21
+ session_dir = sessions_dir / ts
22
+ session_dir.mkdir(parents=True, exist_ok=True)
23
+ (session_dir / "actions").mkdir(exist_ok=True)
24
+
25
+ meta = {
26
+ "session_started": datetime.now(timezone.utc).isoformat(),
27
+ "model": model,
28
+ "engine": engine_name,
29
+ }
30
+ with open(session_dir / "session.meta.json", "w") as f:
31
+ json.dump(meta, f, indent=2)
32
+
33
+ banner(f"Session started: {session_dir}", Color.GREEN)
34
+ return session_dir
35
+
36
+
37
+ def log_step(session_dir: Path, message: str) -> None:
38
+ """Append a timestamped line to session.log."""
39
+ ts = datetime.now(timezone.utc).isoformat()
40
+ with open(session_dir / "session.log", "a") as f:
41
+ f.write(f"[{ts}] {message}\n")
gooseloop/text.py ADDED
@@ -0,0 +1,60 @@
1
+ """Terminal text helpers: ANSI colours, banners, ANSI stripping.
2
+
3
+ JSON extraction used to live here; it moved to gooseloop/extract.py once
4
+ it grew a recognizer dispatch table. text.py is now scoped strictly to
5
+ terminal-output utilities.
6
+ """
7
+
8
+ import re
9
+
10
+
11
+ class Color:
12
+ RESET = '\033[0m'
13
+ BOLD = '\033[1m'
14
+ RED = '\033[91m'
15
+ GREEN = '\033[92m'
16
+ YELLOW = '\033[93m'
17
+ BLUE = '\033[94m'
18
+ MAGENTA = '\033[95m'
19
+ CYAN = '\033[96m'
20
+ WHITE = '\033[97m'
21
+
22
+
23
+ def colored(text: str, color: str) -> str:
24
+ return f"{color}{text}{Color.RESET}"
25
+
26
+
27
+ _ANSI_RE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
28
+
29
+
30
+ def strip_ansi(text: str) -> str:
31
+ return _ANSI_RE.sub('', text)
32
+
33
+
34
+ def banner(text: str, color: str = Color.CYAN, width: int = 70) -> None:
35
+ """Print a colourful double-line box around the given text."""
36
+ top = "╔" + "═" * (width - 2) + "╗"
37
+ bot = "╚" + "═" * (width - 2) + "╝"
38
+
39
+ max_text_width = width - 4
40
+ words = text.split()
41
+ lines: list[str] = []
42
+ current = ""
43
+ for w in words:
44
+ if len(current) + len(w) + 1 > max_text_width:
45
+ lines.append(current.strip())
46
+ current = w
47
+ else:
48
+ current = (current + " " + w).strip() if current else w
49
+ if current:
50
+ lines.append(current.strip())
51
+ if not lines:
52
+ lines = [""]
53
+
54
+ print(colored(top, color))
55
+ for line in lines:
56
+ padding = max_text_width - len(line)
57
+ left = padding // 2
58
+ right = padding - left
59
+ print(colored("║ " + " " * left + line + " " * right + " ║", color))
60
+ print(colored(bot, color))