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.
- aidebrief-0.1.0/PKG-INFO +64 -0
- aidebrief-0.1.0/README.md +37 -0
- aidebrief-0.1.0/aidebrief/__init__.py +7 -0
- aidebrief-0.1.0/aidebrief/__main__.py +439 -0
- aidebrief-0.1.0/aidebrief/capture.py +364 -0
- aidebrief-0.1.0/aidebrief/extract.py +481 -0
- aidebrief-0.1.0/aidebrief/main.py +322 -0
- aidebrief-0.1.0/aidebrief/store.py +925 -0
- aidebrief-0.1.0/aidebrief.egg-info/PKG-INFO +64 -0
- aidebrief-0.1.0/aidebrief.egg-info/SOURCES.txt +19 -0
- aidebrief-0.1.0/aidebrief.egg-info/dependency_links.txt +1 -0
- aidebrief-0.1.0/aidebrief.egg-info/entry_points.txt +2 -0
- aidebrief-0.1.0/aidebrief.egg-info/requires.txt +5 -0
- aidebrief-0.1.0/aidebrief.egg-info/top_level.txt +1 -0
- aidebrief-0.1.0/pyproject.toml +53 -0
- aidebrief-0.1.0/setup.cfg +4 -0
- aidebrief-0.1.0/tests/test_capture.py +288 -0
- aidebrief-0.1.0/tests/test_daemon.py +244 -0
- aidebrief-0.1.0/tests/test_extract.py +254 -0
- aidebrief-0.1.0/tests/test_main.py +235 -0
- aidebrief-0.1.0/tests/test_store.py +645 -0
aidebrief-0.1.0/PKG-INFO
ADDED
|
@@ -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,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()
|