agentgraph-server 0.7.0__tar.gz → 0.7.2__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 (111) hide show
  1. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/PKG-INFO +3 -3
  2. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/README.md +2 -2
  3. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/backends/sqlite/backend.py +75 -29
  4. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/cli.py +0 -2
  5. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/core/storage.py +18 -5
  6. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/demo.py +2 -2
  7. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/link.py +7 -0
  8. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/operations.py +17 -4
  9. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/query.py +62 -22
  10. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/browse_api.py +47 -12
  11. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/graph_api.py +2 -1
  12. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/observation.py +26 -3
  13. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/static/viewer.html +1 -1
  14. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph_server.egg-info/PKG-INFO +3 -3
  15. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/pyproject.toml +1 -1
  16. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_browse.py +22 -4
  17. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_cli.py +1 -1
  18. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_docs_build.py +1 -1
  19. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_observation.py +142 -7
  20. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_schema.py +32 -0
  21. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_upsert.py +37 -0
  22. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_viewer_layout_contract.py +7 -1
  23. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/.agents/skills/agentgraph/SKILL.md +0 -0
  24. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/.agents/skills/agentgraph/references/commands.md +0 -0
  25. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/.agents/skills/agentgraph/references/data-model.md +0 -0
  26. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/.agents/skills/agentgraph/references/operations.md +0 -0
  27. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/.agents/skills/slack-auth/SKILL.md +0 -0
  28. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/LICENSE +0 -0
  29. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/__init__.py +0 -0
  30. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/auth/__init__.py +0 -0
  31. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/auth/credentials.py +0 -0
  32. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/backends/__init__.py +0 -0
  33. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/backends/sqlite/__init__.py +0 -0
  34. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/backends/sqlite/schema.sql +0 -0
  35. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/backends/sqlite/vector.py +0 -0
  36. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/cli_query.py +0 -0
  37. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/cli_sync.py +0 -0
  38. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/config.py +0 -0
  39. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/__init__.py +0 -0
  40. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/base.py +0 -0
  41. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/command_effects.py +0 -0
  42. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/feed.py +0 -0
  43. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/match_patterns.py +0 -0
  44. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/registry.py +0 -0
  45. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/connectors/status.py +0 -0
  46. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/core/__init__.py +0 -0
  47. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/core/context.py +0 -0
  48. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/core/runtime.py +0 -0
  49. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/demo_fixtures/reliable-webhooks.md +0 -0
  50. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/demo_fixtures/retry-guidance.md +0 -0
  51. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/__init__.py +0 -0
  52. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/bookmark.py +0 -0
  53. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/delete.py +0 -0
  54. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/download.py +0 -0
  55. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/embeddings.py +0 -0
  56. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/expiration.py +0 -0
  57. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/fetch.py +0 -0
  58. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/person.py +0 -0
  59. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/graph/upsert.py +0 -0
  60. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/logging.py +0 -0
  61. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/mcp/__init__.py +0 -0
  62. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/mcp/server.py +0 -0
  63. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/perf.py +0 -0
  64. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/query_client.py +0 -0
  65. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/__init__.py +0 -0
  66. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/app.py +0 -0
  67. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/meta_api.py +0 -0
  68. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/presentation.py +0 -0
  69. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/router.py +0 -0
  70. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/sync.py +0 -0
  71. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/sync_api.py +0 -0
  72. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/server/uds.py +0 -0
  73. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph/skills.py +0 -0
  74. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph_server.egg-info/SOURCES.txt +0 -0
  75. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph_server.egg-info/dependency_links.txt +0 -0
  76. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph_server.egg-info/entry_points.txt +0 -0
  77. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph_server.egg-info/requires.txt +0 -0
  78. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/agentgraph_server.egg-info/top_level.txt +0 -0
  79. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/setup.cfg +0 -0
  80. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_auth.py +0 -0
  81. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_auth_status_methods.py +0 -0
  82. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_benchmarks.py +0 -0
  83. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_command_effects.py +0 -0
  84. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_config.py +0 -0
  85. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_connectors.py +0 -0
  86. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_discord_attachments.py +0 -0
  87. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_embeddings.py +0 -0
  88. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_entity_types.py +0 -0
  89. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_expiration.py +0 -0
  90. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_feed_connectors.py +0 -0
  91. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_fetch.py +0 -0
  92. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_graph_operations.py +0 -0
  93. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_launch_demo.py +0 -0
  94. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_logging.py +0 -0
  95. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_match_patterns.py +0 -0
  96. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_mcp_transport.py +0 -0
  97. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_query.py +0 -0
  98. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_query_client.py +0 -0
  99. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_query_parity.py +0 -0
  100. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_registry.py +0 -0
  101. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_release_metadata.py +0 -0
  102. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_route_resolution.py +0 -0
  103. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_router.py +0 -0
  104. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_rss_connector.py +0 -0
  105. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_server_imports.py +0 -0
  106. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_server_lifespan.py +0 -0
  107. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_server_uds.py +0 -0
  108. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_slack_auth_artifacts.py +0 -0
  109. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_slack_oauth.py +0 -0
  110. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_sync.py +0 -0
  111. {agentgraph_server-0.7.0 → agentgraph_server-0.7.2}/tests/test_web_connector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentgraph-server
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: A local-first CLI and MCP server that turns selected digital sources into a searchable graph for the AI agent you already use.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -100,8 +100,8 @@ These connectors are proof of the pattern, not the boundary of the product. A co
100
100
  AgentGraph requires Python 3.12+, [`uv`](https://docs.astral.sh/uv/), and Chrome for browser observation.
101
101
 
102
102
  ```bash
103
- git clone https://github.com/simonexmachina/agent-graph
104
- cd agent-graph
103
+ git clone https://github.com/simonexmachina/agentgraph
104
+ cd agentgraph
105
105
  uv sync --extra all
106
106
  source .venv/bin/activate
107
107
  agentgraph onboard
@@ -59,8 +59,8 @@ These connectors are proof of the pattern, not the boundary of the product. A co
59
59
  AgentGraph requires Python 3.12+, [`uv`](https://docs.astral.sh/uv/), and Chrome for browser observation.
60
60
 
61
61
  ```bash
62
- git clone https://github.com/simonexmachina/agent-graph
63
- cd agent-graph
62
+ git clone https://github.com/simonexmachina/agentgraph
63
+ cd agentgraph
64
64
  uv sync --extra all
65
65
  source .venv/bin/activate
66
66
  agentgraph onboard
@@ -93,6 +93,22 @@ def _new_id() -> str:
93
93
  return str(uuid.uuid4())
94
94
 
95
95
 
96
+ def _content_projection(
97
+ content_limit: int | None, column: str = "content"
98
+ ) -> tuple[str, list[int]]:
99
+ """Return SQL that bounds content before it leaves SQLite."""
100
+ if content_limit is None:
101
+ return column, []
102
+ if content_limit < 1:
103
+ raise ValueError("content_limit must be at least 1")
104
+ return (
105
+ f"CASE WHEN length({column}) > ? THEN substr({column}, 1, ?) || '…' "
106
+ f"ELSE {column} END AS content, "
107
+ f"CASE WHEN length({column}) > ? THEN 1 ELSE 0 END AS content_truncated",
108
+ [content_limit, content_limit - 1, content_limit],
109
+ )
110
+
111
+
96
112
  def _entity_predicates(
97
113
  entity_types: list[str] | None = None,
98
114
  platform: str | None = None,
@@ -1252,6 +1268,7 @@ class SQLiteBackend(StorageBackend):
1252
1268
  authored_by: list[str] | None = None,
1253
1269
  has_attachments: bool = False,
1254
1270
  order_by: str | None = None,
1271
+ content_limit: int | None = None,
1255
1272
  ) -> list[EntityResult]:
1256
1273
  """Select entities by predicate, ranked by relevance or ordered by date.
1257
1274
 
@@ -1279,6 +1296,7 @@ class SQLiteBackend(StorageBackend):
1279
1296
  join_params,
1280
1297
  limit,
1281
1298
  order_by,
1299
+ content_limit,
1282
1300
  )
1283
1301
 
1284
1302
  with timed("sqlite.search_entities", limit=limit, platform=platform):
@@ -1367,17 +1385,18 @@ class SQLiteBackend(StorageBackend):
1367
1385
  id_list = [eid for eid, _ in top]
1368
1386
  score_map = {eid: sc for eid, sc in top}
1369
1387
  placeholders = ",".join("?" * len(id_list))
1388
+ content_column, content_params = _content_projection(content_limit)
1370
1389
  with timed("sqlite.search.hydrate", count=len(id_list)):
1371
1390
  cursor = await conn.execute(
1372
1391
  f"""
1373
1392
  SELECT id, entity_type, platform, platform_entity_id,
1374
- title, content, metadata, created_at, updated_at,
1393
+ title, {content_column}, metadata, created_at, updated_at,
1375
1394
  source_created_at, source_updated_at, synced_at, observed_at,
1376
1395
  retention_policy, retention_parent_id,
1377
1396
  cumulative_observation_duration_ms, bookmarked
1378
1397
  FROM entities WHERE id IN ({placeholders})
1379
1398
  """,
1380
- id_list,
1399
+ [*content_params, *id_list],
1381
1400
  )
1382
1401
  rows = await cursor.fetchall()
1383
1402
  results: list[dict[str, Any]] = []
@@ -1415,15 +1434,17 @@ class SQLiteBackend(StorageBackend):
1415
1434
  join_params: list[Any],
1416
1435
  limit: int,
1417
1436
  order_by: str | None,
1437
+ content_limit: int | None,
1418
1438
  ) -> list[EntityResult]:
1419
1439
  """Return entities matching the predicates alone, newest first."""
1420
1440
  if order_by not in _VALID_ORDER_BY:
1421
1441
  order_by = "observed_at"
1442
+ content_column, content_params = _content_projection(content_limit, "e.content")
1422
1443
  with timed("sqlite.select_entities", order_by=order_by, limit=limit):
1423
1444
  rows = await self._fetchall(
1424
1445
  f"""
1425
1446
  SELECT e.id, e.entity_type, e.platform, e.platform_entity_id,
1426
- e.title, e.content, e.metadata, e.created_at, e.updated_at,
1447
+ e.title, {content_column}, e.metadata, e.created_at, e.updated_at,
1427
1448
  e.source_created_at, e.source_updated_at,
1428
1449
  e.synced_at, e.observed_at,
1429
1450
  e.retention_policy, e.retention_parent_id,
@@ -1434,38 +1455,44 @@ class SQLiteBackend(StorageBackend):
1434
1455
  ORDER BY e.{order_by} DESC
1435
1456
  LIMIT ?
1436
1457
  """,
1437
- [*join_params, *where_params, limit],
1458
+ [*content_params, *join_params, *where_params, limit],
1438
1459
  )
1439
1460
  return [_row_to_entity(row) for row in rows]
1440
1461
 
1441
- async def get_entity_by_id(self, entity_id: str) -> EntityResult | None:
1462
+ async def get_entity_by_id(
1463
+ self, entity_id: str, content_limit: int | None = None
1464
+ ) -> EntityResult | None:
1465
+ content_column, content_params = _content_projection(content_limit)
1442
1466
  row = await self._fetchone(
1443
- """
1467
+ f"""
1444
1468
  SELECT id, entity_type, platform, platform_entity_id,
1445
- title, content, metadata, created_at, updated_at,
1469
+ title, {content_column}, metadata, created_at, updated_at,
1446
1470
  source_created_at, source_updated_at, synced_at, observed_at,
1447
1471
  retention_policy, retention_parent_id,
1448
1472
  cumulative_observation_duration_ms, bookmarked
1449
1473
  FROM entities WHERE id = ?
1450
1474
  """,
1451
- [entity_id],
1475
+ [*content_params, entity_id],
1452
1476
  )
1453
1477
  return _row_to_entity(row) if row else None
1454
1478
 
1455
- async def get_entities_by_ids(self, entity_ids: list[str]) -> list[EntityResult]:
1479
+ async def get_entities_by_ids(
1480
+ self, entity_ids: list[str], content_limit: int | None = None
1481
+ ) -> list[EntityResult]:
1456
1482
  if not entity_ids:
1457
1483
  return []
1458
1484
  placeholders = ",".join("?" * len(entity_ids))
1485
+ content_column, content_params = _content_projection(content_limit)
1459
1486
  rows = await self._fetchall(
1460
1487
  f"""
1461
1488
  SELECT id, entity_type, platform, platform_entity_id,
1462
- title, content, metadata, created_at, updated_at,
1489
+ title, {content_column}, metadata, created_at, updated_at,
1463
1490
  source_created_at, source_updated_at, synced_at, observed_at,
1464
1491
  retention_policy, retention_parent_id,
1465
1492
  cumulative_observation_duration_ms, bookmarked
1466
1493
  FROM entities WHERE id IN ({placeholders})
1467
1494
  """,
1468
- entity_ids,
1495
+ [*content_params, *entity_ids],
1469
1496
  )
1470
1497
  return [_row_to_entity(r) for r in rows]
1471
1498
 
@@ -1476,33 +1503,40 @@ class SQLiteBackend(StorageBackend):
1476
1503
  by_id = {str(entity["id"]): entity for entity in entities}
1477
1504
  return [by_id[entity_id] for entity_id in entity_ids if entity_id in by_id]
1478
1505
 
1479
- async def get_entities_by_id_prefix(self, prefix: str) -> list[EntityResult]:
1506
+ async def get_entities_by_id_prefix(
1507
+ self, prefix: str, content_limit: int | None = None
1508
+ ) -> list[EntityResult]:
1509
+ content_column, content_params = _content_projection(content_limit)
1480
1510
  rows = await self._fetchall(
1481
- """
1511
+ f"""
1482
1512
  SELECT id, entity_type, platform, platform_entity_id,
1483
- title, content, metadata, created_at, updated_at,
1513
+ title, {content_column}, metadata, created_at, updated_at,
1484
1514
  source_created_at, source_updated_at, synced_at, observed_at,
1485
1515
  retention_policy, retention_parent_id,
1486
1516
  cumulative_observation_duration_ms, bookmarked
1487
1517
  FROM entities WHERE id LIKE ?
1488
1518
  """,
1489
- [f"{prefix}%"],
1519
+ [*content_params, f"{prefix}%"],
1490
1520
  )
1491
1521
  return [_row_to_entity(row) for row in rows]
1492
1522
 
1493
1523
  async def get_entity_by_platform(
1494
- self, platform: str, platform_entity_id: str
1524
+ self,
1525
+ platform: str,
1526
+ platform_entity_id: str,
1527
+ content_limit: int | None = None,
1495
1528
  ) -> EntityResult | None:
1529
+ content_column, content_params = _content_projection(content_limit)
1496
1530
  row = await self._fetchone(
1497
- """
1531
+ f"""
1498
1532
  SELECT id, entity_type, platform, platform_entity_id,
1499
- title, content, metadata, created_at, updated_at,
1533
+ title, {content_column}, metadata, created_at, updated_at,
1500
1534
  source_created_at, source_updated_at, synced_at, observed_at,
1501
1535
  retention_policy, retention_parent_id,
1502
1536
  cumulative_observation_duration_ms, bookmarked
1503
1537
  FROM entities WHERE platform = ? AND platform_entity_id = ?
1504
1538
  """,
1505
- [platform, platform_entity_id],
1539
+ [*content_params, platform, platform_entity_id],
1506
1540
  )
1507
1541
  return _row_to_entity(row) if row else None
1508
1542
 
@@ -1512,6 +1546,7 @@ class SQLiteBackend(StorageBackend):
1512
1546
  platform: str | None,
1513
1547
  since: datetime | None,
1514
1548
  limit: int,
1549
+ content_limit: int | None = None,
1515
1550
  ) -> list[EntityResult]:
1516
1551
  clauses: list[str] = []
1517
1552
  params: list[Any] = []
@@ -1527,10 +1562,11 @@ class SQLiteBackend(StorageBackend):
1527
1562
  params.append(_utc_stamp(since))
1528
1563
  where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
1529
1564
  params.append(limit)
1565
+ content_column, content_params = _content_projection(content_limit)
1530
1566
  rows = await self._fetchall(
1531
1567
  f"""
1532
1568
  SELECT id, entity_type, platform, platform_entity_id,
1533
- title, content, metadata, created_at, updated_at,
1569
+ title, {content_column}, metadata, created_at, updated_at,
1534
1570
  source_created_at, source_updated_at, synced_at, observed_at,
1535
1571
  retention_policy, retention_parent_id,
1536
1572
  cumulative_observation_duration_ms, bookmarked
@@ -1539,7 +1575,7 @@ class SQLiteBackend(StorageBackend):
1539
1575
  ORDER BY observed_at DESC
1540
1576
  LIMIT ?
1541
1577
  """,
1542
- params,
1578
+ [*content_params, *params],
1543
1579
  )
1544
1580
  return [_row_to_entity(row) for row in rows]
1545
1581
 
@@ -1552,6 +1588,7 @@ class SQLiteBackend(StorageBackend):
1552
1588
  offset: int,
1553
1589
  order_by: str | None,
1554
1590
  order_dir: str,
1591
+ content_limit: int | None = None,
1555
1592
  ) -> tuple[list[EntityResult], int]:
1556
1593
  order_by_sql = (
1557
1594
  _LIST_PAGE_ORDER_BY.get(order_by, "observed_at") if order_by is not None else None
@@ -1578,10 +1615,11 @@ class SQLiteBackend(StorageBackend):
1578
1615
 
1579
1616
  count_row = await self._fetchone(f"SELECT COUNT(*) AS count FROM entities {where}", params)
1580
1617
  total = int(count_row["count"]) if count_row else 0
1618
+ content_column, content_params = _content_projection(content_limit)
1581
1619
  rows = await self._fetchall(
1582
1620
  f"""
1583
1621
  SELECT id, entity_type, platform, platform_entity_id,
1584
- title, content, metadata, created_at, updated_at,
1622
+ title, {content_column}, metadata, created_at, updated_at,
1585
1623
  source_created_at, source_updated_at, synced_at, observed_at,
1586
1624
  retention_policy, retention_parent_id,
1587
1625
  cumulative_observation_duration_ms, bookmarked
@@ -1590,7 +1628,7 @@ class SQLiteBackend(StorageBackend):
1590
1628
  {order_clause}
1591
1629
  LIMIT ? OFFSET ?
1592
1630
  """,
1593
- [*params, limit, offset],
1631
+ [*content_params, *params, limit, offset],
1594
1632
  )
1595
1633
  return [_row_to_entity(row) for row in rows], total
1596
1634
 
@@ -1603,6 +1641,7 @@ class SQLiteBackend(StorageBackend):
1603
1641
  since: datetime | None,
1604
1642
  authored_by: list[str] | None,
1605
1643
  has_attachments: bool = False,
1644
+ content_limit: int | None = None,
1606
1645
  ) -> list[EntityResult]:
1607
1646
  """Single-type filtered read, for connectors that hold the backend directly."""
1608
1647
  return await self.search_entities(
@@ -1616,6 +1655,7 @@ class SQLiteBackend(StorageBackend):
1616
1655
  authored_by=authored_by,
1617
1656
  has_attachments=has_attachments,
1618
1657
  order_by=order_by,
1658
+ content_limit=content_limit,
1619
1659
  )
1620
1660
 
1621
1661
  async def list_recent_metadata_by_edge_target(
@@ -1740,8 +1780,11 @@ class SQLiteBackend(StorageBackend):
1740
1780
  )
1741
1781
  return [_row_to_edge(row) for row in rows]
1742
1782
 
1743
- async def traverse_graph(self, entity_id: str, max_depth: int) -> dict[str, Any]:
1783
+ async def traverse_graph(
1784
+ self, entity_id: str, max_depth: int, content_limit: int | None = None
1785
+ ) -> dict[str, Any]:
1744
1786
  conn = self._read_conn_or_raise()
1787
+ content_column, content_params = _content_projection(content_limit)
1745
1788
  visited: set[str] = set()
1746
1789
  frontier: list[str] = [entity_id]
1747
1790
  all_nodes: list[EntityResult] = []
@@ -1755,13 +1798,13 @@ class SQLiteBackend(StorageBackend):
1755
1798
  cursor = await conn.execute(
1756
1799
  f"""
1757
1800
  SELECT id, entity_type, platform, platform_entity_id,
1758
- title, content, metadata, created_at, updated_at,
1801
+ title, {content_column}, metadata, created_at, updated_at,
1759
1802
  source_created_at, source_updated_at, synced_at, observed_at,
1760
1803
  retention_policy, retention_parent_id,
1761
1804
  cumulative_observation_duration_ms, bookmarked
1762
1805
  FROM entities WHERE id IN ({placeholders})
1763
1806
  """,
1764
- frontier,
1807
+ [*content_params, *frontier],
1765
1808
  )
1766
1809
  for row in await cursor.fetchall():
1767
1810
  eid = row["id"]
@@ -1802,13 +1845,13 @@ class SQLiteBackend(StorageBackend):
1802
1845
  cursor = await conn.execute(
1803
1846
  f"""
1804
1847
  SELECT id, entity_type, platform, platform_entity_id,
1805
- title, content, metadata, created_at, updated_at,
1848
+ title, {content_column}, metadata, created_at, updated_at,
1806
1849
  source_created_at, source_updated_at, synced_at, observed_at,
1807
1850
  retention_policy, retention_parent_id,
1808
1851
  cumulative_observation_duration_ms, bookmarked
1809
1852
  FROM entities WHERE id IN ({placeholders})
1810
1853
  """,
1811
- unvisited,
1854
+ [*content_params, *unvisited],
1812
1855
  )
1813
1856
  for row in await cursor.fetchall():
1814
1857
  all_nodes.append(_row_to_entity(row))
@@ -2107,7 +2150,7 @@ class SQLiteBackend(StorageBackend):
2107
2150
 
2108
2151
  def _row_to_entity(row: Any) -> EntityResult:
2109
2152
  keys = row.keys() if hasattr(row, "keys") else []
2110
- return {
2153
+ entity: EntityResult = {
2111
2154
  "id": row["id"],
2112
2155
  "entity_type": row["entity_type"],
2113
2156
  "platform": row["platform"],
@@ -2131,6 +2174,9 @@ def _row_to_entity(row: Any) -> EntityResult:
2131
2174
  "bookmarked": bool(row["bookmarked"]) if "bookmarked" in keys else False,
2132
2175
  "score": row["score"] if "score" in keys else None,
2133
2176
  }
2177
+ if "content_truncated" in keys:
2178
+ entity["content_truncated"] = bool(row["content_truncated"])
2179
+ return entity
2134
2180
 
2135
2181
 
2136
2182
  def _row_to_edge(row: Any) -> EdgeResult:
@@ -914,5 +914,3 @@ def poll(
914
914
  from agentgraph.cli_sync import cmd_poll
915
915
 
916
916
  cmd_poll(source=source, as_json=json)
917
-
918
-
@@ -92,6 +92,7 @@ class StorageBackend(ABC):
92
92
  authored_by: list[str] | None = None,
93
93
  has_attachments: bool = False,
94
94
  order_by: str | None = None,
95
+ content_limit: int | None = None,
95
96
  ) -> list[EntityResult]:
96
97
  """Select entities by predicate, ranked by relevance or ordered by date.
97
98
 
@@ -103,17 +104,26 @@ class StorageBackend(ABC):
103
104
  ...
104
105
 
105
106
  @abstractmethod
106
- async def get_entity_by_id(self, entity_id: str) -> EntityResult | None: ...
107
+ async def get_entity_by_id(
108
+ self, entity_id: str, content_limit: int | None = None
109
+ ) -> EntityResult | None: ...
107
110
 
108
111
  @abstractmethod
109
- async def get_entities_by_ids(self, entity_ids: list[str]) -> list[EntityResult]: ...
112
+ async def get_entities_by_ids(
113
+ self, entity_ids: list[str], content_limit: int | None = None
114
+ ) -> list[EntityResult]: ...
110
115
 
111
116
  @abstractmethod
112
- async def get_entities_by_id_prefix(self, prefix: str) -> list[EntityResult]: ...
117
+ async def get_entities_by_id_prefix(
118
+ self, prefix: str, content_limit: int | None = None
119
+ ) -> list[EntityResult]: ...
113
120
 
114
121
  @abstractmethod
115
122
  async def get_entity_by_platform(
116
- self, platform: str, platform_entity_id: str
123
+ self,
124
+ platform: str,
125
+ platform_entity_id: str,
126
+ content_limit: int | None = None,
117
127
  ) -> EntityResult | None: ...
118
128
 
119
129
  @abstractmethod
@@ -123,6 +133,7 @@ class StorageBackend(ABC):
123
133
  platform: str | None,
124
134
  since: datetime | None,
125
135
  limit: int,
136
+ content_limit: int | None = None,
126
137
  ) -> list[EntityResult]: ...
127
138
 
128
139
  @abstractmethod
@@ -135,6 +146,7 @@ class StorageBackend(ABC):
135
146
  offset: int,
136
147
  order_by: str | None,
137
148
  order_dir: str,
149
+ content_limit: int | None = None,
138
150
  ) -> tuple[list[EntityResult], int]:
139
151
  """Return an entity page and total count, optionally ordered by a supported field."""
140
152
  ...
@@ -149,6 +161,7 @@ class StorageBackend(ABC):
149
161
  since: datetime | None,
150
162
  authored_by: list[str] | None,
151
163
  has_attachments: bool = False,
164
+ content_limit: int | None = None,
152
165
  ) -> list[EntityResult]:
153
166
  """Single-type filtered read, kept for connectors that hold a backend directly.
154
167
 
@@ -184,7 +197,7 @@ class StorageBackend(ABC):
184
197
 
185
198
  @abstractmethod
186
199
  async def traverse_graph(
187
- self, entity_id: str, max_depth: int
200
+ self, entity_id: str, max_depth: int, content_limit: int | None = None
188
201
  ) -> dict[str, Any]: ...
189
202
 
190
203
  # --- Linking ---
@@ -15,11 +15,11 @@ DEMO_FIXTURE_METADATA_KEY = "_agentgraph_fixture"
15
15
  DEMO_FIXTURE_METADATA_VALUE = "atlas-demo"
16
16
  DEMO_CREATED_AT = "2026-08-14T08:00:00Z"
17
17
  WEBHOOK_ARTICLE_URL = (
18
- "https://github.com/simonexmachina/agent-graph/blob/main/"
18
+ "https://github.com/simonexmachina/agentgraph/blob/main/"
19
19
  "agentgraph/demo_fixtures/reliable-webhooks.md"
20
20
  )
21
21
  RETRY_GUIDE_URL = (
22
- "https://github.com/simonexmachina/agent-graph/blob/main/"
22
+ "https://github.com/simonexmachina/agentgraph/blob/main/"
23
23
  "agentgraph/demo_fixtures/retry-guidance.md"
24
24
  )
25
25
 
@@ -47,6 +47,13 @@ async def link_entity_to_urls(platform_entity_id: str, platform: str, content: s
47
47
  continue
48
48
  seen.add(ref.resource_id)
49
49
 
50
+ # An entity's own URL is normally part of its content — connectors append
51
+ # it to the rendered body, and a page or work item often links to itself
52
+ # from a comment. A self-loop carries no information, so it is dropped
53
+ # here rather than left for every caller to filter out of a traversal.
54
+ if ref.source == platform and ref.resource_id == platform_entity_id:
55
+ continue
56
+
50
57
  tgt_id = await backend.find_entity_id(ref.source, ref.resource_id)
51
58
  if not tgt_id:
52
59
  entity_type = RESOURCE_TYPE_TO_ENTITY_TYPE[ref.resource_type]
@@ -22,6 +22,8 @@ def is_stub(entity: EntityResult) -> bool:
22
22
  def summarize_entity(entity: EntityResult, *, content_limit: int = 500) -> EntityResult:
23
23
  """Copy an entity and bound its content for list-style responses."""
24
24
  summarized = dict(entity)
25
+ if summarized.get("content_truncated") is True:
26
+ return summarized
25
27
  content = summarized.get("content")
26
28
  if isinstance(content, str) and len(content) > content_limit:
27
29
  summarized["content"] = content[: max(content_limit - 1, 0)].rstrip() + "…"
@@ -40,14 +42,25 @@ def summarize_entities(
40
42
  return [summarize_entity(entity, content_limit=content_limit) for entity in entities]
41
43
 
42
44
 
43
- async def resolve_entity(target: str) -> EntityResult | None:
45
+ async def resolve_entity(
46
+ target: str, content_limit: int | None = None
47
+ ) -> EntityResult | None:
44
48
  """Resolve an existing entity from a UUID, prefix, platform ref, or URL."""
45
- return await get_entity_by_url(target) if is_http_url(target) else await get_entity(target)
49
+ if is_http_url(target):
50
+ if content_limit is None:
51
+ return await get_entity_by_url(target)
52
+ return await get_entity_by_url(target, content_limit=content_limit)
53
+ return await get_entity(target, content_limit=content_limit)
46
54
 
47
55
 
48
- async def get_entity_details(target: str, *, resolve: bool = False) -> EntityResult | None:
56
+ async def get_entity_details(
57
+ target: str,
58
+ *,
59
+ resolve: bool = False,
60
+ content_limit: int | None = None,
61
+ ) -> EntityResult | None:
49
62
  """Resolve an entity and optionally fetch it when it is still a stub."""
50
- entity = await resolve_entity(target)
63
+ entity = await resolve_entity(target, content_limit=content_limit)
51
64
  if entity is None or not resolve or not is_stub(entity):
52
65
  return entity
53
66
  return await refresh_stub(entity)
@@ -62,6 +62,7 @@ async def search_entities(
62
62
  authored_by_me: bool = False,
63
63
  has_attachments: bool = False,
64
64
  order_by: str | None = None,
65
+ content_limit: int | None = None,
65
66
  ) -> list[EntityResult]:
66
67
  """Select entities by filter, ranked by hybrid relevance when a query is given.
67
68
 
@@ -93,12 +94,15 @@ async def search_entities(
93
94
  authored_by=authored_by,
94
95
  has_attachments=has_attachments,
95
96
  order_by=order_by,
97
+ content_limit=content_limit,
96
98
  )
97
99
  _enrich_web_url(results)
98
100
  return results
99
101
 
100
102
 
101
- async def get_entity(entity_id: str) -> EntityResult | None:
103
+ async def get_entity(
104
+ entity_id: str, content_limit: int | None = None
105
+ ) -> EntityResult | None:
102
106
  """Fetch a single entity by UUID, unambiguous UUID prefix, or platform ref.
103
107
 
104
108
  Platform ref formats accepted:
@@ -108,15 +112,15 @@ async def get_entity(entity_id: str) -> EntityResult | None:
108
112
  backend = get_backend()
109
113
  entity: EntityResult | None
110
114
  if len(entity_id) == 36 or (len(entity_id) == 32 and "-" not in entity_id):
111
- entity = await backend.get_entity_by_id(entity_id)
115
+ entity = await backend.get_entity_by_id(entity_id, content_limit=content_limit)
112
116
  elif "/" in entity_id:
113
117
  parts = entity_id.split("/")
114
118
  platform = parts[0]
115
119
  pid = "/".join(parts[2:]) if len(parts) >= 3 else "/".join(parts[1:])
116
- entity = await backend.get_entity_by_platform(platform, pid)
120
+ entity = await backend.get_entity_by_platform(platform, pid, content_limit=content_limit)
117
121
  else:
118
122
  # UUID prefix — must be unambiguous
119
- results = await backend.get_entities_by_id_prefix(entity_id)
123
+ results = await backend.get_entities_by_id_prefix(entity_id, content_limit=content_limit)
120
124
  if len(results) > 1:
121
125
  raise ValueError(
122
126
  f"Ambiguous prefix {entity_id!r} matches {len(results)} entities"
@@ -127,7 +131,9 @@ async def get_entity(entity_id: str) -> EntityResult | None:
127
131
  return entity
128
132
 
129
133
 
130
- async def get_entity_by_url(url: str) -> EntityResult | None:
134
+ async def get_entity_by_url(
135
+ url: str, content_limit: int | None = None
136
+ ) -> EntityResult | None:
131
137
  """Fetch a single existing entity by URL without fetching or creating it."""
132
138
  from agentgraph.connectors.registry import bootstrap, get_connector
133
139
  from agentgraph.server.router import classify_url, normalise_url_for_matching
@@ -138,7 +144,12 @@ async def get_entity_by_url(url: str) -> EntityResult | None:
138
144
  backend = get_backend()
139
145
 
140
146
  if ref is not None:
141
- entity = await backend.get_entity_by_platform(ref.source, ref.resource_id)
147
+ if content_limit is None:
148
+ entity = await backend.get_entity_by_platform(ref.source, ref.resource_id)
149
+ else:
150
+ entity = await backend.get_entity_by_platform(
151
+ ref.source, ref.resource_id, content_limit=content_limit
152
+ )
142
153
  if entity is not None:
143
154
  _enrich_web_url([entity])
144
155
  return entity
@@ -148,9 +159,14 @@ async def get_entity_by_url(url: str) -> EntityResult | None:
148
159
  if web_ref is None:
149
160
  return None
150
161
 
151
- entity = await backend.get_entity_by_platform(web_ref.source, web_ref.resource_id)
162
+ if content_limit is None:
163
+ entity = await backend.get_entity_by_platform(web_ref.source, web_ref.resource_id)
164
+ else:
165
+ entity = await backend.get_entity_by_platform(
166
+ web_ref.source, web_ref.resource_id, content_limit=content_limit
167
+ )
152
168
  if entity is None:
153
- entity = await _get_entity_by_metadata_url(normalised_url)
169
+ entity = await _get_entity_by_metadata_url(normalised_url, content_limit=content_limit)
154
170
  if entity is not None:
155
171
  _enrich_web_url([entity])
156
172
  return entity
@@ -167,8 +183,11 @@ async def get_edges(
167
183
  async def traverse_graph(
168
184
  entity_id: str,
169
185
  max_depth: int = 2,
186
+ content_limit: int | None = None,
170
187
  ) -> dict[str, Any]:
171
- return await get_backend().traverse_graph(entity_id, max_depth)
188
+ return await get_backend().traverse_graph(
189
+ entity_id, max_depth, content_limit=content_limit
190
+ )
172
191
 
173
192
 
174
193
  async def query_by_filter(
@@ -198,9 +217,12 @@ async def list_entities(
198
217
  platform: str | None = None,
199
218
  since: str | None = None,
200
219
  limit: int = 50,
220
+ content_limit: int | None = None,
201
221
  ) -> list[EntityResult]:
202
222
  since_dt = parse_since(since) if since else None
203
- results = await get_backend().list_entities(entity_types, platform, since_dt, limit)
223
+ results = await get_backend().list_entities(
224
+ entity_types, platform, since_dt, limit, content_limit=content_limit
225
+ )
204
226
  _enrich_web_url(results)
205
227
  return results
206
228
 
@@ -213,10 +235,18 @@ async def list_entities_page(
213
235
  offset: int = 0,
214
236
  order_by: str | None = "observed_at",
215
237
  order_dir: str = "desc",
238
+ content_limit: int | None = None,
216
239
  ) -> tuple[list[EntityResult], int]:
217
240
  since_dt = parse_since(since) if since else None
218
241
  results, total = await get_backend().list_entities_page(
219
- entity_types, platform, since_dt, limit, offset, order_by, order_dir
242
+ entity_types,
243
+ platform,
244
+ since_dt,
245
+ limit,
246
+ offset,
247
+ order_by,
248
+ order_dir,
249
+ content_limit=content_limit,
220
250
  )
221
251
  _enrich_web_url(results)
222
252
  return results, total
@@ -226,8 +256,10 @@ async def get_edges_for_entities(entity_ids: list[str]) -> list[EdgeResult]:
226
256
  return await get_backend().get_edges_for_entities(entity_ids)
227
257
 
228
258
 
229
- async def get_entities_by_ids(entity_ids: list[str]) -> list[EntityResult]:
230
- results = await get_backend().get_entities_by_ids(entity_ids)
259
+ async def get_entities_by_ids(
260
+ entity_ids: list[str], content_limit: int | None = None
261
+ ) -> list[EntityResult]:
262
+ results = await get_backend().get_entities_by_ids(entity_ids, content_limit=content_limit)
231
263
  _enrich_web_url(results)
232
264
  return results
233
265
 
@@ -243,18 +275,26 @@ def _resolve_me() -> list[str] | None:
243
275
  return user_ids or None
244
276
 
245
277
 
246
- async def _get_entity_by_metadata_url(url: str) -> EntityResult | None:
278
+ async def _get_entity_by_metadata_url(
279
+ url: str, content_limit: int | None = None
280
+ ) -> EntityResult | None:
247
281
  """Find a document whose connector metadata identifies the browser URL."""
248
282
  backend = get_backend()
249
283
  for key in ("web_url", "url", "final_url"):
250
- results = await backend.query_by_filter(
251
- "Document",
252
- {key: url},
253
- 1,
254
- "updated_at",
255
- None,
256
- None,
257
- )
284
+ if content_limit is None:
285
+ results = await backend.query_by_filter(
286
+ "Document", {key: url}, 1, "updated_at", None, None
287
+ )
288
+ else:
289
+ results = await backend.query_by_filter(
290
+ "Document",
291
+ {key: url},
292
+ 1,
293
+ "updated_at",
294
+ None,
295
+ None,
296
+ content_limit=content_limit,
297
+ )
258
298
  if results:
259
299
  return results[0]
260
300
  return None