agentgraph-server 0.5.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 (49) hide show
  1. agentgraph/__init__.py +1 -0
  2. agentgraph/auth/__init__.py +0 -0
  3. agentgraph/auth/credentials.py +224 -0
  4. agentgraph/backends/__init__.py +50 -0
  5. agentgraph/backends/sqlite/__init__.py +1 -0
  6. agentgraph/backends/sqlite/backend.py +1471 -0
  7. agentgraph/backends/sqlite/vector.py +142 -0
  8. agentgraph/cli.py +721 -0
  9. agentgraph/cli_query.py +519 -0
  10. agentgraph/config.py +90 -0
  11. agentgraph/connectors/__init__.py +0 -0
  12. agentgraph/connectors/base.py +455 -0
  13. agentgraph/connectors/registry.py +78 -0
  14. agentgraph/connectors/status.py +244 -0
  15. agentgraph/core/__init__.py +0 -0
  16. agentgraph/core/context.py +26 -0
  17. agentgraph/core/runtime.py +36 -0
  18. agentgraph/core/storage.py +240 -0
  19. agentgraph/graph/__init__.py +1 -0
  20. agentgraph/graph/bookmark.py +87 -0
  21. agentgraph/graph/delete.py +17 -0
  22. agentgraph/graph/download.py +35 -0
  23. agentgraph/graph/embeddings.py +58 -0
  24. agentgraph/graph/fetch.py +53 -0
  25. agentgraph/graph/gc.py +26 -0
  26. agentgraph/graph/link.py +63 -0
  27. agentgraph/graph/person.py +40 -0
  28. agentgraph/graph/query.py +244 -0
  29. agentgraph/graph/upsert.py +49 -0
  30. agentgraph/logging.py +78 -0
  31. agentgraph/mcp/__init__.py +0 -0
  32. agentgraph/mcp/server.py +811 -0
  33. agentgraph/perf.py +43 -0
  34. agentgraph/server/__init__.py +0 -0
  35. agentgraph/server/app.py +133 -0
  36. agentgraph/server/cli_api.py +708 -0
  37. agentgraph/server/dwell.py +79 -0
  38. agentgraph/server/graph_api.py +46 -0
  39. agentgraph/server/router.py +47 -0
  40. agentgraph/server/sync.py +247 -0
  41. agentgraph/skills.py +93 -0
  42. agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
  43. agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
  44. agentgraph_server-0.5.0.dist-info/METADATA +286 -0
  45. agentgraph_server-0.5.0.dist-info/RECORD +49 -0
  46. agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
  47. agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
  48. agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
  49. agentgraph_server-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,708 @@
1
+ """CLI API router — raw entity/edge data endpoints for the agentgraph CLI."""
2
+
3
+ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
4
+ # pyright: reportUnknownArgumentType=false
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import re
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ from fastapi import APIRouter, HTTPException, Query
14
+
15
+ router = APIRouter(prefix="/api/cli", tags=["cli"])
16
+
17
+ _WHITESPACE_RE = re.compile(r"\s+")
18
+
19
+
20
+ async def search_entities(
21
+ query: str,
22
+ entity_types: list[str] | None = None,
23
+ limit: int = 10,
24
+ min_score: float = 0.03,
25
+ platform: str | None = None,
26
+ ) -> list[dict[str, Any]]:
27
+ from agentgraph.graph.query import search_entities as impl
28
+
29
+ return await impl(query, entity_types, limit, min_score, platform)
30
+
31
+
32
+ async def get_entity(entity_id: str) -> dict[str, Any] | None:
33
+ from agentgraph.graph.query import get_entity as impl
34
+
35
+ return await impl(entity_id)
36
+
37
+
38
+ async def get_entity_by_url(url: str) -> dict[str, Any] | None:
39
+ from agentgraph.graph.query import get_entity_by_url as impl
40
+
41
+ return await impl(url)
42
+
43
+
44
+ async def get_edges(
45
+ entity_id: str,
46
+ edge_type: str | None = None,
47
+ direction: str = "both",
48
+ ) -> list[dict[str, Any]]:
49
+ from agentgraph.graph.query import get_edges as impl
50
+
51
+ return await impl(entity_id, edge_type=edge_type, direction=direction)
52
+
53
+
54
+ async def traverse_graph(entity_id: str, max_depth: int = 2) -> dict[str, Any]:
55
+ from agentgraph.graph.query import traverse_graph as impl
56
+
57
+ return await impl(entity_id, max_depth=max_depth)
58
+
59
+
60
+ async def get_edges_for_entities(entity_ids: list[str]) -> list[dict[str, Any]]:
61
+ from agentgraph.graph.query import get_edges_for_entities as impl
62
+
63
+ return await impl(entity_ids)
64
+
65
+
66
+ async def get_entities_by_ids(entity_ids: list[str]) -> list[dict[str, Any]]:
67
+ from agentgraph.graph.query import get_entities_by_ids as impl
68
+
69
+ return await impl(entity_ids)
70
+
71
+
72
+ async def list_entities(
73
+ entity_types: list[str] | None = None,
74
+ platform: str | None = None,
75
+ since: str | None = None,
76
+ limit: int = 50,
77
+ ) -> list[dict[str, Any]]:
78
+ from agentgraph.graph.query import list_entities as impl
79
+
80
+ return await impl(entity_types, platform, since, limit)
81
+
82
+
83
+ async def list_entities_page(
84
+ entity_types: list[str] | None = None,
85
+ platform: str | None = None,
86
+ since: str | None = None,
87
+ limit: int = 50,
88
+ offset: int = 0,
89
+ order_by: str | None = "last_accessed",
90
+ order_dir: str = "desc",
91
+ ) -> tuple[list[dict[str, Any]], int]:
92
+ from agentgraph.graph.query import list_entities_page as impl
93
+
94
+ return await impl(entity_types, platform, since, limit, offset, order_by, order_dir)
95
+
96
+
97
+ async def query_by_filter(
98
+ entity_type: str,
99
+ filters: dict[str, str],
100
+ limit: int = 50,
101
+ order_by: str = "last_accessed",
102
+ since: str | None = None,
103
+ authored_by_me: bool = False,
104
+ has_attachments: bool = False,
105
+ ) -> list[dict[str, Any]]:
106
+ from agentgraph.graph.query import query_by_filter as impl
107
+
108
+ return await impl(
109
+ entity_type,
110
+ filters=filters,
111
+ limit=limit,
112
+ order_by=order_by,
113
+ since=since,
114
+ authored_by_me=authored_by_me,
115
+ has_attachments=has_attachments,
116
+ )
117
+
118
+
119
+ def parse_since(since: str) -> datetime:
120
+ from agentgraph.graph.query import parse_since as impl
121
+
122
+ return impl(since)
123
+
124
+
125
+ def _normalise_display_text(value: object) -> str | None:
126
+ if not isinstance(value, str):
127
+ return None
128
+ text = _WHITESPACE_RE.sub(" ", value).strip()
129
+ return text or None
130
+
131
+
132
+ def _entity_display_name(entity: dict[str, Any]) -> str:
133
+ metadata = entity.get("metadata")
134
+ metadata_dict = metadata if isinstance(metadata, dict) else {}
135
+ candidates = (
136
+ entity.get("title"),
137
+ metadata_dict.get("display_name"),
138
+ metadata_dict.get("canonical_email"),
139
+ entity.get("content"),
140
+ entity.get("platform_entity_id"),
141
+ entity.get("id"),
142
+ )
143
+ for candidate in candidates:
144
+ text = _normalise_display_text(candidate)
145
+ if text:
146
+ return text
147
+ return "Untitled"
148
+
149
+
150
+ def _truncate_text(value: str, limit: int) -> str:
151
+ if len(value) <= limit:
152
+ return value
153
+ return value[: max(limit - 1, 0)].rstrip() + "…"
154
+
155
+
156
+ def _entity_viewer_label(entity: dict[str, Any]) -> str:
157
+ display_name = _entity_display_name(entity)
158
+ if entity.get("entity_type") == "Message":
159
+ return _truncate_text(display_name, 80)
160
+ return display_name
161
+
162
+
163
+ def _with_display_name(entity: dict[str, Any]) -> dict[str, Any]:
164
+ enriched = dict(entity)
165
+ enriched["display_name"] = _entity_display_name(entity)
166
+ enriched["viewer_label"] = _entity_viewer_label(entity)
167
+ return enriched
168
+
169
+
170
+ def _with_display_names(entities: list[dict[str, Any]]) -> list[dict[str, Any]]:
171
+ return [_with_display_name(entity) for entity in entities]
172
+
173
+
174
+ def _summarize_entity(entity: dict[str, Any], *, content_limit: int = 500) -> dict[str, Any]:
175
+ summarized = dict(entity)
176
+ content = summarized.get("content")
177
+ if isinstance(content, str) and len(content) > content_limit:
178
+ summarized["content"] = _truncate_text(content, content_limit)
179
+ summarized["content_truncated"] = True
180
+ else:
181
+ summarized["content_truncated"] = False
182
+ return summarized
183
+
184
+
185
+ def _summarize_entities(
186
+ entities: list[dict[str, Any]],
187
+ *,
188
+ content_limit: int = 500,
189
+ ) -> list[dict[str, Any]]:
190
+ return [_summarize_entity(entity, content_limit=content_limit) for entity in entities]
191
+
192
+
193
+ @router.get("/meta")
194
+ async def cli_meta(include_dynamic_url_patterns: bool = True) -> dict[str, Any]:
195
+ """Return registered connector sources, URL patterns, and known entity types."""
196
+ from agentgraph.config import get_settings
197
+ from agentgraph.connectors.base import ENTITY_TYPES
198
+ from agentgraph.connectors.registry import get_all_connectors
199
+
200
+ connectors = get_all_connectors()
201
+ seen_patterns: list[str] = []
202
+ seen_set: set[str] = set()
203
+ for c in connectors:
204
+ patterns = (
205
+ await c.observation_url_patterns()
206
+ if include_dynamic_url_patterns
207
+ else c.url_patterns
208
+ )
209
+ for p in patterns:
210
+ if p not in seen_set:
211
+ seen_patterns.append(p)
212
+ seen_set.add(p)
213
+
214
+ return {
215
+ "entity_types": list(ENTITY_TYPES),
216
+ "platforms": sorted({c.source for c in connectors}),
217
+ "url_patterns": seen_patterns,
218
+ "dwell_threshold_ms": get_settings().dwell_threshold_seconds * 1000,
219
+ }
220
+
221
+
222
+ @router.get("/search")
223
+ async def cli_search(
224
+ q: str,
225
+ entity_type: list[str] = Query(default=[]),
226
+ limit: int = Query(default=10, ge=1, le=200),
227
+ min_score: float = Query(default=0.03, ge=0.0, le=1.0),
228
+ platform: str | None = Query(default=None),
229
+ ) -> list[dict[str, Any]]:
230
+ results = await search_entities(
231
+ q, entity_types=entity_type or None, limit=limit, min_score=min_score, platform=platform
232
+ )
233
+ return _summarize_entities(results)
234
+
235
+
236
+ @router.get("/entity/{entity_id:path}")
237
+ async def cli_get_entity(entity_id: str) -> dict[str, Any]:
238
+ entity = await get_entity(entity_id)
239
+ if entity is None:
240
+ raise HTTPException(status_code=404, detail="Entity not found")
241
+ return _with_display_name(entity)
242
+
243
+
244
+ @router.get("/entity-by-url")
245
+ async def cli_get_entity_by_url(url: str = Query(...)) -> dict[str, Any]:
246
+ entity = await get_entity_by_url(url)
247
+ if entity is None:
248
+ raise HTTPException(status_code=404, detail="Entity not found")
249
+ return _with_display_name(entity)
250
+
251
+
252
+ @router.get("/edges/{entity_id:path}")
253
+ async def cli_get_edges(
254
+ entity_id: str,
255
+ edge_type: str | None = Query(default=None),
256
+ direction: str = Query(default="both"),
257
+ ) -> list[dict[str, Any]]:
258
+ entity = await get_entity(entity_id)
259
+ if entity is None:
260
+ raise HTTPException(status_code=404, detail="Entity not found")
261
+ return await get_edges(entity["id"], edge_type=edge_type, direction=direction)
262
+
263
+
264
+ @router.get("/traverse/{entity_id:path}")
265
+ async def cli_traverse(
266
+ entity_id: str,
267
+ depth: int = Query(default=2, ge=0, le=4),
268
+ ) -> dict[str, Any]:
269
+ entity = await get_entity(entity_id)
270
+ if entity is None:
271
+ raise HTTPException(status_code=404, detail="Entity not found")
272
+ return await traverse_graph(entity["id"], max_depth=depth)
273
+
274
+
275
+ _VIEWER_ORDER_FIELDS = {
276
+ "created_at",
277
+ "display_name",
278
+ "entity_type",
279
+ "platform",
280
+ "updated_at",
281
+ "last_accessed",
282
+ "synced_at",
283
+ }
284
+
285
+
286
+ def _viewer_sort_value(node: dict[str, Any], order_by: str) -> str:
287
+ if order_by == "display_name":
288
+ return _entity_display_name(node).casefold()
289
+ value = node.get(order_by)
290
+ return value.casefold() if isinstance(value, str) else ""
291
+
292
+
293
+ def _viewer_updated_at_on_or_after(node: dict[str, Any], cutoff: datetime) -> bool:
294
+ updated_at = node.get("updated_at")
295
+ if not isinstance(updated_at, str):
296
+ return False
297
+ try:
298
+ updated_at_dt = datetime.fromisoformat(updated_at)
299
+ except ValueError:
300
+ return False
301
+ if updated_at_dt.tzinfo is None:
302
+ updated_at_dt = updated_at_dt.replace(tzinfo=UTC)
303
+ if cutoff.tzinfo is None:
304
+ cutoff = cutoff.replace(tzinfo=UTC)
305
+ return updated_at_dt >= cutoff
306
+
307
+
308
+ def _page_entities(
309
+ nodes: list[dict[str, Any]],
310
+ page: int,
311
+ page_size: int,
312
+ order_by: str | None,
313
+ order_dir: str,
314
+ ) -> tuple[list[dict[str, Any]], int]:
315
+ reverse = order_dir.lower() != "asc"
316
+ if order_by in _VIEWER_ORDER_FIELDS:
317
+ nodes = sorted(nodes, key=lambda node: _viewer_sort_value(node, order_by), reverse=reverse)
318
+ total = len(nodes)
319
+ start = (page - 1) * page_size
320
+ return nodes[start : start + page_size], total
321
+
322
+
323
+ async def _resolve_viewer_node_set(
324
+ search: str | None,
325
+ entity_type: list[str],
326
+ platform: str | None,
327
+ since: str | None,
328
+ node_id: str | None,
329
+ depth: int,
330
+ limit: int,
331
+ *,
332
+ page: int | None = None,
333
+ page_size: int | None = None,
334
+ order_by: str = "last_accessed",
335
+ order_dir: str = "desc",
336
+ ordered: bool = True,
337
+ ) -> tuple[list[dict[str, Any]], int, bool]:
338
+ """Resolve the filtered viewer node set; traversal edges are only used for pruning."""
339
+ # --- Phase 1: neighbourhood (only when node_id given) ---
340
+ focal: dict[str, Any] | None = None
341
+ neighbourhood_ids: set[str] | None = None
342
+ traverse_edges: list[dict[str, Any]] = []
343
+
344
+ if node_id is not None:
345
+ focal = await get_entity(node_id)
346
+ if focal is None:
347
+ raise HTTPException(status_code=404, detail="Entity not found")
348
+ tresult = await traverse_graph(focal["id"], max_depth=depth)
349
+ neighbourhood_ids = {n["id"] for n in tresult["nodes"]}
350
+ traverse_edges = tresult["edges"]
351
+
352
+ # --- Phase 2: candidate nodes ---
353
+ if search:
354
+ search_limit = limit + 1 if neighbourhood_ids is None else max(limit + 1, 500)
355
+ # The viewer needs every lexical match up to its active limit so it can
356
+ # reliably expose the More control. Hybrid RRF scores naturally fall
357
+ # below the default cutoff for lower-ranked exact text matches.
358
+ nodes = await search_entities(
359
+ search,
360
+ entity_types=entity_type or None,
361
+ limit=search_limit,
362
+ min_score=0.0,
363
+ )
364
+ if neighbourhood_ids is not None:
365
+ nodes = [n for n in nodes if n["id"] in neighbourhood_ids]
366
+ elif neighbourhood_ids is not None:
367
+ nodes = [n for n in tresult["nodes"]] # type: ignore[possibly-undefined]
368
+ if entity_type:
369
+ allowed_types = set(entity_type)
370
+ nodes = [n for n in nodes if n["entity_type"] in allowed_types]
371
+ elif page is not None and page_size is not None:
372
+ offset = (page - 1) * page_size
373
+ nodes, total = await list_entities_page(
374
+ entity_types=entity_type or None,
375
+ platform=platform,
376
+ since=since,
377
+ limit=min(page_size, max(limit - offset, 0)),
378
+ offset=offset,
379
+ order_by=order_by if ordered else None,
380
+ order_dir=order_dir,
381
+ )
382
+ return nodes, min(total, limit), total > limit
383
+ else:
384
+ nodes = await list_entities(
385
+ entity_types=entity_type or None,
386
+ platform=platform,
387
+ since=since,
388
+ limit=limit + 1,
389
+ )
390
+
391
+ # Apply platform / since on search and neighbourhood paths (list_entities handles them natively)
392
+ if platform and (search or neighbourhood_ids is not None):
393
+ nodes = [n for n in nodes if n.get("platform") == platform]
394
+ if since and (search or neighbourhood_ids is not None):
395
+ cutoff = parse_since(since)
396
+ nodes = [n for n in nodes if _viewer_updated_at_on_or_after(n, cutoff)]
397
+
398
+ has_more = len(nodes) > limit
399
+ nodes = nodes[:limit]
400
+
401
+ # Focal node is always shown, even when it doesn't match the active filters
402
+ if focal is not None and focal["id"] not in {n["id"] for n in nodes}:
403
+ nodes = [focal] + nodes
404
+
405
+ visible_ids = {n["id"] for n in nodes}
406
+
407
+ # --- Phase 3: edges + prune / adjacent expansion ---
408
+ if neighbourhood_ids is not None:
409
+ edges = [
410
+ e for e in traverse_edges
411
+ if e["source_entity_id"] in visible_ids and e["target_entity_id"] in visible_ids
412
+ ]
413
+ # BFS from focal through filtered edges; removes nodes only reachable via hidden types
414
+ reachable: set[str] = set()
415
+ adjacency: dict[str, set[str]] = {}
416
+ for e in edges:
417
+ adjacency.setdefault(e["source_entity_id"], set()).add(e["target_entity_id"])
418
+ adjacency.setdefault(e["target_entity_id"], set()).add(e["source_entity_id"])
419
+ queue = [focal["id"]] # type: ignore[possibly-undefined]
420
+ while queue:
421
+ nid = queue.pop()
422
+ if nid in reachable:
423
+ continue
424
+ reachable.add(nid)
425
+ queue.extend(adjacency.get(nid, set()) - reachable)
426
+ nodes = [n for n in nodes if n["id"] in reachable]
427
+ visible_ids = {n["id"] for n in nodes}
428
+ edges = [
429
+ e for e in edges
430
+ if e["source_entity_id"] in visible_ids and e["target_entity_id"] in visible_ids
431
+ ]
432
+ else:
433
+ edges = await get_edges_for_entities(list(visible_ids))
434
+ # When search + entity_type active, pull in adjacent nodes of those types so that
435
+ # e.g. searching for a Thread also surfaces the Persons connected to it.
436
+ if search and entity_type:
437
+ allowed = set(entity_type)
438
+ neighbour_ids = {
439
+ eid
440
+ for e in edges
441
+ for eid in (e["source_entity_id"], e["target_entity_id"])
442
+ if eid not in visible_ids
443
+ }
444
+ if neighbour_ids:
445
+ neighbours = await get_entities_by_ids(list(neighbour_ids))
446
+ neighbours = [n for n in neighbours if n["entity_type"] in allowed]
447
+ has_more = has_more or len(nodes) + len(neighbours) > limit
448
+ nodes = (nodes + neighbours)[:limit]
449
+
450
+ if page is not None and page_size is not None:
451
+ nodes, total = _page_entities(
452
+ nodes,
453
+ page,
454
+ page_size,
455
+ order_by if ordered else None,
456
+ order_dir,
457
+ )
458
+ else:
459
+ total = len(nodes)
460
+ return nodes, total, has_more
461
+
462
+
463
+ async def _viewer_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
464
+ visible_ids = {node["id"] for node in nodes}
465
+ if not visible_ids:
466
+ return []
467
+ edges = await get_edges_for_entities(list(visible_ids))
468
+ return [
469
+ edge for edge in edges
470
+ if edge["source_entity_id"] in visible_ids and edge["target_entity_id"] in visible_ids
471
+ ]
472
+
473
+
474
+ @router.get("/browse/nodes")
475
+ async def cli_browse_nodes(
476
+ search: str | None = Query(default=None),
477
+ entity_type: list[str] = Query(default=[]),
478
+ platform: str | None = Query(default=None),
479
+ since: str | None = Query(default=None),
480
+ node_id: str | None = Query(default=None),
481
+ depth: int = Query(default=2, ge=0, le=4),
482
+ limit: int = Query(default=50, ge=1, le=1000),
483
+ page: int = Query(default=1, ge=1),
484
+ size: int = Query(default=50, ge=1, le=1000),
485
+ sort: str = Query(default="last_accessed"),
486
+ sort_dir: str = Query(default="desc"),
487
+ ordered: bool = Query(default=True),
488
+ ) -> dict[str, Any]:
489
+ """Return one viewer node page, optionally omitting ordering for graph layout."""
490
+ nodes, total, has_more = await _resolve_viewer_node_set(
491
+ search, entity_type, platform, since, node_id, depth, limit,
492
+ page=page,
493
+ page_size=size,
494
+ order_by=sort,
495
+ order_dir=sort_dir,
496
+ ordered=ordered,
497
+ )
498
+ return {
499
+ "data": _with_display_names(_summarize_entities(nodes, content_limit=300)),
500
+ "last_page": max(1, (total + size - 1) // size),
501
+ "total": total,
502
+ "has_more": has_more,
503
+ }
504
+
505
+
506
+ @router.get("/browse/edges")
507
+ async def cli_browse_edges(node_ids: str = Query(default="")) -> dict[str, Any]:
508
+ """Return edges whose endpoints are both in comma-separated node_ids."""
509
+ ids = list(dict.fromkeys(node_id.strip() for node_id in node_ids.split(",") if node_id.strip()))
510
+ if len(ids) > 1000:
511
+ raise HTTPException(status_code=422, detail="At most 1000 node_ids are allowed")
512
+ edges = await get_edges_for_entities(ids)
513
+ visible_ids = set(ids)
514
+ return {
515
+ "edges": [
516
+ edge for edge in edges
517
+ if edge["source_entity_id"] in visible_ids and edge["target_entity_id"] in visible_ids
518
+ ]
519
+ }
520
+
521
+
522
+ @router.get("/browse")
523
+ async def cli_browse(
524
+ search: str | None = Query(default=None),
525
+ entity_type: list[str] = Query(default=[]),
526
+ platform: str | None = Query(default=None),
527
+ since: str | None = Query(default=None),
528
+ node_id: str | None = Query(default=None),
529
+ depth: int = Query(default=2, ge=0, le=4),
530
+ limit: int = Query(default=50, ge=1, le=1000),
531
+ ) -> dict[str, Any]:
532
+ """Compatibility graph response, composed from the shared node-set resolver."""
533
+ nodes, _, _ = await _resolve_viewer_node_set(
534
+ search, entity_type, platform, since, node_id, depth, limit
535
+ )
536
+ return {
537
+ "nodes": _with_display_names(_summarize_entities(nodes, content_limit=300)),
538
+ "edges": await _viewer_edges(nodes),
539
+ }
540
+
541
+
542
+ @router.get("/query")
543
+ async def cli_query(
544
+ entity_type: str,
545
+ limit: int = Query(default=50, ge=1, le=500),
546
+ order_by: str = Query(default="last_accessed"),
547
+ since: str | None = Query(default=None),
548
+ mine: bool = Query(default=False),
549
+ has_attachments: bool = Query(default=False),
550
+ filter: list[str] = Query(default=[]),
551
+ ) -> list[dict[str, Any]]:
552
+ """Query entities by type with optional filters (key=value pairs)."""
553
+ filters: dict[str, str] = {}
554
+ for f in filter:
555
+ if "=" in f:
556
+ k, _, v = f.partition("=")
557
+ filters[k.strip()] = v.strip()
558
+ results = await query_by_filter(
559
+ entity_type,
560
+ filters=filters,
561
+ limit=limit,
562
+ order_by=order_by,
563
+ since=since,
564
+ authored_by_me=mine,
565
+ has_attachments=has_attachments,
566
+ )
567
+ return _summarize_entities(results)
568
+
569
+
570
+ @router.post("/fetch")
571
+ async def cli_fetch(
572
+ platform: str = Query(...),
573
+ resource_id: str = Query(...),
574
+ ) -> dict[str, Any]:
575
+ """Trigger a connector fetch for a platform entity."""
576
+ from agentgraph.graph.fetch import fetch_entity
577
+
578
+ try:
579
+ return await fetch_entity(platform, resource_id)
580
+ except ValueError as exc:
581
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
582
+ except RuntimeError as exc:
583
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
584
+
585
+
586
+ @router.post("/download")
587
+ async def cli_download(
588
+ entity_id: str = Query(...),
589
+ output_path: str | None = Query(default=None),
590
+ ) -> dict[str, Any]:
591
+ """Download an entity's source file using connector auth."""
592
+ from agentgraph.graph.download import download_entity
593
+
594
+ try:
595
+ return await download_entity(entity_id, output_path)
596
+ except ValueError as exc:
597
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
598
+
599
+
600
+ @router.post("/bookmark")
601
+ async def cli_bookmark(
602
+ target: str | None = Query(default=None),
603
+ entity_id: str | None = Query(default=None),
604
+ bookmarked: bool = Query(default=True),
605
+ ) -> dict[str, Any]:
606
+ """Set bookmark state for an entity or URL."""
607
+ from agentgraph.graph.bookmark import bookmark_target, set_entity_bookmark
608
+
609
+ try:
610
+ bookmark_target_value = target or entity_id
611
+ if bookmark_target_value is None:
612
+ raise ValueError("Missing bookmark target")
613
+ if not bookmarked:
614
+ return await set_entity_bookmark(bookmark_target_value, False)
615
+ return await bookmark_target(bookmark_target_value)
616
+ except ValueError as exc:
617
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
618
+
619
+
620
+ @router.post("/delete")
621
+ async def cli_delete(
622
+ target: str = Query(...),
623
+ ) -> dict[str, Any]:
624
+ """Delete an entity from the graph."""
625
+ from agentgraph.graph.delete import delete_entity
626
+
627
+ try:
628
+ return await delete_entity(target)
629
+ except ValueError as exc:
630
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
631
+
632
+
633
+ @router.post("/unify-persons")
634
+ async def cli_unify_persons(
635
+ primary: str = Query(...),
636
+ duplicate: list[str] = Query(...),
637
+ ) -> dict[str, Any]:
638
+ """Merge duplicate Person entities into a chosen primary Person."""
639
+ from agentgraph.graph.person import unify_persons
640
+
641
+ try:
642
+ return await unify_persons(primary, duplicate)
643
+ except ValueError as exc:
644
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
645
+
646
+
647
+ @router.post("/poll")
648
+ async def cli_poll(
649
+ source: str | None = Query(default=None),
650
+ ) -> dict[str, Any]:
651
+ """Trigger a background poll for one connector (by source) or all connectors."""
652
+ from agentgraph.connectors.registry import get_all_connectors, get_connector
653
+ from agentgraph.server.sync import schedule_poll_connector
654
+
655
+ if source is not None:
656
+ connector = get_connector(source)
657
+ if connector is None:
658
+ raise HTTPException(status_code=404, detail=f"No connector registered for source '{source}'")
659
+ connectors = [connector]
660
+ else:
661
+ connectors = get_all_connectors()
662
+
663
+ polled: list[str] = []
664
+ already_running: list[str] = []
665
+ skipped: list[dict[str, str | None]] = []
666
+ for connector in connectors:
667
+ if connector.poll_interval is None:
668
+ continue
669
+ result = await schedule_poll_connector(connector)
670
+ if result["status"] == "queued":
671
+ polled.append(connector.source)
672
+ elif result["status"] == "already_running":
673
+ already_running.append(connector.source)
674
+ else:
675
+ skipped.append({"source": connector.source, "reason": result["reason"]})
676
+
677
+ return {"polled": polled, "already_running": already_running, "skipped": skipped}
678
+
679
+
680
+ @router.post("/ingest")
681
+ async def cli_ingest(
682
+ source: str = Query(...),
683
+ ) -> dict[str, Any]:
684
+ """Kick off a background bulk ingest for a connector and return immediately."""
685
+ from agentgraph.connectors.registry import get_connector
686
+ from agentgraph.server.sync import run_ingest
687
+
688
+ connector = get_connector(source)
689
+ if connector is None:
690
+ raise HTTPException(status_code=404, detail=f"No connector registered for source '{source}'")
691
+
692
+ asyncio.create_task(run_ingest(connector))
693
+ return {"source": source, "status": "started"}
694
+
695
+
696
+ @router.post("/fetch-entity")
697
+ async def cli_fetch_entity(
698
+ entity_id: str = Query(...),
699
+ ) -> dict[str, Any]:
700
+ """Trigger a connector fetch for an entity by its internal UUID."""
701
+ from agentgraph.graph.fetch import fetch_entity_by_id
702
+
703
+ try:
704
+ return await fetch_entity_by_id(entity_id)
705
+ except ValueError as exc:
706
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
707
+ except RuntimeError as exc:
708
+ raise HTTPException(status_code=400, detail=str(exc)) from exc