unique-sdk 2026.44.0.dev13__tar.gz → 2026.44.0.dev15__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 (92) hide show
  1. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/PKG-INFO +1 -1
  2. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/pyproject.toml +1 -1
  3. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/__init__.py +1 -0
  4. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_api_requestor.py +45 -20
  5. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_error.py +18 -5
  6. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_util.py +50 -32
  7. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/cli.py +44 -3
  8. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/navigation.py +139 -20
  9. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/shell.py +54 -4
  10. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +5 -0
  11. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +3 -2
  12. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/README.md +0 -0
  13. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_api_resource.py +0 -0
  14. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_api_version.py +0 -0
  15. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_http_client.py +0 -0
  16. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_list_object.py +0 -0
  17. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_object_classes.py +0 -0
  18. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_request_options.py +0 -0
  19. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_unique_object.py +0 -0
  20. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_unique_ql.py +0 -0
  21. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_unique_response.py +0 -0
  22. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_version.py +0 -0
  23. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/_webhook.py +0 -0
  24. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/__init__.py +0 -0
  25. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_acronyms.py +0 -0
  26. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_agentic_table.py +0 -0
  27. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_analytics_order.py +0 -0
  28. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_benchmarking.py +0 -0
  29. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_briefing.py +0 -0
  30. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_chat_completion.py +0 -0
  31. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_content.py +0 -0
  32. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_context_memory.py +0 -0
  33. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_elicitation.py +0 -0
  34. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_embedding.py +0 -0
  35. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_event.py +0 -0
  36. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_folder.py +0 -0
  37. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_group.py +0 -0
  38. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_integrated.py +0 -0
  39. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_llm_models.py +0 -0
  40. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_mcp.py +0 -0
  41. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_message.py +0 -0
  42. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_message_assessment.py +0 -0
  43. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_message_execution.py +0 -0
  44. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_message_log.py +0 -0
  45. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_message_tool.py +0 -0
  46. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_module.py +0 -0
  47. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  48. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_search.py +0 -0
  49. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_search_string.py +0 -0
  50. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  51. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_space.py +0 -0
  52. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_user.py +0 -0
  53. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/api_resources/_web_search.py +0 -0
  54. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/__init__.py +0 -0
  55. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/__main__.py +0 -0
  56. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/__init__.py +0 -0
  57. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  58. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/agentic_table.py +0 -0
  59. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  60. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/browser.py +0 -0
  61. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/cite_file.py +0 -0
  62. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/elicitation.py +0 -0
  63. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/files.py +0 -0
  64. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/folders.py +0 -0
  65. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/mcp.py +0 -0
  66. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/read.py +0 -0
  67. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  68. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/search.py +0 -0
  69. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/subagent.py +0 -0
  70. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/web_search.py +0 -0
  71. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/commands/web_search_config.py +0 -0
  72. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/config.py +0 -0
  73. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/formatting.py +0 -0
  74. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/identity.py +0 -0
  75. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/metadata_filter.py +0 -0
  76. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/paths.py +0 -0
  77. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  78. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  79. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  82. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  83. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  84. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/state.py +0 -0
  85. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.44.0.dev13 → unique_sdk-2026.44.0.dev15}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.44.0.dev13
3
+ Version: 2026.44.0.dev15
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.44.0.dev13"
3
+ version = "2026.44.0.dev15"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -34,6 +34,7 @@ from unique_sdk._error import APIConnectionError as APIConnectionError
34
34
  from unique_sdk._error import APIError as APIError
35
35
  from unique_sdk._error import AuthenticationError as AuthenticationError
36
36
  from unique_sdk._error import InvalidRequestError as InvalidRequestError
37
+ from unique_sdk._error import RateLimitError as RateLimitError
37
38
  from unique_sdk._error import (
38
39
  SignatureVerificationError as SignatureVerificationError,
39
40
  )
@@ -392,7 +392,15 @@ class APIRequestor(object):
392
392
  cause = error_data.get("cause", {})
393
393
  if isinstance(cause, str):
394
394
  cause = {"error": {"message": cause}, "status": rcode}
395
- status = cause.get("status", rcode)
395
+
396
+ cause_error = cause.get("error") or {}
397
+ cause_code = cause_error.get("code")
398
+ cause_message = cause_error.get("message")
399
+
400
+ message = error_data.get("message") or cause_message
401
+ code = error_data.get("code") or cause_code
402
+ params = error_data.get("params") or cause_error.get("params")
403
+ status = rcode if error_data.get("code") else cause.get("status", rcode)
396
404
 
397
405
  _util.log_info(
398
406
  "Unique error received",
@@ -402,46 +410,63 @@ class APIRequestor(object):
402
410
  error_params=_util.redacted_error_params(error_data.get("params")),
403
411
  )
404
412
 
405
- error = cause.get("error", {}) if cause else {}
406
- error_code = error.get("code", "<Unknown code>")
407
- error_message = error.get("message", "<No message>")
408
- original_error = f"{error_code}: {error_message}"
413
+ original_error = None
414
+ if cause_code or cause_message:
415
+ original_error = (
416
+ f"{cause_code or '<Unknown code>'}: {cause_message or '<No message>'}"
417
+ )
409
418
 
410
- if status in [400, 404]:
411
- return _error.InvalidRequestError(
412
- message=error_data.get("message"),
413
- params=error_data.get("params"),
414
- code=error_data.get("code"),
419
+ if status == 429:
420
+ return _error.RateLimitError(
421
+ message=message,
422
+ params=params,
423
+ code=code,
415
424
  http_body=rbody,
416
425
  http_status=rcode,
417
426
  json_body=resp,
418
427
  headers=rheaders,
419
428
  original_error=original_error,
420
429
  )
421
- elif status == 401:
422
- return _error.AuthenticationError(
423
- message=error_data.get("message"),
430
+ if status in [400, 404]:
431
+ return _error.InvalidRequestError(
432
+ message=message,
433
+ params=params,
434
+ code=code,
424
435
  http_body=rbody,
425
- http_status=status,
436
+ http_status=rcode,
426
437
  json_body=resp,
427
438
  headers=rheaders,
428
439
  original_error=original_error,
429
440
  )
430
- elif status == 403:
431
- return _error.PermissionError(
432
- message=error_data.get("message"),
441
+ if status == 401:
442
+ return _error.AuthenticationError(
443
+ message=message,
433
444
  http_body=rbody,
434
445
  http_status=status,
435
446
  json_body=resp,
436
447
  headers=rheaders,
448
+ code=code,
437
449
  original_error=original_error,
450
+ params=params,
438
451
  )
439
- else:
440
- return _error.APIError(
441
- message=error_data.get("message"),
452
+ if status == 403:
453
+ return _error.PermissionError(
454
+ message=message,
442
455
  http_body=rbody,
443
456
  http_status=status,
444
457
  json_body=resp,
445
458
  headers=rheaders,
459
+ code=code,
446
460
  original_error=original_error,
461
+ params=params,
447
462
  )
463
+ return _error.APIError(
464
+ message=message,
465
+ http_body=rbody,
466
+ http_status=status,
467
+ json_body=resp,
468
+ headers=rheaders,
469
+ code=code,
470
+ original_error=original_error,
471
+ params=params,
472
+ )
@@ -1,4 +1,4 @@
1
- from typing import cast
1
+ from typing import Any, cast
2
2
 
3
3
 
4
4
  class UniqueError(Exception):
@@ -10,6 +10,7 @@ class UniqueError(Exception):
10
10
  code: str | None
11
11
  request_id: str | None
12
12
  original_error: Exception | str | None
13
+ params: dict[str, Any] | str | None
13
14
 
14
15
  def __init__(
15
16
  self,
@@ -20,6 +21,7 @@ class UniqueError(Exception):
20
21
  headers: dict[str, str] | None = None,
21
22
  code: str | None = None,
22
23
  original_error: Exception | str | None = None,
24
+ params: dict[str, Any] | str | None = None,
23
25
  ):
24
26
  super(UniqueError, self).__init__(message)
25
27
 
@@ -41,6 +43,7 @@ class UniqueError(Exception):
41
43
  self.code = code
42
44
  self.request_id = self.headers.get("request-id", None)
43
45
  self.original_error = original_error
46
+ self.params = params
44
47
 
45
48
  def __str__(self):
46
49
  msg = self._message or "<empty message>"
@@ -51,7 +54,7 @@ class UniqueError(Exception):
51
54
 
52
55
  class UniqueErrorWithParamsCode(UniqueError):
53
56
  # str included: InvalidRequestError callers pass plain strings (e.g. "id")
54
- params: dict[str, str] | str | None
57
+ params: dict[str, Any] | str | None
55
58
 
56
59
  def __init__(
57
60
  self,
@@ -62,12 +65,18 @@ class UniqueErrorWithParamsCode(UniqueError):
62
65
  headers: dict[str, str] | None = None,
63
66
  code: str | None = None,
64
67
  original_error: Exception | str | None = None,
65
- params: dict[str, str] | str | None = None,
68
+ params: dict[str, Any] | str | None = None,
66
69
  ):
67
70
  super().__init__(
68
- message, http_body, http_status, json_body, headers, code, original_error
71
+ message,
72
+ http_body,
73
+ http_status,
74
+ json_body,
75
+ headers,
76
+ code,
77
+ original_error,
78
+ params,
69
79
  )
70
- self.params = params
71
80
 
72
81
  def __repr__(self):
73
82
  return "%s(message=%r, params=%r, code=%r, http_status=%r, request_id=%r)" % (
@@ -112,6 +121,10 @@ class PermissionError(UniqueError):
112
121
  pass
113
122
 
114
123
 
124
+ class RateLimitError(APIError):
125
+ pass
126
+
127
+
115
128
  class InvalidRequestError(UniqueErrorWithParamsCode):
116
129
  def __init__(
117
130
  self,
@@ -333,6 +333,35 @@ class class_method_variant(object):
333
333
  return _wrapper
334
334
 
335
335
 
336
+ def _should_retry_error(
337
+ error: UniqueError,
338
+ error_messages: list[str],
339
+ should_retry_5xx: bool,
340
+ ) -> bool:
341
+ status = error.http_status
342
+ if status is not None and 400 <= status < 500:
343
+ return False
344
+ if isinstance(error.params, Mapping) and error.params.get("retryable") is False:
345
+ return False
346
+ if should_retry_5xx and status is not None and 500 <= status < 600:
347
+ return True
348
+ return any(err_msg.lower() in str(error).lower() for err_msg in error_messages)
349
+
350
+
351
+ def _log_retry(error: UniqueError, attempt: int, max_retries: int) -> None:
352
+ # str(error) can carry upstream provider text via original_error.
353
+ params = error.params if isinstance(error.params, Mapping) else {}
354
+ logger.warning(
355
+ "Retrying Unique API call, attempt %d of %d: "
356
+ "code=%s http_status=%s correlation_id=%s",
357
+ attempt + 1,
358
+ max_retries,
359
+ error.code,
360
+ error.http_status,
361
+ params.get("correlationId"),
362
+ )
363
+
364
+
336
365
  def retry_on_error(
337
366
  error_messages: list[str],
338
367
  max_retries: int = 3,
@@ -341,6 +370,13 @@ def retry_on_error(
341
370
  error_class=APIError,
342
371
  should_retry_5xx=False,
343
372
  ):
373
+ """Retry ``APIError`` and ``APIConnectionError``.
374
+
375
+ 4xx responses and errors with ``params.retryable`` false are not retried.
376
+ After the last attempt ``error_class`` is raised with the last error as
377
+ its ``__cause__``.
378
+ """
379
+
344
380
  def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
345
381
  @wraps(func)
346
382
  async def async_wrapper(*args, **kwargs) -> Any:
@@ -350,25 +386,16 @@ def retry_on_error(
350
386
  return await func(*args, **kwargs)
351
387
  except (APIError, APIConnectionError) as e:
352
388
  e = cast(UniqueError, e)
353
- logger.error(f"Retrying because of {e}")
354
- should_retry = any(
355
- err_msg.lower() in str(e).lower() for err_msg in error_messages
356
- )
357
- # Add 5xx check if `should_retry_5xx` is True
358
- has_valid_status = (
359
- hasattr(e, "http_status") and e.http_status is not None
360
- )
361
- if should_retry_5xx and has_valid_status:
362
- should_retry = should_retry or (
363
- 500 <= int(cast(int, e.http_status)) < 600
364
- )
365
-
366
- if not should_retry:
367
- raise e # Raise the error if no retry condition is met
389
+ if not _should_retry_error(e, error_messages, should_retry_5xx):
390
+ raise
368
391
 
369
392
  attempts += 1
370
393
  if attempts >= max_retries:
371
- raise error_class(f"Failed after {max_retries} attempts: {e}")
394
+ raise error_class(
395
+ f"Failed after {max_retries} attempts: {e}"
396
+ ) from e
397
+
398
+ _log_retry(e, attempts, max_retries)
372
399
 
373
400
  # Calculate exponential backoff with some randomness (jitter)
374
401
  delay = initial_delay * (backoff_factor ** (attempts - 1))
@@ -382,25 +409,16 @@ def retry_on_error(
382
409
  return func(*args, **kwargs)
383
410
  except (APIError, APIConnectionError) as e:
384
411
  e = cast(UniqueError, e)
385
- logger.error(f"Retrying because of {e}")
386
- should_retry = any(
387
- err_msg.lower() in str(e).lower() for err_msg in error_messages
388
- )
389
- # Add 5xx check if `should_retry_5xx` is True
390
- has_valid_status = (
391
- hasattr(e, "http_status") and e.http_status is not None
392
- )
393
- if should_retry_5xx and has_valid_status:
394
- should_retry = should_retry or (
395
- 500 <= int(cast(int, e.http_status)) < 600
396
- )
397
-
398
- if not should_retry:
399
- raise e # Raise the error if no retry condition is met
412
+ if not _should_retry_error(e, error_messages, should_retry_5xx):
413
+ raise
400
414
 
401
415
  attempts += 1
402
416
  if attempts >= max_retries:
403
- raise error_class(f"Failed after {max_retries} attempts: {e}")
417
+ raise error_class(
418
+ f"Failed after {max_retries} attempts: {e}"
419
+ ) from e
420
+
421
+ _log_retry(e, attempts, max_retries)
404
422
 
405
423
  # Calculate exponential backoff with some randomness (jitter)
406
424
  delay = initial_delay * (backoff_factor ** (attempts - 1))
@@ -69,7 +69,13 @@ from unique_sdk.cli.commands.mcp import cmd_mcp
69
69
  from unique_sdk.cli.commands.mcp import (
70
70
  is_error_output as _is_mcp_error_output,
71
71
  )
72
- from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
72
+ from unique_sdk.cli.commands.navigation import (
73
+ LS_DEFAULT_LIMIT,
74
+ LS_MAX_LIMIT,
75
+ cmd_cd,
76
+ cmd_ls,
77
+ cmd_pwd,
78
+ )
73
79
  from unique_sdk.cli.commands.navigation import (
74
80
  is_ls_error_output as _is_ls_error_output,
75
81
  )
@@ -309,13 +315,44 @@ def cd(ctx: click.Context, target: str) -> None:
309
315
 
310
316
  @main.command()
311
317
  @click.argument("target", required=False, default=None)
318
+ @click.option(
319
+ "--limit",
320
+ "-l",
321
+ type=int,
322
+ default=LS_DEFAULT_LIMIT,
323
+ show_default=True,
324
+ help=f"Max folders and max files to show (1-{LS_MAX_LIMIT}).",
325
+ )
326
+ @click.option(
327
+ "--skip",
328
+ type=int,
329
+ default=0,
330
+ show_default=True,
331
+ help="Skip this many folders and files first (for paging).",
332
+ )
333
+ @click.option(
334
+ "--all",
335
+ "-a",
336
+ "all_items",
337
+ is_flag=True,
338
+ help=f"Show up to {LS_MAX_LIMIT} folders and {LS_MAX_LIMIT} files.",
339
+ )
312
340
  @click.pass_context
313
- def ls(ctx: click.Context, target: str | None) -> None:
341
+ def ls(
342
+ ctx: click.Context,
343
+ target: str | None,
344
+ limit: int,
345
+ skip: int,
346
+ all_items: bool,
347
+ ) -> None:
314
348
  """List folders and files in a directory.
315
349
 
316
350
  \b
317
351
  Without TARGET, lists the current directory. With TARGET, lists
318
352
  the specified folder (by name, absolute path, or scope ID).
353
+ Shows 50 folders and 50 files by default. When a folder holds more,
354
+ the output says "showing ... of N" and prints the command for the
355
+ rest: use --skip to page, --limit to show more, or --all.
319
356
 
320
357
  \b
321
358
  Output columns: TYPE NAME ID SIZE UPDATED
@@ -327,8 +364,12 @@ def ls(ctx: click.Context, target: str | None) -> None:
327
364
  unique-cli ls List root folders
328
365
  unique-cli ls /Reports List a specific path
329
366
  unique-cli ls scope_abc List by scope ID
367
+ unique-cli ls /Reports --skip 50 Next page of a large folder
368
+ unique-cli ls /Reports --all Up to 1000 folders and files
330
369
  """
331
- output = cmd_ls(LazyState.get(ctx), target)
370
+ output = cmd_ls(
371
+ LazyState.get(ctx), target, skip=skip, limit=limit, all_items=all_items
372
+ )
332
373
  emit(output, is_error=_is_ls_error_output)
333
374
 
334
375
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import shlex
6
+ from collections.abc import Callable, Mapping
5
7
  from typing import Any
6
8
 
7
9
  import unique_sdk
@@ -13,6 +15,11 @@ from unique_sdk.cli.metadata_filter import (
13
15
  from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
14
16
  from unique_sdk.cli.state import ShellState
15
17
 
18
+ # Server take is capped at 100 and defaults to 50; ls pages to reach --limit rows.
19
+ LS_PAGE_SIZE = 100
20
+ LS_DEFAULT_LIMIT = 50
21
+ LS_MAX_LIMIT = 1000
22
+
16
23
 
17
24
  def cmd_pwd(state: ShellState) -> str:
18
25
  return state.cwd
@@ -87,8 +94,90 @@ def is_ls_error_output(output: str) -> bool:
87
94
  return output.startswith("ls:")
88
95
 
89
96
 
90
- def cmd_ls(state: ShellState, target: str | None = None) -> str:
91
- """List folders and files at the given (or current) path."""
97
+ def _fetch_window(
98
+ fetch_page: Callable[[int, int], Mapping[str, Any]],
99
+ rows_key: str,
100
+ skip: int,
101
+ limit: int,
102
+ ) -> tuple[list[Any], int]:
103
+ """Return up to *limit* rows starting at *skip*, and the API total."""
104
+ rows: list[Any] = []
105
+ total: int | None = None
106
+ offset = skip
107
+ while len(rows) < limit:
108
+ take = min(LS_PAGE_SIZE, limit - len(rows))
109
+ result = fetch_page(offset, take)
110
+ page = result.get(rows_key, [])
111
+ total = result.get("totalCount", total)
112
+ rows.extend(page)
113
+ offset += len(page)
114
+ if len(page) < take or (total is not None and offset >= total):
115
+ break
116
+ return rows, total if total is not None else skip + len(rows)
117
+
118
+
119
+ def _window_note(
120
+ label: str,
121
+ skip: int,
122
+ limit: int,
123
+ shown: dict[str, int],
124
+ totals: dict[str, int],
125
+ hidden_files: int = 0,
126
+ ) -> str:
127
+ """Say which rows this call read and how to list the rest; empty when complete.
128
+
129
+ ``shown`` counts rows read from the server. ``hidden_files`` of them were
130
+ dropped by the task scope and are not printed, so the wording says "read".
131
+ """
132
+ parts: list[str] = []
133
+ for kind in ("folders", "files"):
134
+ total = totals[kind]
135
+ count = shown[kind]
136
+ if total == 0 or (skip == 0 and count >= total):
137
+ continue
138
+ if count == 0:
139
+ parts.append(f"no {kind} after {skip} (of {total})")
140
+ else:
141
+ parts.append(f"{kind} {skip + 1}-{skip + count} of {total}")
142
+ if not parts:
143
+ return ""
144
+ verb = "read" if hidden_files else "showing"
145
+ note = f"\n{verb} " + ", ".join(parts)
146
+ if hidden_files:
147
+ note += f" ({hidden_files} file(s) hidden by task scope)"
148
+ if any(skip + shown[kind] < totals[kind] for kind in shown):
149
+ quoted = shlex.quote(label)
150
+ limit_arg = "" if limit == LS_DEFAULT_LIMIT else f" --limit {limit}"
151
+ note += f"\nmore: unique-cli ls {quoted} --skip {skip + limit}{limit_arg}"
152
+ # --all restarts at row 0, so it only helps when it would show more
153
+ # than this window already does; at the cap it would repeat rows.
154
+ if skip == 0 and limit < LS_MAX_LIMIT:
155
+ note += f" (or: unique-cli ls {quoted} --all)"
156
+ return note
157
+
158
+
159
+ def cmd_ls(
160
+ state: ShellState,
161
+ target: str | None = None,
162
+ *,
163
+ skip: int = 0,
164
+ limit: int = LS_DEFAULT_LIMIT,
165
+ all_items: bool = False,
166
+ ) -> str:
167
+ """List folders and files at the given (or current) path.
168
+
169
+ Shows up to *limit* folders and *limit* files starting at *skip*
170
+ (``all_items`` raises *limit* to ``LS_MAX_LIMIT``). When more exist, the
171
+ output says which rows are shown and ends with the ``ls`` call for the rest.
172
+ """
173
+ if all_items and limit != LS_DEFAULT_LIMIT:
174
+ return "ls: --all and --limit cannot be combined"
175
+ if all_items:
176
+ limit = LS_MAX_LIMIT
177
+ if skip < 0:
178
+ return "ls: --skip must be 0 or more"
179
+ if not 1 <= limit <= LS_MAX_LIMIT:
180
+ return f"ls: --limit must be between 1 and {LS_MAX_LIMIT}"
92
181
  try:
93
182
  if target is not None:
94
183
  resolved_path, scope_id = state.resolve_path(target)
@@ -108,6 +197,16 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
108
197
  f"({state.scope_denial_hint()})."
109
198
  )
110
199
 
200
+ # The scoped root lists a fixed set of folders; there is nothing to page.
201
+ if (
202
+ scope_id is None
203
+ and skip
204
+ and (
205
+ state.workspace_metadata_filter is not None or state.workspace_scope_ids
206
+ )
207
+ ):
208
+ return "ls: paging is not supported at the scoped root"
209
+
111
210
  # At root with a per-message KB scope (e.g. an Agentic Table column's
112
211
  # scope_rules), show only the in-scope folders and explicitly-scoped
113
212
  # documents so the agent explores within the boundary rather than the
@@ -194,27 +293,36 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
194
293
  if scope_id:
195
294
  folder_params["parentId"] = scope_id
196
295
 
197
- folder_result = unique_sdk.Folder.get_infos(
198
- user_id=state.config.user_id,
199
- company_id=state.config.company_id,
200
- **folder_params,
296
+ folders, total_folders = _fetch_window(
297
+ lambda offset, take: unique_sdk.Folder.get_infos(
298
+ user_id=state.config.user_id,
299
+ company_id=state.config.company_id,
300
+ skip=offset,
301
+ take=take,
302
+ **folder_params,
303
+ ),
304
+ "folderInfos",
305
+ skip,
306
+ limit,
201
307
  )
202
- folders = folder_result.get("folderInfos", [])
203
- total_folders = folder_result.get("totalCount", len(folders))
204
-
205
308
  # Every file lives in a folder, so the root has no files of its own.
206
309
  files: list[Any] = []
207
310
  total_files = 0
208
311
  if scope_id:
209
- content_result = unique_sdk.Content.get_infos(
210
- user_id=state.config.user_id,
211
- company_id=state.config.company_id,
212
- metadataFilter=direct_children_filter(scope_id),
312
+ files, total_files = _fetch_window(
313
+ lambda offset, take: unique_sdk.Content.get_infos(
314
+ user_id=state.config.user_id,
315
+ company_id=state.config.company_id,
316
+ metadataFilter=direct_children_filter(scope_id),
317
+ skip=offset,
318
+ take=take,
319
+ ),
320
+ "contentInfos",
321
+ skip,
322
+ limit,
213
323
  )
214
- files = content_result.get("contentInfos", [])
215
- total_files = content_result.get("totalCount", len(files))
216
324
 
217
- if scope_id and not folders and not files:
325
+ if scope_id and not folders and not files and skip == 0:
218
326
  label = target if target is not None else resolved_path
219
327
  counts_error = _counts_without_rows_error(
220
328
  label,
@@ -229,25 +337,36 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
229
337
  # allowlist): read/cite would deny them, so ls must hide them too.
230
338
  # Folder-only filters keep every file (each passes the folderIdPath
231
339
  # leaf); the verdict is cached per content id. See UN-21780.
340
+ # The paging note counts rows read from the server, before the scope filter.
341
+ scanned_files = len(files)
232
342
  if state.workspace_metadata_filter is not None:
233
343
  files = [
234
344
  f for f in files if state.is_content_within_workspace(f.get("id", ""))
235
345
  ]
346
+ note = _window_note(
347
+ target if target is not None else resolved_path,
348
+ skip,
349
+ limit,
350
+ {"folders": len(folders), "files": scanned_files},
351
+ {"folders": total_folders, "files": total_files},
352
+ hidden_files=scanned_files - len(files),
353
+ )
354
+
355
+ output = format_ls(folders, files)
356
+ if state.workspace_metadata_filter is not None:
236
357
  # ``files`` is only the current API page, so the in-scope count is
237
358
  # per-page, not the folder total. Report it as the shown-in-scope
238
359
  # count and keep the API ``totalCount`` as the folder's real size,
239
360
  # rather than overwriting total_files with the page length (which
240
361
  # would silently undercount paginated folders). See UN-21780.
241
- output = format_ls(folders, files)
242
362
  summary = (
243
363
  f"\n{total_folders} folder(s), {len(files)} file(s) in task scope "
244
364
  f"(of {total_files} in folder)"
245
365
  )
246
- return output + summary
366
+ return output + summary + note
247
367
 
248
- output = format_ls(folders, files)
249
368
  summary = f"\n{total_folders} folder(s), {total_files} file(s)"
250
- return output + summary
369
+ return output + summary + note
251
370
 
252
371
  except (ValueError, unique_sdk.UniqueError) as e:
253
372
  return f"ls: {e}"
@@ -27,7 +27,12 @@ from unique_sdk.cli.commands.files import (
27
27
  )
28
28
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
29
29
  from unique_sdk.cli.commands.mcp import cmd_mcp
30
- from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
30
+ from unique_sdk.cli.commands.navigation import (
31
+ LS_DEFAULT_LIMIT,
32
+ cmd_cd,
33
+ cmd_ls,
34
+ cmd_pwd,
35
+ )
31
36
  from unique_sdk.cli.commands.scheduled_tasks import (
32
37
  cmd_schedule_create,
33
38
  cmd_schedule_delete,
@@ -217,10 +222,12 @@ class UniqueShell(cmd.Cmd):
217
222
  def do_ls(self, arg: str) -> None:
218
223
  """List folders and files in a directory.
219
224
 
220
- Usage: ls [target]
225
+ Usage: ls [target] [--limit N] [--skip N] [--all]
221
226
 
222
227
  Without arguments, lists the current directory. With an argument,
223
228
  lists the specified folder (by name, path, or scope ID).
229
+ Shows 50 folders and 50 files by default; when a folder holds more,
230
+ the output says so and prints the command for the rest.
224
231
 
225
232
  Output columns: TYPE NAME ID SIZE UPDATED
226
233
 
@@ -233,8 +240,51 @@ class UniqueShell(cmd.Cmd):
233
240
  /> ls /Reports/Q1
234
241
  /> ls scope_abc123
235
242
  """
236
- target = arg.strip() or None
237
- self._print(cmd_ls(self.state, target))
243
+ try:
244
+ parts = shlex.split(arg)
245
+ except ValueError as e:
246
+ self._print(f"ls: {e}")
247
+ return
248
+ target: str | None = None
249
+ skip = 0
250
+ limit = LS_DEFAULT_LIMIT
251
+ all_items = False
252
+ # Accept --limit=20 / --skip=50 like the one-shot CLI does.
253
+ expanded: list[str] = []
254
+ for part in parts:
255
+ name, sep, value = part.partition("=")
256
+ if sep and name in ("--limit", "--skip"):
257
+ expanded.extend([name, value])
258
+ else:
259
+ expanded.append(part)
260
+ it = iter(expanded)
261
+ for part in it:
262
+ if part in ("--all", "-a"):
263
+ all_items = True
264
+ elif part in ("--limit", "-l", "--skip"):
265
+ value = next(it, None)
266
+ try:
267
+ number = int(value) if value is not None else None
268
+ except ValueError:
269
+ number = None
270
+ if number is None:
271
+ self._print(f"ls: {part} needs a whole number")
272
+ return
273
+ if part == "--skip":
274
+ skip = number
275
+ else:
276
+ limit = number
277
+ elif part.startswith("-") and len(part) > 1:
278
+ self._print(f"ls: unknown option {part}")
279
+ return
280
+ elif target is None:
281
+ target = part
282
+ else:
283
+ self._print("ls: expected at most one target")
284
+ return
285
+ self._print(
286
+ cmd_ls(self.state, target, skip=skip, limit=limit, all_items=all_items)
287
+ )
238
288
 
239
289
  # -- Folder operations --
240
290
 
@@ -42,6 +42,11 @@ unique-cli ls /Reports/Q1
42
42
  # List by scope ID
43
43
  unique-cli ls scope_abc123
44
44
 
45
+ # Large folder: ls shows 50 folders and 50 files by default. When it prints
46
+ # "showing files 1-50 of N", run the "more:" command it gives you
47
+ unique-cli ls /Reports/Q1 --skip 50 # next page
48
+ unique-cli ls /Reports/Q1 --all # up to 1000 folders and 1000 files
49
+
45
50
  # Create a folder
46
51
  unique-cli mkdir Q2
47
52
 
@@ -12,7 +12,8 @@ description: >-
12
12
  (.xlsx/.xls), CSV (.csv), and image files are NOT full-text indexed,
13
13
  so they will not appear in search results. To locate these file types,
14
14
  use the unique-cli-file-management skill instead (browse folders with
15
- `unique-cli ls` to find them by name).
15
+ `unique-cli ls` to find them by name; `ls` shows 50 files at a time, so
16
+ follow its "more:" line or use `--all` for larger folders).
16
17
  ---
17
18
 
18
19
  # Unique CLI -- Knowledge Base Search & Document Read
@@ -32,7 +33,7 @@ Use `read` after a `ls` or `search` surfaces a content ID and you need to go dee
32
33
 
33
34
  Search the Unique knowledge base using combined vector + full-text search via the `unique-cli search` command. Every invocation wraps each result in a `<sourceN>...</sourceN>` block and records a per-turn citation manifest at `.unique/kb-search-refs.jsonl`, so any fact you cite as `[sourceN]` is rendered with a footnote and a clickable reference chip in the chat UI.
34
35
 
35
- > **Limitation:** Excel (`.xlsx`/`.xls`), CSV (`.csv`), and image files are **not** full-text indexed. They will not appear in search results. To find these files, use the `unique-cli-file-management` skill and browse folders with `unique-cli ls` to locate them by name.
36
+ > **Limitation:** Excel (`.xlsx`/`.xls`), CSV (`.csv`), and image files are **not** full-text indexed. They will not appear in search results. To find these files, use the `unique-cli-file-management` skill and browse folders with `unique-cli ls` to locate them by name (`ls` shows 50 files at a time; follow its "more:" line or use `--all` for larger folders).
36
37
 
37
38
 
38
39
  ## Basic Search