semora-fork 0.1.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .coverage
11
+ htmlcov/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+
17
+ # Local tool/editor state — machine-specific, never pushed.
18
+ .claude/
19
+ .codecanvas/
20
+ .vscode/
21
+
22
+
23
+ # Superpowers design/spec scratch — working notes, not project documentation.
24
+ docs/superpowers/
25
+
26
+ # 로컬 자격증명 — 절대 커밋 금지.
27
+ a.txt
28
+ *.token
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: semora-fork
3
+ Version: 0.1.0
4
+ Summary: Fork a conversation from before one injected input, under different controls.
5
+ Project-URL: Homepage, https://github.com/donggyun112/semora
6
+ Project-URL: Source, https://github.com/donggyun112/semora
7
+ Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
8
+ Author: donggyun112
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: langchain-core<2,>=1
17
+ Requires-Dist: semora-store==0.1.0
18
+ Requires-Dist: semora==0.1.0
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "semora-fork"
7
+ version = "0.1.0"
8
+ description = "Fork a conversation from before one injected input, under different controls."
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ authors = [{ name = "donggyun112" }]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Typing :: Typed",
18
+ ]
19
+ urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
20
+ dependencies = [
21
+ "langchain-core>=1,<2",
22
+ "semora==0.1.0",
23
+ "semora-store==0.1.0",
24
+ ]
25
+ # Assembly over the core's public primitives: the ledger keeps the pre-screen original, the
26
+ # transcript rewinds by branch, controls are per-call arguments. This package only walks those
27
+ # seams; putting the walk inside the core would freeze one composition into the contract.
28
+
29
+ [tool.uv.sources]
30
+ semora = { workspace = true }
31
+ semora-store = { workspace = true }
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/semora_fork"]
@@ -0,0 +1,214 @@
1
+ """Fork a conversation from just before one injected input.
2
+
3
+ The core keeps the pieces apart on purpose: the ledger holds each input's pre-screen
4
+ original, the transcript rewinds by preserved branch, and controls arrive as call
5
+ arguments. Forking is therefore composition, not a runtime feature — this package walks
6
+ those seams and adds no authority of its own. The source run's ledger is never touched:
7
+ what actually went out stays the record.
8
+ """
9
+
10
+ from datetime import UTC, datetime
11
+ from typing import Any, NamedTuple
12
+
13
+ from langchain_core.language_models import BaseChatModel
14
+ from semora import AgentRuntime
15
+ from semora.contracts import Agent, Tools
16
+ from semora.controls import Controls
17
+ from semora.history import decode_pending_input
18
+ from semora.transcript import SCHEMA_VERSION, entry_id, messages_at
19
+ from semora_store import ExecutionStore, Transcript
20
+
21
+ __all__ = [
22
+ "EventCheckpoint",
23
+ "ForkCoordinate",
24
+ "fork_event",
25
+ "fork_run",
26
+ "read_event_checkpoint",
27
+ "record_event_checkpoint",
28
+ ]
29
+
30
+
31
+ class ForkCoordinate(NamedTuple):
32
+ """One replayable position attached to an observation edge."""
33
+
34
+ from_run_id: str
35
+ origin_id: str | None
36
+ leaf_uuid: str | None
37
+
38
+
39
+ class EventCheckpoint(NamedTuple):
40
+ """Durable before/after transcript coordinates for one observation."""
41
+
42
+ event_id: str
43
+ conversation_id: str
44
+ before: ForkCoordinate
45
+ after: ForkCoordinate
46
+
47
+
48
+ def _coordinate_payload(coordinate: ForkCoordinate) -> dict[str, str | None]:
49
+ return {
50
+ "from_run_id": coordinate.from_run_id,
51
+ "origin_id": coordinate.origin_id,
52
+ "leaf_uuid": coordinate.leaf_uuid,
53
+ }
54
+
55
+
56
+ def _decode_coordinate(payload: object) -> ForkCoordinate:
57
+ if not isinstance(payload, dict):
58
+ raise TypeError("fork checkpoint coordinate must be a mapping")
59
+ from_run_id = payload.get("from_run_id")
60
+ origin_id = payload.get("origin_id")
61
+ leaf_uuid = payload.get("leaf_uuid")
62
+ if not isinstance(from_run_id, str) or not from_run_id:
63
+ raise TypeError("fork checkpoint coordinate has no source run")
64
+ if origin_id is not None and not isinstance(origin_id, str):
65
+ raise TypeError("fork checkpoint origin must be a string or None")
66
+ if leaf_uuid is not None and not isinstance(leaf_uuid, str):
67
+ raise TypeError("fork checkpoint leaf must be a string or None")
68
+ return ForkCoordinate(from_run_id, origin_id, leaf_uuid)
69
+
70
+
71
+ async def record_event_checkpoint(
72
+ transcript: Transcript,
73
+ checkpoint: EventCheckpoint,
74
+ ) -> bool:
75
+ """Append one immutable event-to-state mapping to the conversation transcript."""
76
+ body = {
77
+ "type": "fork_checkpoint",
78
+ "event_id": checkpoint.event_id,
79
+ "before": _coordinate_payload(checkpoint.before),
80
+ "after": _coordinate_payload(checkpoint.after),
81
+ }
82
+ return await transcript.append(
83
+ {
84
+ "uuid": entry_id(None, body),
85
+ "conversation_id": checkpoint.conversation_id,
86
+ "timestamp": datetime.now(UTC).isoformat(),
87
+ "schema_version": SCHEMA_VERSION,
88
+ **body,
89
+ }
90
+ )
91
+
92
+
93
+ async def read_event_checkpoint(
94
+ transcript: Transcript,
95
+ conversation_id: str,
96
+ event_id: str,
97
+ ) -> EventCheckpoint:
98
+ """Read the newest durable mapping for one event identity."""
99
+ entries = await transcript.read(conversation_id)
100
+ entry = next(
101
+ (
102
+ item
103
+ for item in reversed(entries)
104
+ if item.get("type") == "fork_checkpoint" and item.get("event_id") == event_id
105
+ ),
106
+ None,
107
+ )
108
+ if entry is None:
109
+ raise ValueError(f"conversation {conversation_id!r} has no fork checkpoint {event_id!r}")
110
+ return EventCheckpoint(
111
+ event_id,
112
+ conversation_id,
113
+ _decode_coordinate(entry.get("before")),
114
+ _decode_coordinate(entry.get("after")),
115
+ )
116
+
117
+
118
+ async def fork_run(
119
+ runtime: AgentRuntime,
120
+ store: ExecutionStore,
121
+ *,
122
+ from_run_id: str,
123
+ origin_id: str,
124
+ run_id: str,
125
+ model: BaseChatModel | Agent,
126
+ tools: Tools | str | None = None,
127
+ controls: Controls | None = None,
128
+ conversation_id: str | None = None,
129
+ **options: Any,
130
+ ) -> dict[str, Any]:
131
+ """Re-run the conversation from just before ``origin_id`` entered model context.
132
+
133
+ The pre-screen original is read from ``from_run_id``'s ledger and enqueued for the new
134
+ ``run_id``, so it passes through whatever ``controls`` this call supplies — the fork
135
+ screens, records, and announces it like any live input. The rewind is
136
+ ``run(history=...)``'s own branch-preserving replace: the source branch stays
137
+ observable, the conversation head moves to the fork.
138
+
139
+ Raises ``ValueError`` when the ledger has no such input or it never reached model
140
+ context — either way there is no injection point to fork from.
141
+ """
142
+ records = await store.list_inputs(from_run_id)
143
+ record = next((r for r in records if r.input_id == origin_id), None)
144
+ if record is None:
145
+ raise ValueError(f"run {from_run_id!r} has no ledger record for input {origin_id!r}")
146
+ original = decode_pending_input(record.value)
147
+
148
+ conversation = conversation_id or from_run_id
149
+ history = await runtime.committed_history(from_run_id, conversation)
150
+ cut = next((i for i, message in enumerate(history) if message.id == origin_id), None)
151
+ if cut is None:
152
+ raise ValueError(f"input {origin_id!r} never entered the model context of {conversation!r}")
153
+
154
+ await runtime.submit(run_id, original, conversation_id=conversation)
155
+ return await runtime.run(
156
+ run_id,
157
+ model,
158
+ tools,
159
+ controls=controls,
160
+ conversation_id=conversation,
161
+ history=list(history[:cut]),
162
+ **options,
163
+ )
164
+
165
+
166
+ async def fork_event(
167
+ runtime: AgentRuntime,
168
+ store: ExecutionStore,
169
+ transcript: Transcript,
170
+ *,
171
+ event_id: str,
172
+ edge: str,
173
+ run_id: str,
174
+ model: BaseChatModel | Agent,
175
+ tools: Tools | str | None = None,
176
+ controls: Controls | None = None,
177
+ conversation_id: str,
178
+ **options: Any,
179
+ ) -> dict[str, Any]:
180
+ """Fork from the durable coordinate attached to one observation edge.
181
+
182
+ A before edge with an input origin reuses :func:`fork_run`, so the source ledger's
183
+ pre-screen original crosses the new controls. Other coordinates continue from their
184
+ explicit transcript leaf without copying source-run effect records.
185
+ """
186
+ if edge not in {"before", "after"}:
187
+ raise ValueError("edge must be 'before' or 'after'")
188
+ checkpoint = await read_event_checkpoint(transcript, conversation_id, event_id)
189
+ coordinate = checkpoint.before if edge == "before" else checkpoint.after
190
+ if edge == "before" and coordinate.origin_id is not None:
191
+ return await fork_run(
192
+ runtime,
193
+ store,
194
+ from_run_id=coordinate.from_run_id,
195
+ origin_id=coordinate.origin_id,
196
+ run_id=run_id,
197
+ model=model,
198
+ tools=tools,
199
+ controls=controls,
200
+ conversation_id=checkpoint.conversation_id,
201
+ **options,
202
+ )
203
+
204
+ entries = await transcript.read(checkpoint.conversation_id)
205
+ history = messages_at(entries, coordinate.leaf_uuid)
206
+ return await runtime.run(
207
+ run_id,
208
+ model,
209
+ tools,
210
+ controls=controls,
211
+ conversation_id=checkpoint.conversation_id,
212
+ history=history,
213
+ **options,
214
+ )
File without changes