msg-summarizer 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.
- msg_summarizer/__init__.py +0 -0
- msg_summarizer/advisor.py +125 -0
- msg_summarizer/backfill.py +112 -0
- msg_summarizer/cli.py +394 -0
- msg_summarizer/config.py +57 -0
- msg_summarizer/contacts.py +76 -0
- msg_summarizer/db.py +401 -0
- msg_summarizer/embeddings.py +152 -0
- msg_summarizer/indexer.py +104 -0
- msg_summarizer/models.py +44 -0
- msg_summarizer/stats.py +166 -0
- msg_summarizer/store.py +327 -0
- msg_summarizer/summarizer.py +531 -0
- msg_summarizer/web/__init__.py +0 -0
- msg_summarizer/web/app.py +838 -0
- msg_summarizer/web/static/assets/index-8M1XSupb.css +1 -0
- msg_summarizer/web/static/assets/index-BhdDljAs.js +43 -0
- msg_summarizer/web/static/index.html +13 -0
- msg_summarizer-0.1.0.dist-info/METADATA +178 -0
- msg_summarizer-0.1.0.dist-info/RECORD +23 -0
- msg_summarizer-0.1.0.dist-info/WHEEL +4 -0
- msg_summarizer-0.1.0.dist-info/entry_points.txt +2 -0
- msg_summarizer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from typing import Iterator
|
|
2
|
+
|
|
3
|
+
import anthropic
|
|
4
|
+
|
|
5
|
+
from .models import Conversation
|
|
6
|
+
from .summarizer import (
|
|
7
|
+
_DEFAULT_TOKENS_PER_MINUTE,
|
|
8
|
+
_RESPONSE_TOKENS,
|
|
9
|
+
_chunk_budget,
|
|
10
|
+
fit_recent,
|
|
11
|
+
format_transcript,
|
|
12
|
+
make_client,
|
|
13
|
+
_MODEL,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_SYSTEM_TEMPLATE = """\
|
|
17
|
+
You are a thoughtful, candid assistant helping the user navigate their iMessage conversations.
|
|
18
|
+
|
|
19
|
+
You have full access to the message history between the user and {contact} \
|
|
20
|
+
({count} messages, {start} to {end}). Use it to answer questions, offer honest \
|
|
21
|
+
advice on how to respond, interpret tone and intent, and draft replies when asked.
|
|
22
|
+
|
|
23
|
+
Be concise and direct. When suggesting a response, offer specific wording. \
|
|
24
|
+
When you notice patterns in the other person's communication style, point them out.
|
|
25
|
+
|
|
26
|
+
--- CONVERSATION ({count} messages, {start} to {end}) ---
|
|
27
|
+
{transcript}
|
|
28
|
+
--- END OF CONVERSATION ---\
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prepare_chat(
|
|
33
|
+
conversation: Conversation,
|
|
34
|
+
tokens_per_minute: int = _DEFAULT_TOKENS_PER_MINUTE,
|
|
35
|
+
) -> tuple[str, int, int, str, str]:
|
|
36
|
+
"""Trim *conversation* to fit the rate limit and build the chat system prompt.
|
|
37
|
+
|
|
38
|
+
Returns (system_prompt, used_count, dropped_count, start, end). An interactive
|
|
39
|
+
session can't be chunked, so the most recent messages that fit are kept.
|
|
40
|
+
"""
|
|
41
|
+
kept, dropped = fit_recent(conversation.messages, _chunk_budget(tokens_per_minute))
|
|
42
|
+
convo = Conversation(conversation.contact, kept)
|
|
43
|
+
start = convo.messages[0].timestamp.strftime("%Y-%m-%d")
|
|
44
|
+
end = convo.messages[-1].timestamp.strftime("%Y-%m-%d")
|
|
45
|
+
system_prompt = _SYSTEM_TEMPLATE.format(
|
|
46
|
+
contact=convo.contact,
|
|
47
|
+
count=len(kept),
|
|
48
|
+
start=start,
|
|
49
|
+
end=end,
|
|
50
|
+
transcript=format_transcript(convo),
|
|
51
|
+
)
|
|
52
|
+
return system_prompt, len(kept), dropped, start, end
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stream_chat(
|
|
56
|
+
client: anthropic.Anthropic,
|
|
57
|
+
system_prompt: str,
|
|
58
|
+
history: list[dict],
|
|
59
|
+
) -> Iterator[str]:
|
|
60
|
+
"""Stream a reply for *history* given the cached *system_prompt*, yielding text."""
|
|
61
|
+
with client.messages.stream(
|
|
62
|
+
model=_MODEL,
|
|
63
|
+
max_tokens=_RESPONSE_TOKENS,
|
|
64
|
+
system=[
|
|
65
|
+
{
|
|
66
|
+
"type": "text",
|
|
67
|
+
"text": system_prompt,
|
|
68
|
+
# Cache the transcript — stable for the whole session
|
|
69
|
+
"cache_control": {"type": "ephemeral"},
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
messages=history,
|
|
73
|
+
) as stream:
|
|
74
|
+
yield from stream.text_stream
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_chat(
|
|
78
|
+
conversation: Conversation,
|
|
79
|
+
tokens_per_minute: int = _DEFAULT_TOKENS_PER_MINUTE,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Start an interactive Q&A session about *conversation*. Streams responses."""
|
|
82
|
+
if not conversation.messages:
|
|
83
|
+
print("No messages to chat about.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
client = make_client(max_retries=8)
|
|
87
|
+
system_prompt, used, dropped, start, end = prepare_chat(conversation, tokens_per_minute)
|
|
88
|
+
|
|
89
|
+
if dropped:
|
|
90
|
+
print(
|
|
91
|
+
f"Note: this conversation is large — loaded the most recent "
|
|
92
|
+
f"{used:,} of {len(conversation.messages):,} messages to stay within "
|
|
93
|
+
f"the rate limit.\n"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
history: list[dict] = []
|
|
97
|
+
|
|
98
|
+
print(
|
|
99
|
+
f"Loaded {used} messages with {conversation.contact} "
|
|
100
|
+
f"({start} → {end}).\n"
|
|
101
|
+
"Ask anything about the conversation. Type 'quit' or press Ctrl+C to exit.\n"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
try:
|
|
106
|
+
user_input = input("> ").strip()
|
|
107
|
+
except (EOFError, KeyboardInterrupt):
|
|
108
|
+
print()
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
if not user_input:
|
|
112
|
+
continue
|
|
113
|
+
if user_input.lower() in {"quit", "exit", "q"}:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
history.append({"role": "user", "content": user_input})
|
|
117
|
+
|
|
118
|
+
print()
|
|
119
|
+
full_response = ""
|
|
120
|
+
for text in stream_chat(client, system_prompt, history):
|
|
121
|
+
print(text, end="", flush=True)
|
|
122
|
+
full_response += text
|
|
123
|
+
|
|
124
|
+
print("\n")
|
|
125
|
+
history.append({"role": "assistant", "content": full_response})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Backfill stored per-month summaries for past months on demand.
|
|
2
|
+
|
|
3
|
+
For each past month without a stored summary (in chronological order):
|
|
4
|
+
load the messages for that month, gather any earlier-stored summaries as
|
|
5
|
+
cross-month context, run summarize(), and persist the result. The current
|
|
6
|
+
month is intentionally skipped (it's still growing).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
from .models import Conversation, Message
|
|
13
|
+
from .stats import bucket_of, current_bucket
|
|
14
|
+
from .store import get_notes, list_summaries, save_summary
|
|
15
|
+
from .summarizer import EventHook, _MODEL, Cancelled, summarize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def backfill_summaries(
|
|
19
|
+
target_kind: str,
|
|
20
|
+
target_id: str,
|
|
21
|
+
contact_name: str,
|
|
22
|
+
messages: list[Message],
|
|
23
|
+
tokens_per_minute: int,
|
|
24
|
+
on_event: EventHook | None = None,
|
|
25
|
+
cancel_event: threading.Event | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Generate and persist any missing past-month summaries for *target*.
|
|
28
|
+
|
|
29
|
+
Emits structured events via *on_event*:
|
|
30
|
+
* backfill_start, total_existing, total_missing, missing_buckets
|
|
31
|
+
* bucket_start, bucket, index, total
|
|
32
|
+
* status, bucket, message (relayed from per-bucket summarize)
|
|
33
|
+
* bucket_done, bucket, text
|
|
34
|
+
* cancelled
|
|
35
|
+
* backfill_done
|
|
36
|
+
"""
|
|
37
|
+
cur = current_bucket()
|
|
38
|
+
|
|
39
|
+
by_bucket: dict[str, list[Message]] = defaultdict(list)
|
|
40
|
+
for m in messages:
|
|
41
|
+
by_bucket[bucket_of(m.timestamp)].append(m)
|
|
42
|
+
|
|
43
|
+
existing = list_summaries(target_kind, target_id)
|
|
44
|
+
missing = [b for b in sorted(by_bucket) if b != cur and b not in existing]
|
|
45
|
+
|
|
46
|
+
if on_event is not None:
|
|
47
|
+
on_event(
|
|
48
|
+
{
|
|
49
|
+
"type": "backfill_start",
|
|
50
|
+
"total_existing": len(existing),
|
|
51
|
+
"total_missing": len(missing),
|
|
52
|
+
"missing_buckets": missing,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not missing:
|
|
57
|
+
if on_event is not None:
|
|
58
|
+
on_event({"type": "backfill_done"})
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
def notes_provider() -> str:
|
|
62
|
+
return get_notes(target_kind, target_id)
|
|
63
|
+
|
|
64
|
+
for i, bucket in enumerate(missing, 1):
|
|
65
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
66
|
+
if on_event is not None:
|
|
67
|
+
on_event({"type": "cancelled"})
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if on_event is not None:
|
|
71
|
+
on_event(
|
|
72
|
+
{"type": "bucket_start", "bucket": bucket, "index": i, "total": len(missing)}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Gather prior-stored summaries (chronological order, oldest first).
|
|
76
|
+
prior_map = list_summaries(target_kind, target_id)
|
|
77
|
+
priors: list[tuple[str, str]] = sorted(
|
|
78
|
+
(b, t) for b, t in prior_map.items() if b < bucket
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
conv = Conversation(contact=contact_name, messages=by_bucket[bucket])
|
|
82
|
+
|
|
83
|
+
# Relay only status events from the inner summarize, tagged by bucket;
|
|
84
|
+
# suppress per-chunk part events to avoid flooding the backfill stream.
|
|
85
|
+
def bucket_event_hook(ev: dict, _b: str = bucket) -> None:
|
|
86
|
+
if on_event is None:
|
|
87
|
+
return
|
|
88
|
+
if ev.get("type") == "status":
|
|
89
|
+
on_event({"type": "status", "bucket": _b, "message": ev["message"]})
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
summary = summarize(
|
|
93
|
+
conv,
|
|
94
|
+
verbose=False,
|
|
95
|
+
tokens_per_minute=tokens_per_minute,
|
|
96
|
+
on_event=bucket_event_hook,
|
|
97
|
+
notes_provider=notes_provider,
|
|
98
|
+
cancel_event=cancel_event,
|
|
99
|
+
prior_summaries=priors,
|
|
100
|
+
)
|
|
101
|
+
except Cancelled:
|
|
102
|
+
if on_event is not None:
|
|
103
|
+
on_event({"type": "cancelled", "bucket": bucket})
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
save_summary(target_kind, target_id, bucket, summary, notes_provider(), _MODEL)
|
|
107
|
+
|
|
108
|
+
if on_event is not None:
|
|
109
|
+
on_event({"type": "bucket_done", "bucket": bucket, "text": summary})
|
|
110
|
+
|
|
111
|
+
if on_event is not None:
|
|
112
|
+
on_event({"type": "backfill_done"})
|
msg_summarizer/cli.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import pathlib
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import anthropic
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .advisor import run_chat
|
|
9
|
+
from .config import load_user_env
|
|
10
|
+
from .db import DB_PATH, fetch_messages, list_contacts
|
|
11
|
+
from .summarizer import _DEFAULT_TOKENS_PER_MINUTE, MissingAPIKey, summarize
|
|
12
|
+
|
|
13
|
+
# Load ~/.msg-summarizer/.env into os.environ before anything reads it.
|
|
14
|
+
load_user_env()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_date_range(
|
|
18
|
+
days: int | None,
|
|
19
|
+
from_date: str | None,
|
|
20
|
+
to_date: str | None,
|
|
21
|
+
) -> tuple[datetime.datetime | None, datetime.datetime | None]:
|
|
22
|
+
"""Resolve --days/--from/--to into UTC bounds; any bound may be None (unbounded).
|
|
23
|
+
|
|
24
|
+
With no options at all, returns (None, None) — i.e. the full history.
|
|
25
|
+
"""
|
|
26
|
+
if days is not None and (from_date or to_date):
|
|
27
|
+
raise click.UsageError("Use --days OR --from/--to, not both.")
|
|
28
|
+
|
|
29
|
+
if days is not None:
|
|
30
|
+
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
31
|
+
return now - datetime.timedelta(days=days), now
|
|
32
|
+
|
|
33
|
+
start = end = None
|
|
34
|
+
if from_date:
|
|
35
|
+
start = datetime.datetime.strptime(from_date, "%Y-%m-%d").replace(
|
|
36
|
+
tzinfo=datetime.timezone.utc
|
|
37
|
+
)
|
|
38
|
+
if to_date:
|
|
39
|
+
end = datetime.datetime.strptime(to_date, "%Y-%m-%d").replace(
|
|
40
|
+
tzinfo=datetime.timezone.utc
|
|
41
|
+
) + datetime.timedelta(days=1, seconds=-1)
|
|
42
|
+
return start, end
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _range_label(
|
|
46
|
+
start: datetime.datetime | None, end: datetime.datetime | None
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Human-readable description of a (possibly open-ended) date range."""
|
|
49
|
+
if start is None and end is None:
|
|
50
|
+
return "all time"
|
|
51
|
+
lo = start.date().isoformat() if start else "start"
|
|
52
|
+
hi = end.date().isoformat() if end else "now"
|
|
53
|
+
return f"{lo} → {hi}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.group()
|
|
57
|
+
def main() -> None:
|
|
58
|
+
"""Summarize iMessage conversations using Claude AI."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@main.command("list-contacts")
|
|
62
|
+
@click.option(
|
|
63
|
+
"--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
|
|
64
|
+
)
|
|
65
|
+
@click.option("--limit", default=50, show_default=True, help="Max contacts to show.")
|
|
66
|
+
def list_contacts_cmd(db_path: str | None, limit: int) -> None:
|
|
67
|
+
"""List contacts found in the iMessage database.
|
|
68
|
+
|
|
69
|
+
Use the HANDLE ID column as the CONTACT argument to 'summarize'.
|
|
70
|
+
"""
|
|
71
|
+
resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
|
|
72
|
+
try:
|
|
73
|
+
contacts = list_contacts(db_path=resolved_db, limit=limit)
|
|
74
|
+
except PermissionError as exc:
|
|
75
|
+
click.echo(f"Error: {exc}", err=True)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
if not contacts:
|
|
79
|
+
click.echo("No contacts found in the database.")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
col1 = max(len(h) for h, _, _ in contacts)
|
|
83
|
+
col2 = max((len(d) for _, d, _ in contacts), default=0)
|
|
84
|
+
col1 = max(col1, 9) # min width for "HANDLE ID"
|
|
85
|
+
col2 = max(col2, 12) # min width for "DISPLAY NAME"
|
|
86
|
+
|
|
87
|
+
header = f"{'HANDLE ID':<{col1}} {'DISPLAY NAME':<{col2}} MESSAGES"
|
|
88
|
+
click.echo(header)
|
|
89
|
+
click.echo("-" * len(header))
|
|
90
|
+
for handle_id, display_name, msg_count in contacts:
|
|
91
|
+
click.echo(f"{handle_id:<{col1}} {display_name:<{col2}} {msg_count}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@main.command("messages")
|
|
95
|
+
@click.argument("contact")
|
|
96
|
+
@click.option("--days", type=int, default=None, help="Only the last N days.")
|
|
97
|
+
@click.option(
|
|
98
|
+
"--from", "from_date", default=None, metavar="YYYY-MM-DD", help="Start date (inclusive, UTC)."
|
|
99
|
+
)
|
|
100
|
+
@click.option(
|
|
101
|
+
"--to", "to_date", default=None, metavar="YYYY-MM-DD", help="End date (inclusive, UTC)."
|
|
102
|
+
)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
|
|
105
|
+
)
|
|
106
|
+
def messages_cmd(
|
|
107
|
+
contact: str,
|
|
108
|
+
days: int | None,
|
|
109
|
+
from_date: str | None,
|
|
110
|
+
to_date: str | None,
|
|
111
|
+
db_path: str | None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Print the message history with CONTACT as plain text.
|
|
114
|
+
|
|
115
|
+
CONTACT is matched as a substring against phone numbers, email addresses,
|
|
116
|
+
and chat names. Each line is "[local timestamp] sender: text". With no date
|
|
117
|
+
options the full history is printed; --days/--from/--to narrow the range.
|
|
118
|
+
Run 'list-contacts' first to find the right identifier.
|
|
119
|
+
|
|
120
|
+
\b
|
|
121
|
+
Examples:
|
|
122
|
+
msg-summarizer messages "+15551234567"
|
|
123
|
+
msg-summarizer messages "+15551234567" --days 30
|
|
124
|
+
msg-summarizer messages "alice@example.com" --from 2025-01-01 --to 2025-06-30
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
start, end = _resolve_date_range(days, from_date, to_date)
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise click.UsageError(f"Invalid date format: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
|
|
132
|
+
|
|
133
|
+
click.echo(
|
|
134
|
+
f"Loading messages with '{contact}' ({_range_label(start, end)})…", err=True
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
conversation = fetch_messages(contact, db_path=resolved_db, start=start, end=end)
|
|
139
|
+
except PermissionError as exc:
|
|
140
|
+
click.echo(f"Error: {exc}", err=True)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
if not conversation.messages:
|
|
144
|
+
click.echo(
|
|
145
|
+
"No messages found. Run 'msg-summarizer list-contacts' to see available identifiers.",
|
|
146
|
+
err=True,
|
|
147
|
+
)
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
|
|
150
|
+
for msg in conversation.messages:
|
|
151
|
+
local_ts = msg.timestamp.astimezone()
|
|
152
|
+
click.echo(f"[{local_ts:%Y-%m-%d %H:%M}] {msg.sender}: {msg.text}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@main.command("summarize")
|
|
156
|
+
@click.argument("contact")
|
|
157
|
+
@click.option("--days", type=int, default=None, help="Summarize the last N days.")
|
|
158
|
+
@click.option(
|
|
159
|
+
"--from", "from_date", default=None, metavar="YYYY-MM-DD", help="Start date (inclusive)."
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--to", "to_date", default=None, metavar="YYYY-MM-DD", help="End date (inclusive)."
|
|
163
|
+
)
|
|
164
|
+
@click.option(
|
|
165
|
+
"--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
|
|
166
|
+
)
|
|
167
|
+
@click.option(
|
|
168
|
+
"--tokens-per-minute",
|
|
169
|
+
"tokens_per_minute",
|
|
170
|
+
type=int,
|
|
171
|
+
default=_DEFAULT_TOKENS_PER_MINUTE,
|
|
172
|
+
show_default=True,
|
|
173
|
+
help="Your account's input-tokens/min rate limit. Chunks are sized and paced "
|
|
174
|
+
"to stay under it; raise it if you're on a higher tier.",
|
|
175
|
+
)
|
|
176
|
+
@click.option("--verbose", is_flag=True, help="Show message count and cache stats.")
|
|
177
|
+
def summarize_cmd(
|
|
178
|
+
contact: str,
|
|
179
|
+
days: int | None,
|
|
180
|
+
from_date: str | None,
|
|
181
|
+
to_date: str | None,
|
|
182
|
+
db_path: str | None,
|
|
183
|
+
tokens_per_minute: int,
|
|
184
|
+
verbose: bool,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Summarize iMessage conversations with CONTACT using Claude AI.
|
|
187
|
+
|
|
188
|
+
CONTACT is matched as a substring against phone numbers, email addresses,
|
|
189
|
+
and chat names in the iMessage database. Run 'list-contacts' first to find
|
|
190
|
+
the right identifier. With no date options, the entire history is
|
|
191
|
+
summarized; --days/--from/--to narrow the range.
|
|
192
|
+
|
|
193
|
+
\b
|
|
194
|
+
Examples:
|
|
195
|
+
msg-summarizer summarize "+15551234567"
|
|
196
|
+
msg-summarizer summarize "+15551234567" --days 7
|
|
197
|
+
msg-summarizer summarize "alice@example.com" --from 2024-12-01 --to 2024-12-31
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
start, end = _resolve_date_range(days, from_date, to_date)
|
|
201
|
+
except ValueError as exc:
|
|
202
|
+
raise click.UsageError(f"Invalid date format: {exc}") from exc
|
|
203
|
+
|
|
204
|
+
resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
|
|
205
|
+
|
|
206
|
+
click.echo(
|
|
207
|
+
f"Fetching messages with '{contact}' ({_range_label(start, end)})…",
|
|
208
|
+
err=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
conversation = fetch_messages(contact, db_path=resolved_db, start=start, end=end)
|
|
213
|
+
except PermissionError as exc:
|
|
214
|
+
click.echo(f"Error: {exc}", err=True)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
if not conversation.messages:
|
|
218
|
+
click.echo(
|
|
219
|
+
"No messages found. Run 'msg-summarizer list-contacts' to see available identifiers.",
|
|
220
|
+
err=True,
|
|
221
|
+
)
|
|
222
|
+
sys.exit(0)
|
|
223
|
+
|
|
224
|
+
if verbose:
|
|
225
|
+
click.echo(f"Found {len(conversation.messages)} messages. Summarizing…", err=True)
|
|
226
|
+
else:
|
|
227
|
+
click.echo("Summarizing…", err=True)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
summary = summarize(conversation, verbose=verbose, tokens_per_minute=tokens_per_minute)
|
|
231
|
+
except MissingAPIKey as exc:
|
|
232
|
+
click.echo(f"Error: {exc}", err=True)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
except anthropic.APIError as exc:
|
|
235
|
+
click.echo(f"\nError from the Claude API: {exc}", err=True)
|
|
236
|
+
click.echo(
|
|
237
|
+
"Any completed parts were saved to the temp file noted above. "
|
|
238
|
+
"Try a narrower range (--days/--from/--to), a lower --tokens-per-minute, "
|
|
239
|
+
"or raise your tier.",
|
|
240
|
+
err=True,
|
|
241
|
+
)
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
click.echo(summary)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@main.command("chat")
|
|
247
|
+
@click.argument("contact")
|
|
248
|
+
@click.option("--days", type=int, default=None, help="Load the last N days of messages.")
|
|
249
|
+
@click.option(
|
|
250
|
+
"--from", "from_date", default=None, metavar="YYYY-MM-DD", help="Start date (inclusive)."
|
|
251
|
+
)
|
|
252
|
+
@click.option(
|
|
253
|
+
"--to", "to_date", default=None, metavar="YYYY-MM-DD", help="End date (inclusive)."
|
|
254
|
+
)
|
|
255
|
+
@click.option(
|
|
256
|
+
"--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
|
|
257
|
+
)
|
|
258
|
+
@click.option(
|
|
259
|
+
"--tokens-per-minute",
|
|
260
|
+
"tokens_per_minute",
|
|
261
|
+
type=int,
|
|
262
|
+
default=_DEFAULT_TOKENS_PER_MINUTE,
|
|
263
|
+
show_default=True,
|
|
264
|
+
help="Your account's input-tokens/min rate limit. A large history is trimmed "
|
|
265
|
+
"to the most recent messages that fit under it.",
|
|
266
|
+
)
|
|
267
|
+
def chat_cmd(
|
|
268
|
+
contact: str,
|
|
269
|
+
days: int | None,
|
|
270
|
+
from_date: str | None,
|
|
271
|
+
to_date: str | None,
|
|
272
|
+
db_path: str | None,
|
|
273
|
+
tokens_per_minute: int,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Chat interactively about a conversation — ask questions, get advice, draft replies.
|
|
276
|
+
|
|
277
|
+
With no date options, the entire history is loaded; --days/--from/--to
|
|
278
|
+
narrow the range.
|
|
279
|
+
|
|
280
|
+
\b
|
|
281
|
+
Examples:
|
|
282
|
+
msg-summarizer chat "+15551234567"
|
|
283
|
+
msg-summarizer chat "+15551234567" --days 30
|
|
284
|
+
msg-summarizer chat "alice@example.com" --from 2025-01-01 --to 2025-05-27
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
start, end = _resolve_date_range(days, from_date, to_date)
|
|
288
|
+
except ValueError as exc:
|
|
289
|
+
raise click.UsageError(f"Invalid date format: {exc}") from exc
|
|
290
|
+
|
|
291
|
+
resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
|
|
292
|
+
|
|
293
|
+
click.echo(
|
|
294
|
+
f"Loading messages with '{contact}' ({_range_label(start, end)})…",
|
|
295
|
+
err=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
conversation = fetch_messages(contact, db_path=resolved_db, start=start, end=end)
|
|
300
|
+
except PermissionError as exc:
|
|
301
|
+
click.echo(f"Error: {exc}", err=True)
|
|
302
|
+
sys.exit(1)
|
|
303
|
+
|
|
304
|
+
if not conversation.messages:
|
|
305
|
+
click.echo(
|
|
306
|
+
"No messages found. Run 'msg-summarizer list-contacts' to see available identifiers.",
|
|
307
|
+
err=True,
|
|
308
|
+
)
|
|
309
|
+
sys.exit(0)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
run_chat(conversation, tokens_per_minute=tokens_per_minute)
|
|
313
|
+
except MissingAPIKey as exc:
|
|
314
|
+
click.echo(f"Error: {exc}", err=True)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
except anthropic.APIError as exc:
|
|
317
|
+
click.echo(f"\nError from the Claude API: {exc}", err=True)
|
|
318
|
+
sys.exit(1)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@main.command("setup")
|
|
322
|
+
def setup_cmd() -> None:
|
|
323
|
+
"""Walk through first-run setup: API key + Full Disk Access check."""
|
|
324
|
+
import sqlite3
|
|
325
|
+
|
|
326
|
+
from . import config
|
|
327
|
+
from .db import DB_PATH
|
|
328
|
+
|
|
329
|
+
click.echo("msg-summarizer setup")
|
|
330
|
+
click.echo("====================\n")
|
|
331
|
+
|
|
332
|
+
# --- API key ---
|
|
333
|
+
have_key = bool(
|
|
334
|
+
__import__("os").environ.get("ANTHROPIC_API_KEY")
|
|
335
|
+
or __import__("os").environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
336
|
+
)
|
|
337
|
+
if have_key:
|
|
338
|
+
click.echo("✓ Anthropic API key already set in the environment.")
|
|
339
|
+
else:
|
|
340
|
+
click.echo("Anthropic API key not set.")
|
|
341
|
+
click.echo("Get one at https://console.anthropic.com/")
|
|
342
|
+
key = click.prompt("Paste your key (or press Enter to skip)", default="", show_default=False)
|
|
343
|
+
key = key.strip()
|
|
344
|
+
if not key:
|
|
345
|
+
click.echo(" Skipped. Summary and Chat will be disabled until set.")
|
|
346
|
+
elif not key.startswith(("sk-ant-", "sk-")):
|
|
347
|
+
click.echo(" That doesn't look like an Anthropic key (expected to start with sk-ant-).")
|
|
348
|
+
click.echo(" Skipped.")
|
|
349
|
+
else:
|
|
350
|
+
try:
|
|
351
|
+
config.save_env_var("ANTHROPIC_API_KEY", key)
|
|
352
|
+
click.echo(f"✓ Saved to {config.USER_ENV_FILE} (mode 0600).")
|
|
353
|
+
except OSError as exc:
|
|
354
|
+
click.echo(f" Couldn't write: {exc}")
|
|
355
|
+
click.echo("")
|
|
356
|
+
|
|
357
|
+
# --- Full Disk Access ---
|
|
358
|
+
fda_ok = False
|
|
359
|
+
try:
|
|
360
|
+
sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True).close()
|
|
361
|
+
fda_ok = True
|
|
362
|
+
except sqlite3.OperationalError:
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
if fda_ok:
|
|
366
|
+
click.echo(f"✓ Full Disk Access OK — {DB_PATH} is readable.")
|
|
367
|
+
else:
|
|
368
|
+
click.echo("✗ Can't open ~/Library/Messages/chat.db.")
|
|
369
|
+
click.echo(" Grant Full Disk Access to the app you'll run msg-summarizer from:")
|
|
370
|
+
click.echo(" 1. System Settings → Privacy & Security → Full Disk Access.")
|
|
371
|
+
click.echo(" 2. Click + and add your terminal app (Terminal, iTerm, Warp, VS Code, …).")
|
|
372
|
+
click.echo(" 3. Quit and reopen that app, then run msg-summarizer again.")
|
|
373
|
+
click.echo("")
|
|
374
|
+
|
|
375
|
+
if have_key or fda_ok:
|
|
376
|
+
click.echo("Done. Try `msg-summarizer serve` to launch the app.")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@main.command("serve")
|
|
380
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Host to bind.")
|
|
381
|
+
@click.option("--port", default=8765, show_default=True, type=int, help="Port to bind.")
|
|
382
|
+
@click.option("--no-browser", is_flag=True, help="Don't open a browser automatically.")
|
|
383
|
+
def serve_cmd(host: str, port: int, no_browser: bool) -> None:
|
|
384
|
+
"""Launch the web app (API + UI) and open it in your browser."""
|
|
385
|
+
import threading
|
|
386
|
+
import webbrowser
|
|
387
|
+
|
|
388
|
+
import uvicorn
|
|
389
|
+
|
|
390
|
+
url = f"http://{host}:{port}"
|
|
391
|
+
if not no_browser:
|
|
392
|
+
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
|
393
|
+
click.echo(f"Serving msg-summarizer at {url} (Ctrl+C to stop)…", err=True)
|
|
394
|
+
uvicorn.run("msg_summarizer.web.app:app", host=host, port=port, log_level="info")
|
msg_summarizer/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""User-level configuration: ~/.msg-summarizer/.env for persistent settings.
|
|
2
|
+
|
|
3
|
+
Loading order (highest priority wins, anything already set is kept):
|
|
4
|
+
1. Existing environment variables (explicit `export` or shell config).
|
|
5
|
+
2. ~/.msg-summarizer/.env (the set-it-and-forget-it persistent store).
|
|
6
|
+
|
|
7
|
+
So a user can `export ANTHROPIC_API_KEY=...` for a one-off override without
|
|
8
|
+
touching the file, and the UI can write to the file once for "next time".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
USER_CONFIG_DIR = pathlib.Path.home() / ".msg-summarizer"
|
|
17
|
+
USER_ENV_FILE = USER_CONFIG_DIR / ".env"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_user_env() -> None:
|
|
21
|
+
"""Merge ~/.msg-summarizer/.env into os.environ; existing vars take priority."""
|
|
22
|
+
if USER_ENV_FILE.exists():
|
|
23
|
+
load_dotenv(USER_ENV_FILE, override=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_env_var(key: str, value: str) -> None:
|
|
27
|
+
"""Persist a key=value pair to ~/.msg-summarizer/.env and update os.environ now.
|
|
28
|
+
|
|
29
|
+
Replaces an existing line for the same key (or appends one if absent).
|
|
30
|
+
Creates the file with mode 0600 the first time — it holds secrets.
|
|
31
|
+
"""
|
|
32
|
+
if not key.replace("_", "").isalnum():
|
|
33
|
+
raise ValueError(f"invalid env key: {key!r}")
|
|
34
|
+
|
|
35
|
+
USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
lines: list[str] = USER_ENV_FILE.read_text().splitlines() if USER_ENV_FILE.exists() else []
|
|
37
|
+
|
|
38
|
+
found = False
|
|
39
|
+
out: list[str] = []
|
|
40
|
+
for line in lines:
|
|
41
|
+
stripped = line.strip()
|
|
42
|
+
if "=" in stripped and not stripped.startswith("#"):
|
|
43
|
+
existing_key = stripped.split("=", 1)[0].strip()
|
|
44
|
+
if existing_key == key:
|
|
45
|
+
out.append(f"{key}={value}")
|
|
46
|
+
found = True
|
|
47
|
+
continue
|
|
48
|
+
out.append(line)
|
|
49
|
+
if not found:
|
|
50
|
+
out.append(f"{key}={value}")
|
|
51
|
+
|
|
52
|
+
USER_ENV_FILE.write_text("\n".join(out) + "\n")
|
|
53
|
+
try:
|
|
54
|
+
USER_ENV_FILE.chmod(0o600)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass # filesystem may not support chmod (e.g. exFAT); best-effort
|
|
57
|
+
os.environ[key] = value
|