cortexdb-connectors 0.2.8__tar.gz → 0.2.10__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.
Files changed (49) hide show
  1. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/PKG-INFO +2 -2
  2. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/README.md +1 -1
  3. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/base.py +131 -16
  4. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/cli.py +31 -6
  5. cortexdb_connectors-0.2.10/cortexdb_connectors/jira/__init__.py +556 -0
  6. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/webhooks.py +54 -3
  7. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/PKG-INFO +2 -2
  8. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/SOURCES.txt +2 -0
  9. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/pyproject.toml +1 -1
  10. cortexdb_connectors-0.2.10/tests/test_jira_attachments.py +262 -0
  11. cortexdb_connectors-0.2.10/tests/test_jira_findings_0_2_9.py +341 -0
  12. cortexdb_connectors-0.2.8/cortexdb_connectors/jira/__init__.py +0 -301
  13. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/__init__.py +0 -0
  14. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/confluence/__init__.py +0 -0
  15. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/discord/__init__.py +0 -0
  16. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  17. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/github/__init__.py +0 -0
  18. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/gitlab/__init__.py +0 -0
  19. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/google_workspace/__init__.py +0 -0
  20. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/hubspot/__init__.py +0 -0
  21. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/__init__.py +0 -0
  22. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/api.py +0 -0
  23. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/detectors.py +0 -0
  24. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/engine.py +0 -0
  25. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/intercom/__init__.py +0 -0
  26. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/linear/__init__.py +0 -0
  27. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/notion/__init__.py +0 -0
  28. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/pagerduty/__init__.py +0 -0
  29. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/salesforce/__init__.py +0 -0
  30. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/servicenow/__init__.py +0 -0
  31. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/slack/__init__.py +0 -0
  32. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/state.py +0 -0
  33. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/teams/__init__.py +0 -0
  34. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/tldv/__init__.py +0 -0
  35. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/worker.py +0 -0
  36. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors/zendesk/__init__.py +0 -0
  37. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  38. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  39. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/requires.txt +0 -0
  40. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  41. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/setup.cfg +0 -0
  42. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_base.py +0 -0
  43. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_connector_coverage.py +0 -0
  44. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_github.py +0 -0
  45. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_ingest_e2e.py +0 -0
  46. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_slack.py +0 -0
  47. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_streaming_cursor.py +0 -0
  48. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_webhooks.py +0 -0
  49. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.10}/tests/test_webhooks_replay.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
5
5
  Author-email: CortexDB Team <team@cortexdb.ai>
6
6
  License-Expression: Apache-2.0
@@ -156,7 +156,7 @@ All 16 connectors are **🟢 Free** to self-host and **🔒 Starter+** ($29/mo)
156
156
  slack SLACK_BOT_TOKEN
157
157
  github GITHUB_TOKEN
158
158
  gitlab GITLAB_TOKEN
159
- jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN
159
+ jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN (incl. attachments → /v1/blobs multimodal extraction, 0.2.10+)
160
160
  linear LINEAR_API_KEY
161
161
  confluence CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN
162
162
  notion NOTION_TOKEN
@@ -78,7 +78,7 @@ All 16 connectors are **🟢 Free** to self-host and **🔒 Starter+** ($29/mo)
78
78
  slack SLACK_BOT_TOKEN
79
79
  github GITHUB_TOKEN
80
80
  gitlab GITLAB_TOKEN
81
- jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN
81
+ jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN (incl. attachments → /v1/blobs multimodal extraction, 0.2.10+)
82
82
  linear LINEAR_API_KEY
83
83
  confluence CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN
84
84
  notion NOTION_TOKEN
@@ -102,7 +102,7 @@ class VisibilityLevel(str, Enum):
102
102
  @dataclass(frozen=True)
103
103
  class Source:
104
104
  system: str
105
- connector_version: str = "0.2.5"
105
+ connector_version: str = "0.2.10"
106
106
 
107
107
 
108
108
  @dataclass(frozen=True)
@@ -126,6 +126,25 @@ class Visibility:
126
126
  allowed_principals: tuple[str, ...] = ()
127
127
 
128
128
 
129
+ @dataclass
130
+ class Attachment:
131
+ """A binary attachment destined for `POST /v1/blobs` + a `blob_ref`
132
+ experience, so CortexDB's multimodal pipeline (Tika/vision/ASR content
133
+ processors) extracts and indexes it.
134
+
135
+ `data` carries the raw bytes (downloaded by the connector's
136
+ `fetch_events`); when `data` is None the attachment is metadata-only
137
+ (e.g. oversized skip) and only its filename line survives in text.
138
+ """
139
+
140
+ filename: str
141
+ mime_type: str
142
+ size_bytes: int = 0
143
+ data: bytes | None = None
144
+ source_url: str | None = None
145
+ attachment_id: str = ""
146
+
147
+
129
148
  @dataclass
130
149
  class RawEvent:
131
150
  source: str
@@ -158,6 +177,11 @@ class Episode:
158
177
  parent_id: str | None = None
159
178
  idempotency_key: str | None = None
160
179
  ttl_seconds: int | None = None
180
+ # When set, this episode is a BINARY attachment: `ingest_episode` uploads
181
+ # `blob.data` to `POST /v1/blobs` and sends the experience with
182
+ # `content.kind="blob_ref"` (the multimodal path) instead of text. The
183
+ # `content` field then serves only as the no-bytes fallback text.
184
+ blob: Attachment | None = None
161
185
 
162
186
 
163
187
  @dataclass
@@ -325,10 +349,26 @@ class CortexConnector(ABC):
325
349
 
326
350
  for key, value in replacements.items():
327
351
  tpl = tpl.replace("{" + key + "}", value or "")
352
+ # C6d: an unresolved placeholder used to pass through literally,
353
+ # producing a silently malformed scope (`.../issue:{entity.issue}`)
354
+ # that the server happily stores as a real path. Fail loudly instead —
355
+ # the sync loop records the error per event and holds the cursor.
356
+ leftover = re.findall(r"\{[^{}]+\}", tpl)
357
+ if leftover:
358
+ raise ValueError(
359
+ f"scope template resolved to {tpl!r} with unresolved "
360
+ f"placeholder(s) {', '.join(leftover)} — this episode does "
361
+ "not carry the referenced entity/field."
362
+ )
328
363
  return tpl
329
364
 
330
- def _envelope(self, episode: Episode) -> dict[str, Any]:
331
- """Build the v1 ExperienceItem JSON for this episode."""
365
+ def _envelope(self, episode: Episode, blob_id: str | None = None) -> dict[str, Any]:
366
+ """Build the v1 ExperienceItem JSON for this episode.
367
+
368
+ With `blob_id` (an id returned by `upload_blob`) the content becomes
369
+ `kind="blob_ref"` — the server fetches the stored bytes and runs the
370
+ per-modality content processor on them. Otherwise plain text.
371
+ """
332
372
  scope = self._resolve_scope(episode)
333
373
  modality = (
334
374
  episode.metadata.get("modality")
@@ -343,11 +383,19 @@ class CortexConnector(ABC):
343
383
  for ent in episode.entities:
344
384
  labels.append(f"{ent.entity_type}:{ent.entity_id}")
345
385
 
346
- content: dict[str, Any] = {
347
- "kind": "message",
348
- "role": "user" if modality == "conversation" else "system",
349
- "text": self._guard_content(episode.content, episode),
350
- }
386
+ if blob_id is not None:
387
+ content: dict[str, Any] = {"kind": "blob_ref", "blob_id": blob_id}
388
+ # Filename/MIME ride labels blob_ref content carries no text,
389
+ # and labels are the queryable metadata surface.
390
+ if episode.blob is not None:
391
+ labels.append(f"filename={episode.blob.filename}")
392
+ labels.append(f"mime={episode.blob.mime_type}")
393
+ else:
394
+ content = {
395
+ "kind": "message",
396
+ "role": "user" if modality == "conversation" else "system",
397
+ "text": self._guard_content(episode.content, episode),
398
+ }
351
399
  # The v1 write envelope is STRICT (deny_unknown_fields) as of v0.8.0:
352
400
  # thread_id / meta / preceded_by are NOT envelope context fields, and
353
401
  # sending them returns 422 INVALID_BODY (which stalls the sync cursor
@@ -422,10 +470,67 @@ class CortexConnector(ABC):
422
470
  )
423
471
  return capped
424
472
 
473
+ def _headers(self, content_type: str = "application/json") -> dict[str, str]:
474
+ return {
475
+ "Authorization": f"Bearer {self.cortex_api_key}",
476
+ "X-Cortex-Actor": self.actor,
477
+ "Content-Type": content_type,
478
+ "User-Agent": f"cortexdb-connectors/{Source(system='base').connector_version}",
479
+ }
480
+
481
+ async def upload_blob(self, data: bytes, mime_type: str) -> str:
482
+ """POST raw bytes to `/v1/blobs`; returns the server `blob_id`."""
483
+ import httpx
484
+
485
+ async with httpx.AsyncClient(timeout=120.0) as client:
486
+ resp = await client.post(
487
+ f"{self.cortex_url}/v1/blobs",
488
+ content=data,
489
+ headers=self._headers(mime_type or "application/octet-stream"),
490
+ )
491
+ if resp.status_code >= 400:
492
+ logger.error(
493
+ "blob upload REJECTED: status=%s bytes=%d mime=%s body=%s",
494
+ resp.status_code, len(data), mime_type, resp.text[:300],
495
+ )
496
+ resp.raise_for_status()
497
+ return resp.json()["blob_id"]
498
+
499
+ async def experience_exists(self, idempotency_key: str, scope: str) -> bool:
500
+ """Probe `GET /v1/experience/status` for a prior write with this key.
501
+
502
+ Used before attachment blob uploads so a re-sync doesn't store a
503
+ fresh (orphaned) blob copy for an experience that will just replay.
504
+ Best-effort: any probe failure returns False (upload proceeds — the
505
+ experience write itself still dedups via its idempotency key).
506
+ """
507
+ import httpx
508
+
509
+ try:
510
+ async with httpx.AsyncClient(timeout=15.0) as client:
511
+ resp = await client.get(
512
+ f"{self.cortex_url}/v1/experience/status",
513
+ params={"idempotency_key": idempotency_key, "scope": scope},
514
+ headers=self._headers(),
515
+ )
516
+ if resp.status_code != 200:
517
+ return False
518
+ return bool(resp.json().get("found"))
519
+ except Exception: # noqa: BLE001
520
+ return False
521
+
425
522
  async def ingest_episode(self, episode: Episode) -> str:
426
523
  """POST `/v1/experience` with the v1 envelope.
427
524
 
428
- Returns the server-side event_id on success.
525
+ Blob-carrying episodes (`episode.blob` set with bytes) first upload
526
+ the bytes to `/v1/blobs` and go in as `content.kind="blob_ref"` so the
527
+ server's content processors extract them. A status probe on the
528
+ episode's idempotency key skips the upload when the experience already
529
+ exists (re-sync), avoiding orphaned duplicate blobs. Bytes-less blob
530
+ episodes (oversized skips) fall back to their text content.
531
+
532
+ Returns the server-side event_id on success ("exists" when the
533
+ status probe short-circuited a replay).
429
534
  """
430
535
  import httpx
431
536
 
@@ -441,13 +546,23 @@ class CortexConnector(ABC):
441
546
  "explicitly. The actor must match the token's `sub` claim."
442
547
  )
443
548
 
444
- envelope = self._envelope(episode)
445
- headers = {
446
- "Authorization": f"Bearer {self.cortex_api_key}",
447
- "X-Cortex-Actor": self.actor,
448
- "Content-Type": "application/json",
449
- "User-Agent": f"cortexdb-connectors/{Source(system='base').connector_version}",
450
- }
549
+ blob_id: str | None = None
550
+ if episode.blob is not None and episode.blob.data:
551
+ scope = self._resolve_scope(episode)
552
+ key = episode.idempotency_key or self._fallback_idem(episode, scope)
553
+ if await self.experience_exists(key, scope):
554
+ logger.info(
555
+ "attachment already ingested (idempotency_key=%s); "
556
+ "skipping blob upload",
557
+ key,
558
+ )
559
+ return "exists"
560
+ blob_id = await self.upload_blob(
561
+ episode.blob.data, episode.blob.mime_type
562
+ )
563
+
564
+ envelope = self._envelope(episode, blob_id=blob_id)
565
+ headers = self._headers()
451
566
 
452
567
  async with httpx.AsyncClient(timeout=30.0) as client:
453
568
  resp = await client.post(
@@ -432,16 +432,41 @@ def _cmd_sync(args: argparse.Namespace) -> None:
432
432
 
433
433
  def _cmd_serve(args: argparse.Namespace) -> None:
434
434
  """Run the webhook receiver for real-time create/update/delete."""
435
- from cortexdb_connectors.webhooks import WebhookServer, build_handlers
435
+ # C3: this used to import a `build_handlers` that never existed —
436
+ # `cortexdb-sync serve` ImportError'd on startup, killing every
437
+ # connector's webhook mode. WebhookServer wires its own handlers from
438
+ # env secrets; the CLI only supplies CortexDB creds + bind address.
439
+ import uvicorn
440
+
441
+ from cortexdb_connectors.webhooks import WebhookServer
436
442
 
437
443
  creds = _load_cortex_creds(args)
438
- handlers = build_handlers(creds)
439
- if not handlers:
444
+ secret_envs = [
445
+ "SLACK_SIGNING_SECRET",
446
+ "GITHUB_WEBHOOK_SECRET",
447
+ "JIRA_WEBHOOK_SECRET",
448
+ "FRESHDESK_WEBHOOK_SECRET",
449
+ "TLDV_WEBHOOK_SECRET",
450
+ ]
451
+ if not any(os.environ.get(v) for v in secret_envs):
440
452
  logger.warning(
441
- "no webhook handlers configured — set per-source signing secrets "
442
- "(e.g. SLACK_SIGNING_SECRET) so the receiver can verify deliveries."
453
+ "no webhook signing secrets configured — every delivery will be "
454
+ "rejected as unsigned. Set one or more of: %s",
455
+ ", ".join(secret_envs),
443
456
  )
444
- WebhookServer(handlers, host=args.host, port=args.port).serve_forever()
457
+ server = WebhookServer(
458
+ cortexdb_url=creds["api_url"] or None,
459
+ cortexdb_api_key=creds["api_key"] or None,
460
+ scope=creds["scope_template"] or "user:default",
461
+ actor=creds["actor"] or None,
462
+ )
463
+ logger.info(
464
+ "webhook receiver listening on %s:%d (routes: /webhooks/{slack,github,"
465
+ "jira,freshdesk,tldv}/events, /health)",
466
+ args.host,
467
+ args.port,
468
+ )
469
+ uvicorn.run(server.build_app(), host=args.host, port=args.port, log_level="info")
445
470
 
446
471
 
447
472
  def _cmd_watch(args: argparse.Namespace) -> None: