codebind 0.5.3__py3-none-any.whl → 0.6.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 (21) hide show
  1. codebind/configuration.py +60 -0
  2. codebind/conversation.py +268 -0
  3. codebind/extension.py +86 -54
  4. codebind/instructions.md +11 -0
  5. codebind/jupyter.py +97 -6
  6. codebind/session.py +274 -53
  7. {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/package.json +2 -2
  8. codebind-0.6.0.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.8d95476143320694.js +1 -0
  9. codebind-0.6.0.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.4f0137dd98629dab.js +1 -0
  10. codebind-0.6.0.dist-info/METADATA +112 -0
  11. codebind-0.6.0.dist-info/RECORD +22 -0
  12. codebind-0.5.3.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.65031998ea066c13.js +0 -1
  13. codebind-0.5.3.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.b6b50e099d2c670a.js +0 -1
  14. codebind-0.5.3.dist-info/METADATA +0 -115
  15. codebind-0.5.3.dist-info/RECORD +0 -19
  16. {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/install.json +0 -0
  17. {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/static/style.js +0 -0
  18. {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/static/third-party-licenses.json +0 -0
  19. {codebind-0.5.3.dist-info → codebind-0.6.0.dist-info}/WHEEL +0 -0
  20. {codebind-0.5.3.dist-info → codebind-0.6.0.dist-info}/entry_points.txt +0 -0
  21. {codebind-0.5.3.dist-info → codebind-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,60 @@
1
+ """XDG-backed Codebind configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from models_provider import Models
12
+
13
+
14
+ def configuration_home() -> Path:
15
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
16
+ if xdg_home:
17
+ path = Path(xdg_home).expanduser()
18
+ if path.is_absolute():
19
+ return path
20
+ return Path.home() / ".config"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class CodebindConfiguration:
25
+ model: str
26
+ parameters: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ def load_configuration() -> CodebindConfiguration:
30
+ path = configuration_home() / "codebind" / "configuration.json"
31
+ if not path.exists():
32
+ raise FileNotFoundError(
33
+ f"Codebind configuration is missing: {path}. "
34
+ 'Create it with a JSON object such as {"model": "openai/gpt-5.6-luna"}.'
35
+ )
36
+ value = json.loads(path.read_text())
37
+ if not isinstance(value, dict) or not isinstance(value.get("model"), str):
38
+ raise ValueError(f"Codebind configuration must contain a string model: {path}")
39
+ parameters = value.get("parameters", {})
40
+ if not isinstance(parameters, dict):
41
+ raise ValueError(f"Codebind model parameters must be a JSON object: {path}")
42
+ return CodebindConfiguration(value["model"], parameters)
43
+
44
+
45
+ def load_models() -> Models:
46
+ path = configuration_home() / "codebind" / "models.json"
47
+ if not path.exists():
48
+ return Models()
49
+ values = json.loads(path.read_text())
50
+ if not isinstance(values, dict):
51
+ raise ValueError(f"Codebind model configuration must be a JSON object: {path}")
52
+ return Models(values)
53
+
54
+
55
+ __all__ = [
56
+ "CodebindConfiguration",
57
+ "configuration_home",
58
+ "load_configuration",
59
+ "load_models",
60
+ ]
@@ -0,0 +1,268 @@
1
+ """Durable conversation records and storage boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol
10
+ from uuid import uuid4
11
+
12
+ from langchain_core.messages import BaseMessage, HumanMessage, message_to_dict, messages_from_dict
13
+
14
+ _VERSION = 2
15
+
16
+
17
+ class ConversationStore(Protocol):
18
+ async def load(self) -> dict[str, Any] | None: ...
19
+
20
+ async def save(self, value: dict[str, Any]) -> None: ...
21
+
22
+
23
+ class ConversationBridge(Protocol):
24
+ async def load_conversation(self) -> dict[str, Any] | None: ...
25
+
26
+ async def save_conversation(self, value: dict[str, Any]) -> None: ...
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class Conversation:
31
+ id: str = field(default_factory=lambda: str(uuid4()))
32
+ revision: int = 0
33
+ events: list[dict[str, Any]] = field(default_factory=list)
34
+ cwd: str = field(default_factory=os.getcwd)
35
+
36
+ @classmethod
37
+ def from_dict(cls, value: dict[str, Any]) -> Conversation:
38
+ if value.get("version") != _VERSION:
39
+ raise ValueError("Unsupported Codebind conversation version")
40
+ identifier = value.get("id")
41
+ events = value.get("events")
42
+ revision = value.get("revision")
43
+ cwd = value.get("cwd")
44
+ if not isinstance(identifier, str) or not isinstance(events, list):
45
+ raise ValueError("Invalid Codebind conversation")
46
+ if not isinstance(revision, int) or revision != len(events):
47
+ raise ValueError("Invalid Codebind conversation revision")
48
+ if any(
49
+ not isinstance(event, dict) or event.get("sequence") != sequence
50
+ for sequence, event in enumerate(events, start=1)
51
+ ):
52
+ raise ValueError("Invalid Codebind conversation sequence")
53
+ if not isinstance(cwd, str):
54
+ cwd = ""
55
+ return cls(identifier, revision, deepcopy(events), cwd)
56
+
57
+ def as_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "version": _VERSION,
60
+ "id": self.id,
61
+ "revision": self.revision,
62
+ "cwd": self.cwd,
63
+ "events": deepcopy(self.events),
64
+ }
65
+
66
+ def append(self, event_type: str, **data: Any) -> dict[str, Any]:
67
+ event = {"sequence": self.revision + 1, "type": event_type, **data}
68
+ self.events.append(event)
69
+ self.revision += 1
70
+ return event
71
+
72
+ def append_message(self, message: BaseMessage, turn_id: str) -> dict[str, Any]:
73
+ return self.append("message", turn_id=turn_id, message=message_to_dict(message))
74
+
75
+ def append_notebook(
76
+ self,
77
+ cells: list[dict[str, Any]],
78
+ turn_id: str,
79
+ ) -> dict[str, Any] | None:
80
+ current = self.notebook()
81
+ incoming = _normalize_notebook(cells)
82
+ has_notebook = any(event.get("type") == "notebook" for event in self.events)
83
+
84
+ if not has_notebook:
85
+ payload: dict[str, Any] = {"mode": "snapshot", "cells": incoming}
86
+ else:
87
+ current_by_id = {cell["id"]: cell for cell in current}
88
+ incoming_by_id = {cell["id"]: cell for cell in incoming}
89
+ upsert = [cell for cell in incoming if current_by_id.get(cell["id"]) != cell]
90
+ remove = [cell["id"] for cell in current if cell["id"] not in incoming_by_id]
91
+ current_order = [cell["id"] for cell in current]
92
+ incoming_order = [cell["id"] for cell in incoming]
93
+ if not upsert and not remove and current_order == incoming_order:
94
+ return None
95
+ payload = {"mode": "delta"}
96
+ if upsert:
97
+ payload["upsert"] = upsert
98
+ if remove:
99
+ payload["remove"] = remove
100
+ if current_order != incoming_order:
101
+ payload["order"] = incoming_order
102
+
103
+ content = (
104
+ "<notebook-context>"
105
+ + json.dumps(
106
+ payload,
107
+ ensure_ascii=False,
108
+ separators=(",", ":"),
109
+ )
110
+ + "</notebook-context>"
111
+ )
112
+ return self.append(
113
+ "notebook",
114
+ turn_id=turn_id,
115
+ **payload,
116
+ message=message_to_dict(HumanMessage(content)),
117
+ )
118
+
119
+ def rollback(self, event: dict[str, Any]) -> None:
120
+ if not self.events or self.events[-1] is not event:
121
+ raise RuntimeError("Only the latest conversation event can be rolled back")
122
+ self.events.pop()
123
+ self.revision -= 1
124
+
125
+ def messages(self) -> list[BaseMessage]:
126
+ serialized = [
127
+ event["message"]
128
+ for event in self.events
129
+ if event.get("type") in {"message", "notebook"}
130
+ and isinstance(event.get("message"), dict)
131
+ ]
132
+ return messages_from_dict(serialized)
133
+
134
+ def notebook(self) -> list[dict[str, Any]]:
135
+ cells: list[dict[str, Any]] = []
136
+ for event in self.events:
137
+ if event.get("type") != "notebook":
138
+ continue
139
+ mode = event.get("mode")
140
+ if mode == "snapshot":
141
+ cells = _normalize_notebook(event.get("cells"))
142
+ continue
143
+ if mode != "delta":
144
+ raise ValueError("Invalid Codebind notebook event")
145
+
146
+ by_id = {cell["id"]: cell for cell in cells}
147
+ for identifier in event.get("remove", []):
148
+ if not isinstance(identifier, str):
149
+ raise ValueError("Invalid Codebind notebook removal")
150
+ by_id.pop(identifier, None)
151
+ for cell in _normalize_notebook(event.get("upsert", [])):
152
+ by_id[cell["id"]] = cell
153
+
154
+ order = event.get("order")
155
+ if order is None:
156
+ retained = [cell["id"] for cell in cells if cell["id"] in by_id]
157
+ added = [identifier for identifier in by_id if identifier not in retained]
158
+ order = [*retained, *added]
159
+ if (
160
+ not isinstance(order, list)
161
+ or any(not isinstance(identifier, str) for identifier in order)
162
+ or len(order) != len(set(order))
163
+ or set(order) != set(by_id)
164
+ ):
165
+ raise ValueError("Invalid Codebind notebook order")
166
+ cells = [by_id[identifier] for identifier in order]
167
+ return deepcopy(cells)
168
+
169
+ def unfinished_turns(self) -> list[str]:
170
+ started: list[str] = []
171
+ finished: set[str] = set()
172
+ for event in self.events:
173
+ turn_id = event.get("turn_id")
174
+ if not isinstance(turn_id, str):
175
+ continue
176
+ if event.get("type") == "turn_started":
177
+ started.append(turn_id)
178
+ elif event.get("type") == "turn_finished":
179
+ finished.add(turn_id)
180
+ return [turn_id for turn_id in started if turn_id not in finished]
181
+
182
+ def pending_tool_calls(self, turn_ids: set[str]) -> list[tuple[str, str]]:
183
+ pending: dict[str, str] = {}
184
+ for event in self.events:
185
+ turn_id = event.get("turn_id")
186
+ message = event.get("message")
187
+ if turn_id not in turn_ids or not isinstance(message, dict):
188
+ continue
189
+ message_type = message.get("type")
190
+ data = message.get("data")
191
+ if not isinstance(data, dict):
192
+ continue
193
+ if message_type == "ai":
194
+ tool_calls = data.get("tool_calls", [])
195
+ if isinstance(tool_calls, list):
196
+ for call in tool_calls:
197
+ identifier = call.get("id") if isinstance(call, dict) else None
198
+ if isinstance(identifier, str) and identifier:
199
+ pending[identifier] = turn_id
200
+ elif message_type == "tool":
201
+ identifier = data.get("tool_call_id")
202
+ if isinstance(identifier, str):
203
+ pending.pop(identifier, None)
204
+ return list(pending.items())
205
+
206
+
207
+ class MemoryConversationStore:
208
+ def __init__(self) -> None:
209
+ self.value: dict[str, Any] | None = None
210
+
211
+ async def load(self) -> dict[str, Any] | None:
212
+ return self.value
213
+
214
+ async def save(self, value: dict[str, Any]) -> None:
215
+ self.value = value
216
+
217
+
218
+ class NotebookConversationStore:
219
+ def __init__(self, bridge: ConversationBridge):
220
+ self.bridge = bridge
221
+
222
+ async def load(self) -> dict[str, Any] | None:
223
+ return await self.bridge.load_conversation()
224
+
225
+ async def save(self, value: dict[str, Any]) -> None:
226
+ await self.bridge.save_conversation(value)
227
+
228
+
229
+ def _normalize_notebook(value: Any) -> list[dict[str, Any]]:
230
+ if not isinstance(value, list):
231
+ raise ValueError("Invalid Codebind notebook snapshot")
232
+ cells: list[dict[str, Any]] = []
233
+ identifiers: set[str] = set()
234
+ for raw_cell in value:
235
+ if not isinstance(raw_cell, dict):
236
+ raise ValueError("Invalid Codebind notebook cell")
237
+ identifier = raw_cell.get("id")
238
+ cell_type = raw_cell.get("type")
239
+ source = raw_cell.get("source")
240
+ if (
241
+ not isinstance(identifier, str)
242
+ or not identifier
243
+ or identifier in identifiers
244
+ or cell_type not in {"code", "markdown", "raw"}
245
+ or not isinstance(source, str)
246
+ ):
247
+ raise ValueError("Invalid Codebind notebook cell")
248
+ identifiers.add(identifier)
249
+ cell: dict[str, Any] = {"id": identifier, "type": cell_type, "source": source}
250
+ if cell_type == "code":
251
+ execution_count = raw_cell.get("execution_count")
252
+ outputs = raw_cell.get("outputs", [])
253
+ if not (execution_count is None or isinstance(execution_count, int)) or not isinstance(
254
+ outputs, list
255
+ ):
256
+ raise ValueError("Invalid Codebind code cell")
257
+ cell["execution_count"] = execution_count
258
+ cell["outputs"] = deepcopy(outputs)
259
+ cells.append(cell)
260
+ return cells
261
+
262
+
263
+ __all__ = [
264
+ "Conversation",
265
+ "ConversationStore",
266
+ "MemoryConversationStore",
267
+ "NotebookConversationStore",
268
+ ]
codebind/extension.py CHANGED
@@ -2,76 +2,108 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
- import os
7
- from pathlib import Path
5
+ import asyncio
8
6
  from typing import Any
9
7
 
10
8
  from IPython.core.interactiveshell import InteractiveShell
11
9
  from langchain_core.language_models import BaseChatModel
12
- from models_provider import Models
13
10
 
11
+ from .configuration import load_configuration, load_models
12
+ from .conversation import MemoryConversationStore, NotebookConversationStore
14
13
  from .jupyter import JupyterLabBridge
15
14
  from .session import Session
16
15
 
17
16
 
18
- _NAMESPACE_ATTRIBUTE = "_codebind_extension_namespace"
19
-
20
-
21
- def _models_path() -> Path:
22
- configured = os.environ.get("XDG_CONFIG_HOME")
23
- if configured:
24
- config_home = Path(configured).expanduser()
25
- if config_home.is_absolute():
26
- return config_home / "codebind" / "models.json"
27
- return Path.home() / ".config" / "codebind" / "models.json"
28
-
29
-
30
- def _load_models() -> Models:
31
- path = _models_path()
32
- if not path.exists():
33
- return Models()
34
- values = json.loads(path.read_text())
35
- if not isinstance(values, dict):
36
- raise ValueError(f"Codebind model configuration must be a JSON object: {path}")
37
- return Models(values)
17
+ _STATE_ATTRIBUTE = "_codebind_extension_state"
38
18
 
39
19
 
40
20
  def load_ipython_extension(ipython: InteractiveShell) -> None:
41
- """Load Codebind into the active IPython user namespace."""
42
- models = _load_models()
43
- previous = getattr(ipython, _NAMESPACE_ATTRIBUTE, None)
21
+ """Load Codebind into the active IPython session."""
22
+ previous = getattr(ipython, _STATE_ATTRIBUTE, None)
44
23
  if isinstance(previous, dict):
45
- previous_chat = previous.get("chat")
46
- if isinstance(previous_chat, Session) and previous_chat.bridge is not None:
47
- previous_chat.bridge.close()
48
- ipython.drop_by_id(previous)
24
+ previous_load = previous.get("load_task")
25
+ if isinstance(previous_load, asyncio.Task):
26
+ previous_load.cancel()
27
+ previous_session = previous.get("session")
28
+ if isinstance(previous_session, Session) and previous_session.bridge is not None:
29
+ previous_session.bridge.close()
30
+
31
+ configuration = load_configuration()
32
+ models = load_models()
33
+ model: BaseChatModel | None = None
34
+
35
+ def get_model() -> BaseChatModel:
36
+ nonlocal model
37
+ if model is None:
38
+ model = models.chat(configuration.model, **configuration.parameters)
39
+ return model
40
+
41
+ def discard_model(selected: BaseChatModel) -> None:
42
+ nonlocal model
43
+ if model is selected:
44
+ model = None
45
+
49
46
  bridge = JupyterLabBridge.connect(ipython)
50
- chat = Session(shell=ipython, bridge=bridge)
47
+ store = NotebookConversationStore(bridge) if bridge is not None else MemoryConversationStore()
48
+ session = Session(shell=ipython, bridge=bridge, store=store)
49
+
50
+ async def answer_question(question: str, notebook: list[dict[str, Any]]) -> None:
51
+ selected = get_model()
52
+ try:
53
+ await session.asend(question, selected, notebook=notebook)
54
+ except BaseException:
55
+ discard_model(selected)
56
+ raise
57
+
58
+ def question_magic(line: str, cell: str | None = None) -> None:
59
+ selected = get_model()
60
+ try:
61
+ session.send(cell if cell is not None else line, selected)
62
+ except BaseException:
63
+ discard_model(selected)
64
+ raise
65
+
66
+ load_task: asyncio.Task[None] | None = None
51
67
  if bridge is not None:
52
-
53
- async def answer_question(question: str, model_name: str) -> None:
54
- model = ipython.user_ns.get(model_name)
55
- if not isinstance(model, BaseChatModel):
56
- raise NameError(f"{model_name!r} is not a chat model in the IPython namespace")
57
- await chat.asend(question, model)
58
-
59
68
  bridge.handle_questions(answer_question)
60
- namespace: dict[str, Any] = {
61
- "chat": chat,
62
- "Models": Models,
63
- "models": models,
64
- }
65
- ipython.push(namespace)
66
- setattr(ipython, _NAMESPACE_ATTRIBUTE, namespace)
69
+
70
+ async def prepare_session() -> None:
71
+ try:
72
+ await session.aload()
73
+ except asyncio.CancelledError:
74
+ raise
75
+ except Exception as error:
76
+ bridge.report_session_ready(error)
77
+ else:
78
+ bridge.report_session_ready()
79
+
80
+ try:
81
+ load_task = asyncio.get_running_loop().create_task(prepare_session())
82
+ except RuntimeError:
83
+ pass
84
+ ipython.register_magic_function(question_magic, "line_cell", "question")
85
+ setattr(
86
+ ipython,
87
+ _STATE_ATTRIBUTE,
88
+ {
89
+ "session": session,
90
+ "bridge": bridge,
91
+ "load_task": load_task,
92
+ "get_model": get_model,
93
+ },
94
+ )
67
95
 
68
96
 
69
97
  def unload_ipython_extension(ipython: InteractiveShell) -> None:
70
- """Remove names added by Codebind without touching user replacements."""
71
- namespace = getattr(ipython, _NAMESPACE_ATTRIBUTE, None)
72
- if isinstance(namespace, dict):
73
- chat = namespace.get("chat")
74
- if isinstance(chat, Session) and chat.bridge is not None:
75
- chat.bridge.close()
76
- ipython.drop_by_id(namespace)
77
- delattr(ipython, _NAMESPACE_ATTRIBUTE)
98
+ """Unload Codebind without touching the user namespace."""
99
+ state: Any = getattr(ipython, _STATE_ATTRIBUTE, None)
100
+ if isinstance(state, dict):
101
+ load_task = state.get("load_task")
102
+ if isinstance(load_task, asyncio.Task):
103
+ load_task.cancel()
104
+ session = state.get("session")
105
+ if isinstance(session, Session) and session.bridge is not None:
106
+ session.bridge.close()
107
+ delattr(ipython, _STATE_ATTRIBUTE)
108
+ ipython.magics_manager.magics["line"].pop("question", None)
109
+ ipython.magics_manager.magics["cell"].pop("question", None)
@@ -0,0 +1,11 @@
1
+ You are a coding agent, operating through the user’s live IPython session.
2
+
3
+ - Use the `ipython` tool whenever it can help you answer or act, especially for requests about local files, software, the environment, processes, or hardware.
4
+ - Prefer one batched IPython cell that completes as much of the task as practical instead of giving the user commands to run.
5
+ - Use Python as the primary orchestration language; IPython syntax and Python-launched subprocesses are available.
6
+ - Default to read-only inspection unless the user requests a change.
7
+ - Never claim that local access is unavailable before trying the tool.
8
+ - Keep the user oriented while working. Before meaningful tool work, emit one brief Markdown update saying what you are about to do. Between tool calls, emit another update only for a new phase, an important finding, or a change of plan. Do not perform several meaningful steps silently, but do not narrate trivial operations.
9
+ - Keep responses brief and direct.
10
+ - Treat notebook-context messages as the authoritative notebook document state; later deltas override earlier cell versions.
11
+ - You can freely install libraries like matplotlib, pandas and any others as needed; it's preferrable to use them if you need.
codebind/jupyter.py CHANGED
@@ -11,7 +11,9 @@ from IPython.core.interactiveshell import InteractiveShell
11
11
 
12
12
 
13
13
  _TARGET_NAME = "codebind"
14
- _QuestionHandler = Callable[[str, str], Awaitable[None]]
14
+ _HANDSHAKE_TIMEOUT_SECONDS = 30
15
+ _Notebook = list[dict[str, Any]]
16
+ _QuestionHandler = Callable[[str, _Notebook], Awaitable[None]]
15
17
 
16
18
 
17
19
  class JupyterLabBridge:
@@ -21,6 +23,10 @@ class JupyterLabBridge:
21
23
  self._comm = comm
22
24
  self._question_handler: _QuestionHandler | None = None
23
25
  self._tasks: set[asyncio.Task[None]] = set()
26
+ self._conversation: dict[str, Any] | None = None
27
+ self._conversation_received = False
28
+ self._conversation_waiters: set[asyncio.Future[dict[str, Any] | None]] = set()
29
+ self._save_waiters: dict[str, asyncio.Future[None]] = {}
24
30
  self.ready = False
25
31
  comm.on_msg(self._on_message)
26
32
 
@@ -42,6 +48,10 @@ class JupyterLabBridge:
42
48
  for task in self._tasks:
43
49
  task.cancel()
44
50
  self._tasks.clear()
51
+ for future in (*self._conversation_waiters, *self._save_waiters.values()):
52
+ future.cancel()
53
+ self._conversation_waiters.clear()
54
+ self._save_waiters.clear()
45
55
  self._comm.close()
46
56
  self.ready = False
47
57
 
@@ -49,6 +59,34 @@ class JupyterLabBridge:
49
59
  """Handle questions submitted by Codebind cells."""
50
60
  self._question_handler = handler
51
61
 
62
+ async def load_conversation(self) -> dict[str, Any] | None:
63
+ if self._conversation_received:
64
+ return self._conversation
65
+ future = asyncio.get_running_loop().create_future()
66
+ self._conversation_waiters.add(future)
67
+ try:
68
+ return await asyncio.wait_for(future, _HANDSHAKE_TIMEOUT_SECONDS)
69
+ finally:
70
+ self._conversation_waiters.discard(future)
71
+
72
+ async def save_conversation(self, value: dict[str, Any]) -> None:
73
+ if not self.ready:
74
+ await self.load_conversation()
75
+ request_id = str(uuid4())
76
+ future = asyncio.get_running_loop().create_future()
77
+ self._save_waiters[request_id] = future
78
+ self._comm.send(
79
+ {
80
+ "type": "conversation_save",
81
+ "request_id": request_id,
82
+ "conversation": value,
83
+ }
84
+ )
85
+ try:
86
+ await asyncio.wait_for(future, _HANDSHAKE_TIMEOUT_SECONDS)
87
+ finally:
88
+ self._save_waiters.pop(request_id, None)
89
+
52
90
  def start_code_cell(self, source: str) -> str | None:
53
91
  """Insert a running code cell before its execution begins."""
54
92
  if not self.ready:
@@ -115,30 +153,82 @@ class JupyterLabBridge:
115
153
  self._comm.send({"type": "markdown_cell", "source": source})
116
154
  return True
117
155
 
156
+ def ensure_instructions_cell(self, source: str) -> bool:
157
+ """Show the conversation's immutable system instructions in the notebook."""
158
+ if not self.ready:
159
+ return False
160
+ self._comm.send({"type": "instructions_cell", "source": source})
161
+ return True
162
+
163
+ def report_session_ready(self, error: BaseException | None = None) -> None:
164
+ """Tell JupyterLab whether persisted conversation state is ready."""
165
+ self._comm.send(
166
+ {
167
+ "type": "session_ready",
168
+ "error": (
169
+ {"type": type(error).__name__, "message": str(error)}
170
+ if error is not None
171
+ else None
172
+ ),
173
+ }
174
+ )
175
+
118
176
  def _on_message(self, message: dict[str, Any]) -> None:
119
177
  data = message.get("content", {}).get("data", {})
120
178
  if not isinstance(data, dict):
121
179
  return
122
180
  if data.get("type") == "ready":
181
+ conversation = data.get("conversation")
182
+ self._conversation = conversation if isinstance(conversation, dict) else None
183
+ self._conversation_received = True
123
184
  self.ready = True
185
+ for future in tuple(self._conversation_waiters):
186
+ if not future.done():
187
+ future.set_result(self._conversation)
188
+ return
189
+ if data.get("type") == "conversation_saved":
190
+ request_id = data.get("request_id")
191
+ future = self._save_waiters.get(request_id) if isinstance(request_id, str) else None
192
+ if future is None or future.done():
193
+ return
194
+ error = data.get("error")
195
+ if isinstance(error, str) and error:
196
+ future.set_exception(RuntimeError(error))
197
+ else:
198
+ future.set_result(None)
199
+ return
200
+ if data.get("type") == "cancel":
201
+ for task in tuple(self._tasks):
202
+ task.cancel()
124
203
  return
125
204
  if data.get("type") != "question":
126
205
  return
127
206
  cell_id = data.get("cell_id")
128
207
  question = data.get("question")
129
- model = data.get("model", "model")
130
- if not all(isinstance(value, str) for value in (cell_id, question, model)):
208
+ notebook = data.get("notebook")
209
+ if not all(isinstance(value, str) for value in (cell_id, question)) or not isinstance(
210
+ notebook, list
211
+ ):
131
212
  return
132
- task = asyncio.create_task(self._answer_question(cell_id, question, model))
213
+ self._comm.send({"type": "question_started", "cell_id": cell_id})
214
+ task = asyncio.create_task(self._answer_question(cell_id, question, notebook))
133
215
  self._tasks.add(task)
134
216
  task.add_done_callback(self._tasks.discard)
135
217
 
136
- async def _answer_question(self, cell_id: str, question: str, model: str) -> None:
218
+ async def _answer_question(
219
+ self,
220
+ cell_id: str,
221
+ question: str,
222
+ notebook: _Notebook,
223
+ ) -> None:
137
224
  error: dict[str, str] | None = None
225
+ cancelled = False
138
226
  try:
139
227
  if self._question_handler is None:
140
228
  raise RuntimeError("Codebind is not ready to receive questions.")
141
- await self._question_handler(question, model)
229
+ await self._question_handler(question, notebook)
230
+ except asyncio.CancelledError:
231
+ cancelled = True
142
232
  except Exception as exception:
143
233
  error = {"type": type(exception).__name__, "message": str(exception)}
144
234
  self._comm.send(
@@ -146,5 +236,6 @@ class JupyterLabBridge:
146
236
  "type": "question_finished",
147
237
  "cell_id": cell_id,
148
238
  "error": error,
239
+ "cancelled": cancelled,
149
240
  }
150
241
  )