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