work-recall 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.
- recall/__init__.py +3 -0
- recall/cli.py +418 -0
- recall/config/__init__.py +5 -0
- recall/config/secrets.py +44 -0
- recall/config/settings.py +70 -0
- recall/connectors/__init__.py +16 -0
- recall/connectors/calendar.py +65 -0
- recall/connectors/composio_client.py +71 -0
- recall/connectors/gmail.py +51 -0
- recall/connectors/linear.py +57 -0
- recall/connectors/slack.py +71 -0
- recall/constants.py +45 -0
- recall/extract/__init__.py +1 -0
- recall/extract/blockers.py +52 -0
- recall/extract/commitments.py +48 -0
- recall/extract/entities.py +78 -0
- recall/extract/projects.py +55 -0
- recall/extract/risks.py +52 -0
- recall/extract/signals.py +109 -0
- recall/intelligence/__init__.py +1 -0
- recall/intelligence/followups.py +36 -0
- recall/intelligence/meeting_prep.py +113 -0
- recall/intelligence/risks.py +68 -0
- recall/intelligence/standup.py +51 -0
- recall/intelligence/timeline.py +78 -0
- recall/intelligence/today.py +72 -0
- recall/llm/__init__.py +5 -0
- recall/llm/openai_client.py +32 -0
- recall/llm/prompts.py +121 -0
- recall/memory/__init__.py +1 -0
- recall/memory/cleanup.py +12 -0
- recall/memory/promoter.py +113 -0
- recall/memory/router.py +218 -0
- recall/memory/scorer.py +36 -0
- recall/normalize/__init__.py +16 -0
- recall/normalize/base.py +58 -0
- recall/normalize/calendar.py +56 -0
- recall/normalize/gmail.py +83 -0
- recall/normalize/linear.py +86 -0
- recall/normalize/slack.py +93 -0
- recall/output/__init__.py +5 -0
- recall/output/console.py +7 -0
- recall/output/formatters.py +277 -0
- recall/sample/__init__.py +1 -0
- recall/sample/data/calendar.json +88 -0
- recall/sample/data/gmail.json +120 -0
- recall/sample/data/linear.json +174 -0
- recall/sample/data/slack.json +132 -0
- recall/sample/seed.py +53 -0
- recall/storage/__init__.py +10 -0
- recall/storage/chunks.py +77 -0
- recall/storage/commitments.py +85 -0
- recall/storage/db.py +55 -0
- recall/storage/entities.py +131 -0
- recall/storage/graph.py +82 -0
- recall/storage/hot_memory.py +77 -0
- recall/storage/migrations.py +56 -0
- recall/storage/normalized_events.py +148 -0
- recall/storage/raw_events.py +142 -0
- recall/storage/schema.sql +160 -0
- recall/storage/sync_state.py +87 -0
- recall/sync/__init__.py +5 -0
- recall/sync/checkpoint.py +37 -0
- recall/sync/planner.py +33 -0
- recall/sync/runner.py +156 -0
- recall/sync/scheduler.py +80 -0
- recall/vector/__init__.py +1 -0
- recall/vector/embeddings.py +57 -0
- recall/vector/search.py +65 -0
- recall/vector/sqlite_vec.py +12 -0
- work_recall-0.1.0.dist-info/METADATA +118 -0
- work_recall-0.1.0.dist-info/RECORD +75 -0
- work_recall-0.1.0.dist-info/WHEEL +4 -0
- work_recall-0.1.0.dist-info/entry_points.txt +2 -0
- work_recall-0.1.0.dist-info/licenses/LICENSE +21 -0
recall/__init__.py
ADDED
recall/cli.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Recall CLI entry point. Only orchestration lives here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from recall import __version__
|
|
11
|
+
from recall.config.secrets import get_secret, redact, set_secret
|
|
12
|
+
from recall.config.settings import load_config, save_config
|
|
13
|
+
from recall.constants import (
|
|
14
|
+
CONFIG_PATH,
|
|
15
|
+
DB_PATH,
|
|
16
|
+
LOG_DIR,
|
|
17
|
+
LOG_PATH,
|
|
18
|
+
RECALL_HOME,
|
|
19
|
+
)
|
|
20
|
+
from recall.output.console import console
|
|
21
|
+
from recall.output.formatters import (
|
|
22
|
+
render_followups,
|
|
23
|
+
render_meeting_prep,
|
|
24
|
+
render_risks,
|
|
25
|
+
render_standup,
|
|
26
|
+
render_sync_summary,
|
|
27
|
+
render_timeline,
|
|
28
|
+
render_today,
|
|
29
|
+
)
|
|
30
|
+
from recall.storage import ensure_schema, get_connection
|
|
31
|
+
from recall.storage import sync_state as sync_state_repo
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(help="Recall — local-first AI chief of staff for engineers.", no_args_is_help=True)
|
|
34
|
+
config_app = typer.Typer(help="Manage local config.")
|
|
35
|
+
connect_app = typer.Typer(help="Connect a data source.")
|
|
36
|
+
schedule_app = typer.Typer(help="Local scheduler.")
|
|
37
|
+
app.add_typer(config_app, name="config")
|
|
38
|
+
app.add_typer(connect_app, name="connect")
|
|
39
|
+
app.add_typer(schedule_app, name="schedule")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# global setup
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
def _setup_logging() -> None:
|
|
46
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
logging.basicConfig(
|
|
48
|
+
level=logging.INFO,
|
|
49
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
50
|
+
handlers=[logging.FileHandler(LOG_PATH), logging.StreamHandler(sys.stderr)],
|
|
51
|
+
)
|
|
52
|
+
# Quiet down third-party noise
|
|
53
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
54
|
+
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _open_db():
|
|
58
|
+
conn = get_connection()
|
|
59
|
+
ensure_schema(conn)
|
|
60
|
+
return conn
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# init / doctor
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
@app.command()
|
|
67
|
+
def init() -> None:
|
|
68
|
+
"""Initialize Recall: create ~/.recall and run migrations."""
|
|
69
|
+
RECALL_HOME.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
_setup_logging()
|
|
72
|
+
if not CONFIG_PATH.exists():
|
|
73
|
+
save_config(load_config())
|
|
74
|
+
conn = _open_db()
|
|
75
|
+
conn.close()
|
|
76
|
+
console.print(f"[green]✓[/] Initialized [bold]{RECALL_HOME}[/]")
|
|
77
|
+
console.print(f" • DB: {DB_PATH}")
|
|
78
|
+
console.print(f" • Config: {CONFIG_PATH}")
|
|
79
|
+
console.print(f" • Logs: {LOG_PATH}")
|
|
80
|
+
console.print()
|
|
81
|
+
console.print("Next steps:")
|
|
82
|
+
console.print(" [cyan]recall config set OPENAI_API_KEY <key>[/]")
|
|
83
|
+
console.print(" [cyan]recall config set COMPOSIO_API_KEY <key>[/]")
|
|
84
|
+
console.print(" [cyan]recall sync --sample[/] (try the demo data)")
|
|
85
|
+
console.print(" [cyan]recall connect slack[/] (or gmail/calendar/linear)")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def doctor() -> None:
|
|
90
|
+
"""Check the local setup."""
|
|
91
|
+
_setup_logging()
|
|
92
|
+
cfg = load_config()
|
|
93
|
+
console.print(f"[bold]Recall {__version__}[/]")
|
|
94
|
+
|
|
95
|
+
def _check(label: str, ok: bool, detail: str = "") -> None:
|
|
96
|
+
mark = "[green]✓[/]" if ok else "[red]✗[/]"
|
|
97
|
+
console.print(f" {mark} {label}{(' — ' + detail) if detail else ''}")
|
|
98
|
+
|
|
99
|
+
_check("Home dir", RECALL_HOME.exists(), str(RECALL_HOME))
|
|
100
|
+
_check("Config file", CONFIG_PATH.exists(), str(CONFIG_PATH))
|
|
101
|
+
_check("Database", DB_PATH.exists(), str(DB_PATH))
|
|
102
|
+
_check("OpenAI key", bool(cfg.openai_api_key), redact(cfg.openai_api_key))
|
|
103
|
+
_check("Composio key", bool(cfg.composio_api_key), redact(cfg.composio_api_key))
|
|
104
|
+
|
|
105
|
+
# Try opening the DB and loading sqlite-vec
|
|
106
|
+
try:
|
|
107
|
+
conn = _open_db()
|
|
108
|
+
from recall.storage.db import try_load_sqlite_vec
|
|
109
|
+
|
|
110
|
+
vec_ok = try_load_sqlite_vec(conn)
|
|
111
|
+
conn.close()
|
|
112
|
+
_check("sqlite-vec", vec_ok, "vector search enabled" if vec_ok else "extension not loadable")
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
_check("Database open", False, str(exc))
|
|
115
|
+
|
|
116
|
+
for source, state in [(s.source, s) for s in _list_sync_states()]:
|
|
117
|
+
last = state.last_synced_at.isoformat() if state.last_synced_at else "never"
|
|
118
|
+
console.print(f" • {source}: {state.status or 'idle'} (last sync: {last})")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _list_sync_states():
|
|
122
|
+
conn = _open_db()
|
|
123
|
+
try:
|
|
124
|
+
return sync_state_repo.list_all(conn)
|
|
125
|
+
finally:
|
|
126
|
+
conn.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# config
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
@config_app.command("set")
|
|
133
|
+
def config_set(key: str, value: str) -> None:
|
|
134
|
+
"""Set a config key. Supported keys: OPENAI_API_KEY, COMPOSIO_API_KEY."""
|
|
135
|
+
upper = key.strip().upper()
|
|
136
|
+
if upper in ("OPENAI_API_KEY", "COMPOSIO_API_KEY"):
|
|
137
|
+
set_secret(upper, value) # type: ignore[arg-type]
|
|
138
|
+
console.print(f"[green]✓[/] {upper} updated.")
|
|
139
|
+
# Force the OpenAI client cache to rebuild on next use.
|
|
140
|
+
try:
|
|
141
|
+
from recall.llm.openai_client import reset_client_cache
|
|
142
|
+
|
|
143
|
+
reset_client_cache()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return
|
|
147
|
+
# Generic fall-through into the "extra" bag
|
|
148
|
+
cfg = load_config()
|
|
149
|
+
cfg.extra[key] = value
|
|
150
|
+
save_config(cfg)
|
|
151
|
+
console.print(f"[green]✓[/] {key} = {value}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@config_app.command("get")
|
|
155
|
+
def config_get(key: str) -> None:
|
|
156
|
+
"""Read a config key."""
|
|
157
|
+
upper = key.strip().upper()
|
|
158
|
+
if upper in ("OPENAI_API_KEY", "COMPOSIO_API_KEY"):
|
|
159
|
+
console.print(redact(get_secret(upper))) # type: ignore[arg-type]
|
|
160
|
+
return
|
|
161
|
+
cfg = load_config()
|
|
162
|
+
console.print(cfg.extra.get(key, "(not set)"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@config_app.command("list")
|
|
166
|
+
def config_list() -> None:
|
|
167
|
+
"""List config keys."""
|
|
168
|
+
cfg = load_config()
|
|
169
|
+
console.print("[bold]Secrets[/]")
|
|
170
|
+
console.print(f" OPENAI_API_KEY = {redact(cfg.openai_api_key)}")
|
|
171
|
+
console.print(f" COMPOSIO_API_KEY = {redact(cfg.composio_api_key)}")
|
|
172
|
+
console.print("[bold]Schedule[/]")
|
|
173
|
+
console.print(f" enabled = {cfg.schedule.enabled}")
|
|
174
|
+
console.print(f" every_hours = {cfg.schedule.every_hours}")
|
|
175
|
+
console.print("[bold]Connectors[/]")
|
|
176
|
+
for source in ("slack", "gmail", "calendar", "linear"):
|
|
177
|
+
state = getattr(cfg.connectors, source)
|
|
178
|
+
console.print(
|
|
179
|
+
f" {source:9s} connected={state.connected} "
|
|
180
|
+
f"id={state.connection_id or '—'}"
|
|
181
|
+
)
|
|
182
|
+
if cfg.extra:
|
|
183
|
+
console.print("[bold]Extra[/]")
|
|
184
|
+
for k, v in cfg.extra.items():
|
|
185
|
+
console.print(f" {k} = {v}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# connect
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
def _connect_source(source: str) -> None:
|
|
192
|
+
_setup_logging()
|
|
193
|
+
from recall.connectors import CONNECTORS
|
|
194
|
+
from recall.connectors.composio_client import ComposioKeyMissing
|
|
195
|
+
|
|
196
|
+
connector = CONNECTORS[source]
|
|
197
|
+
try:
|
|
198
|
+
handshake = connector.connect()
|
|
199
|
+
except ComposioKeyMissing as exc:
|
|
200
|
+
console.print(f"[red]✗[/] {exc}")
|
|
201
|
+
raise typer.Exit(code=1)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
console.print(f"[red]✗[/] OAuth start failed: {exc}")
|
|
204
|
+
raise typer.Exit(code=1)
|
|
205
|
+
|
|
206
|
+
if handshake.redirect_url:
|
|
207
|
+
console.print(f"Open this URL to authorize {source}:")
|
|
208
|
+
console.print(f" [cyan]{handshake.redirect_url}[/]")
|
|
209
|
+
cfg = load_config()
|
|
210
|
+
state = getattr(cfg.connectors, source)
|
|
211
|
+
state.connected = True
|
|
212
|
+
state.connection_id = handshake.connection_id
|
|
213
|
+
save_config(cfg)
|
|
214
|
+
console.print(f"[green]✓[/] {source} connected (id={handshake.connection_id}).")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@connect_app.command("slack")
|
|
218
|
+
def connect_slack() -> None:
|
|
219
|
+
_connect_source("slack")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@connect_app.command("gmail")
|
|
223
|
+
def connect_gmail() -> None:
|
|
224
|
+
_connect_source("gmail")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@connect_app.command("calendar")
|
|
228
|
+
def connect_calendar() -> None:
|
|
229
|
+
_connect_source("calendar")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@connect_app.command("linear")
|
|
233
|
+
def connect_linear() -> None:
|
|
234
|
+
_connect_source("linear")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# sync
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
@app.command()
|
|
241
|
+
def sync(
|
|
242
|
+
sample: bool = typer.Option(False, "--sample", help="Use bundled sample data."),
|
|
243
|
+
source: str | None = typer.Option(
|
|
244
|
+
None, "--source", help="Sync only one source (slack|gmail|calendar|linear)."
|
|
245
|
+
),
|
|
246
|
+
full: bool = typer.Option(False, "--full", help="Ignore checkpoints and refetch the last 30 days."),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Run an incremental sync."""
|
|
249
|
+
_setup_logging()
|
|
250
|
+
from recall.sync.runner import run_sync, run_sync_sample
|
|
251
|
+
|
|
252
|
+
conn = _open_db()
|
|
253
|
+
try:
|
|
254
|
+
if sample:
|
|
255
|
+
summary = run_sync_sample(conn)
|
|
256
|
+
else:
|
|
257
|
+
sources = [source] if source else None
|
|
258
|
+
summary = run_sync(conn, sources, full=full)
|
|
259
|
+
finally:
|
|
260
|
+
conn.close()
|
|
261
|
+
render_sync_summary(summary.to_dict())
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# intelligence
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
@app.command()
|
|
268
|
+
def today() -> None:
|
|
269
|
+
"""Today's meetings, blockers, focus items."""
|
|
270
|
+
_setup_logging()
|
|
271
|
+
from recall.intelligence.today import build_today
|
|
272
|
+
|
|
273
|
+
conn = _open_db()
|
|
274
|
+
try:
|
|
275
|
+
brief = build_today(conn)
|
|
276
|
+
finally:
|
|
277
|
+
conn.close()
|
|
278
|
+
render_today(brief)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command()
|
|
282
|
+
def prep(meeting: str = typer.Argument(..., help="Meeting title or keyword.")) -> None:
|
|
283
|
+
"""Prepare for an upcoming meeting."""
|
|
284
|
+
_setup_logging()
|
|
285
|
+
from recall.intelligence.meeting_prep import build_meeting_prep
|
|
286
|
+
|
|
287
|
+
conn = _open_db()
|
|
288
|
+
try:
|
|
289
|
+
data = build_meeting_prep(conn, meeting)
|
|
290
|
+
finally:
|
|
291
|
+
conn.close()
|
|
292
|
+
render_meeting_prep(data)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command()
|
|
296
|
+
def risks() -> None:
|
|
297
|
+
"""At-risk projects, blockers, overdue commitments."""
|
|
298
|
+
_setup_logging()
|
|
299
|
+
from recall.intelligence.risks import build_risks
|
|
300
|
+
|
|
301
|
+
conn = _open_db()
|
|
302
|
+
try:
|
|
303
|
+
data = build_risks(conn)
|
|
304
|
+
finally:
|
|
305
|
+
conn.close()
|
|
306
|
+
render_risks(data)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command()
|
|
310
|
+
def followups(
|
|
311
|
+
actor: str | None = typer.Option(None, "--for", help="Filter to a specific person.")
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Open commitments."""
|
|
314
|
+
_setup_logging()
|
|
315
|
+
from recall.intelligence.followups import build_followups
|
|
316
|
+
|
|
317
|
+
conn = _open_db()
|
|
318
|
+
try:
|
|
319
|
+
data = build_followups(conn, actor=actor)
|
|
320
|
+
finally:
|
|
321
|
+
conn.close()
|
|
322
|
+
render_followups(data)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@app.command()
|
|
326
|
+
def timeline(project: str = typer.Argument(..., help="Project name or slug.")) -> None:
|
|
327
|
+
"""Chronological timeline for a project."""
|
|
328
|
+
_setup_logging()
|
|
329
|
+
from recall.intelligence.timeline import build_timeline
|
|
330
|
+
|
|
331
|
+
conn = _open_db()
|
|
332
|
+
try:
|
|
333
|
+
data = build_timeline(conn, project)
|
|
334
|
+
finally:
|
|
335
|
+
conn.close()
|
|
336
|
+
render_timeline(data)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.command()
|
|
340
|
+
def standup() -> None:
|
|
341
|
+
"""Standup brief: yesterday, today, blockers."""
|
|
342
|
+
_setup_logging()
|
|
343
|
+
from recall.intelligence.standup import build_standup
|
|
344
|
+
|
|
345
|
+
conn = _open_db()
|
|
346
|
+
try:
|
|
347
|
+
data = build_standup(conn)
|
|
348
|
+
finally:
|
|
349
|
+
conn.close()
|
|
350
|
+
render_standup(data)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# schedule
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
@schedule_app.command("enable")
|
|
357
|
+
def schedule_enable(
|
|
358
|
+
every: str | None = typer.Option(None, "--every", help="Interval like '4h'."),
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Enable the local scheduler."""
|
|
361
|
+
from recall.sync.scheduler import set_enabled
|
|
362
|
+
|
|
363
|
+
hours: int | None = None
|
|
364
|
+
if every:
|
|
365
|
+
every = every.strip().lower()
|
|
366
|
+
if every.endswith("h"):
|
|
367
|
+
hours = int(every[:-1])
|
|
368
|
+
else:
|
|
369
|
+
hours = int(every)
|
|
370
|
+
set_enabled(True, every_hours=hours)
|
|
371
|
+
console.print(
|
|
372
|
+
f"[green]✓[/] Scheduler enabled (every {hours or 'default'} hours). "
|
|
373
|
+
"Run `recall schedule run` to start it."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@schedule_app.command("disable")
|
|
378
|
+
def schedule_disable() -> None:
|
|
379
|
+
"""Disable the local scheduler."""
|
|
380
|
+
from recall.sync.scheduler import set_enabled
|
|
381
|
+
|
|
382
|
+
set_enabled(False)
|
|
383
|
+
console.print("[green]✓[/] Scheduler disabled.")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@schedule_app.command("status")
|
|
387
|
+
def schedule_status() -> None:
|
|
388
|
+
"""Show scheduler status."""
|
|
389
|
+
from recall.sync.scheduler import status
|
|
390
|
+
|
|
391
|
+
s = status()
|
|
392
|
+
console.print(f"enabled={s['enabled']} every_hours={s['every_hours']}")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@schedule_app.command("run")
|
|
396
|
+
def schedule_run() -> None:
|
|
397
|
+
"""Start the scheduler in the foreground (blocks)."""
|
|
398
|
+
_setup_logging()
|
|
399
|
+
from recall.sync.scheduler import run_blocking
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
run_blocking()
|
|
403
|
+
except RuntimeError as exc:
|
|
404
|
+
console.print(f"[red]✗[/] {exc}")
|
|
405
|
+
raise typer.Exit(code=1)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
# version
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
@app.command()
|
|
412
|
+
def version() -> None:
|
|
413
|
+
"""Print the Recall version."""
|
|
414
|
+
console.print(__version__)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
if __name__ == "__main__": # pragma: no cover
|
|
418
|
+
app()
|
recall/config/secrets.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Read/write API keys via load_config/save_config.
|
|
2
|
+
|
|
3
|
+
Keys live in `~/.recall/config.yaml`. `.env` variables are honored as a
|
|
4
|
+
development fallback, but the config file is the source of truth.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from recall.config.settings import RecallConfig, load_config, save_config
|
|
13
|
+
|
|
14
|
+
SecretName = Literal["OPENAI_API_KEY", "COMPOSIO_API_KEY"]
|
|
15
|
+
|
|
16
|
+
_FIELDS = {
|
|
17
|
+
"OPENAI_API_KEY": "openai_api_key",
|
|
18
|
+
"COMPOSIO_API_KEY": "composio_api_key",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _env_fallback(name: SecretName) -> str | None:
|
|
23
|
+
return os.environ.get(name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_secret(name: SecretName, *, config: RecallConfig | None = None) -> str | None:
|
|
27
|
+
cfg = config or load_config()
|
|
28
|
+
field = _FIELDS[name]
|
|
29
|
+
value = getattr(cfg, field, None)
|
|
30
|
+
return value or _env_fallback(name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_secret(name: SecretName, value: str) -> None:
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
setattr(cfg, _FIELDS[name], value.strip() or None)
|
|
36
|
+
save_config(cfg)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def redact(value: str | None) -> str:
|
|
40
|
+
if not value:
|
|
41
|
+
return "(not set)"
|
|
42
|
+
if len(value) <= 8:
|
|
43
|
+
return "***"
|
|
44
|
+
return f"{value[:4]}…{value[-4:]}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""YAML-backed configuration at ~/.recall/config.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from recall.constants import CONFIG_PATH, DEFAULT_SCHEDULE_HOURS, RECALL_HOME
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScheduleConfig(BaseModel):
|
|
16
|
+
enabled: bool = False
|
|
17
|
+
every_hours: int = DEFAULT_SCHEDULE_HOURS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectorState(BaseModel):
|
|
21
|
+
connected: bool = False
|
|
22
|
+
connection_id: str | None = None
|
|
23
|
+
account_label: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Connectors(BaseModel):
|
|
27
|
+
slack: ConnectorState = Field(default_factory=ConnectorState)
|
|
28
|
+
gmail: ConnectorState = Field(default_factory=ConnectorState)
|
|
29
|
+
calendar: ConnectorState = Field(default_factory=ConnectorState)
|
|
30
|
+
linear: ConnectorState = Field(default_factory=ConnectorState)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RecallConfig(BaseModel):
|
|
34
|
+
openai_api_key: str | None = None
|
|
35
|
+
composio_api_key: str | None = None
|
|
36
|
+
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
|
|
37
|
+
connectors: Connectors = Field(default_factory=Connectors)
|
|
38
|
+
|
|
39
|
+
# Free-form bag for forward compatibility
|
|
40
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ensure_home() -> None:
|
|
44
|
+
RECALL_HOME.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _restrict_perms(path: Path) -> None:
|
|
48
|
+
"""Best-effort `chmod 600` on POSIX so secrets aren't world-readable."""
|
|
49
|
+
try:
|
|
50
|
+
os.chmod(path, 0o600)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(path: Path = CONFIG_PATH) -> RecallConfig:
|
|
56
|
+
"""Load config from YAML, returning defaults if the file is missing."""
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return RecallConfig()
|
|
59
|
+
with path.open("r") as f:
|
|
60
|
+
data = yaml.safe_load(f) or {}
|
|
61
|
+
return RecallConfig.model_validate(data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_config(config: RecallConfig, path: Path = CONFIG_PATH) -> None:
|
|
65
|
+
"""Persist config to YAML with restricted permissions."""
|
|
66
|
+
_ensure_home()
|
|
67
|
+
data = config.model_dump(mode="json")
|
|
68
|
+
with path.open("w") as f:
|
|
69
|
+
yaml.safe_dump(data, f, sort_keys=False)
|
|
70
|
+
_restrict_perms(path)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Source connectors. Real fetchers go through Composio; sample mode bypasses
|
|
2
|
+
them and reads JSON from disk."""
|
|
3
|
+
|
|
4
|
+
from recall.connectors.calendar import CalendarConnector
|
|
5
|
+
from recall.connectors.gmail import GmailConnector
|
|
6
|
+
from recall.connectors.linear import LinearConnector
|
|
7
|
+
from recall.connectors.slack import SlackConnector
|
|
8
|
+
|
|
9
|
+
CONNECTORS = {
|
|
10
|
+
"slack": SlackConnector(),
|
|
11
|
+
"gmail": GmailConnector(),
|
|
12
|
+
"calendar": CalendarConnector(),
|
|
13
|
+
"linear": LinearConnector(),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
__all__ = ["CONNECTORS", "CalendarConnector", "GmailConnector", "LinearConnector", "SlackConnector"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Google Calendar connector via Composio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from recall.connectors.composio_client import execute_action, start_oauth
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CalendarConnector:
|
|
15
|
+
source = "calendar"
|
|
16
|
+
composio_app = "googlecalendar"
|
|
17
|
+
|
|
18
|
+
def connect(self):
|
|
19
|
+
return start_oauth(self.composio_app)
|
|
20
|
+
|
|
21
|
+
def fetch_since(self, since: datetime) -> Iterable[dict]:
|
|
22
|
+
time_min = since.isoformat()
|
|
23
|
+
# Look 14 days ahead so we capture upcoming meetings for `today`/`prep`.
|
|
24
|
+
time_max = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
|
|
25
|
+
page_token: str | None = None
|
|
26
|
+
while True:
|
|
27
|
+
params = {
|
|
28
|
+
"calendarId": "primary",
|
|
29
|
+
"timeMin": time_min,
|
|
30
|
+
"timeMax": time_max,
|
|
31
|
+
"singleEvents": True,
|
|
32
|
+
"orderBy": "startTime",
|
|
33
|
+
"maxResults": 250,
|
|
34
|
+
}
|
|
35
|
+
if page_token:
|
|
36
|
+
params["pageToken"] = page_token
|
|
37
|
+
try:
|
|
38
|
+
resp = execute_action("GOOGLECALENDAR_LIST_EVENTS", params)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
logger.warning("Calendar list failed: %s", exc)
|
|
41
|
+
break
|
|
42
|
+
data = resp.get("data") or resp
|
|
43
|
+
for ev in data.get("items") or []:
|
|
44
|
+
yield self._reshape(ev)
|
|
45
|
+
page_token = data.get("nextPageToken")
|
|
46
|
+
if not page_token:
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _reshape(ev: dict) -> dict:
|
|
51
|
+
start = (ev.get("start") or {}).get("dateTime") or (ev.get("start") or {}).get("date")
|
|
52
|
+
end = (ev.get("end") or {}).get("dateTime") or (ev.get("end") or {}).get("date")
|
|
53
|
+
return {
|
|
54
|
+
"id": ev.get("id"),
|
|
55
|
+
"title": ev.get("summary") or "",
|
|
56
|
+
"description": ev.get("description") or "",
|
|
57
|
+
"organizer": (ev.get("organizer") or {}).get("email"),
|
|
58
|
+
"attendees": [a.get("email") for a in (ev.get("attendees") or []) if a.get("email")],
|
|
59
|
+
"start": start,
|
|
60
|
+
"end": end,
|
|
61
|
+
"status": ev.get("status"),
|
|
62
|
+
"location": ev.get("location"),
|
|
63
|
+
"meeting_link": ev.get("hangoutLink"),
|
|
64
|
+
"occurred_at": start,
|
|
65
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Thin wrapper around the Composio SDK.
|
|
2
|
+
|
|
3
|
+
We expose two operations the sync layer needs:
|
|
4
|
+
- `start_oauth(app)` for `recall connect <source>`.
|
|
5
|
+
- `execute_action(action, params)` for paged fetches inside each source's
|
|
6
|
+
connector module.
|
|
7
|
+
|
|
8
|
+
Composio is imported lazily so the rest of the CLI works without the SDK
|
|
9
|
+
installed (sample mode does not require it).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from recall.config.secrets import get_secret
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ComposioKeyMissing(RuntimeError):
|
|
25
|
+
"""Raised when COMPOSIO_API_KEY is not configured."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class OAuthHandshake:
|
|
30
|
+
redirect_url: str
|
|
31
|
+
connection_id: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=1)
|
|
35
|
+
def get_toolset():
|
|
36
|
+
"""Return a cached Composio toolset client."""
|
|
37
|
+
try:
|
|
38
|
+
from composio import ComposioToolSet # type: ignore
|
|
39
|
+
except ImportError as exc: # pragma: no cover - optional dep
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"composio-core is not installed. Run `pip install composio-core`."
|
|
42
|
+
) from exc
|
|
43
|
+
|
|
44
|
+
api_key = get_secret("COMPOSIO_API_KEY")
|
|
45
|
+
if not api_key:
|
|
46
|
+
raise ComposioKeyMissing(
|
|
47
|
+
"COMPOSIO_API_KEY is not set. Run `recall config set COMPOSIO_API_KEY <key>`."
|
|
48
|
+
)
|
|
49
|
+
return ComposioToolSet(api_key=api_key)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def start_oauth(app: str) -> OAuthHandshake:
|
|
53
|
+
"""Initiate OAuth for the given Composio app slug (slack, gmail, …)."""
|
|
54
|
+
toolset = get_toolset()
|
|
55
|
+
entity = toolset.get_entity(id="default")
|
|
56
|
+
request = entity.initiate_connection(app_name=app)
|
|
57
|
+
return OAuthHandshake(
|
|
58
|
+
redirect_url=getattr(request, "redirectUrl", ""),
|
|
59
|
+
connection_id=getattr(request, "connectedAccountId", ""),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def execute_action(action: str, params: dict[str, Any]) -> Any:
|
|
64
|
+
"""Invoke a Composio action by name (e.g. SLACK_FETCH_CONVERSATION_HISTORY)."""
|
|
65
|
+
toolset = get_toolset()
|
|
66
|
+
return toolset.execute_action(action=action, params=params)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reset_cache() -> None:
|
|
70
|
+
"""Force a fresh Composio client (call after a config change)."""
|
|
71
|
+
get_toolset.cache_clear()
|