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.
Files changed (75) hide show
  1. recall/__init__.py +3 -0
  2. recall/cli.py +418 -0
  3. recall/config/__init__.py +5 -0
  4. recall/config/secrets.py +44 -0
  5. recall/config/settings.py +70 -0
  6. recall/connectors/__init__.py +16 -0
  7. recall/connectors/calendar.py +65 -0
  8. recall/connectors/composio_client.py +71 -0
  9. recall/connectors/gmail.py +51 -0
  10. recall/connectors/linear.py +57 -0
  11. recall/connectors/slack.py +71 -0
  12. recall/constants.py +45 -0
  13. recall/extract/__init__.py +1 -0
  14. recall/extract/blockers.py +52 -0
  15. recall/extract/commitments.py +48 -0
  16. recall/extract/entities.py +78 -0
  17. recall/extract/projects.py +55 -0
  18. recall/extract/risks.py +52 -0
  19. recall/extract/signals.py +109 -0
  20. recall/intelligence/__init__.py +1 -0
  21. recall/intelligence/followups.py +36 -0
  22. recall/intelligence/meeting_prep.py +113 -0
  23. recall/intelligence/risks.py +68 -0
  24. recall/intelligence/standup.py +51 -0
  25. recall/intelligence/timeline.py +78 -0
  26. recall/intelligence/today.py +72 -0
  27. recall/llm/__init__.py +5 -0
  28. recall/llm/openai_client.py +32 -0
  29. recall/llm/prompts.py +121 -0
  30. recall/memory/__init__.py +1 -0
  31. recall/memory/cleanup.py +12 -0
  32. recall/memory/promoter.py +113 -0
  33. recall/memory/router.py +218 -0
  34. recall/memory/scorer.py +36 -0
  35. recall/normalize/__init__.py +16 -0
  36. recall/normalize/base.py +58 -0
  37. recall/normalize/calendar.py +56 -0
  38. recall/normalize/gmail.py +83 -0
  39. recall/normalize/linear.py +86 -0
  40. recall/normalize/slack.py +93 -0
  41. recall/output/__init__.py +5 -0
  42. recall/output/console.py +7 -0
  43. recall/output/formatters.py +277 -0
  44. recall/sample/__init__.py +1 -0
  45. recall/sample/data/calendar.json +88 -0
  46. recall/sample/data/gmail.json +120 -0
  47. recall/sample/data/linear.json +174 -0
  48. recall/sample/data/slack.json +132 -0
  49. recall/sample/seed.py +53 -0
  50. recall/storage/__init__.py +10 -0
  51. recall/storage/chunks.py +77 -0
  52. recall/storage/commitments.py +85 -0
  53. recall/storage/db.py +55 -0
  54. recall/storage/entities.py +131 -0
  55. recall/storage/graph.py +82 -0
  56. recall/storage/hot_memory.py +77 -0
  57. recall/storage/migrations.py +56 -0
  58. recall/storage/normalized_events.py +148 -0
  59. recall/storage/raw_events.py +142 -0
  60. recall/storage/schema.sql +160 -0
  61. recall/storage/sync_state.py +87 -0
  62. recall/sync/__init__.py +5 -0
  63. recall/sync/checkpoint.py +37 -0
  64. recall/sync/planner.py +33 -0
  65. recall/sync/runner.py +156 -0
  66. recall/sync/scheduler.py +80 -0
  67. recall/vector/__init__.py +1 -0
  68. recall/vector/embeddings.py +57 -0
  69. recall/vector/search.py +65 -0
  70. recall/vector/sqlite_vec.py +12 -0
  71. work_recall-0.1.0.dist-info/METADATA +118 -0
  72. work_recall-0.1.0.dist-info/RECORD +75 -0
  73. work_recall-0.1.0.dist-info/WHEEL +4 -0
  74. work_recall-0.1.0.dist-info/entry_points.txt +2 -0
  75. work_recall-0.1.0.dist-info/licenses/LICENSE +21 -0
recall/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Recall — local-first AI chief of staff for engineers."""
2
+
3
+ __version__ = "0.1.0"
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()
@@ -0,0 +1,5 @@
1
+ """Config + secrets for Recall."""
2
+
3
+ from recall.config.settings import RecallConfig, load_config, save_config
4
+
5
+ __all__ = ["RecallConfig", "load_config", "save_config"]
@@ -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()