python-codex 0.3.2__py3-none-any.whl → 0.4.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 (53) hide show
  1. pycodex/__init__.py +3 -27
  2. pycodex/bootstrap.py +5 -45
  3. pycodex/cli.py +1 -14
  4. pycodex/context.py +1 -1
  5. pycodex/events.py +1 -1
  6. pycodex/model.py +2 -2
  7. pycodex/portable/__init__.py +45 -0
  8. pycodex/{portable.py → portable/client.py} +4 -7
  9. pycodex/{portable_server.py → portable/server.py} +3 -3
  10. pycodex/protocol.py +1 -1
  11. pycodex/runtime.py +1 -1
  12. pycodex/tools/base_tool.py +1 -1
  13. pycodex/tools/close_agent_tool.py +1 -1
  14. pycodex/tools/code_mode_manager.py +1 -1
  15. pycodex/tools/request_permissions_tool.py +1 -1
  16. pycodex/tools/resume_agent_tool.py +1 -1
  17. pycodex/{runtime_services.py → tools/runtime_services.py} +5 -5
  18. pycodex/tools/send_input_tool.py +1 -1
  19. pycodex/tools/spawn_agent_tool.py +1 -1
  20. pycodex/tools/unified_exec_manager.py +1 -1
  21. pycodex/tools/update_plan_tool.py +1 -1
  22. pycodex/tools/view_image_tool.py +5 -0
  23. pycodex/tools/wait_agent_tool.py +1 -1
  24. pycodex/utils/__init__.py +0 -4
  25. pycodex/utils/_aes_gcm/LICENSE +56 -0
  26. pycodex/utils/_aes_gcm/README.md +61 -0
  27. pycodex/utils/_aes_gcm/__init__.py +1 -0
  28. pycodex/utils/_aes_gcm/aes.py +175 -0
  29. pycodex/utils/_aes_gcm/gcm.py +73 -0
  30. pycodex/utils/_aes_gcm/source-sha256.json +4 -0
  31. pycodex/{compat.py → utils/compat.py} +2 -0
  32. pycodex/utils/crypto.py +35 -0
  33. pycodex/utils/debug.py +24 -0
  34. pycodex/{doctor.py → utils/doctor.py} +3 -3
  35. pycodex/utils/image_utils.py +3 -2
  36. pycodex/{model_metadata.py → utils/model_metadata.py} +2 -2
  37. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/METADATA +165 -42
  38. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/RECORD +43 -45
  39. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/entry_points.txt +0 -1
  40. responses_server/app.py +28 -15
  41. responses_server/stream_router.py +17 -13
  42. pycodex/feishu_card.py +0 -793
  43. pycodex/feishu_link.py +0 -440
  44. pycodex/tools/ipython_tool.py +0 -142
  45. workspace_server/__init__.py +0 -39
  46. workspace_server/__main__.py +0 -4
  47. workspace_server/app.py +0 -1713
  48. workspace_server/owned_process.py +0 -27
  49. workspace_server/workspace.html +0 -2211
  50. workspace_server/workspaces.html +0 -550
  51. workspace_server/workspaces.py +0 -628
  52. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/WHEEL +0 -0
  53. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,628 +0,0 @@
1
- import asyncio
2
- import json
3
- import os
4
- import tempfile
5
- import typing
6
- from dataclasses import dataclass
7
- from pathlib import Path
8
- from uuid import uuid4
9
-
10
- from pycodex.utils import uuid7_string
11
-
12
- SessionFactory = typing.Callable[[], object]
13
-
14
-
15
- def default_board_path() -> Path:
16
- return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
17
-
18
-
19
- class WorkspaceStateStore:
20
- def __init__(self, board_path: "typing.Union[Path, None]") -> None:
21
- self.path = (
22
- None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
23
- )
24
-
25
- def load_tabs(self) -> "typing.List[typing.Dict[str, object]]":
26
- if self.path is None or not self.path.is_file():
27
- return []
28
-
29
- try:
30
- payload = json.loads(
31
- self.path.read_text(encoding="utf-8", errors="replace") or "{}"
32
- )
33
- except (OSError, ValueError):
34
- return []
35
-
36
- tabs = payload.get("tabs") if isinstance(payload, dict) else None
37
- if not isinstance(tabs, list):
38
- return []
39
-
40
- result = []
41
- for tab in tabs:
42
- if not isinstance(tab, dict):
43
- continue
44
- title = str(tab.get("title") or "").strip()
45
- rollout_path = str(tab.get("rollout_path") or "").strip()
46
- restored = {"title": title, "rollout_path": rollout_path}
47
- if tab.get("fork") is True:
48
- restored["fork"] = True
49
- result.append(restored)
50
- return result
51
-
52
- def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, object]]") -> None:
53
- if self.path is None:
54
- return
55
-
56
- state_tabs = [
57
- {
58
- "title": str(tab.get("title") or ""),
59
- "rollout_path": str(tab.get("rollout_path") or ""),
60
- **({"fork": True} if tab.get("fork") is True else {}),
61
- }
62
- for tab in tabs
63
- ]
64
- payload = (
65
- json.dumps(
66
- {"version": 1, "tabs": state_tabs},
67
- ensure_ascii=False,
68
- indent=2,
69
- )
70
- + "\n"
71
- )
72
- self.path.parent.mkdir(parents=True, exist_ok=True)
73
- self.path.write_text(payload, encoding="utf-8")
74
-
75
-
76
- @dataclass(frozen=True)
77
- class WorkspaceDefinition:
78
- workspace_id: "str"
79
- board_path: "typing.Union[Path, None]"
80
- work_dir: "Path"
81
-
82
-
83
- def load_workspace_definitions(
84
- config_path: "typing.Union[str, Path]",
85
- ) -> "typing.List[WorkspaceDefinition]":
86
- path = Path(config_path).expanduser().resolve()
87
- if not path.exists():
88
- return []
89
- try:
90
- payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
91
- except OSError as exc:
92
- raise ValueError("unable to read workspace config: {0}".format(exc)) from exc
93
- except ValueError as exc:
94
- raise ValueError("workspace config must be valid JSON") from exc
95
-
96
- if isinstance(payload, dict):
97
- workspaces = payload.get("workspaces")
98
- else:
99
- workspaces = payload
100
- if not isinstance(workspaces, list):
101
- raise ValueError("workspace config must contain a workspace list")
102
-
103
- result = []
104
- seen_ids = set()
105
- seen_boards = set()
106
- for index, item in enumerate(workspaces, start=1):
107
- if not isinstance(item, dict):
108
- raise ValueError("workspace entry {0} must be an object".format(index))
109
- workspace_id = normalize_workspace_id(str(item.get("id") or "").strip())
110
- if not workspace_id:
111
- workspace_id = _next_workspace_id(seen_ids)
112
- if workspace_id in seen_ids:
113
- raise ValueError("duplicate workspace name: {0}".format(workspace_id))
114
- seen_ids.add(workspace_id)
115
-
116
- work_dir_value = item.get("work_dir")
117
- if work_dir_value is None:
118
- work_dir_value = item.get("cwd")
119
- if not str(work_dir_value or "").strip():
120
- raise ValueError(
121
- "workspace `{0}` is missing `work_dir`".format(workspace_id)
122
- )
123
- work_dir = _resolve_workspace_path(str(work_dir_value), path.parent)
124
- if not work_dir.is_dir():
125
- raise ValueError(
126
- "workspace `{0}` work_dir does not exist: {1}".format(
127
- workspace_id,
128
- work_dir,
129
- )
130
- )
131
-
132
- board_value = item.get("board")
133
- board_path = None
134
- if board_value is not False:
135
- if not isinstance(board_value, str) or not board_value.strip():
136
- raise ValueError(
137
- "workspace `{0}` requires a board path or false".format(
138
- workspace_id
139
- )
140
- )
141
- board_path = _resolve_workspace_path(board_value, path.parent)
142
- if board_path in seen_boards:
143
- raise ValueError("duplicate workspace board: {0}".format(board_path))
144
- seen_boards.add(board_path)
145
- if not board_path.parent.is_dir():
146
- raise ValueError(
147
- "workspace `{0}` board parent directory does not exist: {1}".format(
148
- workspace_id,
149
- board_path.parent,
150
- )
151
- )
152
-
153
- result.append(
154
- WorkspaceDefinition(
155
- workspace_id=workspace_id,
156
- board_path=board_path,
157
- work_dir=work_dir,
158
- )
159
- )
160
- return result
161
-
162
-
163
- def save_workspace_definitions(
164
- config_path: "typing.Union[str, Path]",
165
- definitions: "typing.Iterable[WorkspaceDefinition]",
166
- ) -> None:
167
- path = Path(config_path).expanduser().resolve()
168
- base_dir = path.parent
169
- payload = {
170
- "workspaces": [
171
- _workspace_definition_to_json(definition, base_dir)
172
- for definition in definitions
173
- ]
174
- }
175
- path.parent.mkdir(parents=True, exist_ok=True)
176
- path.write_text(
177
- json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
178
- encoding="utf-8",
179
- )
180
-
181
-
182
- def _workspace_definition_to_json(
183
- definition: "WorkspaceDefinition",
184
- base_dir: "Path",
185
- ) -> "typing.Dict[str, object]":
186
- result = {
187
- "id": definition.workspace_id,
188
- "work_dir": _format_path_for_workspace_config(definition.work_dir, base_dir),
189
- }
190
- if definition.board_path is not None:
191
- result["board"] = _format_path_for_workspace_config(
192
- definition.board_path,
193
- base_dir,
194
- )
195
- else:
196
- result["board"] = False
197
- return result
198
-
199
-
200
- def _format_path_for_workspace_config(path: "Path", base_dir: "Path") -> "str":
201
- resolved = path.resolve()
202
- try:
203
- relative = os.path.relpath(str(resolved), str(base_dir.resolve()))
204
- except ValueError:
205
- return str(resolved)
206
- if relative == ".":
207
- return "."
208
- if relative.startswith("..") or os.path.isabs(relative):
209
- return str(resolved)
210
- return relative
211
-
212
-
213
- def _resolve_workspace_path(value: str, base_dir: "Path") -> "Path":
214
- path = Path(str(value)).expanduser()
215
- if not path.is_absolute():
216
- path = base_dir / path
217
- return path.resolve()
218
-
219
-
220
- def _next_workspace_id(existing: "typing.Container[str]") -> str:
221
- index = 1
222
- while True:
223
- candidate = "workspace-{0}".format(index)
224
- if candidate not in existing:
225
- return candidate
226
- index += 1
227
-
228
-
229
- def normalize_workspace_id(workspace_id: str) -> str:
230
- text = str(workspace_id or "").strip()
231
- if not text:
232
- return ""
233
- allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
234
- if any(char not in allowed for char in text):
235
- raise ValueError(
236
- "workspace id may only contain letters, numbers, underscore, and dash: {0}".format(
237
- text
238
- )
239
- )
240
- return text
241
-
242
-
243
- class WorkspaceSessionManager:
244
- def __init__(
245
- self,
246
- session_factory: "SessionFactory",
247
- board_path: "typing.Union[Path, None]" = None,
248
- persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
249
- ) -> None:
250
- self._session_factory = session_factory
251
- self._sessions: "typing.Dict[str, object]" = {}
252
- self._session_order: "typing.List[str]" = []
253
- self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
254
- self._tab_states: "typing.Dict[str, typing.Dict[str, object]]" = {}
255
- self._lock = None
256
- self._state_store = WorkspaceStateStore(board_path)
257
- self._persist_callback = persist_callback
258
-
259
- def _get_lock(self):
260
- if self._lock is None:
261
- self._lock = asyncio.Lock()
262
- return self._lock
263
-
264
- def set_persist_callback(
265
- self,
266
- callback: "typing.Union[typing.Callable[[], None], None]",
267
- ) -> None:
268
- self._persist_callback = callback
269
-
270
- async def start(self) -> None:
271
- state_tabs = self._state_store.load_tabs()
272
- if not state_tabs:
273
- await self.create_session()
274
- return
275
- for tab in state_tabs:
276
- await self._create_session(
277
- title=str(tab.get("title") or ""),
278
- rollout_path=str(tab.get("rollout_path") or ""),
279
- fork=tab.get("fork") is True,
280
- )
281
- for session_id in self._session_order:
282
- self._subscribe_session_state(session_id)
283
- self.persist_workspace_state()
284
-
285
- async def close(self) -> None:
286
- sessions = list(self._sessions.values())
287
- watchers = list(self._state_watchers.values())
288
- try:
289
- for session in sessions:
290
- await session.close()
291
- if watchers:
292
- await asyncio.gather(*watchers)
293
- finally:
294
- for watcher in watchers:
295
- watcher.cancel()
296
- if watchers:
297
- await asyncio.gather(*watchers, return_exceptions=True)
298
- self._sessions.clear()
299
- self._session_order = []
300
- self._state_watchers.clear()
301
- self._tab_states.clear()
302
-
303
- async def create_session(
304
- self,
305
- title: str = "",
306
- rollout_path: str = "",
307
- fork: bool = False,
308
- ) -> str:
309
- session_id = await self._create_session(title, rollout_path, fork)
310
- self._subscribe_session_state(session_id)
311
- self.persist_workspace_state()
312
- return session_id
313
-
314
- async def _create_session(
315
- self,
316
- title: str = "",
317
- rollout_path: str = "",
318
- fork: bool = False,
319
- ) -> str:
320
- async with self._get_lock():
321
- session_id = uuid7_string()
322
- session = self._session_factory()
323
- await session.start()
324
-
325
- try:
326
- if rollout_path or title:
327
- await session.restore_from_rollout(
328
- rollout_path, title=title, fork=fork
329
- )
330
- except BaseException:
331
- await session.close()
332
- raise
333
-
334
- self._sessions[session_id] = session
335
- self._session_order.append(session_id)
336
- self._tab_states[session_id] = {
337
- "title": str(session_summary(session).get("title") or ""),
338
- "rollout_path": rollout_path,
339
- **({"fork": True} if fork else {}),
340
- }
341
- return session_id
342
-
343
- def _subscribe_session_state(self, session_id: str) -> None:
344
- session = self._sessions[session_id]
345
- subscriber = session.subscribe()
346
- self._state_watchers[session_id] = asyncio.create_task(
347
- self._watch_session_state(session_id, session, subscriber)
348
- )
349
-
350
- async def close_session(self, session_id: str) -> None:
351
- async with self._get_lock():
352
- if len(self._session_order) <= 1:
353
- raise ValueError("cannot close the last session")
354
- session = self._sessions.pop(session_id, None)
355
- if session is None:
356
- raise KeyError(session_id)
357
- watcher = self._state_watchers.pop(session_id, None)
358
- self._tab_states.pop(session_id, None)
359
- self._session_order = [
360
- item for item in self._session_order if item != session_id
361
- ]
362
- if watcher is not None:
363
- watcher.cancel()
364
- await asyncio.gather(watcher, return_exceptions=True)
365
- await session.close()
366
- self.persist_workspace_state()
367
-
368
- async def _watch_session_state(self, session_id: str, session, subscriber) -> None:
369
- try:
370
- while True:
371
- event = await subscriber.get()
372
- if event is None:
373
- return
374
- if not isinstance(event, dict) or "snapshot" not in event:
375
- continue
376
- reason = (
377
- event["payload"]["reason"]
378
- if event.get("kind") == "session_state"
379
- else None
380
- )
381
- previous = self._tab_states[session_id]
382
- state = dict(previous)
383
- snapshot = event["snapshot"]
384
- state["title"] = str(snapshot.get("title") or "")
385
- if reason == "identity" and state["rollout_path"]:
386
- # Recreate an unwritten fork from its recorded ancestor.
387
- state["fork"] = True
388
- path = str(snapshot["recorded_rollout_path"] or "")
389
- if path or not snapshot["rollout_path"]:
390
- state["rollout_path"] = path
391
- state.pop("fork", None)
392
- self._tab_states[session_id] = state
393
- if state != previous:
394
- self.persist_workspace_state()
395
- finally:
396
- session.unsubscribe(subscriber)
397
-
398
- def persist_workspace_state(self) -> None:
399
- tabs = []
400
- for session_id in self._session_order:
401
- session = self._sessions.get(session_id)
402
- if session is None:
403
- continue
404
- state = self._tab_states[session_id]
405
- tabs.append(state)
406
- self._state_store.save_tabs(tabs)
407
- if self._persist_callback is not None:
408
- self._persist_callback()
409
-
410
- def get(self, session_id: "typing.Union[str, None]" = None) -> object:
411
- resolved_id = self.resolve_session_id(session_id)
412
- try:
413
- return self._sessions[resolved_id]
414
- except KeyError:
415
- raise KeyError(resolved_id)
416
-
417
- def resolve_session_id(self, session_id: "typing.Union[str, None]" = None) -> str:
418
- if session_id:
419
- return str(session_id)
420
- if not self._session_order:
421
- raise KeyError("no sessions")
422
- return self._session_order[0]
423
-
424
- def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
425
- result = []
426
- for session_id in self._session_order:
427
- session = self._sessions[session_id]
428
- summary = session_summary(session)
429
- result.append(
430
- {
431
- "id": session_id,
432
- "title": summary.get("title") or "pycodex",
433
- "running": bool(summary.get("running")),
434
- "spinner": summary.get("spinner") or "",
435
- "turn_count": summary.get("turn_count") or 0,
436
- "last_assistant": summary.get("last_assistant") or "",
437
- "context_remaining_percent": summary.get(
438
- "context_remaining_percent"
439
- ),
440
- }
441
- )
442
- return result
443
-
444
-
445
- @dataclass(frozen=True)
446
- class WorkspaceEntry:
447
- definition: "WorkspaceDefinition"
448
- manager: "WorkspaceSessionManager"
449
-
450
- def to_dict(self) -> "typing.Dict[str, object]":
451
- return {
452
- "id": self.definition.workspace_id,
453
- "board_path": (
454
- ""
455
- if self.definition.board_path is None
456
- else str(self.definition.board_path)
457
- ),
458
- "work_dir": str(self.definition.work_dir),
459
- "sessions": self.manager.list_sessions(),
460
- }
461
-
462
-
463
- WorkspaceEntryFactory = typing.Callable[
464
- [WorkspaceDefinition, "typing.Union[typing.Callable[[], None], None]"],
465
- WorkspaceEntry,
466
- ]
467
-
468
-
469
- class WorkspaceRegistry:
470
- def __init__(
471
- self,
472
- entries: "typing.Iterable[WorkspaceEntry]",
473
- config_path: "typing.Union[str, Path, None]" = None,
474
- entry_factory: "typing.Union[WorkspaceEntryFactory, None]" = None,
475
- ) -> None:
476
- self._entries: "typing.Dict[str, WorkspaceEntry]" = {}
477
- self._name_to_key: "typing.Dict[str, str]" = {}
478
- self._order: "typing.List[str]" = []
479
- self._started = False
480
- self._config_path = (
481
- None if config_path is None else Path(config_path).expanduser().resolve()
482
- )
483
- self._entry_factory = entry_factory
484
- for entry in entries:
485
- self._register(entry)
486
- if not self._entries and self._entry_factory is None:
487
- raise ValueError("at least one workspace is required")
488
-
489
- def _register(self, entry: "WorkspaceEntry") -> None:
490
- workspace_id = entry.definition.workspace_id
491
- key = self._key_for_definition(entry.definition)
492
- if key in self._entries:
493
- raise ValueError(
494
- "duplicate workspace board: {0}".format(entry.definition.board_path)
495
- )
496
- if workspace_id in self._name_to_key:
497
- raise ValueError("duplicate workspace name: {0}".format(workspace_id))
498
- self._entries[key] = entry
499
- self._name_to_key[workspace_id] = key
500
- self._order.append(key)
501
- entry.manager.set_persist_callback(self.persist_definitions)
502
-
503
- def _key_for_definition(self, definition: "WorkspaceDefinition") -> str:
504
- if definition.board_path is None:
505
- return "workspace:{0}".format(definition.workspace_id)
506
- return str(definition.board_path.resolve())
507
-
508
- async def start(self) -> None:
509
- self._started = True
510
- for key in self._order:
511
- await self._entries[key].manager.start()
512
-
513
- async def close(self) -> None:
514
- for key in reversed(self._order):
515
- await self._entries[key].manager.close()
516
- self._started = False
517
-
518
- def get(self, workspace_id: str) -> "WorkspaceEntry":
519
- workspace_id = normalize_workspace_id(workspace_id)
520
- if not workspace_id:
521
- raise KeyError(workspace_id)
522
- try:
523
- return self._entries[self._name_to_key[workspace_id]]
524
- except KeyError:
525
- raise KeyError(workspace_id)
526
-
527
- async def add_workspace(
528
- self,
529
- name: str,
530
- work_dir: str = "./",
531
- board: "typing.Union[str, bool, None]" = None,
532
- ) -> "WorkspaceEntry":
533
- if self._entry_factory is None:
534
- raise ValueError("workspace creation is unavailable")
535
-
536
- base_dir = (
537
- self._config_path.parent if self._config_path is not None else Path.cwd()
538
- )
539
- resolved_work_dir = _resolve_workspace_path(str(work_dir or "./"), base_dir)
540
- resolved_work_dir.mkdir(parents=True, exist_ok=True)
541
-
542
- board_path = None
543
- if board is not False:
544
- if board is not None and not isinstance(board, str):
545
- raise ValueError("board must be a path string or false")
546
- board_path = (
547
- _resolve_workspace_path(board, base_dir)
548
- if board and board.strip()
549
- else default_board_path()
550
- )
551
- board_path.parent.mkdir(parents=True, exist_ok=True)
552
- board_key = str(board_path.resolve())
553
- if board_key in self._entries:
554
- raise ValueError(
555
- "workspace board already exists: {0}".format(board_path)
556
- )
557
-
558
- workspace_id = normalize_workspace_id(name)
559
- if not workspace_id:
560
- workspace_id = _next_workspace_id(self._name_to_key)
561
- if workspace_id in self._name_to_key:
562
- raise ValueError("workspace name already exists: {0}".format(workspace_id))
563
-
564
- definition = WorkspaceDefinition(
565
- workspace_id=workspace_id,
566
- board_path=board_path,
567
- work_dir=resolved_work_dir,
568
- )
569
- entry = self._entry_factory(definition, self.persist_definitions)
570
- self._register(entry)
571
- if self._started:
572
- await entry.manager.start()
573
- self.persist_definitions()
574
- return entry
575
-
576
- async def delete_workspace(self, name: str) -> "WorkspaceDefinition":
577
- workspace_id = normalize_workspace_id(name)
578
- if not workspace_id:
579
- raise KeyError(workspace_id)
580
- try:
581
- key = self._name_to_key.pop(workspace_id)
582
- entry = self._entries.pop(key)
583
- except KeyError:
584
- raise KeyError(workspace_id)
585
- self._order = [item for item in self._order if item != key]
586
- await entry.manager.close()
587
- self.persist_definitions()
588
- return entry.definition
589
-
590
- def persist_definitions(self) -> None:
591
- if self._config_path is None:
592
- return
593
- save_workspace_definitions(
594
- self._config_path,
595
- [self._entries[key].definition for key in self._order],
596
- )
597
-
598
- def list_workspaces(self) -> "typing.List[typing.Dict[str, object]]":
599
- return [self._entries[key].to_dict() for key in self._order]
600
-
601
-
602
- def session_snapshot(session) -> "typing.Dict[str, object]":
603
- return typing.cast("typing.Dict[str, object]", session.snapshot())
604
-
605
-
606
- def session_summary(session) -> "typing.Dict[str, object]":
607
- summary = getattr(session, "summary", None)
608
- if callable(summary):
609
- return typing.cast("typing.Dict[str, object]", summary())
610
- snapshot = session_snapshot(session)
611
- return {
612
- "title": snapshot.get("title") or "",
613
- "running": bool(snapshot.get("running")),
614
- "spinner": snapshot.get("spinner") or "",
615
- "turn_count": len(snapshot.get("turns") or []),
616
- "last_assistant": _last_assistant_text(snapshot.get("turns") or []),
617
- "context_remaining_percent": snapshot.get("context_remaining_percent"),
618
- }
619
-
620
-
621
- def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
622
- for turn in reversed(list(turns)):
623
- if str(turn.get("kind") or "assistant") == "control":
624
- continue
625
- response = str(turn.get("response") or "").strip()
626
- if response:
627
- return response
628
- return ""