longmem 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.
longmem/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """longmem-cursor: persistent memory MCP server for Cursor IDE."""
2
+
3
+ __version__ = "0.1.0"
longmem/cli.py ADDED
@@ -0,0 +1,544 @@
1
+ """CLI entry point for longmem.
2
+
3
+ Sub-commands:
4
+ (none) — Start the MCP server over stdio (used by Cursor / Claude Code)
5
+ init — Interactive setup wizard
6
+ install — Copy rules files into the current project
7
+ status — Show config, Ollama reachability, and DB stats
8
+ export [file] — Export all entries to a JSON file
9
+ import <file> — Import entries from a JSON export
10
+ review — Manually save a solution when the AI forgot
11
+
12
+ Usage:
13
+ longmem # start MCP server
14
+ longmem init # one-time machine setup
15
+ longmem install # per-project rules setup
16
+ longmem status # health check
17
+ longmem export # backup / share
18
+ longmem import <f> # restore / onboard
19
+ longmem review # manual save fallback
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import asyncio
26
+ import json
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+
33
+ # ── constants ──────────────────────────────────────────────────────────────────
34
+
35
+ _SERVER_NAME = "longmem"
36
+ _MCP_ENTRY = {"command": "longmem", "args": []}
37
+
38
+ _DEFAULT_CONFIG_TOML = """\
39
+ # ~/.longmem/config.toml
40
+ # All values are optional — defaults work with a local Ollama instance.
41
+
42
+ # embedder = "ollama" # "ollama" (default, free, local) or "openai"
43
+ # ollama_url = "http://localhost:11434"
44
+ # ollama_model = "nomic-embed-text"
45
+ # openai_model = "text-embedding-3-small"
46
+ # openai_api_key = "sk-..." # or export OPENAI_API_KEY
47
+ # similarity_threshold = 0.85 # minimum score to surface a cached solution
48
+ # duplicate_threshold = 0.95 # minimum score to block a save as a near-duplicate
49
+
50
+ # ── storage ────────────────────────────────────────────────────────────────────
51
+ # Local path (default):
52
+ # db_path = "/custom/path/db" # default: ~/.longmem/db
53
+
54
+ # Remote / cloud storage (overrides db_path when set):
55
+ # db_uri = "s3://my-bucket/longmem" # S3 — uses AWS env vars for auth
56
+ # db_uri = "gs://my-bucket/longmem" # Google Cloud Storage
57
+ # db_uri = "az://my-container/longmem" # Azure Blob Storage
58
+ # db_uri = "db://my-org/my-db" # LanceDB Cloud
59
+ # lancedb_api_key = "ldb_..." # LanceDB Cloud only (or export LANCEDB_API_KEY)
60
+ """
61
+
62
+ # ── helpers ────────────────────────────────────────────────────────────────────
63
+
64
+ def _ok(msg: str) -> None:
65
+ print(f" [ok] {msg}")
66
+
67
+ def _warn(msg: str) -> None:
68
+ print(f" [!] {msg}")
69
+
70
+ def _info(msg: str) -> None:
71
+ print(f" {msg}")
72
+
73
+
74
+ def _write_mcp_config(path: Path, server_name: str, entry: dict) -> None:
75
+ """Merge our MCP server entry into an existing config JSON, or create it."""
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ existing: dict = {}
78
+ if path.exists():
79
+ try:
80
+ existing = json.loads(path.read_text(encoding="utf-8"))
81
+ except (json.JSONDecodeError, OSError):
82
+ pass
83
+ existing.setdefault("mcpServers", {})
84
+ existing["mcpServers"][server_name] = entry
85
+ path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
86
+
87
+
88
+ def _templates_dir() -> Path:
89
+ return Path(__file__).parent / "templates"
90
+
91
+
92
+ def _dir_size_mb(path: Path) -> float:
93
+ if not path.exists():
94
+ return 0.0
95
+ total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
96
+ return round(total / (1024 * 1024), 2)
97
+
98
+
99
+ # ── init ───────────────────────────────────────────────────────────────────────
100
+
101
+ def cmd_init() -> int:
102
+ """Interactive one-time setup wizard."""
103
+ print()
104
+ print("longmem setup")
105
+ print("=" * 50)
106
+ print()
107
+
108
+ # 1. Check Ollama
109
+ ollama_ok = _init_check_ollama()
110
+
111
+ # 2. Pull embedding model if needed
112
+ if ollama_ok:
113
+ _init_ensure_model()
114
+
115
+ # 3. Choose IDE(s) to configure
116
+ print()
117
+ print("Which IDE(s) would you like to configure?")
118
+ print(" 1) Cursor")
119
+ print(" 2) Claude Code")
120
+ print(" 3) Both")
121
+ choice = input("Choice [1/2/3, default 1]: ").strip() or "1"
122
+ print()
123
+
124
+ cfg_cursor = choice in ("1", "3")
125
+ cfg_claude = choice in ("2", "3")
126
+
127
+ if cfg_cursor:
128
+ path = Path.home() / ".cursor" / "mcp.json"
129
+ _write_mcp_config(path, _SERVER_NAME, _MCP_ENTRY)
130
+ _ok(f"Cursor config → {path}")
131
+
132
+ if cfg_claude:
133
+ path = Path.home() / ".claude" / "mcp.json"
134
+ _write_mcp_config(path, _SERVER_NAME, _MCP_ENTRY)
135
+ _ok(f"Claude Code → {path}")
136
+
137
+ if not cfg_cursor and not cfg_claude:
138
+ _warn("Invalid choice — re-run init to configure.")
139
+
140
+ # 4. Default config.toml
141
+ config_file = Path.home() / ".longmem" / "config.toml"
142
+ if config_file.exists():
143
+ _info(f"Config already exists at {config_file} — not overwritten")
144
+ else:
145
+ config_file.parent.mkdir(parents=True, exist_ok=True)
146
+ config_file.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8")
147
+ _ok(f"Config written → {config_file}")
148
+
149
+ print()
150
+ print("Setup complete.")
151
+ print()
152
+ print("Next — run this in each project to activate the memory rules:")
153
+ print(" longmem install")
154
+ print()
155
+ print("Then restart Cursor / Claude Code to load the MCP server.")
156
+ print()
157
+ return 0
158
+
159
+
160
+ def _init_check_ollama() -> bool:
161
+ try:
162
+ import httpx # already a dependency
163
+ r = httpx.get("http://localhost:11434/api/tags", timeout=5)
164
+ r.raise_for_status()
165
+ _ok("Ollama is running at http://localhost:11434")
166
+ return True
167
+ except Exception:
168
+ _warn("Ollama not found at http://localhost:11434")
169
+ _info("Install from https://ollama.com, then run: ollama pull nomic-embed-text")
170
+ return False
171
+
172
+
173
+ def _init_ensure_model() -> None:
174
+ try:
175
+ import httpx
176
+ r = httpx.get("http://localhost:11434/api/tags", timeout=5)
177
+ models = [m.get("name", "") for m in r.json().get("models", [])]
178
+ if any("nomic-embed-text" in m for m in models):
179
+ _ok("nomic-embed-text is already installed")
180
+ return
181
+ except Exception:
182
+ pass
183
+
184
+ print()
185
+ _info("Pulling nomic-embed-text (required for local embeddings, ~270 MB) ...")
186
+ if shutil.which("ollama"):
187
+ result = subprocess.run(["ollama", "pull", "nomic-embed-text"])
188
+ if result.returncode == 0:
189
+ _ok("nomic-embed-text pulled successfully")
190
+ else:
191
+ _warn("Pull failed — run manually: ollama pull nomic-embed-text")
192
+ else:
193
+ _warn("'ollama' not found in PATH — run manually: ollama pull nomic-embed-text")
194
+
195
+
196
+ # ── install ────────────────────────────────────────────────────────────────────
197
+
198
+ def cmd_install() -> int:
199
+ """Copy rules files into the current project directory."""
200
+ cwd = Path.cwd()
201
+ templates = _templates_dir()
202
+ print()
203
+
204
+ # Cursor rules
205
+ dest_mdc = cwd / ".cursor" / "rules" / "longmem.mdc"
206
+ dest_mdc.parent.mkdir(parents=True, exist_ok=True)
207
+ shutil.copy2(templates / "longmem.mdc", dest_mdc)
208
+ _ok(f"Cursor rules → {dest_mdc}")
209
+
210
+ # Claude Code rules
211
+ dest_claude = cwd / "CLAUDE.md"
212
+ claude_template = (templates / "CLAUDE.md").read_text(encoding="utf-8")
213
+ if dest_claude.exists():
214
+ print()
215
+ _warn(f"CLAUDE.md already exists at {dest_claude}")
216
+ _info("Append the following block to it for Claude Code memory support:")
217
+ print()
218
+ for line in claude_template.splitlines():
219
+ print(f" {line}")
220
+ print()
221
+ else:
222
+ dest_claude.write_text(claude_template, encoding="utf-8")
223
+ _ok(f"Claude Code → {dest_claude}")
224
+
225
+ print()
226
+ print("Done. Restart Cursor / Claude Code to activate.")
227
+ print()
228
+ return 0
229
+
230
+
231
+ # ── status ─────────────────────────────────────────────────────────────────────
232
+
233
+ def cmd_status() -> int:
234
+ """Show config, Ollama reachability, and DB statistics."""
235
+ from .config import CONFIG_FILE, load_config
236
+
237
+ print()
238
+ print("longmem status")
239
+ print("=" * 50)
240
+ print()
241
+
242
+ # Config
243
+ if CONFIG_FILE.exists():
244
+ _ok(f"Config {CONFIG_FILE}")
245
+ else:
246
+ _info(f"No config file at {CONFIG_FILE} — using defaults")
247
+
248
+ try:
249
+ cfg = load_config()
250
+ except Exception as exc:
251
+ _warn(f"Config error: {exc}")
252
+ return 1
253
+
254
+ print(f" Embedder : {cfg.embedder}")
255
+ if cfg.embedder == "ollama":
256
+ print(f" Model : {cfg.ollama_model}")
257
+ print(f" Ollama : {cfg.ollama_url}")
258
+ else:
259
+ print(f" Model : {cfg.openai_model}")
260
+ print(f" Threshold: {cfg.similarity_threshold}")
261
+ if cfg.is_remote:
262
+ print(f" DB URI : {cfg.db_uri} (remote)")
263
+ else:
264
+ print(f" DB path : {cfg.db_path}")
265
+ print()
266
+
267
+ # Ollama
268
+ _check_ollama_status(cfg)
269
+ print()
270
+
271
+ # DB stats
272
+ asyncio.run(_status_db(cfg))
273
+ print()
274
+ return 0
275
+
276
+
277
+ def _check_ollama_status(cfg) -> None:
278
+ if cfg.embedder != "ollama":
279
+ return
280
+ try:
281
+ import httpx
282
+ r = httpx.get(f"{cfg.ollama_url}/api/tags", timeout=5)
283
+ r.raise_for_status()
284
+ models = [m.get("name", "") for m in r.json().get("models", [])]
285
+ has_model = any(cfg.ollama_model in m for m in models)
286
+ _ok(f"Ollama running — {'model found' if has_model else 'WARNING: ' + cfg.ollama_model + ' not installed'}")
287
+ except Exception:
288
+ _warn(f"Ollama not reachable at {cfg.ollama_url}")
289
+
290
+
291
+ async def _status_db(cfg) -> None:
292
+ from .store import SolutionStore
293
+ try:
294
+ store = await SolutionStore.open(cfg)
295
+ stats_data = await store.get_stats()
296
+
297
+ print(f" Total entries : {stats_data['total']}")
298
+ if not cfg.is_remote:
299
+ size_mb = _dir_size_mb(cfg.db_path)
300
+ print(f" DB size : {size_mb} MB")
301
+ if stats_data["oldest_entry"]:
302
+ print(f" Oldest entry : {stats_data['oldest_entry'][:10]}")
303
+ if stats_data["newest_entry"]:
304
+ print(f" Newest entry : {stats_data['newest_entry'][:10]}")
305
+ if stats_data["total"] > 0:
306
+ print()
307
+ print(" By category:")
308
+ for cat, count in list(stats_data["by_category"].items())[:10]:
309
+ print(f" {cat:<22} {count}")
310
+ if stats_data["total"] >= 256:
311
+ print()
312
+ _info("256+ entries — consider running: longmem rebuild-index")
313
+ except Exception as exc:
314
+ _warn(f"Could not read DB: {exc}")
315
+
316
+
317
+ # ── export ─────────────────────────────────────────────────────────────────────
318
+
319
+ def cmd_export(output_path: str | None) -> int:
320
+ """Export all entries to a JSON file."""
321
+ from .config import load_config
322
+
323
+ try:
324
+ cfg = load_config()
325
+ except Exception as exc:
326
+ print(f"Config error: {exc}")
327
+ return 1
328
+
329
+ entries = asyncio.run(_export_entries(cfg))
330
+
331
+ if output_path is None:
332
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
333
+ output_path = f"longmem_export_{ts}.json"
334
+
335
+ out = Path(output_path)
336
+ payload = {
337
+ "version": "1",
338
+ "exported_at": datetime.now(timezone.utc).isoformat(),
339
+ "entry_count": len(entries),
340
+ "entries": entries,
341
+ }
342
+ out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
343
+ print(f"Exported {len(entries)} entries to {out}")
344
+ return 0
345
+
346
+
347
+ async def _export_entries(cfg) -> list[dict]:
348
+ from .store import SolutionStore
349
+ store = await SolutionStore.open(cfg)
350
+ return await store.export_all()
351
+
352
+
353
+ # ── import ─────────────────────────────────────────────────────────────────────
354
+
355
+ def cmd_import(input_path: str) -> int:
356
+ """Import entries from a JSON export file."""
357
+ from .config import load_config
358
+
359
+ src = Path(input_path)
360
+ if not src.exists():
361
+ print(f"File not found: {src}")
362
+ return 1
363
+
364
+ try:
365
+ payload = json.loads(src.read_text(encoding="utf-8"))
366
+ except (json.JSONDecodeError, OSError) as exc:
367
+ print(f"Could not read {src}: {exc}")
368
+ return 1
369
+
370
+ entries = payload.get("entries", [])
371
+ if not entries:
372
+ print("No entries found in export file.")
373
+ return 0
374
+
375
+ try:
376
+ cfg = load_config()
377
+ except Exception as exc:
378
+ print(f"Config error: {exc}")
379
+ return 1
380
+
381
+ added, skipped = asyncio.run(_import_entries(cfg, entries))
382
+ print(f"Import complete: {added} added, {skipped} skipped (already existed or invalid vector).")
383
+ return 0
384
+
385
+
386
+ async def _import_entries(cfg, entries: list[dict]) -> tuple[int, int]:
387
+ from .store import SolutionStore
388
+ store = await SolutionStore.open(cfg)
389
+ return await store.import_entries(entries)
390
+
391
+
392
+ # ── review ─────────────────────────────────────────────────────────────────────
393
+
394
+ def cmd_review() -> int:
395
+ """Manually save a solution — use this when the AI forgot to call confirm_solution."""
396
+ from .store import CATEGORIES
397
+
398
+ print()
399
+ print("longmem review — save a solution manually")
400
+ print("=" * 50)
401
+ print("Use this when the AI forgot to save after solving a problem.")
402
+ print()
403
+
404
+ problem = input("Problem (what was broken or needed): ").strip()
405
+ if not problem:
406
+ _warn("Nothing entered — cancelled.")
407
+ return 0
408
+
409
+ # Category
410
+ print()
411
+ _info("Categories: " + " ".join(CATEGORIES))
412
+ category = input("Category [other]: ").strip() or "other"
413
+ if category not in CATEGORIES:
414
+ _warn(f"Unknown category {category!r} — using 'other'")
415
+ category = "other"
416
+
417
+ # Solution — multiline, terminated by END or Ctrl+D
418
+ print()
419
+ _info("Paste your solution. Type END on a new line when done (or Ctrl+D).")
420
+ lines: list[str] = []
421
+ try:
422
+ while True:
423
+ line = input()
424
+ if line.strip() == "END":
425
+ break
426
+ lines.append(line)
427
+ except EOFError:
428
+ pass
429
+ solution = "\n".join(lines).strip()
430
+ if not solution:
431
+ _warn("No solution entered — cancelled.")
432
+ return 0
433
+
434
+ # Optional metadata
435
+ print()
436
+ tags_raw = input("Tags (comma-separated, optional): ").strip()
437
+ tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
438
+ language = input("Language (optional): ").strip()
439
+ project = input("Project/repo name (optional): ").strip()
440
+
441
+ from .config import load_config
442
+ try:
443
+ cfg = load_config()
444
+ except Exception as exc:
445
+ _warn(f"Config error: {exc}")
446
+ return 1
447
+
448
+ try:
449
+ entry_id = asyncio.run(_review_save(cfg, problem, solution, category, project, tags, language))
450
+ print()
451
+ _ok(f"Saved (id={entry_id})")
452
+ print()
453
+ return 0
454
+ except Exception as exc:
455
+ _warn(f"Save failed: {exc}")
456
+ return 1
457
+
458
+
459
+ async def _review_save(
460
+ cfg,
461
+ problem: str,
462
+ solution: str,
463
+ category: str,
464
+ project: str,
465
+ tags: list[str],
466
+ language: str,
467
+ ) -> str:
468
+ from .embedder import get_embedder
469
+ from .store import SolutionStore
470
+ embedder = get_embedder(cfg)
471
+ store = await SolutionStore.open(cfg)
472
+ vector = await embedder.embed(f"{category}: {problem}")
473
+ return await store.save(
474
+ problem=problem,
475
+ solution=solution,
476
+ vector=vector,
477
+ project=project,
478
+ category=category, # type: ignore[arg-type]
479
+ tags=tags,
480
+ language=language,
481
+ )
482
+
483
+
484
+ # ── main ───────────────────────────────────────────────────────────────────────
485
+
486
+ _SUBCOMMANDS = {"init", "install", "status", "export", "import", "review"}
487
+
488
+
489
+ def _get_version() -> str:
490
+ try:
491
+ from importlib.metadata import version
492
+ return version("longmem")
493
+ except Exception:
494
+ return "unknown"
495
+
496
+
497
+ def main() -> None:
498
+ """
499
+ No sub-command → start MCP server (called by the IDE).
500
+ Sub-command → run that CLI command.
501
+ """
502
+ first = sys.argv[1] if len(sys.argv) > 1 else ""
503
+
504
+ if first == "--version":
505
+ print(_get_version())
506
+ return
507
+
508
+ if first not in _SUBCOMMANDS:
509
+ # MCP server mode — start over stdio
510
+ from .server import main as server_main
511
+ server_main()
512
+ return
513
+
514
+ parser = argparse.ArgumentParser(
515
+ prog="longmem",
516
+ description="Persistent cross-project memory for Cursor and Claude Code.",
517
+ )
518
+ parser.add_argument("--version", action="version", version=_get_version())
519
+ sub = parser.add_subparsers(dest="cmd", metavar="<command>")
520
+
521
+ sub.add_parser("init", help="One-time machine setup wizard")
522
+ sub.add_parser("install", help="Copy rules into current project")
523
+ sub.add_parser("status", help="Show config, Ollama status, and DB stats")
524
+ sub.add_parser("review", help="Manually save a solution when the AI forgot")
525
+
526
+ exp = sub.add_parser("export", help="Export all entries to a JSON file")
527
+ exp.add_argument("output", nargs="?", default=None,
528
+ metavar="FILE",
529
+ help="Output file path (default: longmem_export_<timestamp>.json)")
530
+
531
+ imp = sub.add_parser("import", help="Import entries from a JSON export file")
532
+ imp.add_argument("input", metavar="FILE", help="JSON file produced by longmem export")
533
+
534
+ args = parser.parse_args()
535
+
536
+ dispatch = {
537
+ "init": lambda: cmd_init(),
538
+ "install": lambda: cmd_install(),
539
+ "status": lambda: cmd_status(),
540
+ "export": lambda: cmd_export(getattr(args, "output", None)),
541
+ "import": lambda: cmd_import(args.input),
542
+ "review": lambda: cmd_review(),
543
+ }
544
+ sys.exit(dispatch[args.cmd]())
longmem/config.py ADDED
@@ -0,0 +1,89 @@
1
+ """Configuration loading from ~/.longmem/config.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ CONFIG_DIR = Path.home() / ".longmem"
11
+ DB_PATH = CONFIG_DIR / "db"
12
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
13
+
14
+ # Both Ollama nomic-embed-text and OpenAI text-embedding-3-small
15
+ # can produce 768-dim vectors — keep one fixed dimension for the DB.
16
+ VECTOR_DIM = 768
17
+
18
+ # URI prefixes that indicate a remote LanceDB connection.
19
+ # These are passed to lancedb.connect_async() as-is (no Path conversion, no mkdir).
20
+ _REMOTE_PREFIXES = ("db://", "s3://", "gs://", "az://")
21
+
22
+
23
+ @dataclass
24
+ class Config:
25
+ db_path: Path = DB_PATH
26
+ # Remote URI — overrides db_path when set.
27
+ # Supported: LanceDB Cloud (db://org/db), S3 (s3://bucket/prefix),
28
+ # GCS (gs://bucket/prefix), Azure (az://container/prefix).
29
+ db_uri: str = ""
30
+ lancedb_api_key: str = "" # LanceDB Cloud only (db:// URIs)
31
+ # "ollama" (default, local, no API key) or "openai"
32
+ embedder: str = "ollama"
33
+ ollama_url: str = "http://localhost:11434"
34
+ ollama_model: str = "nomic-embed-text"
35
+ openai_api_key: str = ""
36
+ openai_model: str = "text-embedding-3-small"
37
+ vector_dim: int = VECTOR_DIM
38
+ similarity_threshold: float = 0.85 # minimum score to surface a cached solution
39
+ duplicate_threshold: float = 0.95 # minimum score to treat a new save as a duplicate
40
+
41
+ @property
42
+ def is_remote(self) -> bool:
43
+ """True when db_uri points to a cloud/object-store backend."""
44
+ return bool(self.db_uri and self.db_uri.startswith(_REMOTE_PREFIXES))
45
+
46
+
47
+ def load_config() -> Config:
48
+ cfg = Config()
49
+
50
+ if CONFIG_FILE.exists():
51
+ with open(CONFIG_FILE, "rb") as f:
52
+ data = tomllib.load(f)
53
+
54
+ if "db_path" in data:
55
+ cfg.db_path = Path(data["db_path"])
56
+ cfg.db_uri = data.get("db_uri", cfg.db_uri)
57
+ cfg.lancedb_api_key = data.get("lancedb_api_key", "")
58
+ cfg.embedder = data.get("embedder", cfg.embedder)
59
+ cfg.ollama_url = data.get("ollama_url", cfg.ollama_url)
60
+ cfg.ollama_model = data.get("ollama_model", cfg.ollama_model)
61
+ cfg.openai_api_key = data.get("openai_api_key", "")
62
+ cfg.openai_model = data.get("openai_model", cfg.openai_model)
63
+ raw_threshold = data.get("similarity_threshold", cfg.similarity_threshold)
64
+ try:
65
+ cfg.similarity_threshold = float(raw_threshold)
66
+ except (ValueError, TypeError):
67
+ raise ValueError(
68
+ f"Invalid similarity_threshold {raw_threshold!r} in config.toml — "
69
+ "expected a number between 0.0 and 1.0 (e.g. similarity_threshold = 0.85)"
70
+ ) from None
71
+
72
+ raw_dup = data.get("duplicate_threshold", cfg.duplicate_threshold)
73
+ try:
74
+ cfg.duplicate_threshold = float(raw_dup)
75
+ except (ValueError, TypeError):
76
+ raise ValueError(
77
+ f"Invalid duplicate_threshold {raw_dup!r} in config.toml — "
78
+ "expected a number between 0.0 and 1.0 (e.g. duplicate_threshold = 0.95)"
79
+ ) from None
80
+
81
+ # Env vars take priority
82
+ cfg.openai_api_key = os.environ.get("OPENAI_API_KEY", cfg.openai_api_key)
83
+ cfg.lancedb_api_key = os.environ.get("LANCEDB_API_KEY", cfg.lancedb_api_key)
84
+
85
+ # Only create the local directory when using local storage
86
+ if not cfg.is_remote:
87
+ cfg.db_path.mkdir(parents=True, exist_ok=True)
88
+
89
+ return cfg