baldanders 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.
baldanders/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Baldanders — local personal-data firewall for cloud LLMs."""
2
+
3
+ __version__ = "0.1.0"
baldanders/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """Baldanders CLI — personal-data firewall for cloud LLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TransferSpeedColumn
14
+ from rich.text import Text
15
+
16
+ from baldanders import __version__
17
+ from baldanders.config import Config, create_default_config, load_config
18
+ from baldanders.history import (
19
+ delete_session, list_sessions, load_session, new_session_id, save_session,
20
+ )
21
+ from baldanders.mapping import SessionMap
22
+ from baldanders.model_manager import download_model, is_downloaded, model_dir
23
+ from baldanders.providers.base import BaseProvider
24
+ from baldanders.scrubber.engine import ScrubEngine
25
+
26
+ console = Console(highlight=False)
27
+
28
+ PROVIDERS = ["claude", "chatgpt", "grok", "anthropic-api", "openai-api", "deepseek", "groq"]
29
+
30
+ _OPENAI_COMPATIBLE = {
31
+ # name: (base url, default model, api key env var)
32
+ "openai-api": ("https://api.openai.com/v1", "gpt-4o", "OPENAI_API_KEY"),
33
+ "deepseek": ("https://api.deepseek.com/v1", "deepseek-chat", "DEEPSEEK_API_KEY"),
34
+ "groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", "GROQ_API_KEY"),
35
+ }
36
+
37
+ HELP_TEXT = (
38
+ "/paste multi-line input (end with a single . on its own line)\n"
39
+ "/map show which token stands for which real value\n"
40
+ "/raw toggle scrubbing off/on for the next messages\n"
41
+ "/clear start a fresh conversation (keeps the token map)\n"
42
+ "/help show this help\n"
43
+ "/quit exit"
44
+ )
45
+
46
+
47
+ def _build_provider(name: str, api_key: str | None, base_url: str | None) -> BaseProvider:
48
+ # Consumer-auth providers (no API key needed)
49
+ if name == "claude":
50
+ from baldanders.providers.claude_code import ClaudeCodeProvider
51
+ return ClaudeCodeProvider()
52
+ if name == "chatgpt":
53
+ from baldanders.providers.codex import CodexProvider
54
+ return CodexProvider()
55
+ if name == "grok":
56
+ from baldanders.providers.grok import GrokCLIProvider
57
+ return GrokCLIProvider()
58
+
59
+ # API-key providers
60
+ if name == "anthropic-api":
61
+ from baldanders.providers.anthropic import AnthropicProvider
62
+ return AnthropicProvider(api_key=api_key)
63
+ if name in _OPENAI_COMPATIBLE:
64
+ from baldanders.providers.openai import OpenAIProvider
65
+ url, model, key_env = _OPENAI_COMPATIBLE[name]
66
+ provider = OpenAIProvider(api_key=api_key, base_url=base_url or url, default_model=model, key_env=key_env)
67
+ provider.name = name
68
+ return provider
69
+ raise click.BadParameter(f"Unknown provider: {name}")
70
+
71
+
72
+ def _build_engine(session_map: SessionMap, config: Config) -> ScrubEngine:
73
+ md = model_dir() if is_downloaded() else None
74
+ engine = ScrubEngine(session_map, model_dir=md, config=config)
75
+ for problem in engine.config_errors:
76
+ console.print(f"[yellow]Config:[/yellow] {problem}")
77
+ return engine
78
+
79
+
80
+ def _read_input(raw_mode: bool) -> str | None:
81
+ """Read one message from the terminal. Returns None on EOF/Ctrl-C."""
82
+ prompt = "[bold red]Raw:[/bold red] " if raw_mode else "[bold blue]You:[/bold blue] "
83
+ try:
84
+ first_line = console.input(prompt)
85
+ except (EOFError, KeyboardInterrupt):
86
+ return None
87
+
88
+ if first_line.strip().lower() == "/paste":
89
+ console.print("[dim] Paste your text. Type a single . on a line to send.[/dim]")
90
+ lines: list[str] = []
91
+ while True:
92
+ try:
93
+ line = console.input("[dim]...:[/dim] ")
94
+ except (EOFError, KeyboardInterrupt):
95
+ break
96
+ if line.strip() == ".":
97
+ break
98
+ lines.append(line)
99
+ return "\n".join(lines).strip()
100
+
101
+ lines = [first_line]
102
+ if first_line.rstrip().endswith(("(", "{", "[", ",", "\\", ";")):
103
+ console.print("[dim] (multi-line: empty line to send)[/dim]")
104
+ while True:
105
+ try:
106
+ line = console.input("[dim]...:[/dim] ")
107
+ except (EOFError, KeyboardInterrupt):
108
+ break
109
+ if line.strip() == "":
110
+ break
111
+ lines.append(line)
112
+ return "\n".join(lines).strip()
113
+
114
+
115
+ async def _repl(
116
+ provider: BaseProvider,
117
+ engine: ScrubEngine,
118
+ session_map: SessionMap,
119
+ messages: list[dict[str, str]],
120
+ session_id: str,
121
+ model: str | None,
122
+ verbose: bool = False,
123
+ save: bool = True,
124
+ ) -> None:
125
+ ner = engine.ner_status
126
+ ner_line = "[green]loaded[/green]" if ner == "loaded" else f"[yellow]regex-only[/yellow] [dim]({ner})[/dim]"
127
+ console.print()
128
+ console.print(Panel(
129
+ f"[bold]Baldanders {__version__}[/bold] — your personal data never leaves this machine.\n"
130
+ f"Provider: [bold]{provider.name}[/bold] Session: [dim]{session_id}{'' if save else ' (not saved)'}[/dim]\n"
131
+ f"Name/address model: {ner_line}\n"
132
+ "Commands: [dim]/paste /map /raw /clear /help /quit[/dim]",
133
+ border_style="blue",
134
+ ))
135
+ console.print()
136
+
137
+ raw_mode = False
138
+
139
+ def persist() -> None:
140
+ if save:
141
+ save_session(session_id, session_map, messages, provider_state=provider.get_state())
142
+
143
+ while True:
144
+ raw = _read_input(raw_mode)
145
+ if raw is None:
146
+ console.print("\n[dim]Bye.[/dim]")
147
+ break
148
+ if not raw:
149
+ continue
150
+
151
+ command = raw.lower()
152
+ if command in ("/quit", "/exit"):
153
+ console.print("[dim]Bye.[/dim]")
154
+ break
155
+ if command == "/help":
156
+ console.print(Text(HELP_TEXT, style="dim"))
157
+ console.print()
158
+ continue
159
+ if command == "/map":
160
+ mapping = session_map.get_map()
161
+ if not mapping:
162
+ console.print("[dim]No personal data mapped yet.[/dim]")
163
+ for token, real in mapping.items():
164
+ line = Text(" ")
165
+ line.append(token)
166
+ line.append(" → ")
167
+ line.append(real, style="bold")
168
+ console.print(line)
169
+ console.print()
170
+ continue
171
+ if command == "/raw":
172
+ raw_mode = not raw_mode
173
+ if raw_mode:
174
+ console.print("[bold red]Raw mode ON — messages are sent exactly as typed, with no scrubbing.[/bold red]\n")
175
+ else:
176
+ console.print("[dim]Raw mode OFF — scrubbing enabled.[/dim]\n")
177
+ continue
178
+ if command == "/clear":
179
+ messages.clear()
180
+ provider.reset()
181
+ persist()
182
+ console.print("[dim]Conversation cleared.[/dim]\n")
183
+ continue
184
+
185
+ # Scrub personal data (unless raw mode)
186
+ if raw_mode:
187
+ scrubbed = raw
188
+ else:
189
+ scrubbed = engine.scrub(raw)
190
+ if scrubbed != raw:
191
+ diff = Text(" [scrubbed] ", style="dim yellow")
192
+ diff.append(scrubbed, style="yellow")
193
+ console.print(diff)
194
+
195
+ if verbose:
196
+ console.print()
197
+ console.print(Panel(
198
+ Text(scrubbed),
199
+ title="[bold red]SENT TO AI (exactly this, nothing else)[/bold red]",
200
+ border_style="red",
201
+ ))
202
+ console.print()
203
+
204
+ messages.append({"role": "user", "content": scrubbed})
205
+
206
+ try:
207
+ with console.status("[dim]Thinking...[/dim]"):
208
+ response = await provider.send(messages, model=model)
209
+ except KeyboardInterrupt:
210
+ console.print("[dim]Cancelled.[/dim]\n")
211
+ messages.pop()
212
+ continue
213
+ except Exception as e:
214
+ console.print(Text.assemble(("Error: ", "red"), str(e)))
215
+ console.print()
216
+ messages.pop()
217
+ continue
218
+
219
+ if verbose:
220
+ console.print(Panel(
221
+ Text(response),
222
+ title="[bold cyan]RAW RESPONSE FROM AI[/bold cyan]",
223
+ border_style="cyan",
224
+ ))
225
+
226
+ messages.append({"role": "assistant", "content": response})
227
+
228
+ # Restore personal data in the response
229
+ rehydrated = session_map.rehydrate(response)
230
+
231
+ console.print()
232
+ console.print("[bold green]Assistant:[/bold green]")
233
+ console.print(Markdown(rehydrated))
234
+ console.print()
235
+
236
+ persist()
237
+
238
+
239
+ @click.group(invoke_without_command=True)
240
+ @click.option("--provider", "-p", default="claude", type=click.Choice(PROVIDERS),
241
+ help="LLM backend. claude/chatgpt/grok use your subscription (no API key).")
242
+ @click.option("--model", "-m", default=None, help="Model name override.")
243
+ @click.option("--api-key", default=None, envvar="BALDANDERS_API_KEY",
244
+ help="API key (only needed for direct API providers).")
245
+ @click.option("--base-url", default=None, help="Custom API base URL (OpenAI-compatible providers).")
246
+ @click.option("--verbose", "-v", is_flag=True, help="Show exact text sent to and received from AI.")
247
+ @click.option("--resume", "-r", default=None, help="Resume a previous session by ID.")
248
+ @click.option("--no-save", is_flag=True, help="Don't write this session to disk.")
249
+ @click.version_option(__version__, prog_name="baldanders")
250
+ @click.pass_context
251
+ def main(ctx: click.Context, provider: str, model: str | None, api_key: str | None,
252
+ base_url: str | None, verbose: bool, resume: str | None, no_save: bool) -> None:
253
+ """Baldanders — local personal-data firewall for cloud LLMs."""
254
+ if ctx.invoked_subcommand is not None:
255
+ return
256
+
257
+ try:
258
+ llm = _build_provider(provider, api_key, base_url)
259
+ except (ValueError, RuntimeError, click.BadParameter) as e:
260
+ console.print(Text(str(e), style="red"))
261
+ sys.exit(1)
262
+
263
+ create_default_config()
264
+ config = load_config()
265
+ save = config.save_sessions and not no_save
266
+
267
+ if resume:
268
+ loaded = load_session(resume)
269
+ if not loaded:
270
+ console.print(Text(f"Session '{resume}' not found.", style="red"))
271
+ sys.exit(1)
272
+ session_map, messages, provider_state = loaded
273
+ llm.set_state(provider_state)
274
+ session_id = resume
275
+ console.print(f"[dim]Resumed session {session_id} ({len(messages)} messages)[/dim]")
276
+ else:
277
+ session_map = SessionMap()
278
+ messages = []
279
+ session_id = new_session_id()
280
+
281
+ engine = _build_engine(session_map, config)
282
+
283
+ async def run() -> None:
284
+ try:
285
+ await _repl(llm, engine, session_map, messages, session_id, model, verbose=verbose, save=save)
286
+ finally:
287
+ if save and messages:
288
+ save_session(session_id, session_map, messages, provider_state=llm.get_state())
289
+ await llm.aclose()
290
+
291
+ asyncio.run(run())
292
+
293
+
294
+ @main.command()
295
+ def download() -> None:
296
+ """Download the multilingual name-and-address model (~320 MB)."""
297
+ try:
298
+ import gliner # noqa: F401
299
+ except ImportError:
300
+ console.print('[yellow]Note:[/yellow] the model needs the NER extra: pip install "baldanders[ner]"')
301
+
302
+ with Progress(
303
+ TextColumn("[dim]{task.description}"), BarColumn(), DownloadColumn(), TransferSpeedColumn(),
304
+ console=console,
305
+ ) as progress:
306
+ tasks: dict[str, int] = {}
307
+
308
+ def on_progress(name: str, done: int, total: int | None) -> None:
309
+ if name not in tasks:
310
+ tasks[name] = progress.add_task(name, total=total)
311
+ progress.update(tasks[name], completed=done)
312
+
313
+ try:
314
+ path = asyncio.run(download_model(progress=on_progress))
315
+ except Exception as e:
316
+ console.print(Text(f"Download failed: {e}", style="red"))
317
+ sys.exit(1)
318
+ console.print(f"[green]Model ready in {path}[/green]")
319
+
320
+
321
+ @main.command()
322
+ @click.argument("text", required=False)
323
+ @click.option("--show-map", is_flag=True, help="Also print the token → value mapping (to stderr).")
324
+ @click.option("--json", "as_json", is_flag=True, help="Print detected spans as JSON instead.")
325
+ def scrub(text: str | None, show_map: bool, as_json: bool) -> None:
326
+ """Scrub TEXT (or stdin) and print the result. Nothing is sent anywhere."""
327
+ if text is None:
328
+ text = sys.stdin.read()
329
+ session_map = SessionMap()
330
+ engine = _build_engine(session_map, load_config())
331
+ if as_json:
332
+ spans = engine.detect(text)
333
+ click.echo(json.dumps(
334
+ [{"start": s.start, "end": s.end, "type": s.entity_type, "text": s.text} for s in spans],
335
+ ensure_ascii=False, indent=2,
336
+ ))
337
+ return
338
+ click.echo(engine.scrub(text), nl=not text.endswith("\n"))
339
+ if show_map:
340
+ for token, real in session_map.get_map().items():
341
+ click.echo(f"{token} → {real}", err=True)
342
+
343
+
344
+ @main.command()
345
+ @click.option("--delete", "delete_id", default=None, metavar="ID", help="Delete a saved session.")
346
+ def sessions(delete_id: str | None) -> None:
347
+ """List saved sessions."""
348
+ if delete_id:
349
+ if delete_session(delete_id):
350
+ console.print(f"[dim]Deleted session {delete_id}.[/dim]")
351
+ else:
352
+ console.print(Text(f"Session '{delete_id}' not found.", style="red"))
353
+ return
354
+ saved = list_sessions()
355
+ if not saved:
356
+ console.print("[dim]No saved sessions.[/dim]")
357
+ return
358
+ for s in saved:
359
+ provider = f" {s['provider']}" if s.get("provider") else ""
360
+ console.print(f" [bold]{s['id']}[/bold] {s['message_count']} messages{provider} [dim]{s['updated_at']}[/dim]")
361
+ console.print("\n[dim]Resume with: baldanders --resume <id>[/dim]")
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
baldanders/config.py ADDED
@@ -0,0 +1,133 @@
1
+ """User configuration — allowlist, denylist, custom patterns, regions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import locale
6
+ import logging
7
+ import os
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ BASE_DIR = Path(os.environ.get("BALDANDERS_HOME", Path.home() / ".baldanders"))
15
+ DEFAULT_CONFIG_PATH = BASE_DIR / "config.yaml"
16
+
17
+
18
+ @dataclass
19
+ class Config:
20
+ # Terms that should NEVER be scrubbed (S3 buckets, table names, etc.)
21
+ allowlist: list[str] = field(default_factory=list)
22
+
23
+ # Terms that should ALWAYS be scrubbed (custom sensitive values)
24
+ denylist: dict[str, str] = field(default_factory=dict) # value -> entity_type
25
+
26
+ # Extra regex patterns: entity name -> pattern string
27
+ custom_patterns: dict[str, str] = field(default_factory=dict)
28
+
29
+ # Countries (ISO 3166 alpha-2) whose local phone formats to recognise.
30
+ # Numbers written with a +country prefix are always recognised.
31
+ regions: list[str] = field(default_factory=list)
32
+
33
+ # Also scrub checksum-valid ID numbers that have no keyword ("DNI", "BSN", ...) nearby.
34
+ paranoid: bool = False
35
+
36
+ # NER confidence threshold (0-1) for all entity types. None = tuned per-type defaults.
37
+ ner_threshold: float | None = None
38
+
39
+ # Write conversations and the token mapping to ~/.baldanders/sessions.
40
+ save_sessions: bool = True
41
+
42
+
43
+ def system_regions() -> list[str]:
44
+ """Best guess at the user's country from the OS locale (e.g. es_AR -> AR)."""
45
+ candidates = [os.environ.get(v, "") for v in ("LC_ALL", "LC_TELEPHONE", "LANG")]
46
+ try:
47
+ candidates.append(locale.getlocale()[0] or "")
48
+ except Exception:
49
+ pass
50
+ for value in candidates:
51
+ m = re.match(r"^[a-z]{2,3}[_-]([A-Z]{2})\b", value)
52
+ if m:
53
+ return [m.group(1)]
54
+ return []
55
+
56
+
57
+ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
58
+ """Load config from YAML file. Returns defaults if file doesn't exist."""
59
+ if not path.exists():
60
+ return Config(regions=system_regions())
61
+
62
+ try:
63
+ import yaml
64
+ except ImportError:
65
+ log.warning("PyYAML not installed — ignoring config file. pip install pyyaml")
66
+ return Config(regions=system_regions())
67
+
68
+ try:
69
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
70
+ except Exception:
71
+ log.warning("Failed to parse config at %s", path, exc_info=True)
72
+ return Config(regions=system_regions())
73
+
74
+ regions = [str(r).upper() for r in (data.get("regions") or [])] or system_regions()
75
+ return Config(
76
+ allowlist=[str(t) for t in (data.get("allowlist") or [])],
77
+ denylist={str(k): str(v).upper() for k, v in (data.get("denylist") or {}).items()},
78
+ custom_patterns={str(k): str(v) for k, v in (data.get("custom_patterns") or {}).items()},
79
+ regions=regions,
80
+ paranoid=bool(data.get("paranoid", False)),
81
+ ner_threshold=float(data["ner_threshold"]) if data.get("ner_threshold") is not None else None,
82
+ save_sessions=bool(data.get("save_sessions", True)),
83
+ )
84
+
85
+
86
+ def create_default_config(path: Path = DEFAULT_CONFIG_PATH) -> None:
87
+ """Create a starter config file with examples."""
88
+ if path.exists():
89
+ return
90
+
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ path.write_text("""\
93
+ # Baldanders configuration
94
+ # ========================
95
+
96
+ # Countries whose local phone formats to recognise (ISO codes like AR, US, DE, IN).
97
+ # Numbers with a +country prefix are always caught. Empty = guess from your OS locale.
98
+ regions: []
99
+ # - AR
100
+ # - US
101
+
102
+ # Also scrub checksum-valid ID numbers (BSN, PESEL, Steuer-ID, ...) even when
103
+ # no keyword like "DNI" or "BSN" is nearby. Catches more, but plain numbers
104
+ # in code or maths may get tokenised too.
105
+ paranoid: false
106
+
107
+ # Name/address model confidence (0-1). Lower catches more, with more false
108
+ # positives. Leave unset for tuned defaults (names 0.7, addresses 0.4).
109
+ # ner_threshold: 0.6
110
+
111
+ # Save conversations (including the real values behind each token) to
112
+ # ~/.baldanders/sessions so you can --resume them. Files are readable only by you.
113
+ save_sessions: true
114
+
115
+ # Terms that should NEVER be scrubbed.
116
+ # Use this for internal infrastructure names, bucket names, table names, etc.
117
+ allowlist:
118
+ # - verify-internal-data-prod
119
+ # - my-s3-bucket-name
120
+
121
+ # Terms that should ALWAYS be scrubbed (case-insensitive), mapped to an entity type.
122
+ # Use this for values the detectors might miss.
123
+ denylist: {}
124
+ # "Acme Corp Internal": ORGANIZATION
125
+ # "Project Falcon": PROJECT
126
+
127
+ # Custom regex patterns. The whole match is scrubbed; if the pattern has a
128
+ # capturing group, only the first group is.
129
+ custom_patterns: {}
130
+ # customer_id: "CID-\\\\d{8}"
131
+ # portfolio_ref: "PF/\\\\d{4}/\\\\d{6}"
132
+ """, encoding="utf-8")
133
+ log.info("Created default config at %s", path)
baldanders/history.py ADDED
@@ -0,0 +1,127 @@
1
+ """Session history — persists token mapping and conversation across restarts.
2
+
3
+ Session files contain the real values behind every token, so they are written
4
+ atomically with owner-only permissions (0600, directory 0700).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import tempfile
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from baldanders.config import BASE_DIR
19
+ from baldanders.mapping import SessionMap
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ DEFAULT_HISTORY_DIR = BASE_DIR / "sessions"
24
+
25
+ _SESSION_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
26
+
27
+
28
+ def _session_path(session_id: str, history_dir: Path = DEFAULT_HISTORY_DIR) -> Path:
29
+ if not _SESSION_ID.match(session_id):
30
+ raise ValueError(f"Invalid session id: {session_id!r}")
31
+ return history_dir / f"{session_id}.json"
32
+
33
+
34
+ def _ensure_private_dir(path: Path) -> None:
35
+ path.mkdir(parents=True, exist_ok=True)
36
+ try:
37
+ os.chmod(path, 0o700)
38
+ except OSError:
39
+ pass
40
+
41
+
42
+ def save_session(
43
+ session_id: str,
44
+ session_map: SessionMap,
45
+ messages: list[dict[str, str]],
46
+ history_dir: Path = DEFAULT_HISTORY_DIR,
47
+ provider_state: dict[str, Any] | None = None,
48
+ ) -> None:
49
+ """Save session state to disk."""
50
+ _ensure_private_dir(history_dir)
51
+ path = _session_path(session_id, history_dir)
52
+ data = {
53
+ "session_id": session_id,
54
+ "updated_at": datetime.now(timezone.utc).isoformat(),
55
+ "mapping": session_map.to_dict(),
56
+ "messages": messages,
57
+ "provider_state": provider_state or {},
58
+ }
59
+ # mkstemp creates the file with 0600 permissions.
60
+ fd, tmp = tempfile.mkstemp(dir=history_dir, prefix=".tmp-", suffix=".json")
61
+ try:
62
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
63
+ json.dump(data, f, indent=2, ensure_ascii=False)
64
+ os.replace(tmp, path)
65
+ except BaseException:
66
+ Path(tmp).unlink(missing_ok=True)
67
+ raise
68
+ log.debug("Session saved to %s", path)
69
+
70
+
71
+ def load_session(
72
+ session_id: str,
73
+ history_dir: Path = DEFAULT_HISTORY_DIR,
74
+ ) -> tuple[SessionMap, list[dict[str, str]], dict[str, Any]] | None:
75
+ """Load a saved session. Returns (session_map, messages, provider_state) or None."""
76
+ try:
77
+ path = _session_path(session_id, history_dir)
78
+ except ValueError:
79
+ return None
80
+ if not path.exists():
81
+ return None
82
+
83
+ try:
84
+ data = json.loads(path.read_text(encoding="utf-8"))
85
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
86
+ log.warning("Failed to load session %s", session_id)
87
+ return None
88
+
89
+ session_map = SessionMap.from_dict(data.get("mapping", {}))
90
+ messages = data.get("messages", [])
91
+ return session_map, messages, data.get("provider_state") or {}
92
+
93
+
94
+ def list_sessions(history_dir: Path = DEFAULT_HISTORY_DIR) -> list[dict]:
95
+ """List all saved sessions."""
96
+ if not history_dir.exists():
97
+ return []
98
+
99
+ sessions = []
100
+ for path in sorted(history_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
101
+ try:
102
+ data = json.loads(path.read_text(encoding="utf-8"))
103
+ sessions.append({
104
+ "id": data.get("session_id", path.stem),
105
+ "updated_at": data.get("updated_at", ""),
106
+ "message_count": len(data.get("messages", [])),
107
+ "provider": (data.get("provider_state") or {}).get("provider", ""),
108
+ })
109
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
110
+ continue
111
+ return sessions
112
+
113
+
114
+ def delete_session(session_id: str, history_dir: Path = DEFAULT_HISTORY_DIR) -> bool:
115
+ try:
116
+ path = _session_path(session_id, history_dir)
117
+ except ValueError:
118
+ return False
119
+ if path.exists():
120
+ path.unlink()
121
+ return True
122
+ return False
123
+
124
+
125
+ def new_session_id() -> str:
126
+ """Generate a session ID from the current timestamp."""
127
+ return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")