convo-cli 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 (53) hide show
  1. convo/__init__.py +3 -0
  2. convo/__main__.py +110 -0
  3. convo/bootstrap.py +273 -0
  4. convo/core/__init__.py +1 -0
  5. convo/core/config.py +53 -0
  6. convo/core/contracts.py +160 -0
  7. convo/core/exceptions.py +5 -0
  8. convo/db/__init__.py +1 -0
  9. convo/db/base.py +13 -0
  10. convo/db/chunks.py +132 -0
  11. convo/db/conversations.py +167 -0
  12. convo/db/documents.py +34 -0
  13. convo/db/models.py +81 -0
  14. convo/db/session.py +27 -0
  15. convo/llm/__init__.py +0 -0
  16. convo/llm/auth.py +504 -0
  17. convo/llm/client.py +207 -0
  18. convo/llm/credentials.py +192 -0
  19. convo/llm/models.py +71 -0
  20. convo/llm/streaming.py +62 -0
  21. convo/migrations/env.py +43 -0
  22. convo/migrations/script.py.mako +22 -0
  23. convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
  24. convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
  25. convo/migrations/versions/README.md +3 -0
  26. convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
  27. convo/pipeline/__init__.py +0 -0
  28. convo/pipeline/chat.py +129 -0
  29. convo/pipeline/chunking.py +42 -0
  30. convo/pipeline/embeddings.py +83 -0
  31. convo/pipeline/index.py +160 -0
  32. convo/pipeline/ingestion.py +46 -0
  33. convo/pipeline/reranking.py +63 -0
  34. convo/pipeline/routing.py +64 -0
  35. convo/pipeline/sessions.py +40 -0
  36. convo/pipeline/worker.py +226 -0
  37. convo/prompts/__init__.py +0 -0
  38. convo/prompts/answers.py +97 -0
  39. convo/prompts/schema.py +97 -0
  40. convo/ui/__init__.py +1 -0
  41. convo/ui/app.py +752 -0
  42. convo/ui/commands.py +37 -0
  43. convo/ui/files.py +110 -0
  44. convo/ui/models.py +84 -0
  45. convo/ui/preview.py +20 -0
  46. convo/ui/sessions.py +97 -0
  47. convo/ui/theme.tcss +64 -0
  48. convo/ui/thinking.py +70 -0
  49. convo_cli-0.1.0.dist-info/METADATA +160 -0
  50. convo_cli-0.1.0.dist-info/RECORD +53 -0
  51. convo_cli-0.1.0.dist-info/WHEEL +4 -0
  52. convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
  53. convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """A local terminal RAG assistant for chatting with selected files."""
2
+
3
+ __version__ = "0.1.0"
convo/__main__.py ADDED
@@ -0,0 +1,110 @@
1
+ import argparse
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from convo.ui.app import ConvoApp
6
+ from convo.ui.preview import ShellBackend
7
+ from convo.ui.thinking import thinking_key
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(description="Chat with a file in your current directory.")
12
+ parser.add_argument("--directory", type=Path, default=None, help="Browse this folder instead")
13
+ parser.add_argument(
14
+ "--thinking-key",
15
+ type=thinking_key,
16
+ default=None,
17
+ help="Thinking cycle shortcut (default: CONVO_THINKING_KEY or ctrl+t)",
18
+ )
19
+ mode = parser.add_mutually_exclusive_group()
20
+ mode.add_argument(
21
+ "--shell",
22
+ action="store_true",
23
+ help="Run the UI preview without PostgreSQL, embeddings, or ChatGPT",
24
+ )
25
+ mode.add_argument(
26
+ "--download-model",
27
+ action="store_true",
28
+ help="Download and warm Qwen3 and the reranker outside the TUI, then exit",
29
+ )
30
+ mode.add_argument(
31
+ "--setup",
32
+ action="store_true",
33
+ help="Create and migrate Convo's local PostgreSQL/pgvector storage, then exit",
34
+ )
35
+ args = parser.parse_args()
36
+ directory = (args.directory or Path.cwd()).expanduser().resolve()
37
+ if not directory.is_dir():
38
+ parser.error(f"Not a directory: {directory}")
39
+ if args.shell:
40
+ try:
41
+ app = ConvoApp(directory, ShellBackend(), thinking_shortcut=args.thinking_key)
42
+ except ValueError as error:
43
+ parser.error(str(error))
44
+ app.run()
45
+ return
46
+
47
+ try:
48
+ from convo.core.config import load_settings
49
+ from convo.pipeline.chat import RagBackend
50
+ except ImportError as error:
51
+ parser.error(
52
+ f"Backend dependency {error.name} is missing. Install the published command with "
53
+ "`uv tool install --force convo-cli`; from a checkout run `uv run convo`."
54
+ )
55
+
56
+ if args.setup:
57
+ from convo.bootstrap import SetupError, setup
58
+
59
+ try:
60
+ settings_path = setup()
61
+ except (SetupError, ValueError) as error:
62
+ parser.error(str(error))
63
+ print(f"Convo storage is ready. Settings are in {settings_path}. Run `convo` to start.")
64
+ return
65
+
66
+ if args.download_model:
67
+ # Warming models does not require a running database or a container runtime.
68
+ from convo.core.config import Settings, load_environment
69
+ from convo.core.contracts import Hit
70
+ from convo.pipeline.embeddings import Embedder
71
+ from convo.pipeline.reranking import Reranker
72
+
73
+ load_environment()
74
+ settings = Settings(
75
+ "",
76
+ os.getenv("EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-0.6B"),
77
+ reranker_model=os.getenv("RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L6-v2"),
78
+ )
79
+ print(
80
+ "Downloading/loading Qwen3 embeddings. First use can take several minutes.", flush=True
81
+ )
82
+ Embedder(settings.embedding_model).query("warm up")
83
+ print("Downloading/loading the local reranker…", flush=True)
84
+ Reranker(settings.reranker_model).rerank(
85
+ "What is being cached?", [Hit("Model weights are cached locally.", "warmup", 0.0)]
86
+ )
87
+ print("Both models are ready. Run convo and choose a file with /file.")
88
+ return
89
+
90
+ from convo.bootstrap import SetupError, ensure_storage
91
+
92
+ try:
93
+ ensure_storage()
94
+ settings = load_settings()
95
+ except (SetupError, ValueError) as error:
96
+ parser.error(str(error))
97
+
98
+ backend = RagBackend(settings)
99
+ try:
100
+ try:
101
+ app = ConvoApp(directory, backend, thinking_shortcut=args.thinking_key)
102
+ except ValueError as error:
103
+ parser.error(str(error))
104
+ app.run()
105
+ finally:
106
+ backend.close()
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()
convo/bootstrap.py ADDED
@@ -0,0 +1,273 @@
1
+ """Prepare local pgvector storage on startup and keep migrations available in wheels.
2
+
3
+ Convo manages only its labelled container and named volume. Explicit DATABASE_URL
4
+ connections are left alone at startup; ``convo --setup`` migrates them on request.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import secrets
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from importlib.resources import as_file, files
16
+ from pathlib import Path
17
+ from urllib.parse import quote
18
+
19
+ CONTAINER_NAME = "convo-postgres"
20
+ VOLUME_NAME = "convo-postgres-data"
21
+ IMAGE = "pgvector/pgvector:pg16"
22
+ DATABASE_NAME = "convo"
23
+ DATABASE_USER = "convo"
24
+
25
+
26
+ class SetupError(RuntimeError):
27
+ """The machine cannot safely complete the requested local setup."""
28
+
29
+
30
+ def _run(command: list[str], *, allow_failure: bool = False) -> subprocess.CompletedProcess[str]:
31
+ try:
32
+ result = subprocess.run(command, check=False, capture_output=True, text=True)
33
+ except OSError as error:
34
+ raise SetupError(f"Could not run {command[0]!r}: {error}") from error
35
+ if result.returncode and not allow_failure:
36
+ detail = result.stderr.strip() or result.stdout.strip() or "unknown error"
37
+ raise SetupError(f"{' '.join(command[:2])} failed: {detail}")
38
+ return result
39
+
40
+
41
+ def _container_runtime() -> str:
42
+ installed = []
43
+ for candidate in ("docker", "podman"):
44
+ if shutil.which(candidate):
45
+ installed.append(candidate)
46
+ if _run([candidate, "info"], allow_failure=True).returncode == 0:
47
+ return candidate
48
+ if installed:
49
+ if "podman" in installed and sys.platform == "darwin":
50
+ return "podman"
51
+ return installed[0]
52
+ raise SetupError(
53
+ "Convo needs Docker Desktop or Podman to create its local PostgreSQL storage. "
54
+ "Install one, start it, then run `convo` again."
55
+ )
56
+
57
+
58
+ def _ensure_podman_machine(runtime: str) -> None:
59
+ """Start Podman's macOS VM when it exists; leave other platforms untouched."""
60
+ if runtime != "podman" or sys.platform != "darwin":
61
+ return
62
+ if _run([runtime, "info"], allow_failure=True).returncode == 0:
63
+ return
64
+ machines = _run([runtime, "machine", "list", "--format", "{{.Name}}"], allow_failure=True)
65
+ if machines.returncode:
66
+ raise SetupError(
67
+ "Podman is installed but unavailable. Start Podman, then run `convo --setup`."
68
+ )
69
+ if not machines.stdout.strip():
70
+ _run([runtime, "machine", "init"])
71
+ _run([runtime, "machine", "start"])
72
+
73
+
74
+ def _write_local_settings(database_url: str) -> Path:
75
+ from convo.core.config import user_settings_path
76
+
77
+ target = user_settings_path()
78
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
79
+ contents = (
80
+ "# Created by `convo --setup`. Keep this file private.\n"
81
+ f"DATABASE_URL={database_url}\n"
82
+ "CONVO_LOCAL_CONTAINER=1\n"
83
+ "EMBEDDING_MODEL=Qwen/Qwen3-Embedding-0.6B\n"
84
+ "RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L6-v2\n"
85
+ "RERANK_MIN_SCORE=-3\n"
86
+ "RETRIEVAL_CANDIDATES=20\n"
87
+ )
88
+ try:
89
+ descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
90
+ except FileExistsError as error:
91
+ raise SetupError(
92
+ f"Settings already exist at {target}. Configure DATABASE_URL there."
93
+ ) from error
94
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
95
+ output.write(contents)
96
+ return target
97
+
98
+
99
+ def _configured_database_url() -> str | None:
100
+ from convo.core.config import load_environment, load_settings
101
+
102
+ load_environment()
103
+ if not os.getenv("DATABASE_URL"):
104
+ return None
105
+ # Invalid supplied settings must be reported, never replaced by a new database.
106
+ return load_settings().database_url
107
+
108
+
109
+ def _is_managed_database(database_url: str | None) -> bool:
110
+ from dotenv import dotenv_values
111
+
112
+ from convo.core.config import user_settings_path
113
+
114
+ saved = dotenv_values(user_settings_path())
115
+ return bool(
116
+ database_url
117
+ and saved.get("CONVO_LOCAL_CONTAINER") == "1"
118
+ and saved.get("DATABASE_URL") == database_url
119
+ )
120
+
121
+
122
+ def _ensure_container(runtime: str, database_url: str | None) -> str:
123
+ from convo.core.config import user_settings_path
124
+
125
+ existing = _run([runtime, "container", "inspect", CONTAINER_NAME], allow_failure=True)
126
+ if existing.returncode == 0:
127
+ if database_url is None:
128
+ raise SetupError(
129
+ f"{CONTAINER_NAME!r} already exists, but Convo has no saved database URL. "
130
+ "Set DATABASE_URL yourself or remove that unused container manually before retrying."
131
+ )
132
+ label = _run(
133
+ [
134
+ runtime,
135
+ "inspect",
136
+ "--format",
137
+ '{{index .Config.Labels "io.convo.managed"}}',
138
+ CONTAINER_NAME,
139
+ ]
140
+ )
141
+ if label.stdout.strip() != "true":
142
+ raise SetupError(
143
+ f"{CONTAINER_NAME!r} belongs to another setup. Set DATABASE_URL to use it explicitly."
144
+ )
145
+ _run([runtime, "start", CONTAINER_NAME])
146
+ return database_url
147
+
148
+ if database_url is not None:
149
+ raise SetupError(
150
+ f"Saved local storage exists but {CONTAINER_NAME!r} is missing. "
151
+ f"Restore the container using the saved credentials and volume {VOLUME_NAME!r}."
152
+ )
153
+ if _run([runtime, "volume", "inspect", VOLUME_NAME], allow_failure=True).returncode == 0:
154
+ raise SetupError(
155
+ f"Volume {VOLUME_NAME!r} already exists without saved credentials. "
156
+ "Restore its connection settings before starting Convo."
157
+ )
158
+ if user_settings_path().exists():
159
+ raise SetupError(
160
+ f"Settings already exist at {user_settings_path()}. Configure DATABASE_URL there."
161
+ )
162
+ password = secrets.token_urlsafe(24)
163
+ database_url = (
164
+ f"postgresql+psycopg://{DATABASE_USER}:{quote(password, safe='')}"
165
+ f"@localhost:5432/{DATABASE_NAME}"
166
+ )
167
+ _run(
168
+ [
169
+ runtime,
170
+ "run",
171
+ "--name",
172
+ CONTAINER_NAME,
173
+ "--label",
174
+ "io.convo.managed=true",
175
+ "-e",
176
+ f"POSTGRES_USER={DATABASE_USER}",
177
+ "-e",
178
+ f"POSTGRES_PASSWORD={password}",
179
+ "-e",
180
+ f"POSTGRES_DB={DATABASE_NAME}",
181
+ "-p",
182
+ "127.0.0.1:5432:5432",
183
+ "-v",
184
+ f"{VOLUME_NAME}:/var/lib/postgresql/data",
185
+ "-d",
186
+ IMAGE,
187
+ ]
188
+ )
189
+ _write_local_settings(database_url)
190
+ return database_url
191
+
192
+
193
+ def _wait_for_postgres(runtime: str) -> None:
194
+ for _attempt in range(30):
195
+ ready = _run(
196
+ [
197
+ runtime,
198
+ "exec",
199
+ CONTAINER_NAME,
200
+ "pg_isready",
201
+ "-U",
202
+ DATABASE_USER,
203
+ "-d",
204
+ DATABASE_NAME,
205
+ ],
206
+ allow_failure=True,
207
+ )
208
+ if ready.returncode == 0:
209
+ return
210
+ time.sleep(1)
211
+ raise SetupError("PostgreSQL did not become ready within 30 seconds. Check the container logs.")
212
+
213
+
214
+ def _migration_directory() -> Path | None:
215
+ from convo.core.config import PROJECT_ROOT
216
+
217
+ source_directory = PROJECT_ROOT / "migrations"
218
+ if source_directory.is_dir():
219
+ return source_directory
220
+ return None
221
+
222
+
223
+ def _upgrade_database() -> None:
224
+ from alembic import command
225
+ from alembic.config import Config
226
+
227
+ migration_directory = _migration_directory()
228
+ if migration_directory is not None:
229
+ config = Config()
230
+ config.set_main_option("script_location", str(migration_directory))
231
+ command.upgrade(config, "head")
232
+ return
233
+ # Hatch copies the source migration tree into the installed package. Keep
234
+ # `as_file` open while Alembic reads scripts for zip-safe resource access.
235
+ with as_file(files("convo").joinpath("migrations")) as packaged_directory:
236
+ config = Config()
237
+ config.set_main_option("script_location", str(packaged_directory))
238
+ command.upgrade(config, "head")
239
+
240
+
241
+ def setup() -> Path:
242
+ """Provision local storage or migrate an explicitly configured PostgreSQL database."""
243
+ configured_url = _configured_database_url()
244
+ local_container = _is_managed_database(configured_url)
245
+ if configured_url is not None and not local_container:
246
+ # An explicitly supplied DATABASE_URL may point at an existing remote or
247
+ # shared database. Do not create a second local container around it.
248
+ database_url = configured_url
249
+ else:
250
+ print("Preparing Convo's local storage…", flush=True)
251
+ runtime = _container_runtime()
252
+ _ensure_podman_machine(runtime)
253
+ database_url = _ensure_container(runtime, configured_url)
254
+ _wait_for_postgres(runtime)
255
+ # `load_settings` reads the saved file; this assignment also supports an external DATABASE_URL.
256
+ os.environ["DATABASE_URL"] = database_url
257
+ try:
258
+ _upgrade_database()
259
+ except Exception as error: # Alembic has several driver-specific exception classes.
260
+ raise SetupError(
261
+ "Could not prepare the database schema. Check PostgreSQL access and pgvector, "
262
+ "then retry `convo --setup`."
263
+ ) from error
264
+ from convo.core.config import user_settings_path
265
+
266
+ return user_settings_path()
267
+
268
+
269
+ def ensure_storage() -> None:
270
+ """Bootstrap first use and restart managed storage before entering the TUI."""
271
+ database_url = _configured_database_url()
272
+ if database_url is None or _is_managed_database(database_url):
273
+ setup()
convo/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared backend configuration, values, and errors."""
convo/core/config.py ADDED
@@ -0,0 +1,53 @@
1
+ import math
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
9
+ EMBEDDING_DIMENSIONS = 1024
10
+
11
+
12
+ def user_settings_path() -> Path:
13
+ """Return the private per-user settings file used by an installed CLI."""
14
+ config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
15
+ return config_home / "convo" / ".env"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Settings:
20
+ database_url: str = field(repr=False)
21
+ embedding_model: str
22
+ reranker_model: str = "cross-encoder/ms-marco-MiniLM-L6-v2"
23
+ rerank_min_score: float = -3.0
24
+ retrieval_candidates: int = 20
25
+
26
+
27
+ def load_environment() -> None:
28
+ # Environment variables win. The user file makes an installed tool independent
29
+ # of its read-only site-packages directory; the repository file remains useful
30
+ # for contributors running from a checkout.
31
+ load_dotenv(user_settings_path(), override=False)
32
+ load_dotenv(PROJECT_ROOT / ".env", override=False)
33
+
34
+
35
+ def load_settings() -> Settings:
36
+ load_environment()
37
+ database_url = os.environ.get("DATABASE_URL", "")
38
+ if not database_url.startswith("postgresql+psycopg://"):
39
+ raise ValueError("Set DATABASE_URL to a PostgreSQL/Psycopg URL")
40
+ embedding_model = os.getenv("EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-0.6B")
41
+ if embedding_model != "Qwen/Qwen3-Embedding-0.6B":
42
+ raise ValueError("Changing the embedding model requires a dimension/schema review")
43
+ rerank_min_score = float(os.getenv("RERANK_MIN_SCORE", "-3"))
44
+ retrieval_candidates = int(os.getenv("RETRIEVAL_CANDIDATES", "20"))
45
+ if not math.isfinite(rerank_min_score) or not 5 <= retrieval_candidates <= 50:
46
+ raise ValueError("Use a finite RERANK_MIN_SCORE and RETRIEVAL_CANDIDATES between 5 and 50")
47
+ return Settings(
48
+ database_url=database_url,
49
+ embedding_model=embedding_model,
50
+ reranker_model=os.getenv("RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L6-v2"),
51
+ rerank_min_score=rerank_min_score,
52
+ retrieval_candidates=retrieval_candidates,
53
+ )
@@ -0,0 +1,160 @@
1
+ """Shared values and the single boundary between the TUI and backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Literal, Protocol, runtime_checkable
10
+
11
+ ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
12
+ REASONING_EFFORTS: tuple[ReasoningEffort, ...] = (
13
+ "none",
14
+ "minimal",
15
+ "low",
16
+ "medium",
17
+ "high",
18
+ "xhigh",
19
+ "max",
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ChatModel:
25
+ """A displayable model from the connected account's catalog."""
26
+
27
+ id: str
28
+ name: str
29
+ reasoning_efforts: tuple[ReasoningEffort, ...] = ()
30
+ default_reasoning_effort: ReasoningEffort | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class GenerationSelection:
35
+ model: ChatModel | None = None
36
+ reasoning_effort: ReasoningEffort | None = None # None lets the server choose its default.
37
+ connected: bool = False
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Document:
42
+ id: str
43
+ path: Path
44
+ indexed: bool = True
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Message:
49
+ role: Literal["user", "assistant"]
50
+ content: str
51
+ sources: tuple[Source, ...] = ()
52
+ structured: dict[str, object] | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Source:
57
+ label: str # e.g. "notes.md · lines 12–24"; never invent a locator.
58
+ excerpt: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Answer:
63
+ text: str
64
+ sources: tuple[Source, ...] = ()
65
+ structured: dict[str, object] | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ChunkData:
70
+ text: str
71
+ locator: str
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Hit:
76
+ text: str
77
+ locator: str
78
+ distance: float
79
+ chunk_id: int | None = None
80
+ score: float | None = None
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class SessionSummary:
85
+ id: str
86
+ title: str
87
+ filename: str | None
88
+ updated_at: datetime
89
+ message_count: int
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class SessionSnapshot:
94
+ id: str
95
+ title: str
96
+ document: Document | None
97
+ history: tuple[Message, ...] = ()
98
+
99
+
100
+ class Backend(Protocol):
101
+ async def prepare(self, path: Path) -> Document:
102
+ """Parse, chunk, embed and index a file; return only when ready."""
103
+ ...
104
+
105
+ async def answer(
106
+ self, document: Document, question: str, history: tuple[Message, ...]
107
+ ) -> Answer:
108
+ """Retrieve only this document, call ChatGPT, return text and sources.
109
+
110
+ history contains completed turns, excluding the current question.
111
+ """
112
+ ...
113
+
114
+
115
+ @runtime_checkable
116
+ class PreparationControls(Protocol):
117
+ """Optional progress reporting; callbacks run on the UI event loop."""
118
+
119
+ async def prepare_with_progress(
120
+ self, path: Path, progress: Callable[[str], None]
121
+ ) -> Document: ...
122
+
123
+
124
+ @runtime_checkable
125
+ class SessionControls(Protocol):
126
+ """Durable sessions; successful turns are saved before the response returns."""
127
+
128
+ async def new_session(self, document: Document | None = None) -> SessionSnapshot: ...
129
+
130
+ async def list_sessions(self) -> tuple[SessionSummary, ...]: ...
131
+
132
+ async def load_session(self, session_id: str) -> SessionSnapshot: ...
133
+
134
+ async def answer_session(
135
+ self, session_id: str, question: str, progress: Callable[[str], None] | None = None
136
+ ) -> Answer: ...
137
+
138
+
139
+ @runtime_checkable
140
+ class AccountControls(Protocol):
141
+ """Optional ChatGPT sign-in exposed by a completed backend."""
142
+
143
+ async def login_openai(self) -> str:
144
+ """Connect a ChatGPT account through Sign in with ChatGPT."""
145
+ ...
146
+
147
+
148
+ @runtime_checkable
149
+ class GenerationControls(Protocol):
150
+ """Optional account model and thinking controls, independent of the TUI."""
151
+
152
+ def generation_selection(self) -> GenerationSelection: ...
153
+
154
+ async def list_models(self) -> tuple[ChatModel, ...]: ...
155
+
156
+ async def select_model(self, model_id: str) -> GenerationSelection: ...
157
+
158
+ async def cycle_reasoning(self) -> GenerationSelection: ...
159
+
160
+ async def select_reasoning(self, effort: ReasoningEffort | None) -> GenerationSelection: ...
@@ -0,0 +1,5 @@
1
+ """Errors the UI and backend use to communicate expected failure states."""
2
+
3
+
4
+ class BackendNotConnected(RuntimeError):
5
+ """The selected backend operation needs credentials or setup."""
convo/db/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """SQLAlchemy models, connections, and document queries."""
convo/db/base.py ADDED
@@ -0,0 +1,13 @@
1
+ from sqlalchemy import MetaData
2
+ from sqlalchemy.orm import DeclarativeBase
3
+
4
+
5
+ class Base(DeclarativeBase):
6
+ metadata = MetaData(
7
+ naming_convention={
8
+ "ix": "ix_%(column_0_label)s",
9
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
10
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
11
+ "pk": "pk_%(table_name)s",
12
+ }
13
+ )