unique-sdk 2026.26.0.dev9__tar.gz → 2026.26.0.dev11__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 (85) hide show
  1. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/PKG-INFO +1 -1
  2. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/pyproject.toml +1 -1
  3. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/cli.py +66 -15
  4. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/elicitation.py +31 -3
  5. unique_sdk-2026.26.0.dev11/unique_sdk/cli/commands/read.py +176 -0
  6. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/shell.py +93 -13
  7. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +34 -8
  8. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +60 -0
  9. unique_sdk-2026.26.0.dev9/unique_sdk/cli/commands/read.py +0 -93
  10. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/README.md +0 -0
  11. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/__init__.py +0 -0
  12. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_api_requestor.py +0 -0
  13. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_api_resource.py +0 -0
  14. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_api_version.py +0 -0
  15. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_error.py +0 -0
  16. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_http_client.py +0 -0
  17. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_list_object.py +0 -0
  18. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_object_classes.py +0 -0
  19. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_request_options.py +0 -0
  20. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_unique_object.py +0 -0
  21. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_unique_ql.py +0 -0
  22. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_unique_response.py +0 -0
  23. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_util.py +0 -0
  24. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_version.py +0 -0
  25. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_webhook.py +0 -0
  26. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/__init__.py +0 -0
  27. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_acronyms.py +0 -0
  28. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_agentic_table.py +0 -0
  29. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_analytics_order.py +0 -0
  30. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_benchmarking.py +0 -0
  31. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_briefing.py +0 -0
  32. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_chat_completion.py +0 -0
  33. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_content.py +0 -0
  34. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  35. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_elicitation.py +0 -0
  36. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_embedding.py +0 -0
  37. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_event.py +0 -0
  38. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_folder.py +0 -0
  39. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_group.py +0 -0
  40. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_integrated.py +0 -0
  41. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_llm_models.py +0 -0
  42. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_mcp.py +0 -0
  43. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message.py +0 -0
  44. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_assessment.py +0 -0
  45. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_execution.py +0 -0
  46. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_log.py +0 -0
  47. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_tool.py +0 -0
  48. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_module.py +0 -0
  49. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  50. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_search.py +0 -0
  51. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_search_string.py +0 -0
  52. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  53. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_space.py +0 -0
  54. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_user.py +0 -0
  55. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_web_search.py +0 -0
  56. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/__init__.py +0 -0
  57. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/__main__.py +0 -0
  58. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/__init__.py +0 -0
  59. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  60. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/cite_file.py +0 -0
  61. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  62. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/files.py +0 -0
  63. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/folders.py +0 -0
  64. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/mcp.py +0 -0
  65. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/navigation.py +0 -0
  66. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  67. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/search.py +0 -0
  68. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/subagent.py +0 -0
  69. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/web_search.py +0 -0
  70. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/web_search_config.py +0 -0
  71. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/config.py +0 -0
  72. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/formatting.py +0 -0
  73. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  74. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  75. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  76. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  77. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  78. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/state.py +0 -0
  79. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/analytics_order_run.py +0 -0
  80. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/benchmarking_run.py +0 -0
  81. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/chat_history.py +0 -0
  82. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/chat_in_space.py +0 -0
  83. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/file_io.py +0 -0
  84. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/sources.py +0 -0
  85. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/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.26.0.dev9
3
+ Version: 2026.26.0.dev11
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.26.0.dev9"
3
+ version = "2026.26.0.dev11"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -13,6 +13,7 @@ from unique_sdk.cli.commands.dynamic_frontend import (
13
13
  cmd_dynamic_frontend_list,
14
14
  )
15
15
  from unique_sdk.cli.commands.elicitation import (
16
+ DEFAULT_WAIT_TIMEOUT_SECONDS,
16
17
  cmd_elicit_ask,
17
18
  cmd_elicit_create,
18
19
  cmd_elicit_get,
@@ -398,9 +399,41 @@ def cite(
398
399
 
399
400
  @main.command(name="read")
400
401
  @click.argument("cont_id")
402
+ @click.option(
403
+ "--page",
404
+ "-p",
405
+ type=int,
406
+ default=None,
407
+ help="Read a single page (shorthand for --from-page N --to-page N).",
408
+ )
409
+ @click.option(
410
+ "--from-page",
411
+ type=int,
412
+ default=None,
413
+ help="First page to include (inclusive).",
414
+ )
415
+ @click.option(
416
+ "--to-page",
417
+ type=int,
418
+ default=None,
419
+ help="Last page to include (inclusive).",
420
+ )
421
+ @click.option(
422
+ "--max-chars",
423
+ type=int,
424
+ default=None,
425
+ help="Truncate the printed text to at most N characters.",
426
+ )
401
427
  @click.pass_context
402
- def read_cmd(ctx: click.Context, cont_id: str) -> None:
403
- """Read all indexed text chunks for a known content ID.
428
+ def read_cmd(
429
+ ctx: click.Context,
430
+ cont_id: str,
431
+ page: int | None,
432
+ from_page: int | None,
433
+ to_page: int | None,
434
+ max_chars: int | None,
435
+ ) -> None:
436
+ """Read indexed text chunks for a known content ID.
404
437
 
405
438
  \b
406
439
  CONT_ID must be a content ID (cont_...) obtained from a prior `ls` or
@@ -411,11 +444,34 @@ def read_cmd(ctx: click.Context, cont_id: str) -> None:
411
444
  Use `search` when you need to find documents by topic or keyword.
412
445
  Use `read` when you already know the content ID and want the full text.
413
446
 
447
+ \b
448
+ Restrict to a page range with --page (single page) or --from-page/--to-page.
449
+ A chunk spanning pages 2-4 is returned for any overlapping request; files
450
+ without page numbers (e.g. plain text/markdown) are returned only without a
451
+ page range.
452
+
414
453
  \b
415
454
  Examples:
416
455
  unique-cli read cont_abc123
456
+ unique-cli read cont_abc123 --page 12
457
+ unique-cli read cont_abc123 --from-page 5 --to-page 9
458
+ unique-cli read cont_abc123 --to-page 3 --max-chars 8000
417
459
  """
418
- output = cmd_read(LazyState.get(ctx), cont_id)
460
+ if page is not None and (from_page is not None or to_page is not None):
461
+ click.echo(
462
+ "read: use either --page or --from-page/--to-page, not both", err=True
463
+ )
464
+ raise SystemExit(1)
465
+ if page is not None:
466
+ from_page = page
467
+ to_page = page
468
+ output = cmd_read(
469
+ LazyState.get(ctx),
470
+ cont_id,
471
+ from_page=from_page,
472
+ to_page=to_page,
473
+ max_chars=max_chars,
474
+ )
419
475
  if _is_read_error_output(output):
420
476
  click.echo(output, err=True)
421
477
  raise SystemExit(1)
@@ -1004,19 +1060,16 @@ def elicit() -> None:
1004
1060
  )
1005
1061
  @click.option("--chat-id", "-c", default=None, help="Associated chat ID.")
1006
1062
  @click.option("--message-id", "-m", default=None, help="Associated message ID.")
1007
- @click.option(
1008
- "--expires-in",
1009
- "expires_in_seconds",
1010
- type=int,
1011
- default=None,
1012
- help="Expire the elicitation after N seconds if not answered.",
1013
- )
1014
1063
  @click.option(
1015
1064
  "--timeout",
1016
1065
  type=int,
1017
- default=300,
1066
+ default=DEFAULT_WAIT_TIMEOUT_SECONDS,
1018
1067
  show_default=True,
1019
- help="Max seconds to block waiting for the user's response.",
1068
+ help=(
1069
+ "Max seconds to block waiting for the user's response. This also "
1070
+ "sets when the elicitation expires, so the request expires exactly "
1071
+ "when we stop waiting and the chat UI can offer a way to continue."
1072
+ ),
1020
1073
  )
1021
1074
  @click.option(
1022
1075
  "--poll-interval",
@@ -1075,7 +1128,6 @@ def elicit_ask(
1075
1128
  schema: str | None,
1076
1129
  chat_id: str | None,
1077
1130
  message_id: str | None,
1078
- expires_in_seconds: int | None,
1079
1131
  timeout: int,
1080
1132
  poll_interval: float,
1081
1133
  metadata: tuple[str, ...],
@@ -1112,7 +1164,6 @@ def elicit_ask(
1112
1164
  "schema": schema,
1113
1165
  "chat_id": chat_id,
1114
1166
  "message_id": message_id,
1115
- "expires_in_seconds": expires_in_seconds,
1116
1167
  "timeout": timeout,
1117
1168
  "poll_interval": poll_interval,
1118
1169
  "metadata": parsed_metadata or None,
@@ -1296,7 +1347,7 @@ def elicit_get(ctx: click.Context, elicitation_id: str) -> None:
1296
1347
  @click.option(
1297
1348
  "--timeout",
1298
1349
  type=int,
1299
- default=300,
1350
+ default=DEFAULT_WAIT_TIMEOUT_SECONDS,
1300
1351
  show_default=True,
1301
1352
  help="Max seconds to wait for a terminal state.",
1302
1353
  )
@@ -26,7 +26,7 @@ from unique_sdk.cli.formatting import (
26
26
  from unique_sdk.cli.state import ShellState
27
27
 
28
28
  DEFAULT_POLL_INTERVAL_SECONDS = 2.0
29
- DEFAULT_WAIT_TIMEOUT_SECONDS = 300
29
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 7200
30
30
  TERMINAL_STATUSES = {
31
31
  "RESPONDED",
32
32
  "ACCEPTED",
@@ -669,9 +669,27 @@ def cmd_elicit_wait(
669
669
  terminal_status = status
670
670
  return format_elicitation(elicitation)
671
671
  if time.monotonic() >= deadline:
672
+ # One last read on the deadline. When the caller coupled
673
+ # ``--expires-in`` to ``--timeout`` (the default for
674
+ # ``elicit ask``), the record's ``expiresAt`` lands at this
675
+ # same instant; this fetch forces the backend's lazy expiry to
676
+ # run so we report a clean EXPIRED — and publish it to the chat
677
+ # subscription — instead of a stale PENDING.
678
+ final = dict(
679
+ unique_sdk.Elicitation.get_elicitation(
680
+ user_id=state.config.user_id,
681
+ company_id=state.config.company_id,
682
+ elicitation_id=elicitation_id,
683
+ )
684
+ )
685
+ last = final
686
+ final_status = str(final.get("status", "")).upper()
687
+ if final_status in TERMINAL_STATUSES:
688
+ terminal_status = final_status
689
+ return format_elicitation(final)
672
690
  return (
673
691
  f"elicit: timed out after {timeout}s waiting for "
674
- f"{elicitation_id} (last status: {status or 'UNKNOWN'})\n\n"
692
+ f"{elicitation_id} (last status: {final_status or 'UNKNOWN'})\n\n"
675
693
  f"{format_elicitation(last)}"
676
694
  )
677
695
  time.sleep(poll_interval)
@@ -715,7 +733,6 @@ def cmd_elicit_ask(
715
733
  schema: str | None = None,
716
734
  chat_id: str | None = None,
717
735
  message_id: str | None = None,
718
- expires_in_seconds: int | None = None,
719
736
  timeout: int = DEFAULT_WAIT_TIMEOUT_SECONDS,
720
737
  poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
721
738
  metadata: list[tuple[str, str]] | None = None,
@@ -751,6 +768,17 @@ def cmd_elicit_ask(
751
768
  "required": ["answer"],
752
769
  }
753
770
 
771
+ # `ask` exposes a single knob: `--timeout` is both how long we wait and
772
+ # when the record expires. The two are always the same here, so the
773
+ # backend's default (5 minutes) never leaves the record PENDING after
774
+ # we have stopped waiting — which would otherwise prevent the chat UI
775
+ # from flipping the prompt to EXPIRED and offering the user a way to
776
+ # continue. The poll loop below reads the freshly EXPIRED record and the
777
+ # elicitation subscription delivers the terminal status to the chat.
778
+ # (Use `elicit create --expires-in` if you need expiry decoupled from a
779
+ # local wait.)
780
+ expires_in_seconds = timeout
781
+
754
782
  user_metadata = _parse_metadata_pairs(metadata)
755
783
  effective_message_id = message_id
756
784
  placeholder_message_id: str | None = None
@@ -0,0 +1,176 @@
1
+ """Read command: retrieve all indexed text chunks for a known content ID.
2
+
3
+ Calls ``Content.search(where={"id": {"equals": cont_id}})`` — a direct
4
+ Postgres lookup that returns every indexed chunk for the document in one
5
+ request, no vector search involved.
6
+
7
+ Use this when you already know the ``cont_*`` ID (e.g. from a prior ``ls``
8
+ or ``unique-cli search`` result) and want to read the full document text.
9
+ For discovery or query-based retrieval use ``unique-cli search`` instead.
10
+
11
+ Pass ``from_page``/``to_page`` to read only part of a long document by page
12
+ range; chunks are filtered client-side on the ``startPage``/``endPage`` the
13
+ platform already returns, so no ingestion changes are required.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ import unique_sdk
21
+ from unique_sdk.cli.state import ShellState
22
+
23
+ READ_ERROR_PREFIX = "read:"
24
+
25
+
26
+ def _chunk_in_page_range(
27
+ chunk: dict[str, Any],
28
+ from_page: int | None,
29
+ to_page: int | None,
30
+ ) -> bool:
31
+ """Return True if *chunk* overlaps the requested ``[from_page, to_page]`` span.
32
+
33
+ A chunk covers ``startPage``..``endPage`` inclusive. With page-based chunking
34
+ these are equal (one chunk per page); otherwise a single chunk can span
35
+ several pages, so we keep any chunk that *overlaps* the requested range
36
+ rather than one fully contained in it. Chunks without page numbers are
37
+ excluded, since they cannot be placed on a page. ``from_page``/``to_page``
38
+ that are ``None`` act as open bounds.
39
+ """
40
+ start: int | None = chunk.get("startPage")
41
+ end: int | None = chunk.get("endPage")
42
+ if start is None:
43
+ start = end
44
+ if end is None:
45
+ end = start
46
+ if start is None or end is None:
47
+ return False
48
+
49
+ low = from_page if from_page is not None else start
50
+ high = to_page if to_page is not None else end
51
+ return start <= high and end >= low
52
+
53
+
54
+ def _format_requested_range(from_page: int | None, to_page: int | None) -> str:
55
+ """Human-readable label for a requested page range (for messages)."""
56
+ if from_page is not None and to_page is not None:
57
+ return str(from_page) if from_page == to_page else f"{from_page}-{to_page}"
58
+ if from_page is not None:
59
+ return f"{from_page}+"
60
+ return f"up to {to_page}"
61
+
62
+
63
+ def cmd_read(
64
+ state: ShellState,
65
+ cont_id: str,
66
+ from_page: int | None = None,
67
+ to_page: int | None = None,
68
+ max_chars: int | None = None,
69
+ ) -> str:
70
+ """Return indexed text chunks for *cont_id* as plain text.
71
+
72
+ Args:
73
+ state: Shell state carrying user/company credentials.
74
+ cont_id: A content ID (``cont_...``) to retrieve.
75
+ from_page: First page to include (inclusive). ``None`` = open start.
76
+ to_page: Last page to include (inclusive). ``None`` = open end.
77
+ max_chars: Truncate the returned text to at most this many characters.
78
+
79
+ Returns:
80
+ A formatted string of chunks, or an error message prefixed with
81
+ ``read:``.
82
+
83
+ When ``from_page``/``to_page`` are given, chunks are filtered to those that
84
+ overlap the requested pages. The page numbers come from ingestion; nothing
85
+ needs to change there. A chunk spanning pages 2-4 is returned for any range
86
+ touching 2-4, so the text may include a little from neighbouring pages.
87
+ """
88
+ if not cont_id.startswith("cont_"):
89
+ return f"{READ_ERROR_PREFIX} expected a content ID starting with 'cont_', got: {cont_id!r}"
90
+
91
+ if from_page is not None and to_page is not None and from_page > to_page:
92
+ return f"{READ_ERROR_PREFIX} invalid page range ({from_page} > {to_page})"
93
+
94
+ if max_chars is not None and max_chars < 1:
95
+ return f"{READ_ERROR_PREFIX} invalid --max-chars ({max_chars}); must be >= 1"
96
+
97
+ # Enforce the same .unique-search.json workspace boundary as search/ls/rm.
98
+ # Content.search has no scopeIds param, so we guard by owner scope before
99
+ # the point-lookup — matching rm/mv, not search's API-level scopeIds filter.
100
+ if not state.is_content_within_workspace(cont_id):
101
+ return f"{READ_ERROR_PREFIX} permission denied (outside workspace scope)"
102
+
103
+ try:
104
+ results = unique_sdk.Content.search(
105
+ user_id=state.config.user_id,
106
+ company_id=state.config.company_id,
107
+ where={"id": {"equals": cont_id}},
108
+ )
109
+ except unique_sdk.APIError as e:
110
+ return f"{READ_ERROR_PREFIX} {e}"
111
+
112
+ if not results:
113
+ return f"{READ_ERROR_PREFIX} no content found for ID: {cont_id}"
114
+
115
+ content = results[0]
116
+ title = getattr(content, "title", None) or getattr(content, "key", None) or cont_id
117
+ chunks = getattr(content, "chunks", None) or []
118
+
119
+ if not chunks:
120
+ return (
121
+ f"Content: {title} ({cont_id})\n"
122
+ "No indexed chunks found — the document may still be ingesting or ingestion failed."
123
+ )
124
+
125
+ sorted_chunks = sorted(chunks, key=lambda c: c.get("order") or 0)
126
+
127
+ if from_page is not None or to_page is not None:
128
+ sorted_chunks = [
129
+ c for c in sorted_chunks if _chunk_in_page_range(c, from_page, to_page)
130
+ ]
131
+ if not sorted_chunks:
132
+ page_range = _format_requested_range(from_page, to_page)
133
+ return (
134
+ f"Content: {title} ({cont_id})\n"
135
+ f"No indexed chunks found in page range {page_range}. The document "
136
+ "may not have page numbers (e.g. plain text/markdown) or spans a "
137
+ "different range — read without a page range to see all text."
138
+ )
139
+
140
+ lines: list[str] = [
141
+ f"Content: {title} ({cont_id}) — {len(sorted_chunks)} chunk(s)\n"
142
+ ]
143
+ for chunk in sorted_chunks:
144
+ text = (chunk.get("text") or "").strip()
145
+ if not text:
146
+ continue
147
+ start = chunk.get("startPage")
148
+ end = chunk.get("endPage")
149
+ if start is not None or end is not None:
150
+ page_start = start if start is not None else end
151
+ page_end = end if end is not None else start
152
+ if page_start is not None and page_end is not None:
153
+ page_ref = (
154
+ f"[p.{page_start}]"
155
+ if page_start == page_end
156
+ else f"[p.{page_start}-{page_end}]"
157
+ )
158
+ lines.append(f"{page_ref} {text}")
159
+ else:
160
+ lines.append(text)
161
+ else:
162
+ lines.append(text)
163
+
164
+ output = "\n\n".join(lines)
165
+ if max_chars is not None and len(output) > max_chars:
166
+ if from_page is not None or to_page is not None:
167
+ hint = "narrow the page range or raise --max-chars to see more"
168
+ else:
169
+ hint = "use a page range (--page/--from-page/--to-page) or raise --max-chars to see more"
170
+ output = f"{output[:max_chars]}\n... [truncated at {max_chars} chars; {hint}]"
171
+ return output
172
+
173
+
174
+ def is_error_output(output: str) -> bool:
175
+ """Return ``True`` when *output* is an error message from ``cmd_read``."""
176
+ return output.startswith(READ_ERROR_PREFIX)
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  from unique_sdk.cli import __version__
11
11
  from unique_sdk.cli.commands.elicitation import (
12
+ DEFAULT_WAIT_TIMEOUT_SECONDS,
12
13
  cmd_elicit_ask,
13
14
  cmd_elicit_create,
14
15
  cmd_elicit_get,
@@ -68,7 +69,11 @@ OVERVIEW_HELP = textwrap.dedent("""\
68
69
  --folder <path|id> Restrict to a folder
69
70
  --metadata <key=value> Filter by metadata (repeatable)
70
71
  --limit <N> Max results (default: 200)
71
- read <cont_id> Read all indexed text chunks for a content ID
72
+ read <cont_id> [options] Read indexed text chunks for a content ID
73
+ --page / -p <N> Read a single page
74
+ --from-page <N> First page (inclusive)
75
+ --to-page <N> Last page (inclusive)
76
+ --max-chars <N> Truncate output to N characters
72
77
 
73
78
  MCP:
74
79
  mcp [options] <json> Call an MCP server tool
@@ -83,8 +88,8 @@ OVERVIEW_HELP = textwrap.dedent("""\
83
88
  --schema <json> JSON schema (default: single 'answer' field)
84
89
  --chat-id / -c <id> Associated chat ID
85
90
  --message-id / -m <id> Associated message ID
86
- --expires-in <seconds> Auto-expire the request
87
- --timeout <seconds> Max wait time (default: 300)
91
+ --timeout <seconds> Max wait time, also sets when the
92
+ request expires (default: 7200)
88
93
  --poll-interval <seconds> Poll frequency (default: 2.0)
89
94
  --metadata key=value Metadata (repeatable)
90
95
  --no-visible Skip the UN-19815 visibility workaround
@@ -108,7 +113,7 @@ OVERVIEW_HELP = textwrap.dedent("""\
108
113
  elicit pending List pending elicitations
109
114
  elicit get <id> Show one elicitation
110
115
  elicit wait <id> [opts] Poll until answered / expired
111
- --timeout <seconds> Max wait (default: 300)
116
+ --timeout <seconds> Max wait (default: 7200)
112
117
  --poll-interval <seconds> Poll frequency (default: 2.0)
113
118
  elicit respond <id> [opts] Respond on behalf of the user
114
119
  --action ACCEPT|DECLINE|CANCEL|REJECT Response action (required)
@@ -470,27 +475,95 @@ class UniqueShell(cmd.Cmd):
470
475
  return
471
476
  self._print(cmd_cite_file(self.state, positional[0], pages))
472
477
 
478
+ def _parse_int(self, raw: str, flag: str) -> tuple[int | None, bool]:
479
+ """Parse an int option value, returning (value, ok). Prints on failure."""
480
+ try:
481
+ return int(raw), True
482
+ except ValueError:
483
+ self._print(f"Invalid {flag}: {raw} (expected an integer)")
484
+ return None, False
485
+
473
486
  def do_read(self, arg: str) -> None:
474
- """Read all indexed text chunks for a known content ID.
487
+ """Read indexed text chunks for a known content ID (optionally by page).
475
488
 
476
- Usage: read <cont_id>
489
+ Usage: read <cont_id> [--page N | --from-page N --to-page M] [--max-chars N]
477
490
 
478
491
  Retrieves every indexed chunk for the document directly from the
479
- database — no vector search, no query string needed.
492
+ database — no vector search, no query string needed. Use --page for a
493
+ single page or --from-page/--to-page for a range; a chunk spanning
494
+ pages 2-4 is returned for any overlapping request.
480
495
 
481
496
  Use `search` to find documents by topic; use `read` once you have
482
497
  the content ID and want the full text.
483
498
 
484
499
  Examples:
485
500
  /Reports> read cont_abc123
501
+ /Reports> read cont_abc123 --page 12
502
+ /Reports> read cont_abc123 --from-page 5 --to-page 9
486
503
  """
487
504
  from unique_sdk.cli.commands.read import cmd_read
488
505
 
489
506
  parts = shlex.split(arg)
507
+ usage = (
508
+ "Usage: read <cont_id> "
509
+ "[--page N | --from-page N --to-page M] [--max-chars N]"
510
+ )
490
511
  if not parts:
491
- self._print("Usage: read <cont_id>")
512
+ self._print(usage)
492
513
  return
493
- self._print(cmd_read(self.state, parts[0]))
514
+
515
+ cont_id: str | None = None
516
+ page: int | None = None
517
+ from_page: int | None = None
518
+ to_page: int | None = None
519
+ max_chars: int | None = None
520
+
521
+ int_flags = ("--page", "-p", "--from-page", "--to-page", "--max-chars")
522
+ i = 0
523
+ while i < len(parts):
524
+ tok = parts[i]
525
+ if tok in int_flags:
526
+ if i + 1 >= len(parts):
527
+ self._print(f"Missing value for {tok}")
528
+ return
529
+ value, ok = self._parse_int(parts[i + 1], tok)
530
+ if not ok:
531
+ return
532
+ if tok in ("--page", "-p"):
533
+ page = value
534
+ elif tok == "--from-page":
535
+ from_page = value
536
+ elif tok == "--to-page":
537
+ to_page = value
538
+ else: # --max-chars
539
+ max_chars = value
540
+ i += 2
541
+ elif cont_id is None:
542
+ cont_id = tok
543
+ i += 1
544
+ else:
545
+ self._print(f"Unknown argument: {tok}")
546
+ return
547
+
548
+ if cont_id is None:
549
+ self._print(usage)
550
+ return
551
+ if page is not None and (from_page is not None or to_page is not None):
552
+ self._print("read: use either --page or --from-page/--to-page, not both")
553
+ return
554
+ if page is not None:
555
+ from_page = page
556
+ to_page = page
557
+
558
+ self._print(
559
+ cmd_read(
560
+ self.state,
561
+ cont_id,
562
+ from_page=from_page,
563
+ to_page=to_page,
564
+ max_chars=max_chars,
565
+ )
566
+ )
494
567
 
495
568
  def do_rm(self, arg: str) -> None:
496
569
  """Delete a file.
@@ -716,8 +789,9 @@ class UniqueShell(cmd.Cmd):
716
789
  --mode FORM|URL Display mode (create only, required)
717
790
  --chat-id / -c <id> Associated chat ID
718
791
  --message-id / -m <id> Associated message ID
719
- --expires-in <seconds> Auto-expire the request
720
- --timeout <seconds> (ask / wait) max wait time, default 300
792
+ --expires-in <seconds> Auto-expire the request (create only)
793
+ --timeout <seconds> (ask / wait) max wait time, default 7200;
794
+ for ask this also sets when it expires
721
795
  --poll-interval <seconds> (ask / wait) poll frequency, default 2
722
796
  --external-id <id> External identifier (create only)
723
797
  --metadata key=value Metadata (repeatable)
@@ -791,7 +865,7 @@ class UniqueShell(cmd.Cmd):
791
865
  "message_id": None,
792
866
  "expires_in_seconds": None,
793
867
  "external_elicitation_id": None,
794
- "timeout": 300,
868
+ "timeout": DEFAULT_WAIT_TIMEOUT_SECONDS,
795
869
  "poll_interval": 2.0,
796
870
  "action": None,
797
871
  "content": None,
@@ -900,6 +974,13 @@ class UniqueShell(cmd.Cmd):
900
974
  if not message:
901
975
  self._print("Usage: elicit ask <message> [options]")
902
976
  return
977
+ if opts["expires_in_seconds"] is not None:
978
+ self._print(
979
+ "No such option: --expires-in for 'elicit ask'. Use --timeout "
980
+ "(it also sets when the request expires). For expiry decoupled "
981
+ "from a local wait, use 'elicit create --expires-in'."
982
+ )
983
+ return
903
984
 
904
985
  ask_kwargs: dict[str, Any] = {
905
986
  "message": message,
@@ -907,7 +988,6 @@ class UniqueShell(cmd.Cmd):
907
988
  "schema": opts["schema"],
908
989
  "chat_id": opts["chat_id"],
909
990
  "message_id": opts["message_id"],
910
- "expires_in_seconds": opts["expires_in_seconds"],
911
991
  "timeout": opts["timeout"],
912
992
  "poll_interval": opts["poll_interval"],
913
993
  "metadata": opts["metadata"] or None,
@@ -133,8 +133,7 @@ unique-cli elicit ask "Please provide report settings" \
133
133
  | `--message-id` | `-m` | none | **MANDATORY.** The current assistant message ID. Always pass `"$UNIQUE_MESSAGE_ID"`. Anchors the elicitation to the correct message in the conversation thread. |
134
134
  | `--tool-name` | `-t` | `agent_question` | Short snake_case label shown to the user (e.g. `clarify`, `confirm_delete`, `choose_report`). |
135
135
  | `--schema` | | single `answer` string | JSON Schema for the form body. |
136
- | `--expires-in` | | none | Seconds before the request auto-expires on the platform. |
137
- | `--timeout` | | `300` | Max seconds to block locally before giving up. |
136
+ | `--timeout` | | `7200` | Max seconds to block locally before giving up. This is the single knob for `ask`: it also sets when the request expires on the platform, so the prompt expires exactly when you stop waiting and the chat UI can offer the user a way to continue. |
138
137
  | `--poll-interval` | | `2.0` | Seconds between status polls. |
139
138
  | `--metadata` | | none | `key=value` metadata (repeatable). |
140
139
  | `--assistant-id` | | `$UNIQUE_ASSISTANT_ID`, else latest assistant in chat | Assistant id for the placeholder message created by the visibility workaround. Set this (or export `UNIQUE_ASSISTANT_ID`) only if the chat is brand-new with no prior assistant messages. |
@@ -159,9 +158,34 @@ Terminal statuses:
159
158
  | `RESPONDED` / `COMPLETED` | User answered | Parse `Response:` JSON and proceed. |
160
159
  | `DECLINED` | User explicitly declined | Do not proceed. Acknowledge and stop. |
161
160
  | `CANCELLED` | Cancelled (by user or system) | Do not proceed. |
162
- | `EXPIRED` | Timed out on the platform (via `--expires-in`) | Ask again only if the task still needs it. |
161
+ | `EXPIRED` | Timed out on the platform the user did not answer within `--timeout` | Ask again only if the task still needs it; do not treat the expiry as approval. |
163
162
 
164
- If the CLI itself times out locally (`elicit: timed out after Ns ...`), raise `--timeout` and try again. If this happens repeatedly, double-check that you passed `--chat-id` and did not pass `--no-visible` — an invisible elicitation is the most common cause of a local timeout.
163
+ Because `ask` derives the request's expiry from `--timeout`, when the user does not answer in time the platform expires the request and `elicit ask` returns a clean `EXPIRED` status (rather than a local-only timeout). If you instead see `elicit: timed out after Ns ...`, raise `--timeout` and try again. If this happens repeatedly, double-check that you passed `--chat-id` and did not pass `--no-visible` — an invisible elicitation is the most common cause of a local timeout.
164
+
165
+ ### Repeat the answer back in chat
166
+
167
+ After a `RESPONDED` / `COMPLETED` elicitation, always repeat the user's answer back in the normal chat before you continue. This keeps the decision in the chat history and makes it clear what the user said.
168
+
169
+ Write this as a user-readable summary, not as raw JSON. Use the field descriptions and option labels from the schema to translate the response into plain language:
170
+
171
+ ```markdown
172
+ Got it — you chose Markdown for the report format and asked me to include the appendix.
173
+ ```
174
+
175
+ If the exact structured response is useful for auditing or debugging, put it behind a collapsed details block after the readable summary instead of leading with it:
176
+
177
+ ````markdown
178
+ <details>
179
+ <summary>Structured elicitation response</summary>
180
+
181
+ ```json
182
+ {"format":"Markdown","include_appendix":true}
183
+ ```
184
+
185
+ </details>
186
+ ````
187
+
188
+ Do not expose raw JSON by default when a natural-language confirmation would be clearer.
165
189
 
166
190
  ## Scripting pattern
167
191
 
@@ -192,7 +216,7 @@ esac
192
216
  - Use `enum` for closed choices so the UI can render a selector.
193
217
  - Use `"type": "boolean"` for confirmations; treat `true` as "go ahead", everything else as "stop".
194
218
  - Add short `description` strings -- they are shown as help text next to each field.
195
- - Keep schemas small. Break long flows into several sequential `elicit ask` calls instead of one giant form.
219
+ - Keep schemas small. Ask at most 5 questions in a single elicitation; if you need more, split the flow so the user is not confused by an oversized form.
196
220
 
197
221
  ## Agent workflow rules
198
222
 
@@ -203,9 +227,11 @@ esac
203
227
  5. **Never run destructive CLI commands without a confirmation elicitation.** This includes `rm`, `rmdir -r`, bulk renames, large uploads, schedule deletion, etc.
204
228
  6. **Pick a meaningful `--tool-name`.** `confirm_delete`, `choose_region`, `pick_report` -- short snake_case describing the intent.
205
229
  7. **Constrain answers with a schema** whenever the valid set is finite -- don't rely on parsing free text when `enum` is an option.
206
- 8. **Handle non-`RESPONDED` outcomes explicitly.** If the status is `DECLINED` / `CANCELLED` / `EXPIRED`, tell the user you stopped and ask what they want to do next instead of silently proceeding.
207
- 9. **Don't spam elicitations.** One well-designed form with several fields is better than five sequential yes/no questions.
208
- 10. **Respect timeouts.** The default `--timeout` is 5 minutes -- raise it only if you genuinely expect the user to take longer.
230
+ 8. **Repeat answered elicitations back in chat.** Summarize what the user chose in natural language before acting on it; hide raw JSON in a collapsible details block only when it adds value.
231
+ 9. **Handle non-`RESPONDED` outcomes explicitly.** If the status is `DECLINED` / `CANCELLED` / `EXPIRED`, tell the user you stopped and ask what they want to do next instead of silently proceeding.
232
+ 10. **Don't spam elicitations.** One well-designed form with a few related fields is better than five sequential yes/no questions.
233
+ 11. **Cap each elicitation at 5 questions.** If you need more than 5 answers, split them into multiple focused elicitations so the user can respond confidently.
234
+ 12. **Respect timeouts.** The default `--timeout` is 2 hours -- override it only when the task needs a shorter or longer wait.
209
235
 
210
236
  ## Prerequisites
211
237
 
@@ -4,6 +4,8 @@ description: >-
4
4
  Manage files and folders on the Unique AI Platform using the unique-cli
5
5
  command-line tool. Use when the user asks to upload, download, delete,
6
6
  rename, list, find, restore versions, list versions, look for, or organize files and folders on Unique,
7
+ or to read / view / quote the text contents of a known file (optionally by
8
+ page or page range, e.g. "what's on page 5?", "read pages 10-12"),
7
9
  or when working with scope IDs (scope_*) or content IDs (cont_*).
8
10
  IMPORTANT: When a user says they are "looking for a file" or wants to
9
11
  "find a file", they typically mean locating it within the Unique AI
@@ -64,6 +66,13 @@ unique-cli restore-version cver_abc123
64
66
  unique-cli download report.pdf ./local/
65
67
  unique-cli download cont_abc123 ~/Desktop/
66
68
 
69
+ # Read a file's extracted text by content ID (whole file)
70
+ unique-cli read cont_abc123
71
+
72
+ # Read a single page or a page range
73
+ unique-cli read cont_abc123 --page 12
74
+ unique-cli read cont_abc123 --from-page 5 --to-page 9
75
+
67
76
  # Declare page citations after reading a file
68
77
  unique-cli cite report.pdf --pages 3,5,7
69
78
  unique-cli cite cont_abc123 --pages 1-4
@@ -141,6 +150,57 @@ unique-cli mkdir "2025/Q1/Financials"
141
150
  unique-cli upload ./budget.xlsx /2025/Q1/Financials/
142
151
  ```
143
152
 
153
+ ## Reading File Contents (by page range)
154
+
155
+ Use `read` to retrieve the **extracted text** of a single, known file — for
156
+ example to answer "what does page 5 say?", to quote an exact passage, or to
157
+ read a long document a few pages at a time. This differs from `search`:
158
+ `search` ranks chunks across many files by relevance; `read` returns the text
159
+ of one file in document order.
160
+
161
+ `read` takes a **content ID** (`cont_...`), not a file name. Get the ID first
162
+ from `ls` or `search`, then pass it to `read`.
163
+
164
+ ```bash
165
+ # Whole file
166
+ unique-cli read cont_abc123
167
+
168
+ # A single page
169
+ unique-cli read cont_abc123 --page 12
170
+
171
+ # A page range (inclusive)
172
+ unique-cli read cont_abc123 --from-page 5 --to-page 9
173
+
174
+ # Cap the output size (protects your context window on huge files)
175
+ unique-cli read cont_abc123 --to-page 3 --max-chars 8000
176
+ ```
177
+
178
+ | Option | Description |
179
+ |--------|-------------|
180
+ | `--page` / `-p N` | Read only page N (shorthand for `--from-page N --to-page N`) |
181
+ | `--from-page N` | First page to include (inclusive) |
182
+ | `--to-page N` | Last page to include (inclusive) |
183
+ | `--max-chars N` | Truncate the printed text to N characters |
184
+
185
+ Each chunk is prefixed with its source page(s) as `[p.N]` or `[p.N-M]`, so you
186
+ can attribute text to pages.
187
+
188
+ ### How page filtering behaves (important)
189
+
190
+ - **Page numbers come from ingestion.** Each chunk carries a `startPage` and
191
+ `endPage`; the page filter is applied to those values. Nothing in the
192
+ ingestion pipeline needs to change.
193
+ - **Ranges overlap, they don't slice.** A chunk that spans pages 2-4 is
194
+ returned for `--page 3` (or any range touching 2-4). The returned text is the
195
+ whole chunk, so it may include a little from neighbouring pages. Treat the
196
+ result as "the chunks covering these pages", not a pixel-perfect page cut.
197
+ - **Some files have no page numbers.** Plain text, markdown, and similar
198
+ content has no page numbers; those chunks are returned only when you read
199
+ **without** a page range. A page-filtered read of such a file returns nothing.
200
+ - **Empty / not indexed?** If `read` reports the file is still ingesting or has
201
+ no indexed chunks, there is no extracted text to return — use `download` to
202
+ fetch the original bytes instead.
203
+
144
204
  ## Citing File Pages
145
205
 
146
206
  After reading **any** file and using its content in your answer, declare citations:
@@ -1,93 +0,0 @@
1
- """Read command: retrieve all indexed text chunks for a known content ID.
2
-
3
- Calls ``Content.search(where={"id": {"equals": cont_id}})`` — a direct
4
- Postgres lookup that returns every indexed chunk for the document in one
5
- request, no vector search involved.
6
-
7
- Use this when you already know the ``cont_*`` ID (e.g. from a prior ``ls``
8
- or ``unique-cli search`` result) and want to read the full document text.
9
- For discovery or query-based retrieval use ``unique-cli search`` instead.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import unique_sdk
15
- from unique_sdk.cli.state import ShellState
16
-
17
- READ_ERROR_PREFIX = "read:"
18
-
19
-
20
- def cmd_read(state: ShellState, cont_id: str) -> str:
21
- """Return all indexed text chunks for *cont_id* as plain text.
22
-
23
- Args:
24
- state: Shell state carrying user/company credentials.
25
- cont_id: A content ID (``cont_...``) to retrieve.
26
-
27
- Returns:
28
- A formatted string of chunks, or an error message prefixed with
29
- ``read:``.
30
- """
31
- if not cont_id.startswith("cont_"):
32
- return f"{READ_ERROR_PREFIX} expected a content ID starting with 'cont_', got: {cont_id!r}"
33
-
34
- # Enforce the same .unique-search.json workspace boundary as search/ls/rm.
35
- # Content.search has no scopeIds param, so we guard by owner scope before
36
- # the point-lookup — matching rm/mv, not search's API-level scopeIds filter.
37
- if not state.is_content_within_workspace(cont_id):
38
- return f"{READ_ERROR_PREFIX} permission denied (outside workspace scope)"
39
-
40
- try:
41
- results = unique_sdk.Content.search(
42
- user_id=state.config.user_id,
43
- company_id=state.config.company_id,
44
- where={"id": {"equals": cont_id}},
45
- )
46
- except unique_sdk.APIError as e:
47
- return f"{READ_ERROR_PREFIX} {e}"
48
-
49
- if not results:
50
- return f"{READ_ERROR_PREFIX} no content found for ID: {cont_id}"
51
-
52
- content = results[0]
53
- title = getattr(content, "title", None) or getattr(content, "key", None) or cont_id
54
- chunks = getattr(content, "chunks", None) or []
55
-
56
- if not chunks:
57
- return (
58
- f"Content: {title} ({cont_id})\n"
59
- "No indexed chunks found — the document may still be ingesting or ingestion failed."
60
- )
61
-
62
- sorted_chunks = sorted(chunks, key=lambda c: c.get("order") or 0)
63
-
64
- lines: list[str] = [
65
- f"Content: {title} ({cont_id}) — {len(sorted_chunks)} chunk(s)\n"
66
- ]
67
- for chunk in sorted_chunks:
68
- text = (chunk.get("text") or "").strip()
69
- if not text:
70
- continue
71
- start = chunk.get("startPage")
72
- end = chunk.get("endPage")
73
- if start is not None or end is not None:
74
- page_start = start if start is not None else end
75
- page_end = end if end is not None else start
76
- if page_start is not None and page_end is not None:
77
- page_ref = (
78
- f"[p.{page_start}]"
79
- if page_start == page_end
80
- else f"[p.{page_start}-{page_end}]"
81
- )
82
- lines.append(f"{page_ref} {text}")
83
- else:
84
- lines.append(text)
85
- else:
86
- lines.append(text)
87
-
88
- return "\n\n".join(lines)
89
-
90
-
91
- def is_error_output(output: str) -> bool:
92
- """Return ``True`` when *output* is an error message from ``cmd_read``."""
93
- return output.startswith(READ_ERROR_PREFIX)