hum-cli 0.0.1__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.
hum/runtime/loop.py ADDED
@@ -0,0 +1,247 @@
1
+ """The loop. One Runner drives one session tree. Policy and human are
2
+ interchangeable turn sources; the difference between them is recorded, not
3
+ special-cased. When a human overrides a gated proposal, the runner forks the
4
+ pre-intervention state and lets the policy finish in shadow, so the world can
5
+ grade both futures.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from typing import Any, Callable
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from .autonomy import AutonomyPolicy, Level
16
+ from .drivers import HumanDriver, PolicyDriver
17
+ from .executor import Executor
18
+ from .grader import Grader
19
+ from .models import Intervention, Node, PreferencePair, Turn, Usage, Verdict
20
+ from .prompt import system_prompt as default_prompt
21
+ from .session import Session
22
+ from .tools import ToolRegistry
23
+ from .workspace import describe_change, fingerprint
24
+
25
+
26
+ class RunConfig(BaseModel):
27
+ # "off": the policy acts; the human steers, edits, interrupts — all recorded.
28
+ # "earned": policy turns are gated by earned autonomy (backend-configured sessions).
29
+ gate: str = "off"
30
+ watch_workspace: bool = True # notice files the human changed by hand between turns
31
+ max_turns: int = 200
32
+ max_wall_s: float = 3600.0
33
+ parallel_tools: bool = True
34
+ shadow: bool = True
35
+ shadow_max_turns: int = 100
36
+ task_class: str | None = None
37
+
38
+
39
+ class RunResult(BaseModel):
40
+ leaf: str
41
+ verdict: Verdict | None = None
42
+ pairs: list[PreferencePair] = Field(default_factory=list)
43
+ turns: int = 0
44
+ usage: Usage = Field(default_factory=Usage)
45
+ stop_reason: str = "done"
46
+ level: Level | None = None
47
+
48
+
49
+ class Runner:
50
+ def __init__(self, session: Session, executor: Executor, tools: ToolRegistry, policy: PolicyDriver, *,
51
+ human: HumanDriver | None = None, grader: Grader | None = None, autonomy: AutonomyPolicy | None = None,
52
+ config: RunConfig | None = None, system_prompt: str | None = None,
53
+ shadow_policy_factory: Callable[[], PolicyDriver] | None = None):
54
+ self.session, self.executor, self.tools, self.policy = session, executor, tools, policy
55
+ self.human, self.grader, self.autonomy = human, grader, autonomy
56
+ self.cfg = config or RunConfig()
57
+ self.system_prompt = system_prompt or default_prompt()
58
+ self._shadow_factory = shadow_policy_factory or (lambda: PolicyDriver(self.policy.llm, author="shadow"))
59
+ self._shadows: list[tuple[str, asyncio.Task]] = []
60
+ self._tools_openai = tools.to_openai()
61
+ self.usage = Usage()
62
+ self.turns = 0
63
+ self._t0 = time.monotonic()
64
+ self.interventions = 0
65
+ self.task_class = self.cfg.task_class or session.task.get("task_class")
66
+ self._fp: str | None = None
67
+
68
+ def record_interrupt(self, note: str = "operator interrupted the model") -> Node:
69
+ """The person stopped a model turn mid-flight. The lost turn is not in the
70
+ tree (it never executed); the stop itself is."""
71
+ head = self.session.head("main")
72
+ n = self.session.append(Node(parent=head.id if head else None, branch="main",
73
+ turn=Turn(author="human", content=f"[{note}]"),
74
+ intervention=Intervention(kind="interrupt", target_node=head.id if head else None, note=note),
75
+ extra={"event": "interrupt"}))
76
+ self.interventions += 1
77
+ return n
78
+
79
+ # -- public -----------------------------------------------------------------
80
+ async def run(self, continue_from: str | None = None, initial: Turn | None = None) -> RunResult:
81
+ """``continue_from`` = head node of an ongoing session; ``initial`` = a human
82
+ message/action to apply first (a follow-up in a conversation)."""
83
+ leaf, reason = await self._drive("main", continue_from, self.executor, initial, self.policy, self.human, self.cfg.max_turns)
84
+ verdict = await self._grade(leaf, self.executor)
85
+ pairs = await self._collect_shadows(leaf, verdict)
86
+ level = None
87
+ if self.autonomy and self.task_class:
88
+ # Only policy-only outcomes count toward autonomy: an unsupervised main
89
+ # branch, or each shadow continuation.
90
+ if self.interventions == 0 and verdict is not None:
91
+ level = self.autonomy.update(self.task_class, verdict.passed)
92
+ for p in pairs:
93
+ if p.shadow_verdict is not None:
94
+ level = self.autonomy.update(self.task_class, p.shadow_verdict.passed)
95
+ level = level or self.autonomy.level(self.task_class)
96
+ return RunResult(leaf=leaf, verdict=verdict, pairs=pairs, turns=self.turns, usage=self.usage, stop_reason=reason, level=level)
97
+
98
+ # -- core ---------------------------------------------------------------------
99
+ def _level(self) -> Level:
100
+ return self.autonomy.level(self.task_class) if self.autonomy else ("act" if self.human is None else "propose")
101
+
102
+ def _gated(self, turn: Turn) -> bool:
103
+ if self.human is None or not turn.acts or self.cfg.gate != "earned":
104
+ return False # terminal claims are never gated: the grader decides
105
+ mutating = any(self.tools.is_mutating(c.name) for c in turn.tool_calls)
106
+ return AutonomyPolicy.gates(self._level(), mutating)
107
+
108
+ def _timed_out(self) -> bool:
109
+ return (time.monotonic() - self._t0) > self.cfg.max_wall_s
110
+
111
+ async def _drive(self, branch: str, parent: str | None, executor: Executor, initial: Turn | None,
112
+ policy: PolicyDriver, human: HumanDriver | None, max_turns: int) -> tuple[str, str]:
113
+ node_parent = parent
114
+ pending: Turn | None = initial
115
+ intervention: Intervention | None = None
116
+ takeover = False
117
+ turns = 0
118
+ last_id = parent or ""
119
+ while turns < max_turns and not self._timed_out():
120
+ turn: Turn
121
+ if pending is not None:
122
+ turn, pending = pending, None
123
+ elif takeover:
124
+ action = await human.next() # type: ignore[union-attr]
125
+ if action.intervention and action.intervention.kind == "release":
126
+ takeover = False
127
+ continue
128
+ if action.turn is None:
129
+ continue
130
+ turn, intervention = action.turn.model_copy(update={"author": "human"}), None
131
+ else:
132
+ steer = human.pending() if human else None
133
+ if steer is not None:
134
+ if steer.intervention and steer.intervention.kind == "takeover":
135
+ takeover = True
136
+ self.interventions += 1
137
+ if steer.turn is None:
138
+ continue
139
+ turn, intervention = steer.turn.model_copy(update={"author": "human"}), steer.intervention
140
+ elif steer.turn is not None:
141
+ turn, intervention = steer.turn.model_copy(update={"author": "human"}), None
142
+ else:
143
+ continue
144
+ else:
145
+ if self.cfg.watch_workspace and branch == "main":
146
+ fp = await fingerprint(executor)
147
+ if self._fp is not None and fp != self._fp:
148
+ # files changed and no tool did it: the person did. Record it as their turn.
149
+ desc = await describe_change(executor)
150
+ n = self.session.append(Node(parent=node_parent, branch=branch,
151
+ turn=Turn(author="human", content=f"[edited files by hand]\n{desc}".strip()),
152
+ intervention=Intervention(kind="edit", target_node=node_parent, note="workspace changed outside the loop"),
153
+ extra={"event": "human_edit"}))
154
+ self.interventions += 1
155
+ node_parent = last_id = n.id
156
+ self._fp = fp
157
+ msgs = self.session.messages(node_parent, self.system_prompt)
158
+ turn = await policy.next(msgs, self._tools_openai)
159
+ if self.cfg.watch_workspace and branch == "main" and self._fp is not None:
160
+ fp2 = await fingerprint(executor)
161
+ if fp2 != self._fp:
162
+ # the person edited while the model was thinking; the model's turn did not see it
163
+ desc = await describe_change(executor)
164
+ n = self.session.append(Node(parent=node_parent, branch=branch,
165
+ turn=Turn(author="human", content=f"[edited files by hand]\n{desc}".strip()),
166
+ intervention=Intervention(kind="edit", target_node=node_parent, note="workspace changed while the model was thinking"),
167
+ extra={"event": "human_edit"}))
168
+ self.interventions += 1
169
+ node_parent = last_id = n.id
170
+ self._fp = fp2
171
+ if turn.usage:
172
+ self.usage = self.usage + turn.usage
173
+ if self._gated(turn):
174
+ action = await human.review(turn) # type: ignore[union-attr]
175
+ kind = action.intervention.kind if action.intervention else "accept"
176
+ if kind != "accept":
177
+ self.interventions += 1
178
+ proposal_id = await self._spawn_shadow(node_parent, executor, turn) if self.cfg.shadow else None
179
+ intervention = Intervention(kind=kind, target_node=proposal_id, note=action.intervention.note if action.intervention else None)
180
+ if kind == "takeover":
181
+ takeover = True
182
+ if action.turn is None:
183
+ # rejection without a replacement: tell the policy and let it retry
184
+ note = Turn(author="human", content=f"[{kind}] {intervention.note or 'proposal not accepted'}")
185
+ n = self.session.append(Node(parent=node_parent, branch=branch, turn=note, intervention=intervention))
186
+ node_parent, last_id, intervention = n.id, n.id, None
187
+ continue
188
+ turn = action.turn.model_copy(update={"author": "human"})
189
+ # execute
190
+ results = await self.tools.call_many(turn.tool_calls, executor, self.cfg.parallel_tools) if turn.tool_calls else []
191
+ if results and self.cfg.watch_workspace and branch == "main":
192
+ self._fp = await fingerprint(executor) # the loop's own changes are not the human's
193
+ node = self.session.append(Node(parent=node_parent, branch=branch, turn=turn, results=results, intervention=intervention))
194
+ intervention = None
195
+ node_parent = last_id = node.id
196
+ turns += 1
197
+ if branch == "main":
198
+ self.turns = turns
199
+ if not turn.tool_calls and (turn.author in ("policy", "shadow") or turn.done):
200
+ return last_id, "done"
201
+ return last_id, ("timeout" if self._timed_out() else "budget")
202
+
203
+ # -- shadow -------------------------------------------------------------------
204
+ async def _spawn_shadow(self, parent: str | None, executor: Executor, proposal: Turn) -> str:
205
+ """Fork the pre-intervention state; run the proposal and the policy's
206
+ continuation there. Returns the id of the proposal node on the shadow branch."""
207
+ snap = await executor.snapshot()
208
+ fex = await executor.fork(snap)
209
+ branch = self.session.new_branch_name("shadow")
210
+ shadow_policy = self._shadow_factory()
211
+ ready: asyncio.Future[str] = asyncio.get_event_loop().create_future()
212
+
213
+ async def go() -> tuple[str, Verdict | None]:
214
+ leaf, _ = await self._drive(branch, parent, fex, proposal.model_copy(update={"author": "shadow"}), shadow_policy, None, self.cfg.shadow_max_turns)
215
+ v = await self._grade(leaf, fex)
216
+ await fex.close()
217
+ return leaf, v
218
+
219
+ task = asyncio.create_task(go())
220
+ self._shadows.append((branch, task))
221
+ # the proposal node is appended synchronously on the first iteration of _drive; yield once so it exists
222
+ await asyncio.sleep(0)
223
+ first = self.session.branch_nodes(branch)
224
+ while not first:
225
+ await asyncio.sleep(0)
226
+ first = self.session.branch_nodes(branch)
227
+ return first[0].id
228
+
229
+ async def _collect_shadows(self, main_leaf: str, main_verdict: Verdict | None) -> list[PreferencePair]:
230
+ pairs: list[PreferencePair] = []
231
+ for branch, task in self._shadows:
232
+ leaf, v = await task
233
+ fork = self.session.branch_nodes(branch)[0].id
234
+ pairs.append(self.session.add_pair(PreferencePair(
235
+ fork_node=fork, human_leaf=main_leaf, shadow_leaf=leaf,
236
+ human_verdict=main_verdict, shadow_verdict=v, task_class=self.task_class)))
237
+ return pairs
238
+
239
+ # -- grading ------------------------------------------------------------------
240
+ async def _grade(self, leaf: str, executor: Executor) -> Verdict | None:
241
+ if self.grader is None or not leaf:
242
+ return None
243
+ v = await self.grader.grade(executor, self.session.task)
244
+ node = self.session.nodes[leaf]
245
+ node.verdict = v
246
+ self.session.update(node)
247
+ return v
hum/runtime/models.py ADDED
@@ -0,0 +1,132 @@
1
+ """Data model for one harness session.
2
+
3
+ A session is a TREE of nodes. Each node holds one TURN (who acted, what they
4
+ said, which tools they called), the tool RESULTS that followed, and, when a
5
+ human acted on a policy proposal, the INTERVENTION that relates the two. The
6
+ tree is the unit of data: the main branch is what actually happened; shadow
7
+ branches are what the policy would have done had the human not intervened.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import uuid
13
+ from typing import Any, Literal
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ Author = Literal["policy", "human", "shadow", "system"]
18
+ InterventionKind = Literal["accept", "edit", "reject", "takeover", "release", "interrupt"]
19
+
20
+
21
+ def new_id(prefix: str) -> str:
22
+ return f"{prefix}_{uuid.uuid4().hex[:12]}"
23
+
24
+
25
+ class ToolCall(BaseModel):
26
+ id: str
27
+ name: str
28
+ arguments: dict[str, Any] = Field(default_factory=dict)
29
+
30
+
31
+ class ToolResult(BaseModel):
32
+ call_id: str
33
+ name: str
34
+ content: str
35
+ ok: bool = True
36
+ elapsed_s: float = 0.0
37
+ raw_bytes: int | None = None # size before observation shaping
38
+ extra: dict[str, Any] | None = None
39
+
40
+
41
+ class Usage(BaseModel):
42
+ input_tokens: int = 0
43
+ output_tokens: int = 0
44
+ cache_tokens: int = 0
45
+ cost_usd: float = 0.0
46
+
47
+ def __add__(self, o: "Usage") -> "Usage":
48
+ return Usage(
49
+ input_tokens=self.input_tokens + o.input_tokens,
50
+ output_tokens=self.output_tokens + o.output_tokens,
51
+ cache_tokens=self.cache_tokens + o.cache_tokens,
52
+ cost_usd=self.cost_usd + o.cost_usd,
53
+ )
54
+
55
+
56
+ class Turn(BaseModel):
57
+ author: Author
58
+ content: str = ""
59
+ reasoning: str | None = None
60
+ tool_calls: list[ToolCall] = Field(default_factory=list)
61
+ model: str | None = None
62
+ usage: Usage | None = None
63
+ # Token custody when the serving side exposes it (training path).
64
+ prompt_token_ids: list[int] | None = None
65
+ completion_token_ids: list[int] | None = None
66
+ logprobs: list[float] | None = None
67
+ # A human turn with no tool calls is steering text, not a terminal turn,
68
+ # unless it says so explicitly.
69
+ done: bool = False
70
+
71
+ @property
72
+ def acts(self) -> bool:
73
+ return bool(self.tool_calls)
74
+
75
+
76
+ class Intervention(BaseModel):
77
+ kind: InterventionKind
78
+ target_node: str | None = None # the policy node acted upon (None for takeover/release)
79
+ note: str | None = None
80
+
81
+
82
+ class Verdict(BaseModel):
83
+ passed: bool
84
+ score: float
85
+ grader: str
86
+ detail: dict[str, Any] = Field(default_factory=dict)
87
+ at: float = Field(default_factory=time.time)
88
+
89
+
90
+ class Node(BaseModel):
91
+ id: str = Field(default_factory=lambda: new_id("n"))
92
+ parent: str | None = None
93
+ branch: str = "main"
94
+ turn: Turn
95
+ results: list[ToolResult] = Field(default_factory=list)
96
+ intervention: Intervention | None = None
97
+ # Set on the policy node that a human acted on; points at the branch that
98
+ # carries the counterfactual continuation.
99
+ shadow_branch: str | None = None
100
+ verdict: Verdict | None = None
101
+ at: float = Field(default_factory=time.time)
102
+ extra: dict[str, Any] = Field(default_factory=dict)
103
+
104
+
105
+ class PreferencePair(BaseModel):
106
+ """Two terminal states that share a fork point, each graded by the world.
107
+
108
+ The preference is the verdict, not a rater. Pairs where both sides pass or
109
+ both fail are kept (they are informative about the intervention's cost),
110
+ with ``preferred`` = None.
111
+ """
112
+
113
+ id: str = Field(default_factory=lambda: new_id("pp"))
114
+ fork_node: str
115
+ human_leaf: str
116
+ shadow_leaf: str
117
+ human_verdict: Verdict | None = None
118
+ shadow_verdict: Verdict | None = None
119
+ task_class: str | None = None
120
+
121
+ @property
122
+ def preferred(self) -> Literal["human", "shadow"] | None:
123
+ h, s = self.human_verdict, self.shadow_verdict
124
+ if h is None or s is None:
125
+ return None
126
+ if h.passed and not s.passed:
127
+ return "human"
128
+ if s.passed and not h.passed:
129
+ return "shadow"
130
+ if h.score != s.score:
131
+ return "human" if h.score > s.score else "shadow"
132
+ return None
hum/runtime/observe.py ADDED
@@ -0,0 +1,28 @@
1
+ """Observation shaping. The policy's context is the scarcest resource in the
2
+ loop; what survives from a 40k-line job log is a harness decision, and one of
3
+ the parameters later swept against reward. Defaults here are the v0 guess.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
10
+
11
+
12
+ def shape(text: str, max_bytes: int = 16_000, head_fraction: float = 0.6) -> tuple[str, int]:
13
+ """Return (shaped_text, raw_byte_count). Keeps head and tail; marks the cut."""
14
+ text = _ANSI.sub("", text).replace("\r\n", "\n").replace("\r", "\n")
15
+ raw = len(text.encode("utf-8", errors="replace"))
16
+ if raw <= max_bytes:
17
+ return text, raw
18
+ head_n = int(max_bytes * head_fraction)
19
+ tail_n = max_bytes - head_n
20
+ head = text[:head_n]
21
+ tail = text[-tail_n:] if tail_n > 0 else ""
22
+ # cut on line boundaries where possible
23
+ if "\n" in head:
24
+ head = head[: head.rfind("\n") + 1]
25
+ if "\n" in tail:
26
+ tail = tail[tail.find("\n") + 1 :]
27
+ elided = raw - len(head.encode()) - len(tail.encode())
28
+ return f"{head}\n... [{elided} bytes elided by hum; re-run with a narrower command to see them] ...\n{tail}", raw
hum/runtime/prompt.py ADDED
@@ -0,0 +1,9 @@
1
+ SYSTEM_PROMPT = """You are an engineer working inside a real system. You have native tools; call them directly and read their output before acting again. Prefer small, verifiable steps. Do not claim a result you have not observed. When the task is complete, reply with a short summary and no tool calls.
2
+ {tools_note}{world_note}"""
3
+
4
+
5
+ def system_prompt(tools_note: str = "", world_note: str = "") -> str:
6
+ return SYSTEM_PROMPT.format(
7
+ tools_note=(f"\n\nTools beyond the basics:\n{tools_note}" if tools_note else ""),
8
+ world_note=(f"\n\nAbout this system:\n{world_note}" if world_note else ""),
9
+ )
hum/runtime/session.py ADDED
@@ -0,0 +1,139 @@
1
+ """Session tree with append-only JSONL persistence.
2
+
3
+ Every node is written the moment it exists, so a crashed or timed-out run
4
+ still leaves a complete prefix on disk. ``messages()`` rebuilds the chat
5
+ context for any node by walking its lineage, which is how a branch (human or
6
+ shadow) is re-entered from any point.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .models import Node, PreferencePair, Turn, new_id
15
+
16
+
17
+ class Session:
18
+ def __init__(self, task: dict[str, Any], session_id: str | None = None, path: Path | None = None):
19
+ self.id = session_id or new_id("s")
20
+ self.task = task # {"instruction": str, "task_class": str|None, ...}
21
+ self.path = path
22
+ self.nodes: dict[str, Node] = {}
23
+ self.order: list[str] = []
24
+ self.pairs: list[PreferencePair] = []
25
+ self._branches: dict[str, str] = {} # branch -> head node id
26
+ if path is not None:
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ with path.open("a", encoding="utf-8") as f:
29
+ f.write(json.dumps({"kind": "session", "id": self.id, "task": task}) + "\n")
30
+
31
+ # -- tree -------------------------------------------------------------
32
+ def append(self, node: Node) -> Node:
33
+ if node.parent is not None and node.parent not in self.nodes:
34
+ raise KeyError(f"unknown parent {node.parent}")
35
+ self.nodes[node.id] = node
36
+ self.order.append(node.id)
37
+ self._branches[node.branch] = node.id
38
+ self._persist({"kind": "node", **node.model_dump(mode="json")})
39
+ return node
40
+
41
+ def update(self, node: Node) -> None:
42
+ """Re-persist a node whose verdict / shadow pointer was set after the fact."""
43
+ self.nodes[node.id] = node
44
+ self._persist({"kind": "node_update", **node.model_dump(mode="json")})
45
+
46
+ def add_pair(self, pair: PreferencePair) -> PreferencePair:
47
+ self.pairs.append(pair)
48
+ self._persist({"kind": "pair", **pair.model_dump(mode="json")})
49
+ return pair
50
+
51
+ def head(self, branch: str = "main") -> Node | None:
52
+ nid = self._branches.get(branch)
53
+ return self.nodes[nid] if nid else None
54
+
55
+ def lineage(self, node_id: str | None) -> list[Node]:
56
+ out: list[Node] = []
57
+ while node_id is not None:
58
+ n = self.nodes[node_id]
59
+ out.append(n)
60
+ node_id = n.parent
61
+ out.reverse()
62
+ return out
63
+
64
+ def branch_nodes(self, branch: str) -> list[Node]:
65
+ return [self.nodes[i] for i in self.order if self.nodes[i].branch == branch]
66
+
67
+ def new_branch_name(self, prefix: str = "shadow") -> str:
68
+ n = sum(1 for b in self._branches if b.startswith(prefix))
69
+ return f"{prefix}-{n + 1}"
70
+
71
+ # -- chat reconstruction ---------------------------------------------
72
+ def messages(self, node_id: str | None, system_prompt: str) -> list[dict[str, Any]]:
73
+ """OpenAI-style messages for the lineage ending at ``node_id``.
74
+
75
+ Policy and shadow turns render as assistant turns. Human turns that act
76
+ (tool calls) also render as assistant turns: from the model's point of
77
+ view the work simply happened. Human turns that only speak render as
78
+ user turns (steering). Authorship is kept on the node, not in the chat.
79
+ """
80
+ msgs: list[dict[str, Any]] = [
81
+ {"role": "system", "content": system_prompt},
82
+ {"role": "user", "content": self.task["instruction"]},
83
+ ]
84
+ for n in self.lineage(node_id):
85
+ t = n.turn
86
+ if t.author == "human" and not t.acts:
87
+ if t.content:
88
+ msgs.append({"role": "user", "content": t.content})
89
+ continue
90
+ if t.author == "system":
91
+ msgs.append({"role": "user", "content": t.content})
92
+ continue
93
+ a: dict[str, Any] = {"role": "assistant", "content": t.content or None}
94
+ if t.tool_calls:
95
+ a["tool_calls"] = [
96
+ {
97
+ "id": c.id,
98
+ "type": "function",
99
+ "function": {"name": c.name, "arguments": json.dumps(c.arguments)},
100
+ }
101
+ for c in t.tool_calls
102
+ ]
103
+ msgs.append(a)
104
+ for r in n.results:
105
+ msgs.append({"role": "tool", "tool_call_id": r.call_id, "name": r.name, "content": r.content})
106
+ return msgs
107
+
108
+ # -- persistence ------------------------------------------------------
109
+ def _persist(self, rec: dict[str, Any]) -> None:
110
+ if self.path is None:
111
+ return
112
+ with self.path.open("a", encoding="utf-8") as f:
113
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
114
+
115
+ @classmethod
116
+ def load(cls, path: Path) -> "Session":
117
+ s: Session | None = None
118
+ with path.open(encoding="utf-8") as f:
119
+ for line in f:
120
+ rec = json.loads(line)
121
+ kind = rec.pop("kind")
122
+ if kind == "session":
123
+ s = cls(rec["task"], session_id=rec["id"], path=None)
124
+ s.path = None
125
+ elif kind in ("node", "node_update"):
126
+ assert s is not None
127
+ node = Node.model_validate(rec)
128
+ if kind == "node":
129
+ s.nodes[node.id] = node
130
+ s.order.append(node.id)
131
+ s._branches[node.branch] = node.id
132
+ else:
133
+ s.nodes[node.id] = node
134
+ elif kind == "pair":
135
+ assert s is not None
136
+ s.pairs.append(PreferencePair.model_validate(rec))
137
+ assert s is not None, "empty session file"
138
+ s.path = path
139
+ return s
hum/runtime/store.py ADDED
@@ -0,0 +1,26 @@
1
+ """Where sessions and autonomy live on a machine: ``~/.hum`` (override with
2
+ ``HUM_HOME``). One JSONL per session, one autonomy file per home.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+
10
+
11
+ def home() -> Path:
12
+ p = Path(os.environ.get("HUM_HOME") or Path.home() / ".hum")
13
+ (p / "sessions").mkdir(parents=True, exist_ok=True)
14
+ return p
15
+
16
+
17
+ def session_path(session_id: str) -> Path:
18
+ return home() / "sessions" / f"{time.strftime('%Y%m%d-%H%M%S')}-{session_id}.jsonl"
19
+
20
+
21
+ def autonomy_path() -> Path:
22
+ return home() / "autonomy.json"
23
+
24
+
25
+ def list_sessions() -> list[Path]:
26
+ return sorted((home() / "sessions").glob("*.jsonl"))