open-data-sci 0.1.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 (85) hide show
  1. open_data_sci-0.1.0.dist-info/METADATA +629 -0
  2. open_data_sci-0.1.0.dist-info/RECORD +85 -0
  3. open_data_sci-0.1.0.dist-info/WHEEL +4 -0
  4. open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
  5. open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. opendatasci/__init__.py +47 -0
  7. opendatasci/_tui/__init__.py +1 -0
  8. opendatasci/_tui/adapter.py +102 -0
  9. opendatasci/_tui/app.py +429 -0
  10. opendatasci/_tui/commands.py +95 -0
  11. opendatasci/_tui/completion.py +139 -0
  12. opendatasci/_tui/controller.py +644 -0
  13. opendatasci/_tui/file_refs.py +153 -0
  14. opendatasci/_tui/models.py +4 -0
  15. opendatasci/_tui/presenter.py +259 -0
  16. opendatasci/_tui/service.py +78 -0
  17. opendatasci/_tui/session.py +53 -0
  18. opendatasci/_tui/styles.tcss +248 -0
  19. opendatasci/_tui/styles_visible.tcss +245 -0
  20. opendatasci/_tui/theme.py +113 -0
  21. opendatasci/_tui/tools_display.py +86 -0
  22. opendatasci/_tui/widgets.py +1001 -0
  23. opendatasci/_utils/__init__.py +0 -0
  24. opendatasci/_utils/async_utils.py +11 -0
  25. opendatasci/_utils/data_formats.py +135 -0
  26. opendatasci/_utils/hash_utils.py +52 -0
  27. opendatasci/_utils/langchain_utils.py +155 -0
  28. opendatasci/_utils/streaming_utils.py +23 -0
  29. opendatasci/agents/__init__.py +12 -0
  30. opendatasci/agents/agents.py +515 -0
  31. opendatasci/agents/agents_factory.py +71 -0
  32. opendatasci/agents/chat_memory.py +397 -0
  33. opendatasci/agents/graphs.py +84 -0
  34. opendatasci/agents/nodes.py +74 -0
  35. opendatasci/agents/states.py +36 -0
  36. opendatasci/agents/turn_memory.py +124 -0
  37. opendatasci/configs.py +275 -0
  38. opendatasci/context/__init__.py +7 -0
  39. opendatasci/context/base.py +56 -0
  40. opendatasci/context/local.py +236 -0
  41. opendatasci/models/__init__.py +7 -0
  42. opendatasci/models/anthropic.py +40 -0
  43. opendatasci/models/aws.py +86 -0
  44. opendatasci/models/factory.py +179 -0
  45. opendatasci/models/google.py +79 -0
  46. opendatasci/models/local.py +79 -0
  47. opendatasci/models/microsoft.py +62 -0
  48. opendatasci/models/openai.py +49 -0
  49. opendatasci/models/providers.py +12 -0
  50. opendatasci/prompts/__init__.py +5 -0
  51. opendatasci/prompts/builders.py +85 -0
  52. opendatasci/prompts/caching.py +42 -0
  53. opendatasci/prompts/message_templates.py +7 -0
  54. opendatasci/prompts/prompt_templates.py +227 -0
  55. opendatasci/resources/skills/competitive_data_science.md +241 -0
  56. opendatasci/resources/skills/data_science.md +55 -0
  57. opendatasci/resources/skills/data_science_education.md +42 -0
  58. opendatasci/resources/skills/deep_learning.md +205 -0
  59. opendatasci/resources/skills/machine_learning.md +68 -0
  60. opendatasci/resources/skills/quantitative_analysis.md +45 -0
  61. opendatasci/sandbox/__init__.py +14 -0
  62. opendatasci/sandbox/_runner.py +114 -0
  63. opendatasci/sandbox/base.py +170 -0
  64. opendatasci/sandbox/srt.py +490 -0
  65. opendatasci/skills/__init__.py +9 -0
  66. opendatasci/skills/base.py +28 -0
  67. opendatasci/skills/local.py +131 -0
  68. opendatasci/streaming/__init__.py +37 -0
  69. opendatasci/streaming/events.py +159 -0
  70. opendatasci/streaming/processors.py +387 -0
  71. opendatasci/tools/__init__.py +58 -0
  72. opendatasci/tools/coding.py +261 -0
  73. opendatasci/tools/critic.py +136 -0
  74. opendatasci/tools/dataset_info.py +391 -0
  75. opendatasci/tools/factory.py +172 -0
  76. opendatasci/tools/mcp.py +179 -0
  77. opendatasci/tools/planning.py +88 -0
  78. opendatasci/tools/skills.py +90 -0
  79. opendatasci/tools/user_interaction.py +54 -0
  80. opendatasci/tools/web.py +236 -0
  81. opendatasci/tools/workers.py +237 -0
  82. opendatasci/tools/workspace.py +55 -0
  83. opendatasci/workspace/__init__.py +9 -0
  84. opendatasci/workspace/base.py +20 -0
  85. opendatasci/workspace/local.py +25 -0
@@ -0,0 +1,153 @@
1
+ """File-reference parsing for the @path/to/file syntax in user input."""
2
+
3
+ import re
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from rich.markup import escape as escape_markup
8
+
9
+ # Matches @path/to/file or @Makefile or @.env
10
+ _FILE_REF_RE = re.compile(r"@(\S+?)(?=\s|$)")
11
+
12
+
13
+ class _FileRef:
14
+ """A @file reference parsed from user input."""
15
+
16
+ def __init__(self, path: str) -> None:
17
+ self._path = path
18
+
19
+ @property
20
+ def display_name(self) -> str:
21
+ return Path(self._path).name
22
+
23
+
24
+ def _parse_file_refs(text: str) -> tuple[str, list[_FileRef]]:
25
+ """Return (text_without_refs, list_of_refs)."""
26
+ refs = [_FileRef(path=m.group(1)) for m in _FILE_REF_RE.finditer(text)]
27
+ clean = _FILE_REF_RE.sub("", text).strip()
28
+ return clean, refs
29
+
30
+
31
+ def _build_user_display(clean_text: str, refs: list[_FileRef]) -> str:
32
+ """Build the Rich-markup string shown in the user bubble."""
33
+ parts = []
34
+ for ref in refs:
35
+ parts.append(rf"[bold #58a6ff]\[{ref.display_name}][/bold #58a6ff]")
36
+ if clean_text:
37
+ parts.append(escape_markup(clean_text))
38
+ return "\n".join(parts)
39
+
40
+
41
+ def _build_agent_query(clean_text: str, refs: list[_FileRef]) -> str:
42
+ """Build the query string sent to the agent, with file attachment tags."""
43
+ if not refs:
44
+ return clean_text
45
+ parts = [clean_text] if clean_text else []
46
+ for ref in refs:
47
+ abs_path = str(Path(ref._path).resolve())
48
+ parts.append(f'<file_attachment path="{abs_path}" name="{ref.display_name}"/>')
49
+ return "\n\n".join(parts)
50
+
51
+
52
+ def _split_existing_file_refs(refs: list[_FileRef]) -> tuple[list[_FileRef], list[_FileRef]]:
53
+ """Split refs into (existing_files, missing_or_invalid)."""
54
+ existing: list[_FileRef] = []
55
+ missing: list[_FileRef] = []
56
+ for ref in refs:
57
+ path = Path(ref._path).expanduser()
58
+ if path.exists() and path.is_file():
59
+ existing.append(ref)
60
+ else:
61
+ missing.append(ref)
62
+ return existing, missing
63
+
64
+
65
+ def _find_slash_fragment(text: str) -> str | None:
66
+ """Return the slash fragment if text starts with / and has no space yet."""
67
+ if text.startswith("/") and " " not in text:
68
+ return text
69
+ return None
70
+
71
+
72
+ def _find_at_fragment(text: str) -> tuple[str, int] | None:
73
+ """Return (fragment, at_index) for the last active @-reference being typed, or None."""
74
+ at_pos = text.rfind("@")
75
+ if at_pos == -1:
76
+ return None
77
+ after = text[at_pos + 1 :]
78
+ space_pos = after.find(" ")
79
+ fragment = after[:space_pos] if space_pos != -1 else after
80
+ return fragment, at_pos
81
+
82
+
83
+ _DIR_CACHE_TTL = 2.0
84
+ # Maps str(search_dir) → (timestamp, [(name, is_dir), ...])
85
+ _dir_cache: dict[str, tuple[float, list[tuple[str, bool]]]] = {}
86
+
87
+
88
+ def _discover_files(fragment: str) -> list[str]:
89
+ """Return up to 10 file/dir paths under cwd that match the typed fragment."""
90
+ frag = fragment.replace("\\", "/")
91
+ if "/" in frag:
92
+ dir_part, name_prefix = frag.rsplit("/", 1)
93
+ search_dir = Path(dir_part) if Path(dir_part).is_absolute() else Path.cwd() / dir_part
94
+ else:
95
+ dir_part = ""
96
+ name_prefix = frag
97
+ search_dir = Path.cwd()
98
+
99
+ if not search_dir.is_dir():
100
+ return []
101
+
102
+ cache_key = str(search_dir)
103
+ now = time.monotonic()
104
+ cached = _dir_cache.get(cache_key)
105
+ if cached is None or now - cached[0] > _DIR_CACHE_TTL:
106
+ try:
107
+ entries: list[tuple[str, bool]] = [
108
+ (e.name, e.is_dir()) for e in sorted(search_dir.iterdir())
109
+ ]
110
+ except (PermissionError, OSError):
111
+ return []
112
+ _dir_cache[cache_key] = (now, entries)
113
+ else:
114
+ entries = cached[1]
115
+
116
+ show_hidden = name_prefix.startswith(".")
117
+ matches: list[str] = []
118
+ for name, is_dir in entries:
119
+ if name.startswith(".") and not show_hidden:
120
+ continue
121
+ if name_prefix and not name.lower().startswith(name_prefix.lower()):
122
+ continue
123
+ rel = (dir_part + "/" + name) if dir_part else name
124
+ if is_dir:
125
+ rel += "/"
126
+ matches.append(rel)
127
+
128
+ return matches[:10]
129
+
130
+
131
+ class PasteAttachment:
132
+ """Multi-line text pasted by the user, shown as a compact pill in the UI.
133
+
134
+ The LLM receives the full content inside ``<pasted_content>`` tags;
135
+ the user sees only the pill label in the attachment bar.
136
+ """
137
+
138
+ def __init__(self, content: str) -> None:
139
+ self._content = content
140
+ self._line_count = content.count("\n") + 1
141
+
142
+ @property
143
+ def display_label(self) -> str:
144
+ n = self._line_count
145
+ return f"Text: {n} line{'s' if n != 1 else ''}"
146
+
147
+ @property
148
+ def pill_markup(self) -> str:
149
+ return rf"[bold #58a6ff]\[{escape_markup(self.display_label)}][/bold #58a6ff]"
150
+
151
+ @property
152
+ def xml_tag(self) -> str:
153
+ return f"<pasted_content>\n{self._content}\n</pasted_content>"
@@ -0,0 +1,4 @@
1
+ """Data models and UI constants for OpenDataSci TUI."""
2
+
3
+ SPINNER = "⣾⣽⣻⢿⡿⣟⣯⣷"
4
+ SPINNER_INTERVAL = 0.12
@@ -0,0 +1,259 @@
1
+ """_TurnPresenter — manages all UI state for a single agent-turn stream.
2
+
3
+ Extracted from ``CLIController.run_agent`` to reduce its complexity.
4
+ ``CLIController`` creates one ``_TurnPresenter`` per turn, feeds it events,
5
+ and calls ``cleanup()`` in the ``finally`` block.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+
11
+ from opendatasci.streaming.events import (
12
+ ErrorEvent,
13
+ ReasoningEvent,
14
+ ResponseEvent,
15
+ SubagentEvent,
16
+ TokenEvent,
17
+ ToolCallEvent,
18
+ ToolCommunicationEvent,
19
+ ToolResultEvent,
20
+ UsageEvent,
21
+ WorkerDoneEvent,
22
+ )
23
+ from opendatasci.tools import ToolName
24
+
25
+ from .adapter import EphemeralHandle, MessageHandle, ThinkingHandle, TurnStatusHandle, UIAdapter
26
+ from .tools_display import REGISTRY, ToolDisplay
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class _TurnPresenter:
32
+ """Manages ephemeral UI state (bubbles, tool blocks, thinking) for one turn.
33
+
34
+ All methods are synchronous; awaiting happens inside ``MessageBubble``
35
+ and ``ToolCallBlock`` via Textual's own async machinery.
36
+ """
37
+
38
+ def __init__(self, ui: UIAdapter) -> None:
39
+ self._ui = ui
40
+ self._agent_msg: MessageHandle | None = None
41
+ self._thinking_start: float = 0.0
42
+ self._had_reasoning: bool = False
43
+ self._ephemerals: list[EphemeralHandle] = []
44
+ # tool_call_id → ephemeral (promoted once tool_call fires)
45
+ self._ephemerals_by_id: dict[str, EphemeralHandle] = {}
46
+ # Ephemerals created from leading tool_communication before tool_call fires
47
+ self._pending_ephemerals: dict[str, EphemeralHandle] = {}
48
+ self._worker_block: EphemeralHandle | None = None
49
+ # tool_call_id → latest communication text (buffered until block is ready)
50
+ self._comm_buffers: dict[str, str] = {}
51
+ # tool_call_ids for tools with display=False — no UI created, result silently ignored
52
+ self._hidden_tool_call_ids: set[str] = set()
53
+ # Ephemeral "Thinking..." spinner shown while the LLM is processing
54
+ self._thinking_block: ThinkingHandle | None = None
55
+ self._show_thinking_block()
56
+
57
+ # ── Internal helpers ──────────────────────────────────────────────────────
58
+
59
+ def _show_thinking_block(self) -> None:
60
+ if self._thinking_block is None:
61
+ self._thinking_block = self._ui.add_thinking_block()
62
+ self._had_reasoning = False
63
+
64
+ def _dismiss_thinking_block(self) -> None:
65
+ if self._thinking_block is not None:
66
+ self._thinking_block.dismiss()
67
+ self._thinking_block = None
68
+
69
+ def _finish_thinking(self) -> None:
70
+ if self._thinking_block is None:
71
+ return
72
+ if self._had_reasoning:
73
+ elapsed = int(time.monotonic() - self._thinking_start)
74
+ self._thinking_block.finish(f"Thought for {elapsed}s")
75
+ else:
76
+ self._thinking_block.dismiss()
77
+ self._thinking_block = None
78
+
79
+ @staticmethod
80
+ def _make_label(tool_display: ToolDisplay | None, event: ToolCallEvent) -> str:
81
+ icon = tool_display.icon if tool_display else ""
82
+ label_text = (tool_display.label if tool_display else None) or event.tool.replace(
83
+ "_", " "
84
+ ).title()
85
+ return f"{icon} {label_text}".strip() if icon else label_text
86
+
87
+ @staticmethod
88
+ def _make_summary(tool_display: ToolDisplay | None, event: ToolCallEvent) -> str:
89
+ icon = tool_display.icon if tool_display else ""
90
+ return f"{icon} {event.summary}".strip() if (icon and event.summary) else event.summary
91
+
92
+ # ── Event handlers ────────────────────────────────────────────────────────
93
+
94
+ def handle_reasoning(self, event: ReasoningEvent) -> None:
95
+ if not self._had_reasoning:
96
+ self._thinking_start = time.monotonic()
97
+ self._had_reasoning = True
98
+
99
+ def handle_token(self, event: TokenEvent) -> None:
100
+ self._finish_thinking()
101
+ if self._agent_msg is None:
102
+ self._agent_msg = self._ui.add_message("agent", "")
103
+ self._agent_msg.append(event.content)
104
+
105
+ def handle_tool_communication(self, event: ToolCommunicationEvent) -> None:
106
+ self._finish_thinking()
107
+ tc_id = event.tool_call_id
108
+ comm = event.content
109
+ self._comm_buffers[tc_id] = comm
110
+
111
+ if tc_id and tc_id in self._ephemerals_by_id:
112
+ target = self._ephemerals_by_id[tc_id]
113
+ if target.is_running():
114
+ target.set_communication(comm)
115
+ elif tc_id and tc_id not in self._ephemerals_by_id:
116
+ # First comm token — pre-mount a placeholder ephemeral.
117
+ block = self._ui.add_ephemeral_block(comm, "…", "")
118
+ self._pending_ephemerals[tc_id] = block
119
+ self._ephemerals.append(block)
120
+ self._ephemerals_by_id[tc_id] = block
121
+
122
+ def handle_tool_call(self, event: ToolCallEvent) -> None:
123
+ tool_call_id = event.tool_call_id or ""
124
+ existing = self._pending_ephemerals.pop(tool_call_id, None) if tool_call_id else None
125
+ tool_display = REGISTRY.get(str(event.tool))
126
+
127
+ if tool_display is not None and not tool_display.display:
128
+ # Tool is hidden — discard any pending comm block and never create a new one.
129
+ if existing is not None:
130
+ existing.dismiss()
131
+ self._ephemerals = [e for e in self._ephemerals if e is not existing]
132
+ self._ephemerals_by_id.pop(tool_call_id, None)
133
+ self._comm_buffers.pop(tool_call_id, None)
134
+ if tool_call_id:
135
+ self._hidden_tool_call_ids.add(tool_call_id)
136
+ return
137
+
138
+ self._finish_thinking()
139
+ has_narration = self._agent_msg is not None
140
+ if self._agent_msg is not None:
141
+ self._agent_msg.finish()
142
+ self._agent_msg = None
143
+
144
+ buffered_comm = self._comm_buffers.pop(tool_call_id, "")
145
+ comm = "" if has_narration else buffered_comm
146
+
147
+ if str(event.tool) == ToolName.SPAWN_WORKERS:
148
+ if existing is not None:
149
+ existing.dismiss()
150
+ self._ephemerals = [e for e in self._ephemerals if e is not existing]
151
+ self._ephemerals_by_id.pop(tool_call_id, None)
152
+ block = self._ui.add_worker_block(comm, list(event.worker_summaries))
153
+ self._worker_block = block
154
+ self._ephemerals.append(block)
155
+ if tool_call_id:
156
+ self._ephemerals_by_id[tool_call_id] = block
157
+ elif existing is not None:
158
+ if has_narration:
159
+ existing.set_communication(None)
160
+ existing.upgrade(
161
+ self._make_label(tool_display, event), self._make_summary(tool_display, event)
162
+ )
163
+ else:
164
+ block = self._ui.add_ephemeral_block(
165
+ comm, self._make_label(tool_display, event), self._make_summary(tool_display, event)
166
+ )
167
+ self._ephemerals.append(block)
168
+ if tool_call_id:
169
+ self._ephemerals_by_id[tool_call_id] = block
170
+
171
+ def handle_worker_done(self, event: WorkerDoneEvent) -> None:
172
+ if self._worker_block is not None and event.worker_idx is not None:
173
+ if event.success:
174
+ self._worker_block.mark_worker_done(event.worker_idx)
175
+ else:
176
+ self._worker_block.mark_worker_error(event.worker_idx)
177
+
178
+ def handle_subagent_event(self, event: SubagentEvent) -> None:
179
+ if self._worker_block is None or event.worker_idx is None:
180
+ return
181
+ if event.event_type == "worker_tool_call":
182
+ tool_name = event.content
183
+ tool_display = REGISTRY.get(tool_name)
184
+ activity = event.summary
185
+ icon = tool_display.icon if tool_display else ""
186
+ if activity:
187
+ activity = f"{icon} {activity}".strip() if icon else activity
188
+ elif tool_display:
189
+ activity = f"{icon} {tool_display.label}".strip() if icon else tool_display.label
190
+ else:
191
+ activity = tool_name
192
+ self._worker_block.update_worker_activity(event.worker_idx, activity)
193
+ elif event.event_type == "worker_tool_result":
194
+ # Tool finished — drop the inline activity so the row reverts to the
195
+ # worker's subtask summary while the LLM decides on the next step.
196
+ self._worker_block.update_worker_activity(event.worker_idx, "")
197
+
198
+ def handle_tool_result(self, event: ToolResultEvent) -> None:
199
+ tool_call_id = event.tool_call_id
200
+ if tool_call_id and tool_call_id in self._hidden_tool_call_ids:
201
+ self._hidden_tool_call_ids.discard(tool_call_id)
202
+ return
203
+ if tool_call_id and tool_call_id in self._ephemerals_by_id:
204
+ target = self._ephemerals_by_id.pop(tool_call_id)
205
+ if event.is_error:
206
+ target.set_error()
207
+ else:
208
+ target.set_done()
209
+ self._ephemerals = [e for e in self._ephemerals if e is not target]
210
+ else:
211
+ logger.warning(
212
+ "Received uncorrelated tool_result (tool_call_id=%r); "
213
+ "leaving ephemerals running until cleanup",
214
+ tool_call_id,
215
+ )
216
+ self._show_thinking_block()
217
+
218
+ def handle_usage(self, event: UsageEvent, turn_status: TurnStatusHandle | None) -> None:
219
+ input_tokens = event.input_tokens
220
+ output_tokens = event.output_tokens
221
+ cache_read_tokens = event.cache_read_tokens
222
+
223
+ context_tokens: int | None = None
224
+ if input_tokens is not None or output_tokens is not None:
225
+ context_tokens = int(input_tokens or 0) + int(output_tokens or 0)
226
+
227
+ cached_tokens: int | None = (
228
+ int(cache_read_tokens) if cache_read_tokens is not None else None
229
+ )
230
+
231
+ if turn_status is not None:
232
+ turn_status.update_context(context_tokens, cached_tokens)
233
+
234
+ def handle_response(self, event: ResponseEvent) -> None:
235
+ self._finish_thinking()
236
+ if self._agent_msg is None and event.content:
237
+ self._agent_msg = self._ui.add_message("agent", event.content)
238
+
239
+ def handle_error(self, event: ErrorEvent) -> None:
240
+ self._dismiss_thinking_block()
241
+ if self._agent_msg is None:
242
+ self._agent_msg = self._ui.add_message("agent", "")
243
+ self._agent_msg.append(f"\n\n❌ {event.content}")
244
+
245
+ def handle_exception(self, exc: Exception) -> None:
246
+ self._dismiss_thinking_block()
247
+ if self._agent_msg is None:
248
+ self._agent_msg = self._ui.add_message("agent", "")
249
+ self._agent_msg.set_content(f"❌ **Error:** {exc}")
250
+
251
+ # ── Cleanup ───────────────────────────────────────────────────────────────
252
+
253
+ def cleanup(self) -> None:
254
+ """Finalise all open UI elements (called from the run_agent finally block)."""
255
+ self._finish_thinking()
256
+ for e in self._ephemerals:
257
+ e.set_done()
258
+ if self._agent_msg is not None:
259
+ self._agent_msg.finish()
@@ -0,0 +1,78 @@
1
+ """TUI service layer.
2
+
3
+ ``OpenDataSciTuiService``: the single service class used by ``CLIController``.
4
+ """
5
+
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any, AsyncIterator
9
+
10
+ from opendatasci.agents.agents import BaseOpenDataSciAgent
11
+ from opendatasci.sandbox.base import BaseSandbox
12
+ from opendatasci.streaming import AgentStreamEvent
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class OpenDataSciTuiService:
18
+ """Service layer for the OpenDataSci TUI.
19
+
20
+ Owns the agent and sandbox for the lifetime of a terminal session.
21
+ Create a new instance for each file or workspace loaded by the TUI.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ agent: BaseOpenDataSciAgent,
27
+ sandbox: BaseSandbox,
28
+ workspace_path: Path | None = None,
29
+ ) -> None:
30
+ self._agent = agent
31
+ self._sandbox = sandbox
32
+ self._workspace_path = workspace_path
33
+
34
+ async def __aenter__(self) -> "OpenDataSciTuiService":
35
+ return self
36
+
37
+ async def __aexit__(self, *_: Any) -> None:
38
+ await self.close()
39
+
40
+ async def close(self) -> None:
41
+ """Release sandbox resources (e.g. stop Docker containers)."""
42
+ await self._sandbox.close()
43
+
44
+ async def astream(self, query: str) -> AsyncIterator[AgentStreamEvent]:
45
+ """Stream events for *query* with token-level output."""
46
+ async for event in self._agent.astream(query):
47
+ yield event
48
+
49
+ async def reset_session(self) -> None:
50
+ """Reset the execution session and clear agent conversation."""
51
+ self._sandbox.reset()
52
+ await self._agent.clear_chat_history()
53
+
54
+ async def clear_context(self) -> None:
55
+ """Clear all agent context: conversation history and memory summaries."""
56
+ await self._agent.clear_chat_history()
57
+
58
+ async def rewind_turn(self) -> None:
59
+ """Remove the last turn from the conversation history."""
60
+ await self._agent.rewind_turn()
61
+
62
+ async def compact_chat_history(self) -> str:
63
+ """Compact the conversation history and return the summary."""
64
+ return await self._agent.compact_chat_history()
65
+
66
+ def get_workspace_files(self) -> list[str]:
67
+ """Return names of files/dirs visible in the workspace, relative to its root.
68
+
69
+ Used by the /ls-workspace command.
70
+ """
71
+ if self._workspace_path is None:
72
+ return []
73
+ path = self._workspace_path
74
+ try:
75
+ entries = sorted(path.iterdir(), key=lambda f: (f.is_dir(), f.name.lower()))
76
+ return [e.name + ("/" if e.is_dir() else "") for e in entries]
77
+ except Exception:
78
+ return []
@@ -0,0 +1,53 @@
1
+ """CLISessionInfo: boot-time metadata consumed only by CLIController."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from opendatasci._utils.data_formats import ALL_SUPPORTED_EXTENSIONS
9
+ from opendatasci.configs import OpenDataSciConfig
10
+
11
+
12
+ class CLISessionInfo(BaseModel):
13
+ """Metadata about a loaded session."""
14
+
15
+ path: str
16
+ is_directory: bool
17
+ workspace_count: int
18
+ workspaces: list[dict[str, Any]]
19
+ provider: str
20
+ model: str | None
21
+
22
+ @classmethod
23
+ def from_path(
24
+ cls,
25
+ path: str,
26
+ workspace_path: Path | None,
27
+ config: OpenDataSciConfig,
28
+ ) -> "CLISessionInfo":
29
+ file_path = Path(path)
30
+ is_dir = file_path.is_dir()
31
+
32
+ if is_dir and workspace_path is not None:
33
+ data_files = [
34
+ f
35
+ for f in workspace_path.rglob("*")
36
+ if f.is_file()
37
+ and f.suffix in ALL_SUPPORTED_EXTENSIONS
38
+ and not any(part.startswith(".") for part in f.relative_to(workspace_path).parts)
39
+ ]
40
+ workspaces = [{"name": f.name} for f in data_files]
41
+ workspace_count = len(data_files)
42
+ else:
43
+ workspaces = [{"name": file_path.name}]
44
+ workspace_count = 1
45
+
46
+ return cls(
47
+ path=path,
48
+ is_directory=is_dir,
49
+ workspace_count=workspace_count,
50
+ workspaces=workspaces,
51
+ provider=config.provider,
52
+ model=config.model,
53
+ )