notion-agent-cli 0.1.1__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.
@@ -0,0 +1,28 @@
1
+ """notion-agent-cli — call Notion's ✦ AI endpoint from Python / the shell.
2
+
3
+ Public API:
4
+
5
+ - :class:`NotionAgentClient` — async client for one round-trip to
6
+ ``/api/v3/runInferenceTranscript``. Loads a credential file, builds
7
+ the chat-panel-equivalent payload, streams the NDJSON response.
8
+ - :class:`ChatResponse` / :class:`TokenUsage` — response dataclasses.
9
+ - :class:`NotionAgentError` — single error type for transport / auth /
10
+ Notion-side failures. CLI translates this to a non-zero exit code.
11
+
12
+ Library callers will spend most of their time on those four names; the
13
+ sub-modules (``transcript``, ``ndjson``, ``account``, ``models``) are
14
+ useful for tests + bootstrap helpers and remain importable.
15
+ """
16
+ from notion_agent_cli.exceptions import ErrorCode, NotionAgentError
17
+ from notion_agent_cli.provider import NotionAgentClient
18
+ from notion_agent_cli.types import ChatResponse, TokenUsage
19
+
20
+ __all__ = [
21
+ "ChatResponse",
22
+ "ErrorCode",
23
+ "NotionAgentClient",
24
+ "NotionAgentError",
25
+ "TokenUsage",
26
+ ]
27
+
28
+ __version__ = "0.1.0"
@@ -0,0 +1,141 @@
1
+ """Account credentials + workspace metadata.
2
+
3
+ The credential file (default ``~/.notionagents/notion_account.json``)
4
+ holds a long-lived ``token_v2`` cookie + workspace UUIDs + an optional
5
+ Custom Agent persona binding. Schema details + bootstrap walkthrough
6
+ live in ``docs/01-notion-chat-protocol.md §4``.
7
+
8
+ We prefer ``full_cookie`` (the raw ``document.cookie`` string copied
9
+ from the browser) when present — that's the operator's escape hatch
10
+ when individual-field extraction is too fiddly. Otherwise we stitch
11
+ together the minimum set Notion's edge expects for a logged-in session.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from notion_agent_cli.exceptions import ErrorCode, NotionAgentError
21
+
22
+ _REQUIRED_FIELDS: tuple[str, ...] = (
23
+ "token_v2",
24
+ "user_id",
25
+ "space_id",
26
+ )
27
+
28
+
29
+ @dataclass(slots=True, frozen=True)
30
+ class NotionAccount:
31
+ # --- Credentials ---
32
+ token_v2: str
33
+ full_cookie: str = ""
34
+
35
+ # --- Identity ---
36
+ user_id: str = ""
37
+ user_name: str = ""
38
+ user_email: str = ""
39
+
40
+ # --- Workspace ---
41
+ space_id: str = ""
42
+ space_name: str = ""
43
+ space_view_id: str = ""
44
+
45
+ # --- Browser fingerprint ---
46
+ browser_id: str = ""
47
+ device_id: str = ""
48
+ client_version: str = "23.13.20260516.0113"
49
+ user_agent: str = (
50
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
51
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
52
+ )
53
+ timezone: str = "America/Los_Angeles"
54
+
55
+ # --- Custom agent persona (Jarvis-style binding) ---
56
+ # Setting these makes threads surface in Notion's ✦ AI chat panel
57
+ # under the named persona instead of the default chat.
58
+ agent_name: str = ""
59
+ agent_accessory: str = ""
60
+ agent_context_page_id: str = ""
61
+
62
+ # --- Default model alias ---
63
+ default_model: str = "opus-4.7"
64
+
65
+ # --- Forward-compat ---
66
+ extras: dict[str, Any] = field(default_factory=dict)
67
+
68
+ @property
69
+ def has_jarvis_binding(self) -> bool:
70
+ return bool(self.agent_name and self.agent_context_page_id)
71
+
72
+
73
+ def load_notion_account(path: Path | str) -> NotionAccount:
74
+ """Read ``notion_account.json`` and return a validated NotionAccount.
75
+
76
+ Raises :class:`NotionAgentError` on missing file, malformed JSON, or
77
+ missing required fields so misconfiguration fails at startup rather
78
+ than mid-stream.
79
+ """
80
+ p = Path(path).expanduser()
81
+ if not p.exists():
82
+ raise NotionAgentError(
83
+ f"notion_account.json not found at {p}; run "
84
+ "`notion-agent init --cookie <value>` to bootstrap one.",
85
+ code=ErrorCode.ACCOUNT_MISSING,
86
+ )
87
+ try:
88
+ data: dict[str, Any] = json.loads(p.read_text(encoding="utf-8"))
89
+ except json.JSONDecodeError as e:
90
+ raise NotionAgentError(
91
+ f"notion_account.json malformed: {e}",
92
+ code=ErrorCode.ACCOUNT_MALFORMED,
93
+ ) from e
94
+
95
+ missing = [f for f in _REQUIRED_FIELDS if not data.get(f)]
96
+ if missing:
97
+ raise NotionAgentError(
98
+ f"notion_account.json missing required fields: {missing}. "
99
+ "Run `notion-agent init` to regenerate.",
100
+ code=ErrorCode.ACCOUNT_INVALID,
101
+ )
102
+
103
+ known = {f.name for f in NotionAccount.__dataclass_fields__.values()} - {"extras"}
104
+ kwargs = {k: data[k] for k in known if k in data}
105
+ extras = {k: v for k, v in data.items() if k not in known}
106
+ return NotionAccount(**kwargs, extras=extras)
107
+
108
+
109
+ def save_notion_account(acc: NotionAccount, path: Path | str) -> None:
110
+ """Serialize a NotionAccount to JSON. Used by the `init` subcommand."""
111
+ p = Path(path).expanduser()
112
+ p.parent.mkdir(parents=True, exist_ok=True)
113
+ data: dict[str, Any] = {}
114
+ for f in NotionAccount.__dataclass_fields__.values():
115
+ if f.name == "extras":
116
+ continue
117
+ data[f.name] = getattr(acc, f.name)
118
+ data.update(acc.extras) # extras round-trip
119
+ p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
120
+
121
+
122
+ def build_cookie_header(acc: NotionAccount) -> str:
123
+ """Cookie header value — prefers full_cookie when set, else stitches
124
+ together the minimum logged-in subset."""
125
+ if acc.full_cookie:
126
+ return acc.full_cookie
127
+
128
+ user_id = acc.user_id
129
+ user_id_nodash = user_id.replace("-", "")
130
+ parts = [
131
+ f"notion_browser_id={acc.browser_id}",
132
+ f"device_id={acc.device_id}",
133
+ f"notion_user_id={user_id}",
134
+ f'notion_users=[%22{user_id}%22]',
135
+ "notion_check_cookie_consent=false",
136
+ "notion_locale=en-US/legacy",
137
+ "notion_cookie_sync_completed=%7B%22completed%22%3Atrue%2C%22version%22%3A4%7D",
138
+ f"_cioid={user_id_nodash}",
139
+ f"token_v2={acc.token_v2}",
140
+ ]
141
+ return "; ".join(parts)
@@ -0,0 +1,129 @@
1
+ """Parse ``/api/v3/getCustomAgents`` into agent + thread summaries.
2
+
3
+ A single round-trip to ``getCustomAgents`` answers both "what custom
4
+ agents do I have here?" and "what threads have I run recently?" —
5
+ :func:`parse_custom_agents` splits the response into two ranked lists
6
+ the ``agents list`` / ``threads list`` CLI subcommands print.
7
+
8
+ The endpoint *does not* return agent display names — only their
9
+ ``persistent_instructions_page`` UUIDs. To find the human name of an
10
+ agent you still need :func:`notion_agent_cli.bootstrap.fetch_user_content`
11
+ or a page-chunk lookup; for the MVP we surface the page id + activity
12
+ score + the title of the agent's most recent thread as a breadcrumb.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+
20
+ @dataclass(slots=True, frozen=True)
21
+ class ThreadSummary:
22
+ """One entry from ``mostRecentTranscripts``."""
23
+ thread_id: str
24
+ title: str | None
25
+ parent_agent_id: str | None
26
+ created_at_ms: int | None
27
+ updated_at_ms: int | None
28
+ created_by_id: str | None
29
+ created_source: str | None
30
+
31
+
32
+ @dataclass(slots=True, frozen=True)
33
+ class AgentSummary:
34
+ """One custom agent + its most recent activity breadcrumb."""
35
+ agent_id: str
36
+ activity_score: int | None # epoch ms, None if no activity recorded
37
+ most_recent_thread_id: str | None
38
+ most_recent_thread_title: str | None
39
+
40
+
41
+ def _to_int(value: Any) -> int | None:
42
+ if value is None:
43
+ return None
44
+ try:
45
+ return int(value)
46
+ except (TypeError, ValueError):
47
+ return None
48
+
49
+
50
+ def parse_threads(response: dict[str, Any]) -> list[ThreadSummary]:
51
+ """Pull ``mostRecentTranscripts`` into a list sorted updated→created→id desc.
52
+
53
+ Missing fields land as ``None`` rather than raising — Notion returns
54
+ nulls for transcripts that never got past creation.
55
+ """
56
+ out: list[ThreadSummary] = []
57
+ for entry in response.get("mostRecentTranscripts") or []:
58
+ if not isinstance(entry, dict):
59
+ continue
60
+ tid = entry.get("id")
61
+ if not isinstance(tid, str):
62
+ continue
63
+ out.append(ThreadSummary(
64
+ thread_id= tid,
65
+ title= entry.get("title") if isinstance(entry.get("title"), str) else None,
66
+ parent_agent_id= entry.get("parent_id") if isinstance(entry.get("parent_id"), str) else None,
67
+ created_at_ms= _to_int(entry.get("created_time")),
68
+ updated_at_ms= _to_int(entry.get("updated_time")),
69
+ created_by_id= entry.get("created_by_id") if isinstance(entry.get("created_by_id"), str) else None,
70
+ created_source= entry.get("created_source") if isinstance(entry.get("created_source"), str) else None,
71
+ ))
72
+ # Sort newest-first by max(updated, created) so transcripts that
73
+ # never got an updated_at still slot in chronologically by their
74
+ # creation time — matches what the CLI prints in the timestamp
75
+ # column. Tiebreak on thread_id for stable output.
76
+ def _sort_key(t: ThreadSummary) -> tuple[int, str]:
77
+ newest = max(t.updated_at_ms or 0, t.created_at_ms or 0)
78
+ return (-newest, t.thread_id)
79
+ out.sort(key=_sort_key)
80
+ return out
81
+
82
+
83
+ def parse_agents(response: dict[str, Any]) -> list[AgentSummary]:
84
+ """Combine ``agentIds`` + ``activityScores`` + most-recent thread title.
85
+
86
+ Sorted by activity_score desc (most-recently-used first). Agents
87
+ with no recorded activity sink to the bottom with ``activity_score
88
+ = None``.
89
+ """
90
+ agent_ids: list[str] = [
91
+ x for x in (response.get("agentIds") or []) if isinstance(x, str)
92
+ ]
93
+
94
+ activity: dict[str, int] = {}
95
+ for entry in response.get("activityScores") or []:
96
+ if not isinstance(entry, dict):
97
+ continue
98
+ pid = entry.get("parent_id")
99
+ score = _to_int(entry.get("activity_score"))
100
+ if (
101
+ isinstance(pid, str)
102
+ and score is not None
103
+ and (pid not in activity or activity[pid] < score)
104
+ ):
105
+ # If duplicates, keep the highest score (= most recent activity).
106
+ activity[pid] = score
107
+
108
+ # Best-effort breadcrumb: agent's most recent thread (title + id).
109
+ threads = parse_threads(response) # already sorted newest-first
110
+ recent_by_agent: dict[str, ThreadSummary] = {}
111
+ for t in threads:
112
+ if t.parent_agent_id and t.parent_agent_id not in recent_by_agent:
113
+ recent_by_agent[t.parent_agent_id] = t
114
+
115
+ out: list[AgentSummary] = []
116
+ for aid in agent_ids:
117
+ recent = recent_by_agent.get(aid)
118
+ out.append(AgentSummary(
119
+ agent_id= aid,
120
+ activity_score= activity.get(aid),
121
+ most_recent_thread_id= recent.thread_id if recent else None,
122
+ most_recent_thread_title=recent.title if recent else None,
123
+ ))
124
+
125
+ def _sort_key(a: AgentSummary) -> tuple[int, str]:
126
+ # None scores sink to the bottom; tiebreak on agent_id for stable order.
127
+ return (-(a.activity_score or 0), a.agent_id)
128
+ out.sort(key=_sort_key)
129
+ return out
@@ -0,0 +1,253 @@
1
+ """Workspace metadata bootstrap.
2
+
3
+ Library function used by the ``notion-agent init`` CLI subcommand.
4
+ Takes a ``token_v2`` cookie (the only thing the operator can copy from
5
+ a browser in <30s) plus optional fingerprint UUIDs, then calls
6
+ ``/api/v3/loadUserContent`` to enumerate workspaces + extract user
7
+ identity. Returns a populated :class:`NotionAccount` ready to save.
8
+
9
+ If the user has multiple workspaces and the caller didn't pre-select
10
+ one, :func:`bootstrap_account` raises :class:`AmbiguousWorkspaceError`
11
+ carrying the available choices so the CLI can prompt.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from notion_agent_cli.account import NotionAccount
22
+ from notion_agent_cli.exceptions import ErrorCode, NotionAgentError
23
+
24
+ BASE_URL = "https://www.notion.so/api/v3"
25
+ DEFAULT_UA = (
26
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
27
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
28
+ )
29
+
30
+
31
+ @dataclass(slots=True, frozen=True)
32
+ class Workspace:
33
+ space_id: str
34
+ space_view_id: str
35
+ space_name: str
36
+ domain: str
37
+
38
+
39
+ @dataclass(slots=True, frozen=True)
40
+ class UserInfo:
41
+ user_id: str
42
+ user_name: str
43
+ user_email: str
44
+
45
+
46
+ class AmbiguousWorkspaceError(NotionAgentError):
47
+ """Raised when bootstrap finds >1 workspace and caller didn't pick one."""
48
+
49
+ def __init__(self, workspaces: list[Workspace]):
50
+ super().__init__(
51
+ f"{len(workspaces)} workspaces available — caller must specify which: "
52
+ + ", ".join(f"{w.space_name!r}" for w in workspaces),
53
+ code=ErrorCode.WORKSPACE_AMBIGUOUS,
54
+ )
55
+ self.workspaces = workspaces
56
+
57
+
58
+ def parse_browser_cookie(cookie: str) -> dict[str, str]:
59
+ """Parse a ``document.cookie`` string into ``{name: value}`` pairs.
60
+
61
+ Values are kept verbatim — ``token_v2`` in particular is left in its
62
+ URL-encoded form (``v03%3A...``), which is what Notion's edge accepts
63
+ in subsequent requests. Whitespace around names and values is trimmed.
64
+ Entries without ``=`` are dropped silently.
65
+ """
66
+ out: dict[str, str] = {}
67
+ for part in cookie.split(";"):
68
+ part = part.strip()
69
+ if not part or "=" not in part:
70
+ continue
71
+ name, _, value = part.partition("=")
72
+ out[name.strip()] = value.strip()
73
+ return out
74
+
75
+
76
+ def _build_cookie(token_v2: str, browser_id: str, user_id: str) -> str:
77
+ return "; ".join([
78
+ f"notion_browser_id={browser_id}",
79
+ f"notion_user_id={user_id}",
80
+ f'notion_users=[%22{user_id}%22]',
81
+ "notion_check_cookie_consent=false",
82
+ "notion_locale=en-US/legacy",
83
+ f"token_v2={token_v2}",
84
+ ])
85
+
86
+
87
+ def _bootstrap_headers(token_v2: str, browser_id: str, user_id: str) -> dict[str, str]:
88
+ return {
89
+ "accept": "application/json",
90
+ "accept-language": "en-US,en;q=0.9",
91
+ "content-type": "application/json",
92
+ "notion-audit-log-platform": "web",
93
+ "notion-client-version": "23.13.20260516.0113",
94
+ "origin": "https://www.notion.so",
95
+ "referer": "https://www.notion.so/",
96
+ "user-agent": DEFAULT_UA,
97
+ "x-notion-active-user-header": user_id,
98
+ "sec-ch-ua": '"Not-A.Brand";v="24", "Chromium";v="146"',
99
+ "sec-ch-ua-mobile": "?0",
100
+ "sec-ch-ua-platform": '"macOS"',
101
+ "sec-fetch-dest": "empty",
102
+ "sec-fetch-mode": "cors",
103
+ "sec-fetch-site": "same-origin",
104
+ "cookie": _build_cookie(token_v2, browser_id, user_id),
105
+ }
106
+
107
+
108
+ def _walk_record(record: dict[str, Any]) -> dict[str, Any]:
109
+ """Unwrap Notion's ``{'role': '...', 'value': {...}}`` records."""
110
+ if not isinstance(record, dict):
111
+ return {}
112
+ val = record.get("value")
113
+ if isinstance(val, dict):
114
+ inner = val.get("value")
115
+ if isinstance(inner, dict):
116
+ return inner
117
+ return val
118
+ return record
119
+
120
+
121
+ def _extract_workspaces(load_data: dict[str, Any]) -> list[Workspace]:
122
+ rm = load_data.get("recordMap") or {}
123
+ spaces_raw = rm.get("space") or {}
124
+ space_views_raw = rm.get("space_view") or {}
125
+
126
+ sv_by_space: dict[str, str] = {}
127
+ for sv_id, sv_rec in space_views_raw.items():
128
+ sv = _walk_record(sv_rec)
129
+ sp = sv.get("space_id")
130
+ if isinstance(sp, str) and sp not in sv_by_space:
131
+ sv_by_space[sp] = sv_id
132
+
133
+ out: list[Workspace] = []
134
+ for space_id, space_rec in spaces_raw.items():
135
+ sp = _walk_record(space_rec)
136
+ out.append(Workspace(
137
+ space_id=space_id,
138
+ space_view_id=sv_by_space.get(space_id, ""),
139
+ space_name=sp.get("name") or "",
140
+ domain=sp.get("domain") or "",
141
+ ))
142
+ return out
143
+
144
+
145
+ def _extract_user(load_data: dict[str, Any], user_id: str) -> UserInfo:
146
+ rm = load_data.get("recordMap") or {}
147
+ users_raw = rm.get("notion_user") or {}
148
+ rec = users_raw.get(user_id)
149
+ if not rec:
150
+ return UserInfo(user_id=user_id, user_name="", user_email="")
151
+ u = _walk_record(rec)
152
+ name_parts = [u.get("given_name") or "", u.get("family_name") or ""]
153
+ name = " ".join(p for p in name_parts if p).strip() or u.get("name") or ""
154
+ return UserInfo(user_id=user_id, user_name=name, user_email=u.get("email") or "")
155
+
156
+
157
+ async def fetch_user_content(
158
+ *, token_v2: str, user_id: str, browser_id: str,
159
+ http_client: httpx.AsyncClient | None = None,
160
+ ) -> dict[str, Any]:
161
+ """Call /api/v3/loadUserContent and return the parsed response."""
162
+ headers = _bootstrap_headers(token_v2, browser_id, user_id)
163
+ owns_client = http_client is None
164
+ client = http_client or httpx.AsyncClient(timeout=30.0)
165
+ try:
166
+ resp = await client.post(f"{BASE_URL}/loadUserContent", json={}, headers=headers)
167
+ if resp.status_code != 200:
168
+ code = (
169
+ ErrorCode.AUTH_INVALID
170
+ if resp.status_code in (401, 403)
171
+ else ErrorCode.HTTP_ERROR
172
+ )
173
+ raise NotionAgentError(
174
+ f"loadUserContent failed: HTTP {resp.status_code} body={resp.text[:500]!r}",
175
+ code=code,
176
+ )
177
+ return resp.json()
178
+ finally:
179
+ if owns_client:
180
+ await client.aclose()
181
+
182
+
183
+ def _ensure_uuid(value: str | None) -> str:
184
+ return value or str(uuid.uuid4())
185
+
186
+
187
+ async def bootstrap_account(
188
+ *,
189
+ token_v2: str,
190
+ user_id: str,
191
+ browser_id: str | None = None,
192
+ space_name: str | None = None,
193
+ space_domain: str | None = None,
194
+ agent_name: str = "",
195
+ agent_accessory: str = "",
196
+ agent_context_page_id: str = "",
197
+ default_model: str = "opus-4.7",
198
+ timezone: str = "America/Los_Angeles",
199
+ http_client: httpx.AsyncClient | None = None,
200
+ ) -> NotionAccount:
201
+ """Probe ``/api/v3/loadUserContent`` and return a populated NotionAccount.
202
+
203
+ Raises :class:`AmbiguousWorkspaceError` when multiple workspaces are
204
+ available and ``space_name`` / ``space_domain`` didn't disambiguate.
205
+ """
206
+ browser_id = _ensure_uuid(browser_id)
207
+ data = await fetch_user_content(
208
+ token_v2=token_v2, user_id=user_id, browser_id=browser_id,
209
+ http_client=http_client,
210
+ )
211
+ workspaces = _extract_workspaces(data)
212
+ if not workspaces:
213
+ raise NotionAgentError(
214
+ "loadUserContent returned no workspaces — token invalid?",
215
+ code=ErrorCode.WORKSPACE_EMPTY,
216
+ )
217
+
218
+ if len(workspaces) == 1:
219
+ chosen = workspaces[0]
220
+ elif space_domain:
221
+ match = next((w for w in workspaces if w.domain == space_domain), None)
222
+ if match is None:
223
+ raise AmbiguousWorkspaceError(workspaces)
224
+ chosen = match
225
+ elif space_name:
226
+ match = next(
227
+ (w for w in workspaces if w.space_name.lower() == space_name.lower()),
228
+ None,
229
+ )
230
+ if match is None:
231
+ raise AmbiguousWorkspaceError(workspaces)
232
+ chosen = match
233
+ else:
234
+ raise AmbiguousWorkspaceError(workspaces)
235
+
236
+ user = _extract_user(data, user_id)
237
+
238
+ return NotionAccount(
239
+ token_v2=token_v2,
240
+ user_id=user_id,
241
+ user_name=user.user_name,
242
+ user_email=user.user_email,
243
+ space_id=chosen.space_id,
244
+ space_view_id=chosen.space_view_id,
245
+ space_name=chosen.space_name,
246
+ browser_id=browser_id,
247
+ device_id=_ensure_uuid(None),
248
+ timezone=timezone,
249
+ agent_name=agent_name,
250
+ agent_accessory=agent_accessory,
251
+ agent_context_page_id=agent_context_page_id,
252
+ default_model=default_model,
253
+ )
@@ -0,0 +1,6 @@
1
+ """CLI surface — entry point is :mod:`notion_agent_cli.cli.__main__`.
2
+
3
+ The package exposes a single ``notion-agent`` console-script registered
4
+ in pyproject.toml. Subcommands live in this module; for now everything
5
+ fits in ``__main__.py``, but we'll split per-command files as they grow.
6
+ """