aidebrief 0.1.0__tar.gz

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,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: aidebrief
3
+ Version: 0.1.0
4
+ Summary: Passive AI conversation capture, full-text search, and decision extraction for your local dev environment
5
+ Author-email: Deepan Karthik <deepankarthik@gmail.com>
6
+ License: MIT
7
+ Project-URL: homepage, https://github.com/deepankarthik/aidebrief
8
+ Project-URL: repository, https://github.com/deepankarthik/aidebrief
9
+ Project-URL: changelog, https://github.com/deepankarthik/aidebrief/releases
10
+ Keywords: ai,conversation,search,mcp,sqlite,fts5,copilot,claude,opencode
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Version Control
20
+ Classifier: Topic :: Text Processing :: Indexing
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: mcp>=1.0.0
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7.0; extra == "test"
26
+ Requires-Dist: ruff>=0.4; extra == "test"
27
+
28
+ # aidebrief
29
+
30
+ **Passive AI conversation capture + full-text search + decision extraction for your local dev environment.**
31
+
32
+ Every AI coding conversation — from Copilot, Claude Code, opencode — vanishes the
33
+ moment you close the session. aidebrief captures them automatically, extracts
34
+ engineering decisions, and makes everything searchable in a local SQLite database.
35
+ No cloud, no telemetry, no infrastructure.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ pip install aidebrief
41
+ aidebrief init
42
+ aidebrief daemon
43
+ aidebrief search "why did we use sqlite instead of postgres"
44
+ ```
45
+
46
+ ## How it works
47
+
48
+ aidebrief runs as a background daemon that monitors hook files dropped by AI
49
+ coding tools. When a conversation completes, it captures the transcript, indexes
50
+ it with FTS5, and extracts structured engineering decisions using regex patterns.
51
+ An MCP server exposes the database via tools that AI agents can call directly.
52
+
53
+ ## Features
54
+
55
+ - **Capture** — Hooks into Copilot, Claude Code, opencode, and git
56
+ - **Search** — Full-text search across all conversations
57
+ - **Context** — Given your current question, returns the most relevant past sessions
58
+ - **Decisions** — Automatically extracts structured engineering decisions
59
+ - **Contradictions** — Checks if a new proposal conflicts with past decisions
60
+ - **Supervise** — One combined call returning context + decisions + contradictions — designed for AI subagents
61
+
62
+ ## Requirements
63
+
64
+ Python 3.11+ and `mcp>=1.0.0`. No other dependencies.
@@ -0,0 +1,37 @@
1
+ # aidebrief
2
+
3
+ **Passive AI conversation capture + full-text search + decision extraction for your local dev environment.**
4
+
5
+ Every AI coding conversation — from Copilot, Claude Code, opencode — vanishes the
6
+ moment you close the session. aidebrief captures them automatically, extracts
7
+ engineering decisions, and makes everything searchable in a local SQLite database.
8
+ No cloud, no telemetry, no infrastructure.
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ pip install aidebrief
14
+ aidebrief init
15
+ aidebrief daemon
16
+ aidebrief search "why did we use sqlite instead of postgres"
17
+ ```
18
+
19
+ ## How it works
20
+
21
+ aidebrief runs as a background daemon that monitors hook files dropped by AI
22
+ coding tools. When a conversation completes, it captures the transcript, indexes
23
+ it with FTS5, and extracts structured engineering decisions using regex patterns.
24
+ An MCP server exposes the database via tools that AI agents can call directly.
25
+
26
+ ## Features
27
+
28
+ - **Capture** — Hooks into Copilot, Claude Code, opencode, and git
29
+ - **Search** — Full-text search across all conversations
30
+ - **Context** — Given your current question, returns the most relevant past sessions
31
+ - **Decisions** — Automatically extracts structured engineering decisions
32
+ - **Contradictions** — Checks if a new proposal conflicts with past decisions
33
+ - **Supervise** — One combined call returning context + decisions + contradictions — designed for AI subagents
34
+
35
+ ## Requirements
36
+
37
+ Python 3.11+ and `mcp>=1.0.0`. No other dependencies.
@@ -0,0 +1,7 @@
1
+ """
2
+ aidebrief — Passive AI conversation capture and full-text search.
3
+
4
+ Captures conversations from Copilot, Claude Code, and opencode into a
5
+ local SQLite database with FTS5 search. Provides an MCP server with
6
+ three tools: capture, search, health.
7
+ """
@@ -0,0 +1,439 @@
1
+ """Entry point for `aidebrief` CLI."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from .capture import Daemon, _ingest_opencode_events, ingest_hook_file
15
+ from .main import main as mcp_main
16
+ from .store import Store, get_default_db_path, get_default_hooks_dir
17
+
18
+
19
+ def _get_store(db_path: str | None = None) -> Store:
20
+ store = Store(db_path or get_default_db_path())
21
+ store.init()
22
+ return store
23
+
24
+
25
+ def _bold(text: str) -> str:
26
+ return f"\033[1m{text}\033[0m"
27
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(
31
+ prog="aidebrief",
32
+ description="Passive AI conversation capture + full-text search",
33
+ )
34
+ sub = parser.add_subparsers(dest="command")
35
+
36
+ sub.add_parser("daemon", help="Run the background capture daemon")
37
+ sub.add_parser("serve", help="Run the MCP server (stdio)")
38
+ sub.add_parser("health", help="Check server health")
39
+
40
+ init_parser = sub.add_parser("init", help="Install hooks and create database")
41
+ init_parser.add_argument("--db", help="Path to SQLite database", default="")
42
+ init_parser.add_argument("--hooks-dir", help="Hook install directory", default="")
43
+ init_parser.add_argument("--backfill", type=int, default=0,
44
+ help="Import last N opencode sessions into the DB")
45
+
46
+ capture_parser = sub.add_parser("capture", help="Capture a single hook file")
47
+ capture_parser.add_argument("file", help="Path to .complete.json hook file")
48
+
49
+ search_parser = sub.add_parser("search", help="Search captured conversations")
50
+ search_parser.add_argument("query", help="Search query")
51
+ search_parser.add_argument("--limit", type=int, default=10)
52
+ search_parser.add_argument("--source")
53
+
54
+ list_parser = sub.add_parser("list", help="List recent sessions")
55
+ list_parser.add_argument("--limit", type=int, default=20)
56
+ list_parser.add_argument("--source")
57
+
58
+ context_parser = sub.add_parser("context", help="Retrieve relevant past sessions as context")
59
+ context_parser.add_argument("query", help="Current prompt or topic to find context for")
60
+ context_parser.add_argument("--limit", type=int, default=3, help="Max sessions to return")
61
+ context_parser.add_argument("--source", help="Filter by source")
62
+ context_parser.add_argument("--file-paths", nargs="*", help="Current files being worked on")
63
+
64
+ decisions_parser = sub.add_parser("decisions", help="Search extracted engineering decisions")
65
+ decisions_parser.add_argument("query", nargs="?", help="Search topic or keyword (omit for all)")
66
+ decisions_parser.add_argument("--limit", type=int, default=10)
67
+ decisions_parser.add_argument("--source")
68
+ decisions_parser.add_argument("--file-paths", nargs="*")
69
+
70
+ contradictions_parser = sub.add_parser("contradictions", help="Check for contradictions with past decisions")
71
+ contradictions_parser.add_argument("query", help="Current prompt or proposal")
72
+ contradictions_parser.add_argument("--file-paths", nargs="*")
73
+
74
+ supervise_parser = sub.add_parser(
75
+ "supervise",
76
+ help="Combined context + decisions + contradictions for the current prompt",
77
+ )
78
+ supervise_parser.add_argument("query", help="Current user message")
79
+ supervise_parser.add_argument("--file-paths", nargs="*")
80
+
81
+ sub.add_parser("open", help="Open the database in a SQLite browser")
82
+ sub.add_parser("status", help="Database summary")
83
+
84
+ prune_parser = sub.add_parser("prune", help="Delete old sessions with tiered retention")
85
+ prune_parser.add_argument("--days", type=int, default=30,
86
+ help="Sessions older than this many days are candidates (default: 30)")
87
+ prune_parser.add_argument("--apply", action="store_true",
88
+ help="Actually delete (default is dry-run)")
89
+ prune_parser.add_argument("--include-committed", action="store_true",
90
+ help="Also delete committed sessions (default: protected)")
91
+ prune_parser.add_argument("--keep-last", type=int, default=0,
92
+ help="Always keep the N most recent sessions (default: 0)")
93
+
94
+ args = parser.parse_args()
95
+
96
+ dispatch = {
97
+ "init": lambda: _do_init(args.db, args.hooks_dir, args.backfill),
98
+ "capture": lambda: _do_capture(args.file),
99
+ "search": lambda: _do_search(args.query, args.limit, args.source),
100
+ "context": lambda: _do_context(args.query, args.limit, args.source, args.file_paths),
101
+ "decisions": lambda: _do_decisions(args.query, args.limit, args.source, args.file_paths),
102
+ "contradictions": lambda: _do_contradictions(args.query, args.file_paths),
103
+ "supervise": lambda: _do_supervise(args.query, args.file_paths),
104
+ "list": lambda: _do_list(args.limit, args.source),
105
+ "open": _do_open,
106
+ "status": _do_status,
107
+ "prune": lambda: _do_prune(args.days, args.apply, args.include_committed, args.keep_last),
108
+ "daemon": lambda: Daemon(get_default_db_path()).run(),
109
+ "serve": lambda: asyncio.run(mcp_main()),
110
+ "health": _do_health,
111
+ }
112
+ handler = dispatch.get(args.command)
113
+ if handler:
114
+ handler()
115
+ else:
116
+ parser.print_help()
117
+ sys.exit(1)
118
+
119
+
120
+
121
+ def _do_init(db: str, hooks_dir: str, backfill: int = 0) -> None:
122
+ db_path = Path(db or get_default_db_path()).expanduser()
123
+ hooks_path = Path(hooks_dir or get_default_hooks_dir(str(db_path))).expanduser()
124
+
125
+ hooks_path.mkdir(parents=True, exist_ok=True)
126
+ db_path.parent.mkdir(parents=True, exist_ok=True)
127
+
128
+ _init_install_resources(hooks_path)
129
+ store = _init_create_db_and_workdirs(db_path, hooks_path)
130
+ _init_backfill(store, hooks_path, db_path, backfill)
131
+ _init_print_instructions(hooks_path)
132
+
133
+
134
+ def _init_install_resources(hooks_path: Path) -> None:
135
+ resources = Path(__file__).parent.parent.parent / "resources"
136
+ entries = [
137
+ ("hooks/aidebrief_hook.py", None),
138
+ ("hooks/post-commit", None),
139
+ ("bridge/opencode_bridge.py", None),
140
+ ]
141
+ for rel, dst_name in entries:
142
+ src = resources / rel
143
+ if not src.exists():
144
+ continue
145
+ dst = hooks_path / (dst_name or src.name)
146
+ shutil.copy2(str(src), str(dst))
147
+ dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
148
+ print(f" Hook: {dst}")
149
+
150
+
151
+ def _init_create_db_and_workdirs(db_path: Path, hooks_path: Path) -> Store:
152
+ store = Store(str(db_path))
153
+ store.init()
154
+ print(f" Database: {db_path}")
155
+
156
+ for name in ("runs", "commits"):
157
+ (hooks_path / name).mkdir(parents=True, exist_ok=True)
158
+ print(f" {name.capitalize()} dir: {hooks_path / name}")
159
+
160
+ return store
161
+
162
+
163
+ def _init_backfill(store: Store, hooks_path: Path, db_path: Path, count: int) -> None:
164
+ if count <= 0:
165
+ return
166
+ bridge_script = hooks_path / "opencode_bridge.py"
167
+ if not bridge_script.exists():
168
+ return
169
+ project_flag = []
170
+ if os.environ.get("AIDEBRIEF_DB"):
171
+ project_flag = ["--project-dir", str(db_path.parent.parent.resolve())]
172
+ print(f"\n Backfilling last {count} opencode sessions...")
173
+ try:
174
+ result = subprocess.run(
175
+ [sys.executable, str(bridge_script), "--backfill", str(count), *project_flag],
176
+ capture_output=True, text=True, timeout=60,
177
+ )
178
+ ingested = _ingest_opencode_events(store, result.stdout)
179
+ print(f" Backfilled {ingested} opencode sessions")
180
+ except Exception as e:
181
+ print(f" Backfill error: {e}")
182
+
183
+
184
+ def _init_print_instructions(hooks_path: Path) -> None:
185
+ print()
186
+ print("aidebrief initialized.")
187
+ print()
188
+ print("To enable Copilot hooks, add to ~/.copilot/hooks/<name>.json:")
189
+ print(json.dumps({
190
+ "hooks": {
191
+ "Stop": [{
192
+ "type": "command",
193
+ "command": f"python3 {hooks_path / 'aidebrief_hook.py'}",
194
+ "timeout": 30,
195
+ }],
196
+ },
197
+ }, indent=2))
198
+ print()
199
+ print("Then enable: VS Code setting 'chat.useHooks: true'")
200
+ print()
201
+ print("To enable post-commit hooks:")
202
+ print(f" git config --global core.hooksPath {hooks_path}")
203
+ print()
204
+ print("To enable opencode bridge:")
205
+ print(" aidebrief daemon")
206
+ print()
207
+ print("For Claude Code, add to ~/.claude.json:")
208
+ print(json.dumps({
209
+ "mcpServers": {
210
+ "aidebrief": {
211
+ "command": sys.executable,
212
+ "args": ["-m", "aidebrief"],
213
+ },
214
+ },
215
+ }, indent=2))
216
+
217
+
218
+ def _do_health() -> None:
219
+ store = _get_store()
220
+ health = store.health()
221
+ print(json.dumps(health, indent=2))
222
+
223
+
224
+ def _do_capture(file: str) -> None:
225
+ store = _get_store()
226
+ result = ingest_hook_file(store, file)
227
+ print(json.dumps(result, indent=2))
228
+
229
+
230
+ def _do_search(query: str, limit: int, source: str | None) -> None:
231
+ store = _get_store()
232
+ results = store.search_sessions(query, source=source, limit=limit)
233
+ print(json.dumps(results, indent=2, default=str))
234
+
235
+
236
+ def _do_context(
237
+ query: str, limit: int, source: str | None, file_paths: list[str] | None,
238
+ ) -> None:
239
+ store = _get_store()
240
+ sessions = store.get_context(
241
+ query=query,
242
+ source=source,
243
+ max_sessions=limit,
244
+ file_paths=file_paths or None,
245
+ )
246
+
247
+ if not sessions:
248
+ print("No relevant past sessions found.")
249
+ return
250
+
251
+ for s in sessions:
252
+ started = datetime.fromtimestamp(s["started_at"] / 1000).strftime("%b %d %H:%M")
253
+ print(f"## Session: {s['title']} ({s['source']}, {started})")
254
+ print(f" Score: {s['score']:.2f} "
255
+ f"(relevance={s['fts_score']:.2f}, "
256
+ f"recency={s['recency_score']:.2f}, "
257
+ f"file_overlap={s['file_overlap_score']:.2f})")
258
+ print()
259
+ for msg in s["messages"]:
260
+ if msg["prompt"]:
261
+ prompt = msg["prompt"][:200]
262
+ print(f" User: {prompt}")
263
+ if msg["response"]:
264
+ resp = msg["response"][:200]
265
+ print(f" Assistant: {resp}")
266
+ if msg.get("file_paths"):
267
+ print(f" Files: {' '.join(msg['file_paths'])}")
268
+ print()
269
+ print()
270
+
271
+
272
+ def _do_decisions(
273
+ query: str | None, limit: int, source: str | None, file_paths: list[str] | None,
274
+ ) -> None:
275
+ store = _get_store()
276
+ results = store.search_decisions(
277
+ query=query, source=source, file_paths=file_paths or None, limit=limit,
278
+ )
279
+ print(json.dumps(results, indent=2, default=str))
280
+
281
+
282
+ def _do_contradictions(query: str, file_paths: list[str] | None) -> None:
283
+ store = _get_store()
284
+ results = store.check_contradictions(query=query, file_paths=file_paths or None)
285
+
286
+ if not results:
287
+ print("No contradictions found.")
288
+ return
289
+
290
+ print("## Potential Contradictions")
291
+ for d in results:
292
+ topic = d.get("topic") or "unknown"
293
+ print()
294
+ print(f"### {topic}")
295
+ print(f"- **Decision:** {d['decision_text']}")
296
+ if d.get("rationale"):
297
+ print(f"- **Rationale:** {d['rationale']}")
298
+ if d.get("related_files"):
299
+ files = d["related_files"]
300
+ if isinstance(files, list):
301
+ print(f"- **Files:** {' '.join(files)}")
302
+ print(f"- **Session:** {d['session_id'][:12]} ({d['source']})")
303
+ print(f"- **Status:** {d['status']}")
304
+
305
+
306
+ def _do_supervise(query: str, file_paths: list[str] | None) -> None:
307
+ store = _get_store()
308
+ result = store.get_supervisory_context(query=query, file_paths=file_paths)
309
+ print(json.dumps(result, indent=2, default=str))
310
+
311
+
312
+ def _do_status() -> None:
313
+ store = _get_store()
314
+ s = store.status()
315
+
316
+ size_mb = s["db_size_bytes"] / (1024 * 1024)
317
+ print(f"Database: {s['db_path']} ({size_mb:.1f} MB)")
318
+ print()
319
+
320
+ header = (
321
+ f"{'Sessions':<10} {'Messages':<10} {'Decisions':<10} "
322
+ f"{'Committed':<11} {'Uncommitted':<13} {'Pending Extr':<12}"
323
+ )
324
+ print(header)
325
+ print("-" * len(header))
326
+ print(
327
+ f"{s['sessions']:<10} {s['messages']:<10} {s['decisions']:<10} "
328
+ f"{s['committed_sessions']:<11} {s['uncommitted_sessions']:<13} {s['pending_extraction']:<12}"
329
+ )
330
+
331
+ if s["last_session_at"]:
332
+ dt = datetime.fromtimestamp(s["last_session_at"] / 1000).strftime("%b %d %H:%M")
333
+ title = (s["last_session_title"] or "")[:60]
334
+ source = s["last_session_source"] or "?"
335
+ print()
336
+ print(f'Last session: "{title}" ({source}, {dt})')
337
+
338
+
339
+ def _do_list(limit: int, source: str | None) -> None:
340
+ store = _get_store()
341
+ sessions = store.list_sessions(limit=limit, source=source)
342
+
343
+ if not sessions:
344
+ print("No sessions found.")
345
+ return
346
+
347
+ header = f"{'Session':<12} {'Source':<12} {'Msgs':<5} {'Date':<17} {'Title'}"
348
+ print(header)
349
+ print("-" * len(header))
350
+ for s in sessions:
351
+ sid = s["id"][:12]
352
+ source_label = s["source"][:12]
353
+ msgs = s["message_count"]
354
+ started = datetime.fromtimestamp(s["started_at"] / 1000).strftime("%b %d %H:%M")
355
+ title = (s["title"] or "")[:60]
356
+ print(f"{sid:<12} {source_label:<12} {msgs:<5} {started:<17} {title}")
357
+
358
+
359
+ def _do_open() -> None:
360
+ db_path = get_default_db_path()
361
+ if not os.path.exists(db_path):
362
+ print(f"Database not found at {db_path}")
363
+ return
364
+ print(f"Database: {db_path}")
365
+ print()
366
+
367
+ viewers = [
368
+ ("sqlite3", [shutil.which("sqlite3"), db_path]),
369
+ ("sqlitebrowser", [shutil.which("sqlitebrowser"), db_path]),
370
+ ("datasette", [shutil.which("datasette"), db_path]),
371
+ ("lazycli", [shutil.which("lazycli"), db_path]),
372
+ ]
373
+ viewers = [(name, args) for name, args in viewers if args[0] is not None]
374
+
375
+ if not viewers:
376
+ print("No SQLite viewer found. Install one:")
377
+ print(" sudo apt install sqlite3 # CLI")
378
+ print(" npm install -g datasette # Web UI")
379
+ print(" sudo apt install sqlitebrowser # GUI")
380
+ print()
381
+ print("Or open it manually:")
382
+ print(f" sqlite3 {db_path}")
383
+ return
384
+
385
+ for name, args in viewers:
386
+ print(f"Opening with {name}...")
387
+ try:
388
+ subprocess.run([a for a in args if a is not None], check=True)
389
+ return
390
+ except (FileNotFoundError, subprocess.CalledProcessError):
391
+ continue
392
+
393
+ print("Could not open database with any viewer.")
394
+ print(f"Try: sqlite3 {db_path}")
395
+
396
+
397
+ def _do_prune(days: int, apply: bool = False, include_committed: bool = False, keep_last: int = 0) -> None:
398
+ db_path = get_default_db_path()
399
+ if not os.path.exists(db_path):
400
+ print(f"Database not found at {db_path}")
401
+ return
402
+ store = _get_store(db_path)
403
+ dry_run = not apply
404
+ result = store.prune_sessions(
405
+ days=days,
406
+ include_committed=include_committed,
407
+ keep_last=keep_last,
408
+ dry_run=dry_run,
409
+ )
410
+ c = result["total_candidates"]
411
+ if c == 0:
412
+ print(f"No sessions older than {days} days to prune.")
413
+ return
414
+
415
+ label = "Would delete" if dry_run else "Deleted"
416
+ print(f" {_bold(label)} {c} session{'s' if c != 1 else ''} older than {days} days:")
417
+ print(f" Committed (protected): {result['committed']:>3}")
418
+ print(f" Uncommitted with decisions: {result['uncommitted_with_decisions']:>3}")
419
+ print(f" Uncommitted without decisions: {result['uncommitted_without_decisions']:>3}")
420
+
421
+ if result["oldest_title"]:
422
+ oldest_dt = datetime.fromtimestamp(result["oldest_started_at"] / 1000, tz=timezone.utc)
423
+ newest_dt = datetime.fromtimestamp(result["newest_started_at"] / 1000, tz=timezone.utc)
424
+ print(f" Oldest: \"{result['oldest_title']}\" ({oldest_dt.strftime('%b %d')})")
425
+ print(f" Newest: \"{result['newest_title']}\" ({newest_dt.strftime('%b %d')})")
426
+
427
+ if dry_run:
428
+ print()
429
+ print(f" Pass {_bold('--apply')} to delete,"
430
+ f" or {_bold('--include-committed')} to also delete committed sessions.")
431
+ if keep_last > 0:
432
+ print(f" {_bold(f'--keep-last {keep_last}')} is active"
433
+ f" (protecting the {keep_last} most recent sessions).")
434
+ else:
435
+ print(f" Deleted {result['deleted']} session(s).")
436
+
437
+
438
+ if __name__ == "__main__":
439
+ main()