mycode-sdk 0.4.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.
mycode/session.py ADDED
@@ -0,0 +1,562 @@
1
+ """Session storage and timeline events (append-only JSONL).
2
+
3
+ Inspired by pi/mom design principles:
4
+ - append-only message log (JSONL)
5
+ - small metadata file per session
6
+ - no rewriting of full conversation on each turn
7
+
8
+ On disk:
9
+
10
+ ~/.mycode/sessions/<session_id>/
11
+ meta.json
12
+ messages.jsonl # Internal message/block dicts (excluding system prompt)
13
+ tool-output/ # large bash outputs (referenced by tool results)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import shutil
22
+ from dataclasses import asdict, dataclass, field
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Any, TypedDict, cast
26
+ from uuid import uuid4
27
+
28
+ from mycode.messages import ConversationMessage, build_message, flatten_message_text, text_block, tool_result_block
29
+
30
+ # Filesystem layout: ``$MYCODE_HOME`` (default ``~/.mycode``) holds session
31
+ # storage, CLI history, and the optional global config. Path helpers live with
32
+ # ``SessionStore`` so the SDK can resolve them without depending on the CLI.
33
+ _DEFAULT_MYCODE_HOME = "~/.mycode"
34
+
35
+
36
+ def resolve_mycode_home() -> Path:
37
+ """Resolve the mycode home directory (``$MYCODE_HOME`` or ``~/.mycode``)."""
38
+
39
+ raw = os.environ.get("MYCODE_HOME", _DEFAULT_MYCODE_HOME)
40
+ return Path(raw).expanduser().resolve(strict=False)
41
+
42
+
43
+ def resolve_sessions_dir() -> Path:
44
+ """Resolve the default directory used for persisted sessions."""
45
+
46
+ return resolve_mycode_home() / "sessions"
47
+
48
+
49
+ # ---------------------------------------------------------------------
50
+ # Session format and compacting defaults
51
+ # ---------------------------------------------------------------------
52
+
53
+ MESSAGE_FORMAT_VERSION = 5
54
+ DEFAULT_SESSION_PROVIDER = "anthropic"
55
+ DEFAULT_SESSION_TITLE = "New chat"
56
+ DEFAULT_COMPACT_THRESHOLD = 0.8
57
+
58
+ COMPACT_SUMMARY_PROMPT = """\
59
+ Summarize this conversation to create a continuation document. \
60
+ This summary will replace the full conversation history, so it must \
61
+ capture everything needed to continue the work seamlessly.
62
+
63
+ Include:
64
+
65
+ 1. **User Requests**: Every distinct request or instruction the user gave, \
66
+ in chronological order. Preserve the user's original wording for ambiguous \
67
+ or nuanced requests.
68
+ 2. **Completed Work**: What was accomplished — files created, modified, or \
69
+ deleted; bugs fixed; features added. Include file paths and function names.
70
+ 3. **Current State**: The exact state of the work right now — what is working, \
71
+ what is broken, what is partially done.
72
+ 4. **Key Decisions**: Important decisions made, constraints discovered, \
73
+ approaches chosen or rejected, and why.
74
+ 5. **Next Steps**: What remains to be done, any work that was in progress \
75
+ when this summary was generated.
76
+
77
+ Rules:
78
+ - Be specific: include file paths, function names, error messages, and \
79
+ concrete details.
80
+ - Do not add suggestions or opinions — only summarize what happened.
81
+ - Keep it concise but complete.\
82
+ """
83
+
84
+ _COMPACT_ACK = "Understood. I have the context from the conversation summary and will continue the work."
85
+
86
+
87
+ # ---------------------------------------------------------------------
88
+ # Compact and rewind session events
89
+ # ---------------------------------------------------------------------
90
+
91
+
92
+ def _now() -> str:
93
+ return datetime.now(UTC).isoformat()
94
+
95
+
96
+ def should_compact(
97
+ last_usage: dict[str, Any] | None,
98
+ context_window: int | None,
99
+ threshold: float,
100
+ ) -> bool:
101
+ """Return True when the last response input tokens exceed the threshold."""
102
+
103
+ if not last_usage or not context_window or threshold <= 0:
104
+ return False
105
+
106
+ # Providers report prompt/input usage under slightly different field names.
107
+ input_tokens = int(
108
+ last_usage.get("input_tokens") or last_usage.get("prompt_tokens") or last_usage.get("prompt_token_count") or 0
109
+ )
110
+ return input_tokens >= context_window * threshold
111
+
112
+
113
+ def build_compact_event(
114
+ summary_text: str,
115
+ *,
116
+ provider: str,
117
+ model: str,
118
+ compacted_count: int,
119
+ usage: dict[str, Any] | None = None,
120
+ ) -> ConversationMessage:
121
+ """Build the compact event stored in session JSONL."""
122
+
123
+ meta: dict[str, Any] = {
124
+ "provider": provider,
125
+ "model": model,
126
+ "compacted_count": compacted_count,
127
+ }
128
+ if usage is not None:
129
+ meta["usage"] = usage
130
+ return build_message("compact", [text_block(summary_text)], meta=meta)
131
+
132
+
133
+ def apply_compact(messages: list[ConversationMessage]) -> list[ConversationMessage]:
134
+ """Replace the latest compact event with a summary + synthetic ack."""
135
+
136
+ # Only the newest compact event matters. Older history before it is no
137
+ # longer visible once the summary replaces that earlier conversation.
138
+ last_compact_index: int | None = None
139
+ for index, message in enumerate(messages):
140
+ if message.get("role") == "compact":
141
+ last_compact_index = index
142
+
143
+ if last_compact_index is None:
144
+ return messages
145
+
146
+ summary_text = ""
147
+ for block in messages[last_compact_index].get("content") or []:
148
+ if isinstance(block, dict) and block.get("type") == "text":
149
+ summary_text = str(block.get("text") or "")
150
+ break
151
+
152
+ return [
153
+ build_message(
154
+ "user",
155
+ [text_block(f"[Conversation Summary]\n\n{summary_text}")],
156
+ meta={"synthetic": True},
157
+ ),
158
+ build_message("assistant", [text_block(_COMPACT_ACK)], meta={"synthetic": True}),
159
+ *messages[last_compact_index + 1 :],
160
+ ]
161
+
162
+
163
+ def build_rewind_event(rewind_to: int) -> ConversationMessage:
164
+ """Build a rewind marker to append to session JSONL."""
165
+
166
+ return {
167
+ "role": "rewind",
168
+ "meta": {
169
+ "rewind_to": rewind_to,
170
+ "created_at": _now(),
171
+ },
172
+ }
173
+
174
+
175
+ def apply_rewind(messages: list[ConversationMessage]) -> list[ConversationMessage]:
176
+ """Apply rewind markers inline while replaying the raw message log."""
177
+
178
+ result: list[ConversationMessage] = []
179
+ for message in messages:
180
+ if message.get("role") == "rewind":
181
+ # Rewind indices refer to the visible message list at that moment,
182
+ # so replay truncates the accumulated result in place.
183
+ rewind_to = (message.get("meta") or {}).get("rewind_to", 0)
184
+ result = result[:rewind_to]
185
+ else:
186
+ result.append(message)
187
+ return result
188
+
189
+
190
+ # ---------------------------------------------------------------------
191
+ # Session metadata
192
+ # ---------------------------------------------------------------------
193
+
194
+
195
+ @dataclass
196
+ class SessionMeta:
197
+ id: str
198
+ title: str
199
+ provider: str
200
+ model: str
201
+ cwd: str
202
+ api_base: str | None
203
+ message_format_version: int
204
+ created_at: str
205
+ updated_at: str
206
+
207
+
208
+ SessionMetaDict = dict[str, object]
209
+
210
+
211
+ class SessionData(TypedDict):
212
+ session: SessionMetaDict
213
+ messages: list[ConversationMessage]
214
+
215
+
216
+ # ---------------------------------------------------------------------
217
+ # Session store
218
+ # ---------------------------------------------------------------------
219
+
220
+
221
+ @dataclass
222
+ class SessionStore:
223
+ """File-based session store backed by append-only JSONL files."""
224
+
225
+ data_dir: Path = field(default_factory=resolve_sessions_dir)
226
+
227
+ def __post_init__(self) -> None:
228
+ self.data_dir.mkdir(parents=True, exist_ok=True)
229
+
230
+ # ---------------------------------------------------------------------
231
+ # Session paths and small JSON helpers
232
+ # ---------------------------------------------------------------------
233
+
234
+ def session_dir(self, session_id: str) -> Path:
235
+ return self.data_dir / session_id
236
+
237
+ def meta_path(self, session_id: str) -> Path:
238
+ return self.session_dir(session_id) / "meta.json"
239
+
240
+ def messages_path(self, session_id: str) -> Path:
241
+ return self.session_dir(session_id) / "messages.jsonl"
242
+
243
+ def _ensure_session_dir(self, session_id: str) -> None:
244
+ session_dir = self.session_dir(session_id)
245
+ session_dir.mkdir(parents=True, exist_ok=True)
246
+ (session_dir / "tool-output").mkdir(parents=True, exist_ok=True)
247
+
248
+ def _read_meta(self, session_id: str) -> SessionMetaDict | None:
249
+ path = self.meta_path(session_id)
250
+ if not path.exists():
251
+ return None
252
+ return json.loads(path.read_text(encoding="utf-8"))
253
+
254
+ def _write_meta(self, session_id: str, meta: SessionMetaDict) -> None:
255
+ self.meta_path(session_id).write_text(json.dumps(meta, indent=2), encoding="utf-8")
256
+
257
+ # ---------------------------------------------------------------------
258
+ # Session CRUD and loading
259
+ # ---------------------------------------------------------------------
260
+
261
+ def draft_session(
262
+ self,
263
+ title: str | None,
264
+ *,
265
+ provider: str = DEFAULT_SESSION_PROVIDER,
266
+ model: str,
267
+ cwd: str,
268
+ api_base: str | None,
269
+ ) -> SessionData:
270
+ session_id = uuid4().hex
271
+ now = _now()
272
+ meta = asdict(
273
+ SessionMeta(
274
+ id=session_id,
275
+ title=title or DEFAULT_SESSION_TITLE,
276
+ provider=provider,
277
+ model=model,
278
+ cwd=os.path.abspath(cwd),
279
+ api_base=api_base,
280
+ message_format_version=MESSAGE_FORMAT_VERSION,
281
+ created_at=now,
282
+ updated_at=now,
283
+ )
284
+ )
285
+ return {"session": meta, "messages": []}
286
+
287
+ async def create_session(
288
+ self,
289
+ title: str | None,
290
+ *,
291
+ session_id: str | None = None,
292
+ provider: str = DEFAULT_SESSION_PROVIDER,
293
+ model: str,
294
+ cwd: str,
295
+ api_base: str | None,
296
+ ) -> SessionData:
297
+ data = self.draft_session(
298
+ title,
299
+ provider=provider,
300
+ model=model,
301
+ cwd=cwd,
302
+ api_base=api_base,
303
+ )
304
+ session = data["session"]
305
+ if session_id:
306
+ session["id"] = session_id
307
+ session_id = str(session["id"])
308
+
309
+ def write_files() -> None:
310
+ self._ensure_session_dir(session_id)
311
+ self._write_meta(session_id, session)
312
+ self.messages_path(session_id).touch(exist_ok=True)
313
+
314
+ await asyncio.to_thread(write_files)
315
+ return data
316
+
317
+ async def list_sessions(self, *, cwd: str | None = None) -> list[SessionMetaDict]:
318
+ normalized = os.path.abspath(cwd) if cwd else None
319
+
320
+ def load_all() -> list[SessionMetaDict]:
321
+ out: list[SessionMetaDict] = []
322
+ for entry in self.data_dir.iterdir():
323
+ if not entry.is_dir():
324
+ continue
325
+ mp = entry / "meta.json"
326
+ if not mp.exists():
327
+ continue
328
+ try:
329
+ meta = json.loads(mp.read_text(encoding="utf-8"))
330
+ if normalized and os.path.abspath(meta.get("cwd") or "") != normalized:
331
+ continue
332
+ out.append(meta)
333
+ except Exception:
334
+ continue
335
+
336
+ out.sort(key=lambda m: str(m.get("updated_at") or ""), reverse=True)
337
+ return out
338
+
339
+ return await asyncio.to_thread(load_all)
340
+
341
+ async def latest_session(self, *, cwd: str | None = None) -> SessionMetaDict | None:
342
+ sessions = await self.list_sessions(cwd=cwd)
343
+ return sessions[0] if sessions else None
344
+
345
+ def _repair_interrupted_tool_loop(
346
+ self,
347
+ session_id: str,
348
+ meta: SessionMetaDict,
349
+ messages: list[ConversationMessage],
350
+ ) -> None:
351
+ """Append a synthetic tool result when the latest tool loop was interrupted.
352
+
353
+ The runtime persists sessions as append-only JSONL. If a previous run was
354
+ interrupted after an assistant emitted `tool_use` blocks but before a
355
+ matching `tool_result` user message was written, repair the session by
356
+ appending one synthetic error result message.
357
+ """
358
+
359
+ pending_tool_use_ids: list[str] = []
360
+ pending_tool_call_index: int | None = None
361
+
362
+ # Find the latest assistant message that started a tool loop.
363
+ for index in range(len(messages) - 1, -1, -1):
364
+ message = messages[index]
365
+ if message.get("role") != "assistant":
366
+ continue
367
+
368
+ blocks = message.get("content")
369
+ if not isinstance(blocks, list):
370
+ continue
371
+
372
+ tool_use_ids = [
373
+ str(block.get("id") or "")
374
+ for block in blocks
375
+ if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
376
+ ]
377
+ if not tool_use_ids:
378
+ continue
379
+
380
+ pending_tool_use_ids = tool_use_ids
381
+ pending_tool_call_index = index
382
+ break
383
+
384
+ if pending_tool_call_index is None:
385
+ return
386
+
387
+ # Collect tool results that were recorded after the assistant message.
388
+ completed_tool_use_ids: set[str] = set()
389
+ for message in messages[pending_tool_call_index + 1 :]:
390
+ if message.get("role") != "user":
391
+ continue
392
+
393
+ blocks = message.get("content")
394
+ if not isinstance(blocks, list):
395
+ continue
396
+
397
+ for block in blocks:
398
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
399
+ continue
400
+ tool_use_id = str(block.get("tool_use_id") or "")
401
+ if tool_use_id:
402
+ completed_tool_use_ids.add(tool_use_id)
403
+
404
+ missing_tool_use_ids = [
405
+ tool_use_id for tool_use_id in pending_tool_use_ids if tool_use_id not in completed_tool_use_ids
406
+ ]
407
+ if not missing_tool_use_ids:
408
+ return
409
+
410
+ repair_message = build_message(
411
+ "user",
412
+ [
413
+ tool_result_block(
414
+ tool_use_id=tool_use_id,
415
+ model_text="error: tool call was interrupted",
416
+ display_text="Tool call was interrupted",
417
+ is_error=True,
418
+ )
419
+ for tool_use_id in missing_tool_use_ids
420
+ ],
421
+ )
422
+
423
+ with self.messages_path(session_id).open("a", encoding="utf-8") as handle:
424
+ handle.write(json.dumps(repair_message, ensure_ascii=False))
425
+ handle.write("\n")
426
+
427
+ meta["updated_at"] = _now()
428
+ self._write_meta(session_id, meta)
429
+ messages.append(repair_message)
430
+
431
+ def load_session_sync(self, session_id: str) -> SessionData | None:
432
+ """Synchronous variant of :meth:`load_session`.
433
+
434
+ Used by :class:`Agent` to restore history during construction without
435
+ requiring an event loop.
436
+ """
437
+
438
+ meta = self._read_meta(session_id)
439
+ if meta is None:
440
+ return None
441
+
442
+ # Read the raw append-only log first. Replay happens after that.
443
+ raw_messages: list[ConversationMessage] = []
444
+ messages_path = self.messages_path(session_id)
445
+ try:
446
+ with messages_path.open("r", encoding="utf-8") as handle:
447
+ for line in handle:
448
+ line = line.strip()
449
+ if not line:
450
+ continue
451
+ try:
452
+ msg = json.loads(line)
453
+ if isinstance(msg, dict):
454
+ raw_messages.append(cast(ConversationMessage, msg))
455
+ except Exception:
456
+ continue
457
+ except FileNotFoundError:
458
+ pass
459
+
460
+ # Replay order defines the visible conversation state.
461
+ # 1) compact rewrites older history into one summary view
462
+ # 2) rewind truncates that visible list by message index
463
+ # 3) interrupted tool repair patches the final visible state
464
+ visible_messages = apply_compact(raw_messages)
465
+ visible_messages = apply_rewind(visible_messages)
466
+ self._repair_interrupted_tool_loop(session_id, meta, visible_messages)
467
+
468
+ return {"session": meta, "messages": visible_messages}
469
+
470
+ async def load_session(self, session_id: str) -> SessionData | None:
471
+ return await asyncio.to_thread(self.load_session_sync, session_id)
472
+
473
+ async def delete_session(self, session_id: str) -> None:
474
+ def delete() -> None:
475
+ sdir = self.session_dir(session_id)
476
+ if sdir.exists():
477
+ shutil.rmtree(sdir, ignore_errors=True)
478
+
479
+ await asyncio.to_thread(delete)
480
+
481
+ async def clear_session(self, session_id: str) -> None:
482
+ def clear() -> None:
483
+ meta = self._read_meta(session_id)
484
+ if meta is None:
485
+ return
486
+ meta["updated_at"] = _now()
487
+ self._write_meta(session_id, meta)
488
+ self.messages_path(session_id).write_text("", encoding="utf-8")
489
+
490
+ await asyncio.to_thread(clear)
491
+
492
+ # ---------------------------------------------------------------------
493
+ # Append-only updates
494
+ # ---------------------------------------------------------------------
495
+
496
+ async def append_rewind(self, session_id: str, rewind_to: int) -> None:
497
+ """Append a rewind marker to the session JSONL."""
498
+
499
+ def append() -> None:
500
+ meta = self._read_meta(session_id)
501
+ if meta is None:
502
+ return
503
+
504
+ event = build_rewind_event(rewind_to)
505
+ messages_path = self.messages_path(session_id)
506
+ with messages_path.open("a", encoding="utf-8") as handle:
507
+ handle.write(json.dumps(event, ensure_ascii=False))
508
+ handle.write("\n")
509
+
510
+ meta["updated_at"] = _now()
511
+ self._write_meta(session_id, meta)
512
+
513
+ await asyncio.to_thread(append)
514
+
515
+ async def append_message(
516
+ self,
517
+ session_id: str,
518
+ message: ConversationMessage,
519
+ *,
520
+ provider: str,
521
+ model: str,
522
+ cwd: str,
523
+ api_base: str | None,
524
+ ) -> None:
525
+ def append() -> None:
526
+ meta = self._read_meta(session_id)
527
+ if meta is None:
528
+ # Create the on-disk session only when the first message is persisted.
529
+ self._ensure_session_dir(session_id)
530
+ now = _now()
531
+ meta = asdict(
532
+ SessionMeta(
533
+ id=session_id,
534
+ title=DEFAULT_SESSION_TITLE,
535
+ provider=provider,
536
+ model=model,
537
+ cwd=os.path.abspath(cwd),
538
+ api_base=api_base,
539
+ message_format_version=MESSAGE_FORMAT_VERSION,
540
+ created_at=now,
541
+ updated_at=now,
542
+ )
543
+ )
544
+
545
+ messages_path = self.messages_path(session_id)
546
+ with messages_path.open("a", encoding="utf-8") as handle:
547
+ handle.write(json.dumps(message, ensure_ascii=False))
548
+ handle.write("\n")
549
+
550
+ meta["updated_at"] = _now()
551
+ meta.setdefault("message_format_version", MESSAGE_FORMAT_VERSION)
552
+
553
+ if meta.get("title") == DEFAULT_SESSION_TITLE and message.get("role") == "user":
554
+ # Keep the default title until we see the first real user text,
555
+ # then promote a short preview into the session title.
556
+ title_text = flatten_message_text(message, include_thinking=False).replace("\n", " ").strip()
557
+ if title_text:
558
+ meta["title"] = title_text[:48]
559
+
560
+ self._write_meta(session_id, meta)
561
+
562
+ await asyncio.to_thread(append)