walkthru 0.0.2__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.
walkthru/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """walkthru — editable, re-renderable demo/tour artifacts from a command sequence.
2
+
3
+ walkthru owns the *representation* (the Demo Document) and a tiny pure playback/capture
4
+ engine, ``play(demoDoc, executor, observers)``. It never renders the final video — it hands a
5
+ validated artifact to a renderer (the ``reelee`` ecosystem, Remotion, moviepy/ffmpeg). Owning
6
+ representation, not pixels, is the load-bearing boundary of the design.
7
+
8
+ Two modes share one data model and one engine: *generative* (an author supplies the document;
9
+ walkthru plays it while recording) and *capture* (a human drives; walkthru records the video
10
+ and the underlying command stream into the same document).
11
+
12
+ See ``PLAN.md``, ``DECISIONS.md``, and the repository's enhancement issues for the design and
13
+ its running development journal. This package currently holds the Python side (schema SSOT +
14
+ core + render hand-off); the live capture/play engine ships separately as ``acture-walkthru``.
15
+
16
+ Quickstart::
17
+
18
+ import asyncio
19
+ from walkthru import DemoDocument, Section, CommandStep, Command, Timing, play
20
+
21
+ doc = DemoDocument(
22
+ id="demo",
23
+ sections=[Section(id="s1", steps=[
24
+ CommandStep(id="step-1", command=Command(id="app.open"), timing=Timing(duration_ms=500)),
25
+ ])],
26
+ )
27
+
28
+ async def executor(command):
29
+ print("run", command.id)
30
+ return {"ok": True}
31
+
32
+ asyncio.run(play(doc, executor))
33
+ """
34
+
35
+ from walkthru.core import ( # noqa: F401
36
+ Anchor,
37
+ AssetRef,
38
+ Beat,
39
+ CalloutCue,
40
+ Command,
41
+ CommandInvocation,
42
+ CommandStep,
43
+ Cue,
44
+ CursorCue,
45
+ DemoDocument,
46
+ Event,
47
+ HighlightCue,
48
+ HotspotCue,
49
+ Locator,
50
+ Meta,
51
+ NarrationSegment,
52
+ Observer,
53
+ Outcome,
54
+ Rect,
55
+ ResolvedCamera,
56
+ ResolvedCue,
57
+ ResolvedNarration,
58
+ ResolvedStep,
59
+ Section,
60
+ SpotlightCue,
61
+ Step,
62
+ Target,
63
+ Timeline,
64
+ Timing,
65
+ Tracks,
66
+ demo_document_json_schema,
67
+ iter_events,
68
+ iter_resolved_steps,
69
+ play,
70
+ record,
71
+ resolve_timeline,
72
+ )
73
+
74
+ __version__ = "0.0.1"
75
+
76
+ __all__ = [
77
+ "__version__",
78
+ "play",
79
+ "record",
80
+ "iter_events",
81
+ "resolve_timeline",
82
+ "iter_resolved_steps",
83
+ "Timeline",
84
+ "ResolvedStep",
85
+ "ResolvedCue",
86
+ "ResolvedNarration",
87
+ "ResolvedCamera",
88
+ "demo_document_json_schema",
89
+ "DemoDocument",
90
+ "Meta",
91
+ "Section",
92
+ "Step",
93
+ "CommandStep",
94
+ "Beat",
95
+ "Command",
96
+ "Timing",
97
+ "Anchor",
98
+ "Target",
99
+ "Locator",
100
+ "Rect",
101
+ "Tracks",
102
+ "Cue",
103
+ "HighlightCue",
104
+ "SpotlightCue",
105
+ "HotspotCue",
106
+ "CalloutCue",
107
+ "CursorCue",
108
+ "NarrationSegment",
109
+ "AssetRef",
110
+ "Event",
111
+ "Observer",
112
+ "Outcome",
113
+ "CommandInvocation",
114
+ ]
@@ -0,0 +1,10 @@
1
+ """Optional, isolated port implementations — the firewall's outer ring.
2
+
3
+ Each adapter implements one or more :mod:`walkthru.ports` against a concrete tool (Playwright,
4
+ OBS/ffmpeg, WhisperX, Piper, driver.js, ...). Adapters depend on ports, **never the reverse**, and
5
+ the core never imports anything from here. Each adapter's heavy dependency is an *optional* extra,
6
+ so installing walkthru's core pulls in no vendor SDK.
7
+
8
+ No adapters are implemented yet — the web-first generative path (Playwright player/recorder/
9
+ locator + driver.js cues) is MVP Stage 3. See ``PLAN.md`` §3.4 and issue #5.
10
+ """
@@ -0,0 +1,16 @@
1
+ """Dependency-free export targets — the renderer hand-off and caption sidecars.
2
+
3
+ These adapters turn a validated Demo Document into the artifacts a renderer or player consumes.
4
+ They depend only on the core (schema + :mod:`timeline <walkthru.core.timeline>`), never the reverse,
5
+ and pull in no vendor dependency — so they sit behind the ports firewall yet need no optional extra.
6
+
7
+ * :class:`JsonArtifactTarget` / :func:`to_json` — the frozen JSON projection of the Demo Document,
8
+ the brief's *primary* renderer contract (the renderer owns pixels; we own representation).
9
+ * :func:`narration_to_webvtt` — WebVTT captions derived from the narration track, "nearly free"
10
+ from the resolved timeline.
11
+ """
12
+
13
+ from walkthru.adapters.export.json_target import JsonArtifactTarget, to_json
14
+ from walkthru.adapters.export.webvtt import narration_to_webvtt
15
+
16
+ __all__ = ["JsonArtifactTarget", "to_json", "narration_to_webvtt"]
@@ -0,0 +1,40 @@
1
+ """The frozen JSON projection — walkthru's primary renderer hand-off.
2
+
3
+ The boundary the whole design rests on: walkthru owns the *representation* (the Demo Document) and
4
+ hands a renderer a validated JSON artifact; the renderer owns the pixels and may ignore anything it
5
+ does not understand. :class:`JsonArtifactTarget` is the reference :class:`~walkthru.ports.RenderTarget`
6
+ that emits exactly that artifact.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Union
13
+
14
+ from walkthru.core.schema import AssetRef, DemoDocument
15
+
16
+
17
+ def to_json(document: DemoDocument, *, indent: int | None = 2) -> str:
18
+ """The frozen JSON projection (camelCase keys), validated by construction.
19
+
20
+ Because ``document`` is a validated :class:`~walkthru.core.schema.DemoDocument`, the emitted
21
+ JSON conforms to the published schema; the TS side / a renderer can consume it directly.
22
+ """
23
+ return document.model_dump_json(by_alias=True, indent=indent)
24
+
25
+
26
+ class JsonArtifactTarget:
27
+ """A :class:`~walkthru.ports.RenderTarget` that writes the Demo Document's JSON projection.
28
+
29
+ Args:
30
+ out_dir: directory to write ``<document.id>.json`` into (created on demand).
31
+ """
32
+
33
+ def __init__(self, out_dir: Union[str, Path] = "."):
34
+ self._out_dir = Path(out_dir)
35
+
36
+ async def export(self, artifact: DemoDocument) -> AssetRef:
37
+ path = self._out_dir / f"{artifact.id}.json"
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text(to_json(artifact) + "\n", encoding="utf-8")
40
+ return AssetRef(uri=str(path), mime="application/json")
@@ -0,0 +1,38 @@
1
+ """WebVTT captions derived from the narration track.
2
+
3
+ Narration is timed, editable text anchored to steps; once the timeline is composed to absolute
4
+ time, captions fall out almost for free (Report 01 §D.2). This exporter is the web-native default;
5
+ SRT is deliberately not implemented until a real need appears (YAGNI).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from walkthru.core.schema import DemoDocument
11
+ from walkthru.core.timeline import resolve_timeline
12
+
13
+
14
+ def _timestamp(ms: int) -> str:
15
+ """Format milliseconds as a WebVTT timestamp ``HH:MM:SS.mmm``."""
16
+ hours, rem = divmod(ms, 3_600_000)
17
+ minutes, rem = divmod(rem, 60_000)
18
+ seconds, millis = divmod(rem, 1000)
19
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
20
+
21
+
22
+ def narration_to_webvtt(document: DemoDocument) -> str:
23
+ """Render the narration track as a WebVTT caption document.
24
+
25
+ Cue start/end come from the resolved (absolute) timeline; cues are emitted in time order.
26
+ Returns a complete ``.vtt`` document (trailing newline included).
27
+ """
28
+ timeline = resolve_timeline(document)
29
+ segments = sorted(timeline.narration, key=lambda r: (r.start_ms, r.end_ms))
30
+
31
+ lines = ["WEBVTT", ""]
32
+ for index, segment in enumerate(segments, start=1):
33
+ lines.append(str(index))
34
+ lines.append(f"{_timestamp(segment.start_ms)} --> {_timestamp(segment.end_ms)}")
35
+ lines.append(segment.text)
36
+ lines.append("")
37
+
38
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,116 @@
1
+ """walkthru's pure core: the Demo Document schema, the lifecycle events, and the engine.
2
+
3
+ Nothing in this package imports from :mod:`walkthru.adapters` or :mod:`walkthru.ecosystem`, and it
4
+ has no vendor dependencies — only Pydantic (for the schema SSOT). Import the engine and schema from
5
+ here or from the top-level :mod:`walkthru` package.
6
+ """
7
+
8
+ from walkthru.core.engine import iter_events, play, record
9
+ from walkthru.core.timeline import (
10
+ ResolvedCamera,
11
+ ResolvedCue,
12
+ ResolvedNarration,
13
+ ResolvedStep,
14
+ Timeline,
15
+ iter_resolved_steps,
16
+ resolve_timeline,
17
+ )
18
+ from walkthru.core.events import (
19
+ AfterCommand,
20
+ BeatEvent,
21
+ BeforeCommand,
22
+ CommandError,
23
+ CommandInvocation,
24
+ CueBegin,
25
+ CueEnd,
26
+ DemoEnd,
27
+ DemoStart,
28
+ Event,
29
+ Narration,
30
+ Observer,
31
+ Outcome,
32
+ SectionEnter,
33
+ SectionExit,
34
+ StepEnter,
35
+ StepExit,
36
+ )
37
+ from walkthru.core.schema import (
38
+ Anchor,
39
+ AssetRef,
40
+ Beat,
41
+ CalloutCue,
42
+ Command,
43
+ CommandStep,
44
+ Cue,
45
+ CursorCue,
46
+ DemoDocument,
47
+ HighlightCue,
48
+ HotspotCue,
49
+ Locator,
50
+ Meta,
51
+ NarrationSegment,
52
+ Rect,
53
+ Section,
54
+ SpotlightCue,
55
+ Step,
56
+ Target,
57
+ Timing,
58
+ Tracks,
59
+ demo_document_json_schema,
60
+ )
61
+
62
+ __all__ = [
63
+ # engine
64
+ "play",
65
+ "record",
66
+ "iter_events",
67
+ # timeline
68
+ "resolve_timeline",
69
+ "iter_resolved_steps",
70
+ "Timeline",
71
+ "ResolvedStep",
72
+ "ResolvedCue",
73
+ "ResolvedNarration",
74
+ "ResolvedCamera",
75
+ # schema
76
+ "DemoDocument",
77
+ "Meta",
78
+ "Section",
79
+ "Step",
80
+ "CommandStep",
81
+ "Beat",
82
+ "Command",
83
+ "Timing",
84
+ "Anchor",
85
+ "Target",
86
+ "Locator",
87
+ "Rect",
88
+ "Tracks",
89
+ "Cue",
90
+ "HighlightCue",
91
+ "SpotlightCue",
92
+ "HotspotCue",
93
+ "CalloutCue",
94
+ "CursorCue",
95
+ "NarrationSegment",
96
+ "AssetRef",
97
+ "demo_document_json_schema",
98
+ # events
99
+ "Event",
100
+ "Observer",
101
+ "Outcome",
102
+ "CommandInvocation",
103
+ "DemoStart",
104
+ "DemoEnd",
105
+ "SectionEnter",
106
+ "SectionExit",
107
+ "StepEnter",
108
+ "StepExit",
109
+ "BeforeCommand",
110
+ "AfterCommand",
111
+ "CommandError",
112
+ "CueBegin",
113
+ "CueEnd",
114
+ "Narration",
115
+ "BeatEvent",
116
+ ]
@@ -0,0 +1,196 @@
1
+ """The pure engine: walk a Demo Document, emit the lifecycle event stream.
2
+
3
+ One small core, one lifecycle protocol, two modes with the *driver inverted* (Report 02 §B.2):
4
+
5
+ * :func:`play` — **generative**: the executor drives. Walk the document, call ``executor`` for
6
+ each command, and emit the lifecycle :mod:`events <walkthru.core.events>`. Observers record,
7
+ draw, narrate.
8
+ * :func:`record` — **capture**: the human drives. Consume a stream of already-executed commands
9
+ and assemble the *same* :class:`~walkthru.core.schema.DemoDocument`, emitting the *same*
10
+ lifecycle so every observer behaves identically.
11
+
12
+ The engine performs exactly one injected effect — calling ``executor`` — and never records,
13
+ renders, or speaks. All other effects are injected observers/ports. ``executor`` and observers may
14
+ be sync or async; the engine awaits whatever is awaitable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import inspect
20
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
21
+ from typing import Any, Union
22
+
23
+ from walkthru.core.events import (
24
+ AfterCommand,
25
+ BeatEvent,
26
+ BeforeCommand,
27
+ CommandError,
28
+ CommandInvocation,
29
+ CueBegin,
30
+ CueEnd,
31
+ DemoEnd,
32
+ DemoStart,
33
+ Event,
34
+ Narration,
35
+ Observer,
36
+ Outcome,
37
+ SectionEnter,
38
+ SectionExit,
39
+ StepEnter,
40
+ StepExit,
41
+ )
42
+ from walkthru.core.schema import (
43
+ Command,
44
+ CommandStep,
45
+ Cue,
46
+ DemoDocument,
47
+ NarrationSegment,
48
+ Section,
49
+ Step,
50
+ Timing,
51
+ )
52
+
53
+ #: The executor is any callable taking a :class:`~walkthru.core.schema.Command`; sync or async.
54
+ Executor = Callable[[Command], Union[Awaitable[Any], Any]]
55
+
56
+
57
+ async def _maybe_await(value: Any) -> Any:
58
+ """Await ``value`` if it is awaitable, else return it as-is."""
59
+ if inspect.isawaitable(value):
60
+ return await value
61
+ return value
62
+
63
+
64
+ def _cues_for(document: DemoDocument, step_id: str) -> Iterable[Cue]:
65
+ """The cues anchored to ``step_id`` (anchor is the SSOT for cue-to-step association)."""
66
+ for cue in document.tracks.cues:
67
+ if cue.anchor.step_id == step_id:
68
+ yield cue
69
+
70
+
71
+ def _narration_for(document: DemoDocument, step_id: str) -> Iterable[NarrationSegment]:
72
+ """The narration segments anchored to ``step_id``."""
73
+ for segment in document.tracks.narration:
74
+ if segment.anchor.step_id == step_id:
75
+ yield segment
76
+
77
+
78
+ async def iter_events(
79
+ document: DemoDocument, executor: Executor
80
+ ) -> AsyncIterator[Event]:
81
+ """Walk ``document`` and yield the lifecycle event stream (generative mode).
82
+
83
+ This is the canonical realization of the lifecycle protocol. The only injected effect is
84
+ ``executor``, awaited between :class:`~walkthru.core.events.BeforeCommand` and
85
+ :class:`~walkthru.core.events.AfterCommand`. A command that raises yields a
86
+ :class:`~walkthru.core.events.CommandError` and the walk continues (the renderer/observer
87
+ decides what a failed step means).
88
+ """
89
+ yield DemoStart(document)
90
+ errors: list[CommandError] = []
91
+ steps_run = 0
92
+
93
+ for section in document.sections:
94
+ yield SectionEnter(section)
95
+ for step in section.steps:
96
+ yield StepEnter(step)
97
+
98
+ for segment in _narration_for(document, step.id):
99
+ yield Narration(segment)
100
+
101
+ if isinstance(step, CommandStep):
102
+ yield BeforeCommand(step.command, step)
103
+ try:
104
+ result = await _maybe_await(executor(step.command))
105
+ except Exception as error: # noqa: BLE001 — surfaced as a CommandError event
106
+ err = CommandError(step.command, error, step)
107
+ errors.append(err)
108
+ yield err
109
+ else:
110
+ yield AfterCommand(step.command, result, step)
111
+ steps_run += 1
112
+ else:
113
+ yield BeatEvent(step)
114
+
115
+ for cue in _cues_for(document, step.id):
116
+ yield CueBegin(cue)
117
+ yield CueEnd(cue)
118
+
119
+ yield StepExit(step)
120
+ yield SectionExit(section)
121
+
122
+ yield DemoEnd(Outcome(ok=not errors, steps_run=steps_run, errors=tuple(errors)))
123
+
124
+
125
+ async def _emit(event: Event, observers: Iterable[Observer]) -> None:
126
+ """Fan one event out to every observer, awaiting async observers."""
127
+ for observer in observers:
128
+ await _maybe_await(observer(event))
129
+
130
+
131
+ async def play(
132
+ document: DemoDocument,
133
+ executor: Executor,
134
+ *,
135
+ observers: Iterable[Observer] = (),
136
+ ) -> Outcome:
137
+ """Play ``document`` generatively, driving ``executor`` and notifying ``observers``.
138
+
139
+ Returns the run :class:`~walkthru.core.events.Outcome`. This is a thin driver over
140
+ :func:`iter_events`: it forwards each event to every observer and captures the final outcome.
141
+ """
142
+ observers = tuple(observers)
143
+ outcome = Outcome(ok=True, steps_run=0)
144
+ async for event in iter_events(document, executor):
145
+ if isinstance(event, DemoEnd):
146
+ outcome = event.outcome
147
+ await _emit(event, observers)
148
+ return outcome
149
+
150
+
151
+ async def _aiter(items: Union[Iterable[Any], AsyncIterator[Any]]) -> AsyncIterator[Any]:
152
+ """Normalize a sync or async iterable to an async iterator."""
153
+ if hasattr(items, "__aiter__"):
154
+ async for item in items: # type: ignore[union-attr]
155
+ yield item
156
+ else:
157
+ for item in items: # type: ignore[union-attr]
158
+ yield item
159
+
160
+
161
+ async def record(
162
+ invocations: Union[Iterable[CommandInvocation], AsyncIterator[CommandInvocation]],
163
+ *,
164
+ observers: Iterable[Observer] = (),
165
+ document_id: str = "capture",
166
+ section_id: str = "captured",
167
+ section_title: str | None = None,
168
+ default_duration_ms: int = 1000,
169
+ ) -> DemoDocument:
170
+ """Capture mode: assemble a Demo Document from a stream of executed commands.
171
+
172
+ Each :class:`~walkthru.core.events.CommandInvocation` becomes a
173
+ :class:`~walkthru.core.schema.CommandStep`, and the same lifecycle events are emitted so
174
+ observers (a logger, a video recorder, a transcriber) behave exactly as in generative mode.
175
+ The returned document plays back through :func:`play` to reproduce the captured commands — the
176
+ two modes are inverses over one data model.
177
+ """
178
+ observers = tuple(observers)
179
+ steps: list[Step] = []
180
+
181
+ async for invocation in _aiter(invocations):
182
+ step = CommandStep(
183
+ id=f"step-{len(steps) + 1}",
184
+ command=invocation.command,
185
+ timing=Timing(duration_ms=invocation.duration_ms or default_duration_ms),
186
+ )
187
+ steps.append(step)
188
+ await _emit(StepEnter(step), observers)
189
+ await _emit(BeforeCommand(step.command, step), observers)
190
+ await _emit(AfterCommand(step.command, invocation.result, step), observers)
191
+ await _emit(StepExit(step), observers)
192
+
193
+ return DemoDocument(
194
+ id=document_id,
195
+ sections=[Section(id=section_id, title=section_title, steps=steps)],
196
+ )
@@ -0,0 +1,154 @@
1
+ """The lifecycle protocol, as a typed event stream.
2
+
3
+ The brief describes the engine's lifecycle as a set of named observer hooks (``onStepEnter``,
4
+ ``beforeCommand``, ...). walkthru realizes that protocol as a stream of **typed events** consumed
5
+ by observer **callables** — the functional equivalent (each event type corresponds 1:1 to a hook
6
+ name), chosen for composability and to match Thor's functional conventions. See ``DECISIONS.md``
7
+ §D9.
8
+
9
+ An :data:`Observer` is any callable taking one :data:`Event`; it may be sync or async. The engine
10
+ (:mod:`walkthru.core.engine`) emits events; observers are pure subscribers — a recorder, an
11
+ overlay renderer, a narrator, a pacer, and a logger are all just observers.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Awaitable, Callable
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Union
19
+
20
+ from walkthru.core.schema import (
21
+ Beat,
22
+ Command,
23
+ CommandStep,
24
+ Cue,
25
+ DemoDocument,
26
+ NarrationSegment,
27
+ Section,
28
+ Step,
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Outcome:
34
+ """The result of a play/record run."""
35
+
36
+ ok: bool
37
+ steps_run: int
38
+ errors: tuple["CommandError", ...] = field(default_factory=tuple)
39
+
40
+
41
+ # --- lifecycle events (one type per hook in the protocol) ---
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DemoStart:
46
+ """Emitted once, before any section."""
47
+
48
+ document: DemoDocument
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class DemoEnd:
53
+ """Emitted once, after the last section; carries the run :class:`Outcome`."""
54
+
55
+ outcome: Outcome
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SectionEnter:
60
+ section: Section
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class SectionExit:
65
+ section: Section
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class StepEnter:
70
+ step: Step
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class StepExit:
75
+ step: Step
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class BeforeCommand:
80
+ """Emitted just before the executor runs the command."""
81
+
82
+ command: Command
83
+ step: CommandStep
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class AfterCommand:
88
+ """Emitted after the executor returns successfully."""
89
+
90
+ command: Command
91
+ result: Any
92
+ step: CommandStep
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class CommandError:
97
+ """Emitted when the executor raises; the run continues unless an observer stops it."""
98
+
99
+ command: Command
100
+ error: BaseException
101
+ step: CommandStep
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class CueBegin:
106
+ cue: Cue
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class CueEnd:
111
+ cue: Cue
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class Narration:
116
+ segment: NarrationSegment
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class BeatEvent:
121
+ beat: Beat
122
+
123
+
124
+ Event = Union[
125
+ DemoStart,
126
+ DemoEnd,
127
+ SectionEnter,
128
+ SectionExit,
129
+ StepEnter,
130
+ StepExit,
131
+ BeforeCommand,
132
+ AfterCommand,
133
+ CommandError,
134
+ CueBegin,
135
+ CueEnd,
136
+ Narration,
137
+ BeatEvent,
138
+ ]
139
+
140
+ #: An observer is any callable that consumes an event; it may return ``None`` or an awaitable.
141
+ Observer = Callable[[Event], Union[Awaitable[None], None]]
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class CommandInvocation:
146
+ """A command that has already executed — the unit of the capture-mode input stream.
147
+
148
+ In capture mode the human drives the app, each dispatch is observed *after the fact*, and the
149
+ engine records it. ``result``/``durationMs`` are what the live dispatch returned.
150
+ """
151
+
152
+ command: Command
153
+ result: Any = None
154
+ duration_ms: int | None = None