opennous 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: opennous
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for Nous — GTM data infrastructure for agents
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://opennous.cloud
7
+ Project-URL: Repository, https://github.com/bennetglinder1/nous
8
+ Project-URL: Documentation, https://docs.opennous.cloud
9
+ Keywords: nous,opennous,ai,agents,crm,memory,sdk,mcp
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: httpx>=0.27.0
@@ -0,0 +1,53 @@
1
+ # opennous · Python SDK
2
+
3
+ Official Python SDK for the [Nous](https://opennous.cloud) API — GTM data infrastructure for agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install opennous
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from opennous import NousClient
15
+
16
+ client = NousClient(api_key="your-api-key")
17
+
18
+ # Get full contact profile before acting
19
+ contact = client.get_contact("sarah@acme.com")
20
+ print(contact["summary"])
21
+
22
+ # Log an interaction
23
+ client.track(email="sarah@acme.com", type="call_held", description="30 min discovery call")
24
+
25
+ # Store a fact
26
+ client.remember(email="sarah@acme.com", text="Concerned about Salesforce migration and Q3 budget.")
27
+
28
+ # Store workspace-level facts
29
+ client.remember(text="ICP: technical founders of AI sales tools, 2-20 people.", category="ICP")
30
+
31
+ # Semantic search
32
+ results = client.search("budget concerns")
33
+ ```
34
+
35
+ ## Auth
36
+
37
+ Set your API key via env var or pass directly:
38
+
39
+ ```bash
40
+ export NOUS_API_KEY=your-api-key
41
+ ```
42
+
43
+ ```python
44
+ client = NousClient() # picks up NOUS_API_KEY automatically
45
+ ```
46
+
47
+ ## Docs
48
+
49
+ Full API reference: [docs.opennous.cloud](https://docs.opennous.cloud)
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,3 @@
1
+ from .client import NousClient, NousError
2
+
3
+ __all__ = ["NousClient", "NousError"]
@@ -0,0 +1,382 @@
1
+ """Nous Python SDK — contact memory for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Literal, Optional
7
+
8
+ import httpx
9
+
10
+ DEFAULT_BASE_URL = "https://api.opennous.cloud"
11
+
12
+ ActivityType = Literal[
13
+ "email_sent", "email_reply",
14
+ "call_held", "meeting_held",
15
+ "linkedin_message", "linkedin_connected",
16
+ "follow_up_sent", "proposal_sent",
17
+ "website_visit", "content_download", "trial_started",
18
+ "manual_note",
19
+ ]
20
+
21
+ MemoryCategory = Literal[
22
+ "ICP", "Product", "Pricing", "Market",
23
+ "Competitors", "Team", "Patterns", "General",
24
+ ]
25
+
26
+
27
+ class NousError(Exception):
28
+ def __init__(self, message: str, status: int, code: str | None = None) -> None:
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.code = code
32
+
33
+
34
+ class NousClient:
35
+ """
36
+ Nous contact memory client.
37
+
38
+ Usage::
39
+
40
+ from opennous import NousClient
41
+
42
+ client = NousClient(api_key="YOUR_API_KEY")
43
+
44
+ # Before acting on a contact
45
+ contact = client.get_contact("sarah@acme.com")
46
+ print(contact["summary"])
47
+
48
+ # After an interaction
49
+ client.track(email="sarah@acme.com", type="call_held", description="30 min discovery call")
50
+ client.remember(email="sarah@acme.com", text="Concerned about Salesforce migration.")
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: str | None = None,
56
+ base_url: str | None = None,
57
+ timeout: float = 30.0,
58
+ ) -> None:
59
+ self._api_key = api_key or os.environ.get("NOUS_API_KEY")
60
+ if not self._api_key:
61
+ raise ValueError(
62
+ "api_key is required. Pass it explicitly or set the NOUS_API_KEY environment variable."
63
+ )
64
+ self._base_url = (base_url or os.environ.get("NOUS_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
65
+ self._client = httpx.Client(
66
+ base_url=self._base_url,
67
+ headers={"Authorization": f"Bearer {self._api_key}", "X-Nous-Client": "sdk-python"},
68
+ timeout=timeout,
69
+ )
70
+
71
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
72
+ res = self._client.get(path, params=params)
73
+ return self._handle(res)
74
+
75
+ def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
76
+ res = self._client.post(path, json=body)
77
+ return self._handle(res)
78
+
79
+ def _patch(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
80
+ res = self._client.patch(path, json=body)
81
+ return self._handle(res)
82
+
83
+ def _delete(self, path: str) -> dict[str, Any]:
84
+ res = self._client.delete(path)
85
+ return self._handle(res)
86
+
87
+ @staticmethod
88
+ def _handle(res: httpx.Response) -> dict[str, Any]:
89
+ if not res.is_success:
90
+ try:
91
+ err = res.json()
92
+ msg = err.get("message") or err.get("error") or res.reason_phrase
93
+ code = err.get("error")
94
+ except Exception:
95
+ msg = res.reason_phrase
96
+ code = None
97
+ raise NousError(msg, res.status_code, code)
98
+ if res.status_code == 204:
99
+ return {}
100
+ return res.json()
101
+
102
+ # ── Activity ──────────────────────────────────────────────────────────────
103
+
104
+ def track(
105
+ self,
106
+ *,
107
+ email: str | None = None,
108
+ contact_id: str | None = None,
109
+ type: ActivityType,
110
+ description: str | None = None,
111
+ occurred_at: str | None = None,
112
+ source: str = "sdk",
113
+ ) -> dict[str, Any]:
114
+ """
115
+ Log that something happened with a contact.
116
+ Auto-creates the contact if they don't exist yet.
117
+
118
+ :param email: Contact email address (required if contact_id not given)
119
+ :param contact_id: Contact UUID (required if email not given)
120
+ :param type: Activity type — e.g. "call_held", "email_sent"
121
+ :param description: Brief summary of what happened
122
+ :param occurred_at: ISO timestamp (defaults to now)
123
+ :returns: { contact_id, activity_id, type, occurred_at, created_contact }
124
+ """
125
+ if not email and not contact_id:
126
+ raise ValueError("Provide either email or contact_id")
127
+ body: dict[str, Any] = {"type": type, "source": source}
128
+ if email: body["email"] = email
129
+ if contact_id: body["contact_id"] = contact_id
130
+ if description: body["description"] = description
131
+ if occurred_at: body["occurred_at"] = occurred_at
132
+ return self._post("/v1/track", body)
133
+
134
+ # ── Memory ────────────────────────────────────────────────────────────────
135
+
136
+ def remember(
137
+ self,
138
+ *,
139
+ email: str | None = None,
140
+ contact_id: str | None = None,
141
+ company_id: str | None = None,
142
+ text: str,
143
+ category: MemoryCategory = "General",
144
+ source: str = "sdk",
145
+ ) -> dict[str, Any]:
146
+ """
147
+ Store what was learned about a contact, company, or workspace.
148
+ Pass a single sentence or a full transcript — AI extracts durable facts either way.
149
+ Omit email, contact_id, and company_id to store workspace-level facts (ICP, product, market).
150
+
151
+ :param text: The text to extract facts from
152
+ :param category: Memory category (ICP, Product, Pricing, etc.)
153
+ :returns: { stored: int, facts: list[{ id, content, written_at }] }
154
+ """
155
+ body: dict[str, Any] = {"text": text, "category": category, "source": source}
156
+ if email: body["email"] = email
157
+ if contact_id: body["contact_id"] = contact_id
158
+ if company_id: body["company_id"] = company_id
159
+ return self._post("/v1/remember", body)
160
+
161
+ def get_memories(
162
+ self,
163
+ *,
164
+ category: str | None = None,
165
+ limit: int = 50,
166
+ ) -> dict[str, Any]:
167
+ """
168
+ Load all workspace-level facts — ICP, product, pricing, market, competitive intel.
169
+ Call before drafting outreach or any task requiring workspace context.
170
+
171
+ :param category: Optional filter — ICP, Product, Pricing, Market, Competitors, Team, Patterns, General
172
+ :param limit: Max facts to return (default 50, max 200)
173
+ :returns: { memories: list[{ id, category, content, created_at }], total: int }
174
+ """
175
+ params: dict[str, Any] = {"limit": limit}
176
+ if category: params["category"] = category
177
+ return self._get("/v1/memories", params=params)
178
+
179
+ def search(
180
+ self,
181
+ q: str,
182
+ *,
183
+ contact_id: Optional[str] = None,
184
+ company_id: Optional[str] = None,
185
+ limit: int = 10,
186
+ threshold: Optional[float] = None,
187
+ ) -> dict[str, Any]:
188
+ """
189
+ Semantic search across workspace memories.
190
+
191
+ :param q: Search query
192
+ :param contact_id: Scope search to one contact (uses lenient threshold 0.45)
193
+ :param company_id: Scope search to one company
194
+ :param limit: Max results (default 10)
195
+ :param threshold: Override similarity threshold (0–1)
196
+ :returns: { results: list, count: int }
197
+ """
198
+ body: dict[str, Any] = {"q": q, "limit": limit}
199
+ if contact_id: body["contact_id"] = contact_id
200
+ if company_id: body["company_id"] = company_id
201
+ if threshold is not None: body["threshold"] = threshold
202
+ return self._post("/v1/search", body)
203
+
204
+ def delete_memory(self, memory_id: str) -> dict[str, Any]:
205
+ """
206
+ Soft-delete a workspace memory by UUID.
207
+ Get the ID from get_memories(). Marks the fact inactive — won't appear in future reads.
208
+
209
+ :param memory_id: Memory UUID
210
+ :returns: { deleted: True, id, content }
211
+ """
212
+ from urllib.parse import quote
213
+ return self._delete(f"/v1/memory/{quote(memory_id, safe='')}")
214
+
215
+ # ── Contacts ──────────────────────────────────────────────────────────────
216
+
217
+ def get_contact(self, identifier: str) -> dict[str, Any]:
218
+ """
219
+ Full contact profile — structured JSON.
220
+ Returns identity, pipeline stage, AI summary, scores, channels, last 25 activities
221
+ (with message body where available), facts, and company details.
222
+
223
+ :param identifier: Email address or contact UUID
224
+ :returns: Full contact profile dict
225
+ """
226
+ from urllib.parse import quote
227
+ return self._get(f"/v1/contacts/{quote(identifier, safe='')}")
228
+
229
+ def get_contact_activity(
230
+ self,
231
+ identifier: str,
232
+ *,
233
+ limit: int = 20,
234
+ offset: int = 0,
235
+ type: Optional[str] = None,
236
+ before: Optional[str] = None,
237
+ after: Optional[str] = None,
238
+ ) -> dict[str, Any]:
239
+ """
240
+ Paginated activity history for a contact.
241
+ Use when total_activities is high or you need to filter by type / date range.
242
+ Each activity includes `body` (message text) where available.
243
+
244
+ :param identifier: Email address or contact UUID
245
+ :param limit: Number of activities to return (default 20, max 100)
246
+ :param offset: Pagination offset
247
+ :param type: Filter by type e.g. "linkedin_message", "email_received"
248
+ :param before: ISO date — return activities before this date
249
+ :param after: ISO date — return activities after this date
250
+ :returns: { activities: list, total: int, limit: int, offset: int }
251
+ """
252
+ from urllib.parse import quote
253
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
254
+ if type: params["type"] = type
255
+ if before: params["before"] = before
256
+ if after: params["after"] = after
257
+ return self._get(f"/v1/contacts/{quote(identifier, safe='')}/activity", params=params)
258
+
259
+ def list_contacts(
260
+ self,
261
+ *,
262
+ stage: Optional[str] = None,
263
+ search: Optional[str] = None,
264
+ linkedin_url: Optional[str] = None,
265
+ limit: int = 20,
266
+ offset: int = 0,
267
+ ) -> dict[str, Any]:
268
+ """
269
+ List contacts, optionally filtered by pipeline stage or LinkedIn URL.
270
+
271
+ :param stage: Pipeline stage filter — identified | aware | interested | evaluating | client
272
+ :param search: Search query (name, email, or company)
273
+ :param linkedin_url: Exact LinkedIn profile URL filter (normalized before matching)
274
+ :param limit: Max contacts to return (default 20, max 100)
275
+ :param offset: Pagination offset
276
+ :returns: { contacts: list, total: int }
277
+ """
278
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
279
+ if stage: params["stage"] = stage
280
+ if search: params["search"] = search
281
+ if linkedin_url: params["linkedin_url"] = linkedin_url
282
+ return self._get("/v1/contacts", params=params)
283
+
284
+ def create_contact(
285
+ self,
286
+ *,
287
+ email: Optional[str] = None,
288
+ first_name: Optional[str] = None,
289
+ last_name: Optional[str] = None,
290
+ company: Optional[str] = None,
291
+ job_title: Optional[str] = None,
292
+ phone: Optional[str] = None,
293
+ linkedin_url: Optional[str] = None,
294
+ notes: Optional[str] = None,
295
+ ) -> dict[str, Any]:
296
+ """
297
+ Create a new contact with full profile fields.
298
+ email is required unless linkedin_url is provided.
299
+ Returns 409 if a contact with that email or LinkedIn URL already exists.
300
+
301
+ :param email: Email address (required if linkedin_url not given, must be unique)
302
+ :param linkedin_url: LinkedIn profile URL (required if email not given, must be unique)
303
+ :returns: { id, email, name, company, job_title, pipeline_stage, created_at }
304
+ """
305
+ if not email and not linkedin_url:
306
+ raise ValueError("Provide either email or linkedin_url")
307
+ body: dict[str, Any] = {}
308
+ if email: body["email"] = email
309
+ if first_name: body["first_name"] = first_name
310
+ if last_name: body["last_name"] = last_name
311
+ if company: body["company"] = company
312
+ if job_title: body["job_title"] = job_title
313
+ if phone: body["phone"] = phone
314
+ if linkedin_url: body["linkedin_url"] = linkedin_url
315
+ if notes: body["notes"] = notes
316
+ return self._post("/v1/contacts", body)
317
+
318
+ def update_contact(
319
+ self,
320
+ identifier: str,
321
+ *,
322
+ first_name: Optional[str] = None,
323
+ last_name: Optional[str] = None,
324
+ company: Optional[str] = None,
325
+ job_title: Optional[str] = None,
326
+ phone: Optional[str] = None,
327
+ linkedin_url: Optional[str] = None,
328
+ notes: Optional[str] = None,
329
+ ) -> dict[str, Any]:
330
+ """
331
+ Update one or more profile fields on an existing contact.
332
+ Only provided fields are changed.
333
+
334
+ :param identifier: Email address or contact UUID
335
+ :returns: { id, email, name, company, job_title, pipeline_stage }
336
+ """
337
+ from urllib.parse import quote
338
+ body: dict[str, Any] = {}
339
+ if first_name is not None: body["first_name"] = first_name
340
+ if last_name is not None: body["last_name"] = last_name
341
+ if company is not None: body["company"] = company
342
+ if job_title is not None: body["job_title"] = job_title
343
+ if phone is not None: body["phone"] = phone
344
+ if linkedin_url is not None: body["linkedin_url"] = linkedin_url
345
+ if notes is not None: body["notes"] = notes
346
+ return self._patch(f"/v1/contacts/{quote(identifier, safe='')}", body)
347
+
348
+ def delete_contact(self, identifier: str) -> dict[str, Any]:
349
+ """
350
+ Permanently delete a contact and all their data — activities and memories.
351
+ Cannot be undone. Pass email address or contact UUID.
352
+
353
+ :param identifier: Email address or contact UUID
354
+ :returns: { deleted: True, contact_id, email }
355
+ """
356
+ from urllib.parse import quote
357
+ return self._delete(f"/v1/contacts/{quote(identifier, safe='')}")
358
+
359
+ # ── Company ───────────────────────────────────────────────────────────────
360
+
361
+ def get_company(self, company_id: str) -> dict[str, Any]:
362
+ """
363
+ Full token-budgeted company profile.
364
+ Returns org details + all contacts + company facts.
365
+
366
+ :param company_id: Company UUID
367
+ :returns: Full company profile dict
368
+ """
369
+ from urllib.parse import quote
370
+ return self._get(f"/v1/company/{quote(company_id, safe='')}")
371
+
372
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
373
+
374
+ def close(self) -> None:
375
+ """Close the underlying HTTP client."""
376
+ self._client.close()
377
+
378
+ def __enter__(self) -> "NousClient":
379
+ return self
380
+
381
+ def __exit__(self, *_: Any) -> None:
382
+ self.close()
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: opennous
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for Nous — GTM data infrastructure for agents
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://opennous.cloud
7
+ Project-URL: Repository, https://github.com/bennetglinder1/nous
8
+ Project-URL: Documentation, https://docs.opennous.cloud
9
+ Keywords: nous,opennous,ai,agents,crm,memory,sdk,mcp
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: httpx>=0.27.0
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ opennous/__init__.py
4
+ opennous/client.py
5
+ opennous.egg-info/PKG-INFO
6
+ opennous.egg-info/SOURCES.txt
7
+ opennous.egg-info/dependency_links.txt
8
+ opennous.egg-info/requires.txt
9
+ opennous.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx>=0.27.0
@@ -0,0 +1 @@
1
+ opennous
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opennous"
7
+ version = "1.0.0"
8
+ description = "Official Python SDK for Nous — GTM data infrastructure for agents"
9
+ license = "MIT"
10
+ requires-python = ">=3.9"
11
+ keywords = ["nous", "opennous", "ai", "agents", "crm", "memory", "sdk", "mcp"]
12
+ dependencies = [
13
+ "httpx>=0.27.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://opennous.cloud"
18
+ Repository = "https://github.com/bennetglinder1/nous"
19
+ Documentation = "https://docs.opennous.cloud"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["opennous*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+