wirestudio 0.10.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.
Files changed (145) hide show
  1. wirestudio/__init__.py +3 -0
  2. wirestudio/agent/__init__.py +8 -0
  3. wirestudio/agent/agent.py +313 -0
  4. wirestudio/agent/session.py +65 -0
  5. wirestudio/agent/tools.py +518 -0
  6. wirestudio/api/__init__.py +3 -0
  7. wirestudio/api/__main__.py +45 -0
  8. wirestudio/api/app.py +884 -0
  9. wirestudio/api/schemas.py +236 -0
  10. wirestudio/api/serve.py +73 -0
  11. wirestudio/csp/__init__.py +20 -0
  12. wirestudio/csp/compatibility.py +414 -0
  13. wirestudio/csp/pin_solver.py +521 -0
  14. wirestudio/designs/__init__.py +11 -0
  15. wirestudio/designs/active.py +36 -0
  16. wirestudio/designs/events.py +108 -0
  17. wirestudio/designs/seed.py +200 -0
  18. wirestudio/designs/store.py +129 -0
  19. wirestudio/enclosure/__init__.py +30 -0
  20. wirestudio/enclosure/openscad.py +195 -0
  21. wirestudio/enclosure/search.py +217 -0
  22. wirestudio/examples/__init__.py +1 -0
  23. wirestudio/examples/attic-logger.json +82 -0
  24. wirestudio/examples/awning-control.json +158 -0
  25. wirestudio/examples/bl0906-mainmeter.json +67 -0
  26. wirestudio/examples/bluemotion.json +97 -0
  27. wirestudio/examples/bluesonoff.json +77 -0
  28. wirestudio/examples/desk-climate.json +69 -0
  29. wirestudio/examples/desk-matrix.json +72 -0
  30. wirestudio/examples/distance-sensor.json +119 -0
  31. wirestudio/examples/esp32-audio.json +124 -0
  32. wirestudio/examples/garage-motion.json +90 -0
  33. wirestudio/examples/keypad.json +86 -0
  34. wirestudio/examples/multi-temp.json +98 -0
  35. wirestudio/examples/nextion-thermostat.json +86 -0
  36. wirestudio/examples/oled.json +87 -0
  37. wirestudio/examples/parking-distance.json +75 -0
  38. wirestudio/examples/rc522.json +132 -0
  39. wirestudio/examples/room-climate.json +81 -0
  40. wirestudio/examples/rs485-energy.json +72 -0
  41. wirestudio/examples/securitypanel.json +151 -0
  42. wirestudio/examples/smart-plug-v1.json +84 -0
  43. wirestudio/examples/smart-plug.json +87 -0
  44. wirestudio/examples/ttgo-lora32.json +136 -0
  45. wirestudio/examples/tuya-smart-plug.json +97 -0
  46. wirestudio/examples/wasserpir.json +79 -0
  47. wirestudio/examples/weather-station.json +95 -0
  48. wirestudio/examples/wemosgps.json +101 -0
  49. wirestudio/fleet/__init__.py +4 -0
  50. wirestudio/fleet/client.py +266 -0
  51. wirestudio/generate/__init__.py +4 -0
  52. wirestudio/generate/__main__.py +48 -0
  53. wirestudio/generate/ascii_gen.py +114 -0
  54. wirestudio/generate/yaml_gen.py +321 -0
  55. wirestudio/kicad/__init__.py +5 -0
  56. wirestudio/kicad/generator.py +331 -0
  57. wirestudio/kicad/import.py +11 -0
  58. wirestudio/kicad/importer.py +232 -0
  59. wirestudio/kicad/symbol_parser.py +152 -0
  60. wirestudio/library/__init__.py +198 -0
  61. wirestudio/library/boards/esp01_1m.yaml +30 -0
  62. wirestudio/library/boards/esp32-c3-devkitm-1.yaml +53 -0
  63. wirestudio/library/boards/esp32-devkitc-v4.yaml +83 -0
  64. wirestudio/library/boards/esp32-s3-devkitc-1.yaml +77 -0
  65. wirestudio/library/boards/esp32-wrover-cam.yaml +71 -0
  66. wirestudio/library/boards/esp32cam-ai-thinker.yaml +78 -0
  67. wirestudio/library/boards/esp8285-1m.yaml +45 -0
  68. wirestudio/library/boards/m5stack-atom.yaml +49 -0
  69. wirestudio/library/boards/m5stack-atoms3.yaml +67 -0
  70. wirestudio/library/boards/nodemcu-32s.yaml +72 -0
  71. wirestudio/library/boards/nodemcu-v2.yaml +60 -0
  72. wirestudio/library/boards/ttgo-lora32-v1.yaml +91 -0
  73. wirestudio/library/boards/ttgo-t-beam.yaml +75 -0
  74. wirestudio/library/boards/wemos-d1-mini.yaml +67 -0
  75. wirestudio/library/components/adc.yaml +71 -0
  76. wirestudio/library/components/ads1115.yaml +61 -0
  77. wirestudio/library/components/ads1115_channel.yaml +69 -0
  78. wirestudio/library/components/aht10.yaml +63 -0
  79. wirestudio/library/components/apa102.yaml +66 -0
  80. wirestudio/library/components/bh1750.yaml +54 -0
  81. wirestudio/library/components/bl0906.yaml +97 -0
  82. wirestudio/library/components/bme280.yaml +51 -0
  83. wirestudio/library/components/bmp180.yaml +53 -0
  84. wirestudio/library/components/bmp280.yaml +61 -0
  85. wirestudio/library/components/cc1101.yaml +77 -0
  86. wirestudio/library/components/cse7766.yaml +58 -0
  87. wirestudio/library/components/dht.yaml +54 -0
  88. wirestudio/library/components/ds18b20.yaml +63 -0
  89. wirestudio/library/components/esp32_camera.yaml +103 -0
  90. wirestudio/library/components/esp32_rmt_led_strip.yaml +59 -0
  91. wirestudio/library/components/gpio_input.yaml +61 -0
  92. wirestudio/library/components/gpio_output.yaml +51 -0
  93. wirestudio/library/components/hc-sr04.yaml +55 -0
  94. wirestudio/library/components/hc-sr501.yaml +52 -0
  95. wirestudio/library/components/hlw8012.yaml +79 -0
  96. wirestudio/library/components/htu21d.yaml +53 -0
  97. wirestudio/library/components/hx711.yaml +60 -0
  98. wirestudio/library/components/ili9xxx.yaml +78 -0
  99. wirestudio/library/components/lcd_pcf8574.yaml +59 -0
  100. wirestudio/library/components/ld2420.yaml +57 -0
  101. wirestudio/library/components/max31855.yaml +58 -0
  102. wirestudio/library/components/max7219.yaml +62 -0
  103. wirestudio/library/components/max98357a.yaml +46 -0
  104. wirestudio/library/components/mcp23008.yaml +50 -0
  105. wirestudio/library/components/mcp23017.yaml +50 -0
  106. wirestudio/library/components/modbus.yaml +62 -0
  107. wirestudio/library/components/mpu6050.yaml +72 -0
  108. wirestudio/library/components/nextion.yaml +73 -0
  109. wirestudio/library/components/pcf8574.yaml +60 -0
  110. wirestudio/library/components/pulse_counter.yaml +70 -0
  111. wirestudio/library/components/rc522.yaml +51 -0
  112. wirestudio/library/components/rcwl-0516.yaml +49 -0
  113. wirestudio/library/components/rdm6300.yaml +43 -0
  114. wirestudio/library/components/rf_bridge.yaml +41 -0
  115. wirestudio/library/components/rotary_encoder.yaml +76 -0
  116. wirestudio/library/components/rtttl.yaml +52 -0
  117. wirestudio/library/components/sdm_meter.yaml +82 -0
  118. wirestudio/library/components/sht3xd.yaml +58 -0
  119. wirestudio/library/components/ssd1306.yaml +55 -0
  120. wirestudio/library/components/st7789.yaml +72 -0
  121. wirestudio/library/components/sx127x.yaml +90 -0
  122. wirestudio/library/components/tm1638.yaml +56 -0
  123. wirestudio/library/components/tsl2561.yaml +66 -0
  124. wirestudio/library/components/tuya.yaml +66 -0
  125. wirestudio/library/components/tuya_sensor.yaml +60 -0
  126. wirestudio/library/components/tuya_switch.yaml +53 -0
  127. wirestudio/library/components/uart_gps.yaml +41 -0
  128. wirestudio/library/components/vl53l0x.yaml +72 -0
  129. wirestudio/library/components/ws2812b.yaml +53 -0
  130. wirestudio/library/components/xpt2046.yaml +65 -0
  131. wirestudio/mcp/__init__.py +20 -0
  132. wirestudio/mcp/auth.py +114 -0
  133. wirestudio/mcp/server.py +528 -0
  134. wirestudio/model.py +153 -0
  135. wirestudio/recommend/__init__.py +8 -0
  136. wirestudio/recommend/recommender.py +223 -0
  137. wirestudio/schema/__init__.py +1 -0
  138. wirestudio/schema/design.schema.json +213 -0
  139. wirestudio/validate.py +26 -0
  140. wirestudio-0.10.0.dist-info/LICENSE +21 -0
  141. wirestudio-0.10.0.dist-info/METADATA +563 -0
  142. wirestudio-0.10.0.dist-info/RECORD +145 -0
  143. wirestudio-0.10.0.dist-info/WHEEL +5 -0
  144. wirestudio-0.10.0.dist-info/entry_points.txt +3 -0
  145. wirestudio-0.10.0.dist-info/top_level.txt +1 -0
wirestudio/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wirestudio: design.json -> ESPHome YAML + ASCII diagram."""
2
+
3
+ __version__ = "0.10.0"
@@ -0,0 +1,8 @@
1
+ """Claude tool-using agent for design.json edits.
2
+
3
+ The agent layer is intentionally small: tools mutate a per-turn working copy
4
+ of `design`, the manual agentic loop runs until the model emits `end_turn`,
5
+ and the updated design is returned alongside the assistant's text. Conversation
6
+ history persists in `sessions/<id>.jsonl`; the design itself is owned by the
7
+ client and arrives with each turn.
8
+ """
@@ -0,0 +1,313 @@
1
+ """The agentic loop: Claude + tool calls + design mutation."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Iterator, Optional
9
+
10
+ from wirestudio.agent.session import SessionStore, FileSessionStore, new_session_id
11
+ from wirestudio.agent.tools import TOOL_SCHEMAS, execute_tool
12
+ from wirestudio.library import Library, default_library
13
+
14
+ # Guard the import so the module is still importable in environments that
15
+ # haven't installed `anthropic` yet (the API endpoint will then 503).
16
+ try:
17
+ import anthropic # noqa: F401
18
+ _ANTHROPIC_INSTALLED = True
19
+ except ImportError: # pragma: no cover - exercised in deployment, not tests
20
+ _ANTHROPIC_INSTALLED = False
21
+
22
+
23
+ # Default agent model. Sonnet handles tool-routing + design-dict editing
24
+ # fine and is ~5x cheaper than Opus. Override via WIRESTUDIO_AGENT_MODEL
25
+ # (e.g., set to claude-opus-4-7 for the long-tail ambiguous prompts where
26
+ # Opus's reasoning shows up). Per-call override still wins over the env.
27
+ DEFAULT_AGENT_MODEL = "claude-sonnet-4-6"
28
+
29
+
30
+ def _resolve_model(explicit: Optional[str] = None) -> str:
31
+ return explicit or os.environ.get("WIRESTUDIO_AGENT_MODEL") or DEFAULT_AGENT_MODEL
32
+
33
+
34
+ SYSTEM_INSTRUCTIONS = """\
35
+ You are a helper inside wirestudio, a tool that turns a `design.json` \
36
+ document into ESPHome YAML + an ASCII wiring diagram + a BOM.
37
+
38
+ You edit the user's current design via tools; the user already sees the \
39
+ rendered output update live. Be concise -- one or two sentences confirming \
40
+ what you changed is plenty. Do not paste the YAML back at them unless asked.
41
+
42
+ Conventions:
43
+ - Never invent a `library_id`. Use `search_components` (or `list_boards`) first.
44
+ - Prefer `add_component` over manually editing the design -- it auto-wires \
45
+ rails by voltage match and bus pins to a matching bus.
46
+ - After a non-trivial change, call `validate` once to make sure the design \
47
+ still renders. If it doesn't, fix the issue (commonly a missing bus or \
48
+ unset gpio pin) and re-validate.
49
+ - Pin assignments are the user's call. Don't try to swap pins to "free up" a \
50
+ GPIO unless the user asks.
51
+ - The user owns the design. Confirm destructive operations (remove_component, \
52
+ replacing the board) only when the prompt is genuinely ambiguous.
53
+ """
54
+
55
+
56
+ def is_available() -> tuple[bool, str | None]:
57
+ if not _ANTHROPIC_INSTALLED:
58
+ return False, "anthropic SDK not installed; install wirestudio[agent]."
59
+ if not os.environ.get("ANTHROPIC_API_KEY"):
60
+ return False, "ANTHROPIC_API_KEY environment variable is not set."
61
+ return True, None
62
+
63
+
64
+ @dataclass
65
+ class TurnResult:
66
+ session_id: str
67
+ design: dict
68
+ assistant_text: str
69
+ tool_calls: list[dict]
70
+ stop_reason: str
71
+ usage: dict
72
+ model: str = ""
73
+
74
+
75
+ def _build_library_context(library: Library) -> str:
76
+ """Dump the library to JSON. Stable across turns -> cacheable."""
77
+ boards = [b.model_dump() for b in library.list_boards()]
78
+ components = [c.model_dump() for c in library.list_components()]
79
+ payload = {"boards": boards, "components": components}
80
+ return (
81
+ "## Library reference\n\n"
82
+ "Below is every board and component the studio currently ships, as JSON. "
83
+ "Use this to look up `params_schema`, electrical metadata, ESPHome "
84
+ "templates, and pin definitions. Do not mention contents of this "
85
+ "block to the user unless asked -- it is reference material, not chat.\n\n"
86
+ f"```json\n{json.dumps(payload, indent=2, default=str)}\n```"
87
+ )
88
+
89
+
90
+ def _build_user_message(design: dict, message: str) -> str:
91
+ return (
92
+ f"Current design state:\n```json\n{json.dumps(design, indent=2, default=str)}\n```\n\n"
93
+ f"User: {message}"
94
+ )
95
+
96
+
97
+
98
+ def _initialize_turn(design: dict, user_message: str, session_id: Optional[str], sessions: Optional[SessionStore], library: Library):
99
+ sessions_store = sessions or FileSessionStore()
100
+ sid = session_id or new_session_id()
101
+ history = sessions_store.load(sid)
102
+ working_design = copy.deepcopy(design)
103
+ messages: list[dict[str, Any]] = [{"role": entry["role"], "content": entry["content"]} for entry in history]
104
+ messages.append({"role": "user", "content": _build_user_message(working_design, user_message)})
105
+ library_block = _build_library_context(library)
106
+ return sid, sessions_store, working_design, messages, library_block
107
+
108
+ def _process_tool_calls(response, working_design: dict, library: Library, tool_calls_log: list[dict]):
109
+ tool_results: list[dict] = []
110
+ events = []
111
+ for block in response.content:
112
+ if getattr(block, "type", None) != "tool_use":
113
+ continue
114
+ tool_input = dict(block.input)
115
+ events.append({
116
+ "type": "tool_use_start",
117
+ "tool_use_id": block.id,
118
+ "tool": block.name,
119
+ "input": tool_input,
120
+ })
121
+ result_str, is_error = execute_tool(block.name, tool_input, working_design, library)
122
+ events.append({
123
+ "type": "tool_result",
124
+ "tool_use_id": block.id,
125
+ "tool": block.name,
126
+ "is_error": is_error,
127
+ })
128
+ tool_calls_log.append({
129
+ "tool": block.name,
130
+ "input": tool_input,
131
+ "is_error": is_error,
132
+ })
133
+ tool_results.append({
134
+ "type": "tool_result",
135
+ "tool_use_id": block.id,
136
+ "content": result_str,
137
+ "is_error": is_error,
138
+ })
139
+ return tool_results, events
140
+
141
+
142
+ def _serialize_assistant_block(block: object) -> dict:
143
+ """Strip SDK-side parser metadata from a content block before feeding
144
+ it back as conversation history.
145
+
146
+ `Message.content[*].model_dump()` includes fields the SDK attaches
147
+ while parsing the streamed response (e.g. `parsed_output` on text
148
+ blocks under structured-output paths). The Anthropic API rejects
149
+ those extras on input -- a multi-turn agent loop fails on the next
150
+ request with `messages.N.content.M.text.parsed_output: Extra inputs
151
+ are not permitted`. So we hand-pick only the fields the API
152
+ documents as input-valid for each block type.
153
+ """
154
+ btype = getattr(block, "type", None)
155
+ if btype == "text":
156
+ return {"type": "text", "text": getattr(block, "text", "")}
157
+ if btype == "tool_use":
158
+ return {
159
+ "type": "tool_use",
160
+ "id": getattr(block, "id", None),
161
+ "name": getattr(block, "name", None),
162
+ "input": getattr(block, "input", {}),
163
+ }
164
+ # Defensive: future block kinds (e.g. thinking, server_tool_use) get
165
+ # dumped as-is. Add explicit branches when we start using them.
166
+ return block.model_dump() if hasattr(block, "model_dump") else dict(block) # type: ignore[arg-type]
167
+
168
+
169
+ def stream_turn_events(
170
+ *,
171
+ design: dict,
172
+ user_message: str,
173
+ session_id: Optional[str] = None,
174
+ library: Optional[Library] = None,
175
+ sessions: Optional[SessionStore] = None,
176
+ model: Optional[str] = None,
177
+ max_iterations: int = 12,
178
+ ) -> Iterator[dict]:
179
+ """Run a single user turn and yield events as they happen.
180
+
181
+ `model` defaults to `WIRESTUDIO_AGENT_MODEL` env var, falling back to
182
+ `DEFAULT_AGENT_MODEL` (Sonnet). Pass an explicit string to override
183
+ per-call (e.g., the agent UI could expose a model picker)."""
184
+ available, reason = is_available()
185
+ if not available:
186
+ yield {"type": "error", "message": reason or "agent unavailable"}
187
+ return
188
+
189
+ library_instance = library or default_library()
190
+ resolved_model = _resolve_model(model)
191
+
192
+ sid, sessions_store, working_design, messages, library_block = _initialize_turn(
193
+ design, user_message, session_id, sessions, library_instance
194
+ )
195
+
196
+ yield {"type": "session_start", "session_id": sid}
197
+
198
+ import anthropic
199
+ client = anthropic.Anthropic()
200
+
201
+ tool_calls_log: list[dict] = []
202
+ accumulated_usage = {"input_tokens": 0, "output_tokens": 0,
203
+ "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}
204
+
205
+ final_text_pieces: list[str] = []
206
+ stop_reason = ""
207
+
208
+ try:
209
+ for _ in range(max_iterations):
210
+ with client.messages.stream(
211
+ model=resolved_model,
212
+ max_tokens=4096,
213
+ thinking={"type": "adaptive"},
214
+ # Two cache breakpoints: SYSTEM_INSTRUCTIONS is small but stable
215
+ # across every turn -> always a hit after the first, ~90% read
216
+ # discount. library_block is ~30-50KB stable JSON -> the big
217
+ # win. Anthropic caches at each breakpoint that has cache_control.
218
+ system=[
219
+ {"type": "text", "text": SYSTEM_INSTRUCTIONS, "cache_control": {"type": "ephemeral"}},
220
+ {"type": "text", "text": library_block, "cache_control": {"type": "ephemeral"}},
221
+ ],
222
+ tools=TOOL_SCHEMAS,
223
+ messages=messages,
224
+ ) as stream:
225
+ for event in stream:
226
+ etype = getattr(event, "type", None)
227
+ if etype == "content_block_delta":
228
+ delta = getattr(event, "delta", None)
229
+ if delta is not None and getattr(delta, "type", None) == "text_delta":
230
+ yield {"type": "text_delta", "text": delta.text}
231
+ response = stream.get_final_message()
232
+
233
+ for k in accumulated_usage:
234
+ accumulated_usage[k] += getattr(response.usage, k, 0) or 0
235
+
236
+ stop_reason = response.stop_reason or ""
237
+
238
+ text_pieces = [b.text for b in response.content if getattr(b, "type", None) == "text"]
239
+ if text_pieces:
240
+ final_text_pieces = text_pieces
241
+
242
+ if response.stop_reason != "tool_use":
243
+ break
244
+
245
+ messages.append({"role": "assistant", "content": [_serialize_assistant_block(b) for b in response.content]})
246
+ tool_results, tool_events = _process_tool_calls(response, working_design, library_instance, tool_calls_log)
247
+ for event in tool_events:
248
+ yield event
249
+ messages.append({"role": "user", "content": tool_results})
250
+ else:
251
+ if not final_text_pieces:
252
+ final_text_pieces = ["(agent exceeded max iterations without finishing the turn)"]
253
+ except anthropic.APIError as e:
254
+ yield {"type": "error", "message": f"agent API call failed: {e}"}
255
+ return
256
+
257
+ final_text = "\n\n".join(final_text_pieces).strip() if final_text_pieces else ""
258
+
259
+ sessions_store.append(sid, "user", user_message)
260
+ sessions_store.append(sid, "assistant", final_text or "(no reply)")
261
+
262
+ yield {
263
+ "type": "turn_complete",
264
+ "session_id": sid,
265
+ "design": working_design,
266
+ "assistant_text": final_text,
267
+ "tool_calls": tool_calls_log,
268
+ "stop_reason": stop_reason,
269
+ "usage": accumulated_usage,
270
+ "model": resolved_model,
271
+ }
272
+
273
+
274
+
275
+ def run_turn(
276
+ *,
277
+ design: dict,
278
+ user_message: str,
279
+ session_id: Optional[str] = None,
280
+ library: Optional[Library] = None,
281
+ sessions: Optional[SessionStore] = None,
282
+ model: Optional[str] = None,
283
+ max_iterations: int = 12,
284
+ ) -> TurnResult:
285
+ """Non-streaming wrapper. Consumes stream_turn_events and collapses
286
+ its events into a TurnResult so existing callers don't change."""
287
+ final: dict | None = None
288
+ error_msg: str | None = None
289
+ for event in stream_turn_events(
290
+ design=design,
291
+ user_message=user_message,
292
+ session_id=session_id,
293
+ library=library,
294
+ sessions=sessions,
295
+ model=model,
296
+ max_iterations=max_iterations,
297
+ ):
298
+ if event["type"] == "turn_complete":
299
+ final = event
300
+ elif event["type"] == "error":
301
+ error_msg = event["message"]
302
+
303
+ if final is None:
304
+ raise RuntimeError(error_msg or "agent turn did not complete")
305
+ return TurnResult(
306
+ session_id=final["session_id"],
307
+ design=final["design"],
308
+ assistant_text=final["assistant_text"],
309
+ tool_calls=final["tool_calls"],
310
+ stop_reason=final["stop_reason"],
311
+ usage=final["usage"],
312
+ model=final.get("model", ""),
313
+ )
@@ -0,0 +1,65 @@
1
+ """Append-only conversation history at sessions/<id>.jsonl.
2
+
3
+ Stores plain {role, content, timestamp} entries -- the within-turn
4
+ tool_use / tool_result ceremony stays in memory. The design itself is
5
+ *never* persisted in a session; the client owns it and ships it with
6
+ every turn.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import secrets
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional, Protocol
15
+
16
+ SESSIONS_DIR_ENV_DEFAULT = Path(__file__).resolve().parent.parent.parent / "sessions"
17
+
18
+
19
+ def new_session_id() -> str:
20
+ return secrets.token_urlsafe(8)
21
+
22
+
23
+ class SessionStore(Protocol):
24
+ def exists(self, session_id: str) -> bool: ...
25
+ def load(self, session_id: str) -> list[dict]: ...
26
+ def append(self, session_id: str, role: str, content: str) -> dict: ...
27
+
28
+ class FileSessionStore(SessionStore):
29
+
30
+ """One-line-per-message JSONL files. Cheap, greppable, agent-friendly."""
31
+
32
+ def __init__(self, root: Optional[Path] = None) -> None:
33
+ self.root = Path(root) if root else SESSIONS_DIR_ENV_DEFAULT
34
+ self.root.mkdir(parents=True, exist_ok=True)
35
+
36
+ def path(self, session_id: str) -> Path:
37
+ if not session_id or "/" in session_id or ".." in session_id:
38
+ raise ValueError(f"invalid session_id: {session_id!r}")
39
+ return self.root / f"{session_id}.jsonl"
40
+
41
+ def exists(self, session_id: str) -> bool:
42
+ return self.path(session_id).exists()
43
+
44
+ def load(self, session_id: str) -> list[dict]:
45
+ path = self.path(session_id)
46
+ if not path.exists():
47
+ return []
48
+ out: list[dict] = []
49
+ with path.open() as f:
50
+ for line in f:
51
+ line = line.strip()
52
+ if not line:
53
+ continue
54
+ out.append(json.loads(line))
55
+ return out
56
+
57
+ def append(self, session_id: str, role: str, content: str) -> dict:
58
+ entry = {
59
+ "role": role,
60
+ "content": content,
61
+ "timestamp": datetime.now(timezone.utc).isoformat(),
62
+ }
63
+ with self.path(session_id).open("a") as f:
64
+ f.write(json.dumps(entry) + "\n")
65
+ return entry