memex-plugin 0.20.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 (49) hide show
  1. memex/__init__.py +47 -0
  2. memex/__main__.py +3 -0
  3. memex/ask.py +452 -0
  4. memex/auth.py +75 -0
  5. memex/cli.py +1164 -0
  6. memex/config.py +138 -0
  7. memex/credentials.py +122 -0
  8. memex/db_utils.py +192 -0
  9. memex/dreamer.py +793 -0
  10. memex/extract.py +605 -0
  11. memex/llm.py +45 -0
  12. memex/observations.py +810 -0
  13. memex/paths.py +141 -0
  14. memex/py.typed +1 -0
  15. memex/scripts/__init__.py +0 -0
  16. memex/scripts/backfill_has_memo.py +269 -0
  17. memex/scripts/backfill_tokens.py +199 -0
  18. memex/scripts/backfill_topic_tags.py +256 -0
  19. memex/scripts/batch_extract_observations.py +378 -0
  20. memex/scripts/check_tag_canonical.py +280 -0
  21. memex/scripts/crystallization_check.py +1030 -0
  22. memex/scripts/curation_audit.py +330 -0
  23. memex/scripts/date_utils.py +209 -0
  24. memex/scripts/discover_sessions.py +863 -0
  25. memex/scripts/embeddings.py +1932 -0
  26. memex/scripts/extract_observations.py +6 -0
  27. memex/scripts/frontmatter_audit.py +238 -0
  28. memex/scripts/graph_queries.py +705 -0
  29. memex/scripts/hybrid_search.py +1094 -0
  30. memex/scripts/index_rebuild.py +2082 -0
  31. memex/scripts/portable_plugin.py +249 -0
  32. memex/scripts/project_audit.py +272 -0
  33. memex/scripts/reconcile_orphans.py +497 -0
  34. memex/scripts/scrub.py +6 -0
  35. memex/scripts/search.py +561 -0
  36. memex/scripts/similarity_detection.py +262 -0
  37. memex/scripts/sync_auto_memory.py +704 -0
  38. memex/scripts/temporal_scan.py +328 -0
  39. memex/scripts/topic_resolve.py +216 -0
  40. memex/scripts/transcript_to_md.py +1210 -0
  41. memex/scripts/utils.py +887 -0
  42. memex/scripts/wikilink_filters.py +74 -0
  43. memex/scrub.py +444 -0
  44. memex/sidecars.py +1006 -0
  45. memex_plugin-0.20.1.dist-info/METADATA +364 -0
  46. memex_plugin-0.20.1.dist-info/RECORD +49 -0
  47. memex_plugin-0.20.1.dist-info/WHEEL +4 -0
  48. memex_plugin-0.20.1.dist-info/entry_points.txt +2 -0
  49. memex_plugin-0.20.1.dist-info/licenses/LICENSE +21 -0
memex/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Memex - Personal knowledge base for Claude Code sessions."""
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ UNKNOWN_VERSION = "0.0.0+unknown"
6
+
7
+
8
+ def _pyproject_version(pyproject: Path) -> str | None:
9
+ """Version from ``pyproject`` only when it really describes memex.
10
+
11
+ A vendored copy can sit under a host application's ``src/``; its
12
+ pyproject names the host (and may use dynamic versioning with no
13
+ ``version`` key). Neither must be reported as memex's version.
14
+ """
15
+ try:
16
+ with pyproject.open("rb") as f:
17
+ project = tomllib.load(f).get("project", {})
18
+ except (OSError, tomllib.TOMLDecodeError):
19
+ return None
20
+ if project.get("name") not in {"memex", "memex-plugin"}:
21
+ return None
22
+ version = project.get("version")
23
+ return version if isinstance(version, str) and version else None
24
+
25
+
26
+ def _read_version() -> str:
27
+ """Read live source metadata in checkouts, distribution metadata in wheels."""
28
+ source_dir = Path(__file__).resolve().parent.parent
29
+ pyproject = source_dir.parent / "pyproject.toml"
30
+ # Vaults and plugin caches preserve src/memex; prefer the live version
31
+ # there even if an editable install's distribution metadata is stale.
32
+ if source_dir.name == "src" and pyproject.is_file():
33
+ version = _pyproject_version(pyproject)
34
+ if version:
35
+ return version
36
+
37
+ # Installed wheels do not contain the source tree's pyproject.toml.
38
+ from importlib.metadata import PackageNotFoundError, version
39
+
40
+ try:
41
+ return version("memex-plugin")
42
+ except PackageNotFoundError:
43
+ # Vendored or copied without metadata: importing must still succeed.
44
+ return _pyproject_version(pyproject) or UNKNOWN_VERSION
45
+
46
+
47
+ __version__ = _read_version()
memex/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Allow `python -m memex` to run the CLI."""
2
+ from memex.cli import main
3
+ main()
memex/ask.py ADDED
@@ -0,0 +1,452 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import sqlite3
6
+ import sys
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from memex.db_utils import connect_index
12
+ from memex.paths import get_index_path
13
+
14
+ from memex.observations import (
15
+ fetch_observations,
16
+ init_observation_schema,
17
+ search_observations_fts,
18
+ vector_search_observations,
19
+ )
20
+ from memex.scripts.wikilink_filters import strip_code_spans
21
+
22
+ STOP_WORDS = {
23
+ "why", "what", "how", "when", "where", "who", "which",
24
+ "did", "do", "does", "is", "was", "were", "are", "been",
25
+ "we", "you", "they", "our", "the", "a", "an",
26
+ "about", "with", "from", "for", "of", "in", "on", "to", "at",
27
+ "this", "that", "these", "those",
28
+ }
29
+
30
+ _TOKENIZER = None
31
+
32
+
33
+ def _get_tokenizer():
34
+ global _TOKENIZER
35
+ if _TOKENIZER is None:
36
+ import tiktoken
37
+ _TOKENIZER = tiktoken.get_encoding("cl100k_base")
38
+ return _TOKENIZER
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class AskResult:
43
+ path: str
44
+ title: str
45
+ project: str
46
+ date: str
47
+ score: float
48
+ match_type: str
49
+ content: str
50
+ outgoing_links: list[str] = field(default_factory=list)
51
+ backlink_count: int = 0
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class ObservationResult:
56
+ content: str
57
+ obs_type: str
58
+ source_memo: str
59
+ confidence: str
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class AskResponse:
64
+ results: list[AskResult]
65
+ observations: list[ObservationResult]
66
+ query_info: dict[str, Any]
67
+
68
+
69
+ def extract_keywords(question: str) -> str:
70
+ # Alphanumeric runs cannot introduce FTS operators or punctuation. Keep
71
+ # short acronyms, and sanitize the fallback for queries such as "C++?".
72
+ words = re.findall(r"[^\W_]+", question.lower())
73
+ keywords = [word for word in words if word not in STOP_WORDS and len(word) >= 2]
74
+ return " OR ".join((keywords or words)[:6])
75
+
76
+
77
+ def ask(
78
+ question: str,
79
+ index_path: Path,
80
+ vault_path: Path,
81
+ project: str | None = None,
82
+ depth: str = "quick",
83
+ max_content_tokens: int = 800,
84
+ ) -> AskResponse:
85
+ limit = 10 if depth == "quick" else 20
86
+ return_count = 5 if depth == "quick" else 10
87
+ fts_query = extract_keywords(question)
88
+ vector_query = question
89
+ if not fts_query:
90
+ return AskResponse(
91
+ results=[],
92
+ observations=[],
93
+ query_info={
94
+ "fts_query": "",
95
+ "vector_query": vector_query,
96
+ "total_candidates": 0,
97
+ "results_returned": 0,
98
+ "gaps": _infer_gaps([], []),
99
+ },
100
+ )
101
+
102
+ # Pre-compute query embedding once for thorough mode (used by both doc + obs vector search)
103
+ query_embedding = None
104
+ if depth == "thorough":
105
+ pipeline = _build_pipeline()
106
+ if pipeline is not None and getattr(pipeline, "enabled", False):
107
+ query_embedding = pipeline.embed_query(question)
108
+
109
+ conn = connect_index(index_path)
110
+ try:
111
+ init_observation_schema(conn)
112
+ fts_rows = _fts_candidates(conn, fts_query, project=project, limit=limit)
113
+ if depth == "thorough" and query_embedding:
114
+ vector_rows = _vector_candidates(conn, query_embedding, project=project, limit=limit)
115
+ merged = _merge_candidates(fts_rows, vector_rows)
116
+ else:
117
+ merged = fts_rows
118
+ top_results = merged[:return_count]
119
+
120
+ results: list[AskResult] = []
121
+ for row in top_results:
122
+ rel_path = str(row["path"])
123
+ content = _read_and_truncate(vault_path, rel_path, max_content_tokens)
124
+ results.append(
125
+ AskResult(
126
+ path=rel_path,
127
+ title=str(row["title"]),
128
+ project=str(row["project"] or ""),
129
+ date=str(row["date"] or ""),
130
+ score=float(row["score"]),
131
+ match_type=str(row["match_type"]),
132
+ content=content,
133
+ outgoing_links=_extract_wikilinks(content),
134
+ backlink_count=_get_backlink_count(conn, rel_path),
135
+ )
136
+ )
137
+
138
+ observations = _observation_results(
139
+ conn,
140
+ fts_query=fts_query,
141
+ query_embedding=query_embedding,
142
+ project=project,
143
+ )
144
+
145
+ query_info = {
146
+ "fts_query": fts_query,
147
+ "vector_query": vector_query,
148
+ "total_candidates": len(merged),
149
+ "results_returned": len(results),
150
+ "gaps": _infer_gaps(results, observations),
151
+ }
152
+ return AskResponse(results=results, observations=observations, query_info=query_info)
153
+ finally:
154
+ conn.close()
155
+
156
+
157
+ def _fts_candidates(
158
+ conn: sqlite3.Connection,
159
+ query: str,
160
+ *,
161
+ project: str | None,
162
+ limit: int,
163
+ ) -> list[dict[str, Any]]:
164
+ sql = """
165
+ SELECT path, title, project, date, bm25(fts_content) AS score
166
+ FROM fts_content
167
+ WHERE fts_content MATCH ?
168
+ """
169
+ params: list[object] = [query]
170
+ if project:
171
+ sql += " AND project = ?"
172
+ params.append(project)
173
+ sql += " ORDER BY score LIMIT ?"
174
+ params.append(limit)
175
+ rows = conn.execute(sql, params).fetchall()
176
+ results: list[dict[str, Any]] = []
177
+ for rank, row in enumerate(rows, start=1):
178
+ results.append(
179
+ {
180
+ "path": row[0],
181
+ "title": row[1],
182
+ "project": row[2],
183
+ "date": row[3],
184
+ "score": float(row[4]),
185
+ "rank": rank,
186
+ "match_type": "fts",
187
+ "source": "fts",
188
+ }
189
+ )
190
+ return results
191
+
192
+
193
+ def _vector_candidates(
194
+ conn: sqlite3.Connection,
195
+ query_embedding,
196
+ *,
197
+ project: str | None,
198
+ limit: int,
199
+ ) -> list[dict[str, Any]]:
200
+ # Align the native-dim query vector to vec_chunks' stored dimension so
201
+ # search still works during a config-flip-before-migrate window (and at
202
+ # any index_dimensions setting). Lazy import avoids a circular dependency.
203
+ from memex.scripts.embeddings import match_query_dim
204
+ query_embedding = match_query_dim(conn, "vec_chunks", query_embedding)
205
+
206
+ # Run the KNN FIRST (vec_chunks → chunks by rowid only), then enrich with
207
+ # fts_content metadata in a single batched lookup. Joining fts_content
208
+ # INSIDE the KNN query makes SQLite scan fts_content as the OUTER loop and
209
+ # re-run the vec match once per doc — O(docs × KNN), which hangs for minutes
210
+ # on a real-size index. fts_content has no path index (FTS5), so it must
211
+ # never drive the join. (Mirrors hybrid_search.vector_search.)
212
+ knn_sql = (
213
+ "SELECT c.doc_path, v.distance "
214
+ "FROM vec_chunks v JOIN chunks c ON c.id = v.rowid "
215
+ "WHERE v.embedding MATCH ? AND k = ?"
216
+ )
217
+ knn: list | None = None
218
+ if project:
219
+ # Push the project filter into the KNN via the v0.15.0 metadata column.
220
+ try:
221
+ knn = conn.execute(
222
+ knn_sql + " AND v.doc_project = ?",
223
+ [query_embedding, limit, project],
224
+ ).fetchall()
225
+ except sqlite3.OperationalError:
226
+ knn = None # pre-metadata index → bare KNN + post-filter below
227
+ if knn is None:
228
+ try:
229
+ knn = conn.execute(knn_sql, [query_embedding, limit]).fetchall()
230
+ except sqlite3.OperationalError:
231
+ return []
232
+ if not knn:
233
+ return []
234
+
235
+ # Best (smallest) distance per doc.
236
+ dist_by_path: dict[str, float] = {}
237
+ for doc_path, distance in knn:
238
+ if doc_path not in dist_by_path or distance < dist_by_path[doc_path]:
239
+ dist_by_path[doc_path] = distance
240
+
241
+ placeholders = ",".join("?" for _ in dist_by_path)
242
+ meta_rows = conn.execute(
243
+ f"SELECT path, title, project, date FROM fts_content WHERE path IN ({placeholders})",
244
+ list(dist_by_path.keys()),
245
+ ).fetchall()
246
+
247
+ results: list[dict[str, Any]] = []
248
+ for path, title, proj, date in meta_rows:
249
+ if project and proj != project:
250
+ continue # safety net for the bare-KNN fallback path
251
+ results.append({
252
+ "path": path,
253
+ "title": title,
254
+ "project": proj,
255
+ "date": date,
256
+ "score": max(0.0, 1.0 - float(dist_by_path[path])),
257
+ })
258
+ # RRF must preserve nearest-neighbor order even when the displayed score
259
+ # clamps multiple L2 distances greater than or equal to 1 to zero.
260
+ results.sort(key=lambda item: dist_by_path[item["path"]])
261
+ return [
262
+ {**row, "rank": rank, "source": "vector"}
263
+ for rank, row in enumerate(results[:limit], start=1)
264
+ ]
265
+
266
+
267
+ def _merge_candidates(
268
+ fts_rows: list[dict[str, Any]],
269
+ vector_rows: list[dict[str, Any]],
270
+ ) -> list[dict[str, Any]]:
271
+ scores: dict[str, dict[str, Any]] = {}
272
+
273
+ for row in fts_rows:
274
+ entry = scores.setdefault(
275
+ row["path"],
276
+ {
277
+ "path": row["path"],
278
+ "title": row["title"],
279
+ "project": row["project"],
280
+ "date": row["date"],
281
+ "score": 0.0,
282
+ "sources": set(),
283
+ },
284
+ )
285
+ entry["score"] += 1.0 / (60 + row["rank"])
286
+ entry["sources"].add("fts")
287
+
288
+ for row in vector_rows:
289
+ entry = scores.setdefault(
290
+ row["path"],
291
+ {
292
+ "path": row["path"],
293
+ "title": row["title"],
294
+ "project": row["project"],
295
+ "date": row["date"],
296
+ "score": 0.0,
297
+ "sources": set(),
298
+ },
299
+ )
300
+ entry["score"] += 1.0 / (60 + row["rank"])
301
+ entry["sources"].add("vector")
302
+
303
+ merged = []
304
+ for entry in scores.values():
305
+ sources = entry.pop("sources")
306
+ if sources == {"fts", "vector"}:
307
+ entry["match_type"] = "hybrid"
308
+ elif sources == {"vector"}:
309
+ entry["match_type"] = "vector"
310
+ else:
311
+ entry["match_type"] = "fts"
312
+ merged.append(entry)
313
+ merged.sort(key=lambda item: item["score"], reverse=True)
314
+ return merged
315
+
316
+
317
+ def _observation_results(
318
+ conn: sqlite3.Connection,
319
+ *,
320
+ fts_query: str,
321
+ query_embedding=None,
322
+ project: str | None,
323
+ ) -> list[ObservationResult]:
324
+ fts_matches = search_observations_fts(conn, fts_query, project=project, limit=10)
325
+ ranked: dict[int, tuple[float, ObservationResult]] = {}
326
+
327
+ for rank, match in enumerate(fts_matches, start=1):
328
+ ranked[match.id] = (
329
+ 1.0 / (60 + rank),
330
+ ObservationResult(
331
+ content=match.content,
332
+ obs_type=match.obs_type,
333
+ source_memo=match.doc_path,
334
+ confidence=match.confidence,
335
+ ),
336
+ )
337
+
338
+ if query_embedding is not None:
339
+ for rank, (match, distance) in enumerate(
340
+ vector_search_observations(conn, query_embedding, project=project, limit=10),
341
+ start=1,
342
+ ):
343
+ score = 1.0 / (60 + rank) + max(0.0, 1.0 - float(distance))
344
+ existing = ranked.get(match.id)
345
+ result = ObservationResult(
346
+ content=match.content,
347
+ obs_type=match.obs_type,
348
+ source_memo=match.doc_path,
349
+ confidence=match.confidence,
350
+ )
351
+ if existing is None or score > existing[0]:
352
+ ranked[match.id] = (score, result)
353
+
354
+ ordered = [item[1] for item in sorted(ranked.values(), key=lambda value: value[0], reverse=True)]
355
+ return ordered[:10]
356
+
357
+
358
+ def _read_and_truncate(vault_path: Path, rel_path: str, max_tokens: int) -> str:
359
+ try:
360
+ content = (vault_path / rel_path).read_text()
361
+ except (FileNotFoundError, OSError):
362
+ return "[File not found]"
363
+ if content.startswith("---"):
364
+ end = content.find("\n---", 3)
365
+ if end != -1:
366
+ content = content[end + 4 :].strip()
367
+ tokens = _get_tokenizer().encode(content)
368
+ if len(tokens) <= max_tokens:
369
+ return content
370
+ return _get_tokenizer().decode(tokens[:max_tokens]) + "\n\n[Truncated]"
371
+
372
+
373
+ def _extract_wikilinks(content: str) -> list[str]:
374
+ # Strip code spans so fenced examples / inline code don't seed graph-hop
375
+ # expansion with phantom links (shared with the indexer + crystallization
376
+ # checker via wikilink_filters).
377
+ return re.findall(r"\[\[([^\]]+)\]\]", strip_code_spans(content))
378
+
379
+
380
+ def _get_backlink_count(conn: sqlite3.Connection, path: str) -> int:
381
+ try:
382
+ row = conn.execute(
383
+ "SELECT count(*) FROM wikilinks WHERE target_path = ?",
384
+ (path,),
385
+ ).fetchone()
386
+ return int(row[0]) if row else 0
387
+ except sqlite3.OperationalError:
388
+ return 0
389
+
390
+
391
+ def _infer_gaps(
392
+ results: list[AskResult],
393
+ observations: list[ObservationResult],
394
+ ) -> list[str]:
395
+ gaps: list[str] = []
396
+ if not results:
397
+ gaps.append("No matching memos or notes were found.")
398
+ if results and not observations:
399
+ gaps.append("Relevant documents were found, but no atomic observations matched directly.")
400
+ return gaps
401
+
402
+
403
+ def _build_pipeline():
404
+ try:
405
+ from memex.scripts.embeddings import EmbeddingPipeline
406
+
407
+ pipeline = EmbeddingPipeline()
408
+ if pipeline.enabled:
409
+ return pipeline
410
+ except Exception:
411
+ return None
412
+ return None
413
+
414
+
415
+ def response_to_dict(response: AskResponse) -> dict[str, Any]:
416
+ return {
417
+ "results": [asdict(item) for item in response.results],
418
+ "observations": [asdict(item) for item in response.observations],
419
+ "query_info": response.query_info,
420
+ }
421
+
422
+
423
+ def main() -> None:
424
+ import argparse
425
+
426
+ parser = argparse.ArgumentParser(description="Ask a deep question of the memex index")
427
+ parser.add_argument("question")
428
+ parser.add_argument("--index", type=Path, help="Path to index (auto-detected)")
429
+ parser.add_argument("--vault", type=Path, help="Path to vault (auto-detected)")
430
+ parser.add_argument("--project")
431
+ parser.add_argument("--depth", choices=["quick", "thorough"], default="quick")
432
+ args = parser.parse_args()
433
+
434
+ # Auto-detect paths if not provided
435
+ if args.vault is None:
436
+ from memex.paths import get_memex_path
437
+ args.vault = get_memex_path()
438
+ if args.index is None:
439
+ args.index = get_index_path(args.vault)
440
+
441
+ response = ask(
442
+ question=args.question,
443
+ index_path=args.index,
444
+ vault_path=args.vault,
445
+ project=args.project,
446
+ depth=args.depth,
447
+ )
448
+ print(json.dumps(response_to_dict(response), indent=2))
449
+
450
+
451
+ if __name__ == "__main__":
452
+ main()
memex/auth.py ADDED
@@ -0,0 +1,75 @@
1
+ """Local credential setup commands; no authentication or API calls on their own."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+
9
+
10
+ app = typer.Typer(help="Set up Gemini credentials for this installation.", no_args_is_help=True)
11
+
12
+
13
+ @app.command("set-key")
14
+ def set_key(
15
+ from_env: bool = typer.Option(False, "--from-env", help="Save the current environment key instead of prompting."),
16
+ ) -> None:
17
+ """Save a Gemini key locally for automatic loading on future runs."""
18
+ from memex.config import get_settings
19
+ from memex.credentials import gemini_key_from_env, gemini_key_path, save_gemini_key
20
+
21
+ typer.echo(f"Stores an unencrypted key in {gemini_key_path()} (owner-only permissions).")
22
+ try:
23
+ if from_env:
24
+ credential = gemini_key_from_env(get_settings().embeddings.api_key_env)
25
+ if credential is None:
26
+ raise ValueError("No Gemini key found in the environment; nothing was saved.")
27
+ value = credential.value
28
+ elif sys.stdin.isatty():
29
+ value = typer.prompt("Gemini API key", hide_input=True)
30
+ else:
31
+ # Piped input: read one line directly. getpass's non-TTY fallback
32
+ # would print a "may be echoed" warning and a traceback fragment.
33
+ value = sys.stdin.readline()
34
+ if not value:
35
+ raise ValueError("No key provided on stdin; nothing was saved.")
36
+ save_gemini_key(value)
37
+ except (ValueError, OSError) as exc:
38
+ typer.echo(f"Error: {exc}", err=True)
39
+ raise typer.Exit(1) from exc
40
+ typer.echo("Saved. Memex will load this key automatically when no environment key is set.")
41
+ typer.echo("The key has not been sent to Gemini or validated with the API.")
42
+
43
+
44
+ @app.command("status")
45
+ def status() -> None:
46
+ """Show the credential source without printing keys or contacting Gemini."""
47
+ from memex.config import get_settings
48
+ from memex.credentials import missing_gemini_key_help, resolve_gemini_key
49
+
50
+ settings = get_settings()
51
+ typer.echo(f"Embedding provider: {settings.embeddings.provider}")
52
+ typer.echo(f"Embeddings enabled: {settings.embeddings.enabled}")
53
+ try:
54
+ credential = resolve_gemini_key(settings.embeddings.api_key_env)
55
+ except ValueError as exc:
56
+ typer.echo(f"Error: {exc}", err=True)
57
+ raise typer.Exit(1) from exc
58
+ if credential is None:
59
+ typer.echo(missing_gemini_key_help(settings.embeddings.api_key_env))
60
+ raise typer.Exit(1)
61
+ typer.echo(f"Gemini credential source: {credential.source}")
62
+ typer.echo("Local configuration only; API validity has not been checked.")
63
+
64
+
65
+ @app.command("clear-key")
66
+ def clear_key() -> None:
67
+ """Remove the locally saved key. Environment variables still take precedence."""
68
+ from memex.credentials import clear_gemini_key
69
+
70
+ try:
71
+ removed = clear_gemini_key()
72
+ except (OSError, ValueError) as exc:
73
+ typer.echo(f"Error: {exc}", err=True)
74
+ raise typer.Exit(1) from exc
75
+ typer.echo("Removed the saved Gemini key." if removed else "No saved Gemini key to remove.")