aipromptdock 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.
@@ -0,0 +1,8 @@
1
+ """AI Prompt Dock — a local timeline for your AI CLI prompts."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("aipromptdock")
7
+ except PackageNotFoundError: # pragma: no cover - source tree without installation
8
+ __version__ = "0.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m aipromptdock`."""
2
+
3
+ from aipromptdock.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1 @@
1
+ """HTTP API routes (filled in Phase 2)."""
@@ -0,0 +1,54 @@
1
+ """Versioned HTTP API."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from typing import Annotated, Any
5
+
6
+ from fastapi import APIRouter, Query, Request
7
+ from sse_starlette.sse import EventSourceResponse
8
+
9
+ from aipromptdock.core.events import EventBroadcaster
10
+ from aipromptdock.core.models import PromptPage, ProviderId, ProviderInfo
11
+ from aipromptdock.core.store import PromptStore
12
+
13
+ router = APIRouter(prefix="/api/v1")
14
+
15
+
16
+ def _store(request: Request) -> PromptStore:
17
+ store: PromptStore = request.app.state.store
18
+ # Fingerprint-cheap: stats a handful of files, rescans only what changed.
19
+ # Keeps responses correct even before the file watcher has fired.
20
+ store.refresh()
21
+ return store
22
+
23
+
24
+ @router.get("/providers")
25
+ def providers(request: Request) -> list[ProviderInfo]:
26
+ return _store(request).provider_infos()
27
+
28
+
29
+ @router.get("/prompts")
30
+ def prompts(
31
+ request: Request,
32
+ provider: Annotated[ProviderId | None, Query()] = None,
33
+ q: Annotated[str | None, Query(max_length=500)] = None,
34
+ before: Annotated[str | None, Query(max_length=200)] = None,
35
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
36
+ ) -> PromptPage:
37
+ return _store(request).page(provider=provider, q=q, before=before, limit=limit)
38
+
39
+
40
+ @router.get("/events")
41
+ async def events(request: Request) -> EventSourceResponse:
42
+ """Server-Sent Events: emits a `change` event whenever provider data changes."""
43
+ broadcaster: EventBroadcaster = request.app.state.broadcaster
44
+
45
+ async def stream() -> AsyncIterator[dict[str, Any]]:
46
+ queue = broadcaster.subscribe()
47
+ try:
48
+ while True:
49
+ provider_id = await queue.get()
50
+ yield {"event": "change", "data": provider_id}
51
+ finally:
52
+ broadcaster.unsubscribe(queue)
53
+
54
+ return EventSourceResponse(stream(), ping=15)
aipromptdock/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ """Command-line interface for AI Prompt Dock."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from aipromptdock import __version__
8
+
9
+ app = typer.Typer(
10
+ name="aipromptdock",
11
+ help=(
12
+ "A local timeline for every prompt you've sent to Claude Code, Codex CLI, and Gemini CLI."
13
+ ),
14
+ add_completion=False,
15
+ )
16
+
17
+
18
+ def _version_callback(value: bool) -> None:
19
+ if value:
20
+ typer.echo(f"aipromptdock {__version__}")
21
+ raise typer.Exit()
22
+
23
+
24
+ @app.callback(invoke_without_command=True)
25
+ def main(
26
+ ctx: typer.Context,
27
+ port: Annotated[int, typer.Option("--port", "-p", help="Port to serve on.")] = 8745,
28
+ host: Annotated[
29
+ str, typer.Option("--host", help="Host to bind. Localhost-only by default.")
30
+ ] = "127.0.0.1",
31
+ no_browser: Annotated[
32
+ bool, typer.Option("--no-browser", help="Do not open the browser automatically.")
33
+ ] = False,
34
+ version: Annotated[
35
+ bool,
36
+ typer.Option(
37
+ "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
38
+ ),
39
+ ] = False,
40
+ ) -> None:
41
+ """Serve the AI Prompt Dock web app (default command)."""
42
+ if ctx.invoked_subcommand is not None:
43
+ return
44
+
45
+ if host not in {"127.0.0.1", "localhost", "::1"}:
46
+ typer.secho(
47
+ "Warning: binding to a non-loopback host exposes your prompt history "
48
+ "to the network without authentication.",
49
+ fg="yellow",
50
+ err=True,
51
+ )
52
+
53
+ from aipromptdock.server import serve
54
+
55
+ serve(host=host, port=port, open_browser=not no_browser)
56
+
57
+
58
+ @app.command()
59
+ def paths() -> None:
60
+ """Doctor: show detected CLIs, their data locations, and prompt counts."""
61
+ from rich.console import Console
62
+ from rich.table import Table
63
+
64
+ from aipromptdock.core.store import PromptStore
65
+ from aipromptdock.providers import default_adapters
66
+
67
+ store = PromptStore(default_adapters())
68
+ store.refresh()
69
+
70
+ table = Table(title="AI Prompt Dock — provider detection")
71
+ table.add_column("Provider", style="bold")
72
+ table.add_column("Detected")
73
+ table.add_column("Prompts", justify="right")
74
+ table.add_column("Data read from")
75
+ for info in store.provider_infos():
76
+ table.add_row(
77
+ info.name,
78
+ "[green]yes[/green]" if info.detected else "[red]no[/red]",
79
+ str(info.prompt_count),
80
+ "\n".join(info.paths),
81
+ )
82
+ Console().print(table)
@@ -0,0 +1 @@
1
+ """Core domain: models, prompt store, file watcher (filled in Phase 1)."""
@@ -0,0 +1,42 @@
1
+ """Fan-out of provider-change notifications to SSE subscribers."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from aipromptdock.core.models import ProviderId
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class EventBroadcaster:
12
+ """Bridges watchdog worker threads to the asyncio SSE streams.
13
+
14
+ The watcher publishes from its own threads; ``publish_threadsafe`` hops
15
+ onto the server's event loop and fans the provider id out to every
16
+ subscribed queue.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ self._queues: set[asyncio.Queue[ProviderId]] = set()
21
+ self._loop: asyncio.AbstractEventLoop | None = None
22
+
23
+ def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
24
+ self._loop = loop
25
+
26
+ def publish_threadsafe(self, provider_id: ProviderId) -> None:
27
+ loop = self._loop
28
+ if loop is None or loop.is_closed():
29
+ return
30
+ loop.call_soon_threadsafe(self._publish, provider_id)
31
+
32
+ def _publish(self, provider_id: ProviderId) -> None:
33
+ for queue in self._queues:
34
+ queue.put_nowait(provider_id)
35
+
36
+ def subscribe(self) -> asyncio.Queue[ProviderId]:
37
+ queue: asyncio.Queue[ProviderId] = asyncio.Queue()
38
+ self._queues.add(queue)
39
+ return queue
40
+
41
+ def unsubscribe(self, queue: asyncio.Queue[ProviderId]) -> None:
42
+ self._queues.discard(queue)
@@ -0,0 +1,63 @@
1
+ """Domain models shared across providers, store, and API."""
2
+
3
+ import hashlib
4
+ from datetime import UTC, datetime
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel
8
+
9
+ ProviderId = Literal["claude", "codex", "gemini"]
10
+
11
+
12
+ def epoch_to_utc(value: float) -> datetime:
13
+ """Convert a Unix epoch in seconds *or* milliseconds to an aware UTC datetime.
14
+
15
+ Claude Code records milliseconds, Codex CLI records seconds; anything above
16
+ 1e11 (~5138 AD in seconds) is treated as milliseconds.
17
+ """
18
+ if value > 1e11:
19
+ value /= 1000.0
20
+ return datetime.fromtimestamp(value, tz=UTC)
21
+
22
+
23
+ def iso_to_utc(value: str) -> datetime:
24
+ """Parse an ISO-8601 string (Gemini/Claude transcripts use a trailing Z) to UTC."""
25
+ dt = datetime.fromisoformat(value)
26
+ if dt.tzinfo is None:
27
+ dt = dt.replace(tzinfo=UTC)
28
+ return dt.astimezone(UTC)
29
+
30
+
31
+ class PromptEvent(BaseModel):
32
+ """One prompt the user typed into an AI CLI."""
33
+
34
+ id: str
35
+ provider: ProviderId
36
+ text: str
37
+ timestamp: datetime
38
+ session_id: str | None = None
39
+ project: str | None = None
40
+ source: str
41
+
42
+ @staticmethod
43
+ def make_id(provider: str, session_id: str | None, timestamp: datetime, text: str) -> str:
44
+ """Stable identifier, identical across rescans of the same data."""
45
+ raw = f"{provider}|{session_id or ''}|{timestamp.isoformat()}|{text}"
46
+ return hashlib.sha1(raw.encode("utf-8", "replace")).hexdigest()[:16]
47
+
48
+
49
+ class ProviderInfo(BaseModel):
50
+ """Detection status of one provider, shown in tabs and the doctor command."""
51
+
52
+ id: ProviderId
53
+ name: str
54
+ detected: bool
55
+ prompt_count: int
56
+ paths: list[str]
57
+
58
+
59
+ class PromptPage(BaseModel):
60
+ """One page of timeline results."""
61
+
62
+ items: list[PromptEvent]
63
+ next_cursor: str | None
@@ -0,0 +1,109 @@
1
+ """In-memory prompt store: merge, sort, search, paginate, cache."""
2
+
3
+ import logging
4
+ import threading
5
+ from datetime import datetime
6
+
7
+ from aipromptdock.core.models import PromptEvent, PromptPage, ProviderId, ProviderInfo
8
+ from aipromptdock.providers.base import ProviderAdapter
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ DEFAULT_PAGE_SIZE = 50
13
+
14
+
15
+ def _sort_key(event: PromptEvent) -> tuple[datetime, str]:
16
+ return (event.timestamp, event.id)
17
+
18
+
19
+ class PromptStore:
20
+ """Holds all prompts in memory, newest first.
21
+
22
+ ``refresh()`` is cheap when nothing changed: each adapter exposes a
23
+ stat-based fingerprint over the files it reads, and only adapters whose
24
+ fingerprint moved are rescanned. Thread-safe — the file watcher refreshes
25
+ from its own thread while the API reads.
26
+ """
27
+
28
+ def __init__(self, adapters: list[ProviderAdapter]) -> None:
29
+ self._adapters = adapters
30
+ self._lock = threading.Lock()
31
+ self._events: list[PromptEvent] = []
32
+ self._by_provider: dict[ProviderId, list[PromptEvent]] = {}
33
+ self._fingerprints: dict[ProviderId, object] = {}
34
+
35
+ @property
36
+ def adapters(self) -> list[ProviderAdapter]:
37
+ return self._adapters
38
+
39
+ def refresh(self, force: bool = False) -> bool:
40
+ """Rescan adapters whose data changed. Returns True if anything did."""
41
+ with self._lock:
42
+ changed = False
43
+ for adapter in self._adapters:
44
+ fingerprint = adapter.fingerprint()
45
+ if not force and self._fingerprints.get(adapter.id) == fingerprint:
46
+ continue
47
+ try:
48
+ events = adapter.scan()
49
+ except Exception: # defensive: a broken adapter must not kill the app
50
+ logger.exception("Provider %s failed to scan", adapter.id)
51
+ events = []
52
+ events.sort(key=_sort_key, reverse=True)
53
+ self._by_provider[adapter.id] = events
54
+ self._fingerprints[adapter.id] = fingerprint
55
+ changed = True
56
+ if changed:
57
+ merged = [e for events in self._by_provider.values() for e in events]
58
+ merged.sort(key=_sort_key, reverse=True)
59
+ self._events = merged
60
+ return changed
61
+
62
+ def page(
63
+ self,
64
+ provider: ProviderId | None = None,
65
+ q: str | None = None,
66
+ before: str | None = None,
67
+ limit: int = DEFAULT_PAGE_SIZE,
68
+ ) -> PromptPage:
69
+ """Newest-first page. ``before`` is the opaque cursor from the previous page."""
70
+ with self._lock:
71
+ events = self._by_provider.get(provider, []) if provider else self._events
72
+
73
+ if q:
74
+ needle = q.lower()
75
+ events = [e for e in events if needle in e.text.lower()]
76
+
77
+ if before:
78
+ cursor = _decode_cursor(before)
79
+ if cursor:
80
+ events = [e for e in events if _sort_key(e) < cursor]
81
+
82
+ items = events[:limit]
83
+ next_cursor = _encode_cursor(items[-1]) if len(events) > limit else None
84
+ return PromptPage(items=items, next_cursor=next_cursor)
85
+
86
+ def provider_infos(self) -> list[ProviderInfo]:
87
+ with self._lock:
88
+ return [
89
+ ProviderInfo(
90
+ id=adapter.id,
91
+ name=adapter.name,
92
+ detected=adapter.detected,
93
+ prompt_count=len(self._by_provider.get(adapter.id, [])),
94
+ paths=[str(p) for p in adapter.data_paths()[:3]] or [str(adapter.data_dir)],
95
+ )
96
+ for adapter in self._adapters
97
+ ]
98
+
99
+
100
+ def _encode_cursor(event: PromptEvent) -> str:
101
+ return f"{event.timestamp.isoformat()}|{event.id}"
102
+
103
+
104
+ def _decode_cursor(cursor: str) -> tuple[datetime, str] | None:
105
+ timestamp_raw, _, event_id = cursor.partition("|")
106
+ try:
107
+ return (datetime.fromisoformat(timestamp_raw), event_id)
108
+ except ValueError:
109
+ return None
@@ -0,0 +1,67 @@
1
+ """Watch the providers' data directories and report (debounced) changes."""
2
+
3
+ import logging
4
+ import threading
5
+ from collections.abc import Callable
6
+
7
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
8
+ from watchdog.observers import Observer
9
+
10
+ from aipromptdock.core.models import ProviderId
11
+ from aipromptdock.providers.base import ProviderAdapter
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DEBOUNCE_SECONDS = 1.0
16
+
17
+
18
+ class _Handler(FileSystemEventHandler):
19
+ def __init__(self, adapter: ProviderAdapter, notify: Callable[[ProviderId], None]) -> None:
20
+ self._adapter = adapter
21
+ self._notify = notify
22
+ self._timer: threading.Timer | None = None
23
+ self._timer_lock = threading.Lock()
24
+
25
+ def on_any_event(self, event: FileSystemEvent) -> None:
26
+ if event.is_directory:
27
+ return
28
+ path = str(event.src_path)
29
+ if not self._adapter.watch_filter(path):
30
+ return
31
+ # Debounce: CLIs write in bursts; fire once per quiet second.
32
+ with self._timer_lock:
33
+ if self._timer is not None:
34
+ self._timer.cancel()
35
+ self._timer = threading.Timer(DEBOUNCE_SECONDS, self._notify, args=(self._adapter.id,))
36
+ self._timer.daemon = True
37
+ self._timer.start()
38
+
39
+
40
+ class DataWatcher:
41
+ """One watchdog observer over all detected providers' data directories.
42
+
43
+ ``on_change`` is called from a worker thread with the provider id after
44
+ each debounced burst of file events.
45
+ """
46
+
47
+ def __init__(
48
+ self, adapters: list[ProviderAdapter], on_change: Callable[[ProviderId], None]
49
+ ) -> None:
50
+ self._observer = Observer()
51
+ self._started = False
52
+ for adapter in adapters:
53
+ if not adapter.detected:
54
+ continue
55
+ handler = _Handler(adapter, on_change)
56
+ self._observer.schedule(handler, str(adapter.data_dir), recursive=True)
57
+ self._started = True
58
+
59
+ def start(self) -> None:
60
+ if self._started:
61
+ self._observer.start()
62
+ logger.info("Watching provider data directories for changes")
63
+
64
+ def stop(self) -> None:
65
+ if self._started and self._observer.is_alive():
66
+ self._observer.stop()
67
+ self._observer.join(timeout=3)
@@ -0,0 +1,34 @@
1
+ """Provider adapters for Claude Code, Codex CLI, and Gemini CLI."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from aipromptdock.providers.base import ProviderAdapter
7
+ from aipromptdock.providers.claude import ClaudeAdapter
8
+ from aipromptdock.providers.codex import CodexAdapter
9
+ from aipromptdock.providers.gemini import GeminiAdapter
10
+
11
+ __all__ = [
12
+ "ClaudeAdapter",
13
+ "CodexAdapter",
14
+ "GeminiAdapter",
15
+ "ProviderAdapter",
16
+ "default_adapters",
17
+ ]
18
+
19
+
20
+ def _env_dir(var: str, default: Path) -> Path:
21
+ value = os.environ.get(var)
22
+ return Path(value).expanduser() if value else default
23
+
24
+
25
+ def default_adapters() -> list[ProviderAdapter]:
26
+ """The three adapters, honoring each CLI's data-directory override."""
27
+ home = Path.home()
28
+ claude = ClaudeAdapter(_env_dir("CLAUDE_CONFIG_DIR", home / ".claude"))
29
+ codex = CodexAdapter(_env_dir("CODEX_HOME", home / ".codex"))
30
+ # Gemini directories are sha256(project path); paths known to Claude/Codex
31
+ # let us label Gemini prompts with real project names.
32
+ candidates = list(claude.iter_project_paths()) + list(codex.iter_project_paths())
33
+ gemini = GeminiAdapter(home / ".gemini", project_candidates=candidates)
34
+ return [claude, codex, gemini]
@@ -0,0 +1,74 @@
1
+ """Provider adapter contract."""
2
+
3
+ import json
4
+ import logging
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from aipromptdock.core.models import PromptEvent, ProviderId
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ProviderAdapter(ABC):
16
+ """Reads one CLI's on-disk prompt history.
17
+
18
+ Adapters must be defensive: the formats are undocumented internals of
19
+ third-party tools, so malformed lines are skipped (and logged at debug),
20
+ and a missing data directory simply means the provider is not detected.
21
+ Adapters never raise on bad data.
22
+ """
23
+
24
+ id: ProviderId
25
+ name: str
26
+
27
+ def __init__(self, data_dir: Path) -> None:
28
+ self.data_dir = data_dir
29
+
30
+ @property
31
+ def detected(self) -> bool:
32
+ return self.data_dir.is_dir()
33
+
34
+ @abstractmethod
35
+ def data_paths(self) -> list[Path]:
36
+ """Existing files this provider reads (doctor output + change fingerprint)."""
37
+
38
+ @abstractmethod
39
+ def scan(self) -> list[PromptEvent]:
40
+ """Parse all prompts from disk."""
41
+
42
+ def watch_filter(self, path: str) -> bool:
43
+ """Whether a filesystem event at ``path`` could change this provider's data."""
44
+ return True
45
+
46
+ def fingerprint(self) -> tuple[tuple[str, int, int], ...]:
47
+ """Cheap stat-based change token over everything ``scan()`` reads."""
48
+ stats = []
49
+ for p in self.data_paths():
50
+ try:
51
+ st = p.stat()
52
+ except OSError:
53
+ continue
54
+ stats.append((str(p), st.st_mtime_ns, st.st_size))
55
+ return tuple(sorted(stats))
56
+
57
+
58
+ def iter_jsonl(path: Path) -> Iterator[dict[str, Any]]:
59
+ """Yield parsed objects from a JSON-Lines file, skipping malformed lines."""
60
+ try:
61
+ with path.open(encoding="utf-8", errors="replace") as fh:
62
+ for line in fh:
63
+ line = line.strip()
64
+ if not line:
65
+ continue
66
+ try:
67
+ obj = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ logger.debug("Skipping malformed JSONL line in %s", path)
70
+ continue
71
+ if isinstance(obj, dict):
72
+ yield obj
73
+ except OSError as exc:
74
+ logger.debug("Cannot read %s: %s", path, exc)