unique-sdk 2026.26.0.dev9__tar.gz → 2026.26.0.dev10__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.dev10}/PKG-INFO +1 -1
  2. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/pyproject.toml +1 -1
  3. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/cli.py +58 -3
  4. unique_sdk-2026.26.0.dev10/unique_sdk/cli/commands/read.py +176 -0
  5. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/shell.py +78 -6
  6. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +60 -0
  7. unique_sdk-2026.26.0.dev9/unique_sdk/cli/commands/read.py +0 -93
  8. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/README.md +0 -0
  9. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/__init__.py +0 -0
  10. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_api_requestor.py +0 -0
  11. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_api_resource.py +0 -0
  12. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_api_version.py +0 -0
  13. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_error.py +0 -0
  14. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_http_client.py +0 -0
  15. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_list_object.py +0 -0
  16. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_object_classes.py +0 -0
  17. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_request_options.py +0 -0
  18. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_unique_object.py +0 -0
  19. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_unique_ql.py +0 -0
  20. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_unique_response.py +0 -0
  21. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_util.py +0 -0
  22. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_version.py +0 -0
  23. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/_webhook.py +0 -0
  24. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/__init__.py +0 -0
  25. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_acronyms.py +0 -0
  26. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_agentic_table.py +0 -0
  27. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_analytics_order.py +0 -0
  28. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_benchmarking.py +0 -0
  29. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_briefing.py +0 -0
  30. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_chat_completion.py +0 -0
  31. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_content.py +0 -0
  32. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  33. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_elicitation.py +0 -0
  34. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_embedding.py +0 -0
  35. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_event.py +0 -0
  36. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_folder.py +0 -0
  37. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_group.py +0 -0
  38. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_integrated.py +0 -0
  39. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_llm_models.py +0 -0
  40. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_mcp.py +0 -0
  41. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message.py +0 -0
  42. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_assessment.py +0 -0
  43. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_execution.py +0 -0
  44. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_log.py +0 -0
  45. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_tool.py +0 -0
  46. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_module.py +0 -0
  47. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  48. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_search.py +0 -0
  49. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_search_string.py +0 -0
  50. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  51. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_space.py +0 -0
  52. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_user.py +0 -0
  53. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_web_search.py +0 -0
  54. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/__init__.py +0 -0
  55. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/__main__.py +0 -0
  56. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/__init__.py +0 -0
  57. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  58. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/cite_file.py +0 -0
  59. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  60. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/elicitation.py +0 -0
  61. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/files.py +0 -0
  62. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/folders.py +0 -0
  63. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/mcp.py +0 -0
  64. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/navigation.py +0 -0
  65. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  66. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/search.py +0 -0
  67. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/subagent.py +0 -0
  68. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/web_search.py +0 -0
  69. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/web_search_config.py +0 -0
  70. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/config.py +0 -0
  71. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/formatting.py +0 -0
  72. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  73. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  74. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  75. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  76. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  77. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  78. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/state.py +0 -0
  79. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/analytics_order_run.py +0 -0
  80. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/benchmarking_run.py +0 -0
  81. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/chat_history.py +0 -0
  82. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/chat_in_space.py +0 -0
  83. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/file_io.py +0 -0
  84. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/sources.py +0 -0
  85. {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev10}/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.dev10
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.dev10"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -398,9 +398,41 @@ def cite(
398
398
 
399
399
  @main.command(name="read")
400
400
  @click.argument("cont_id")
401
+ @click.option(
402
+ "--page",
403
+ "-p",
404
+ type=int,
405
+ default=None,
406
+ help="Read a single page (shorthand for --from-page N --to-page N).",
407
+ )
408
+ @click.option(
409
+ "--from-page",
410
+ type=int,
411
+ default=None,
412
+ help="First page to include (inclusive).",
413
+ )
414
+ @click.option(
415
+ "--to-page",
416
+ type=int,
417
+ default=None,
418
+ help="Last page to include (inclusive).",
419
+ )
420
+ @click.option(
421
+ "--max-chars",
422
+ type=int,
423
+ default=None,
424
+ help="Truncate the printed text to at most N characters.",
425
+ )
401
426
  @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.
427
+ def read_cmd(
428
+ ctx: click.Context,
429
+ cont_id: str,
430
+ page: int | None,
431
+ from_page: int | None,
432
+ to_page: int | None,
433
+ max_chars: int | None,
434
+ ) -> None:
435
+ """Read indexed text chunks for a known content ID.
404
436
 
405
437
  \b
406
438
  CONT_ID must be a content ID (cont_...) obtained from a prior `ls` or
@@ -411,11 +443,34 @@ def read_cmd(ctx: click.Context, cont_id: str) -> None:
411
443
  Use `search` when you need to find documents by topic or keyword.
412
444
  Use `read` when you already know the content ID and want the full text.
413
445
 
446
+ \b
447
+ Restrict to a page range with --page (single page) or --from-page/--to-page.
448
+ A chunk spanning pages 2-4 is returned for any overlapping request; files
449
+ without page numbers (e.g. plain text/markdown) are returned only without a
450
+ page range.
451
+
414
452
  \b
415
453
  Examples:
416
454
  unique-cli read cont_abc123
455
+ unique-cli read cont_abc123 --page 12
456
+ unique-cli read cont_abc123 --from-page 5 --to-page 9
457
+ unique-cli read cont_abc123 --to-page 3 --max-chars 8000
417
458
  """
418
- output = cmd_read(LazyState.get(ctx), cont_id)
459
+ if page is not None and (from_page is not None or to_page is not None):
460
+ click.echo(
461
+ "read: use either --page or --from-page/--to-page, not both", err=True
462
+ )
463
+ raise SystemExit(1)
464
+ if page is not None:
465
+ from_page = page
466
+ to_page = page
467
+ output = cmd_read(
468
+ LazyState.get(ctx),
469
+ cont_id,
470
+ from_page=from_page,
471
+ to_page=to_page,
472
+ max_chars=max_chars,
473
+ )
419
474
  if _is_read_error_output(output):
420
475
  click.echo(output, err=True)
421
476
  raise SystemExit(1)
@@ -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)
@@ -68,7 +68,11 @@ OVERVIEW_HELP = textwrap.dedent("""\
68
68
  --folder <path|id> Restrict to a folder
69
69
  --metadata <key=value> Filter by metadata (repeatable)
70
70
  --limit <N> Max results (default: 200)
71
- read <cont_id> Read all indexed text chunks for a content ID
71
+ read <cont_id> [options] Read indexed text chunks for a content ID
72
+ --page / -p <N> Read a single page
73
+ --from-page <N> First page (inclusive)
74
+ --to-page <N> Last page (inclusive)
75
+ --max-chars <N> Truncate output to N characters
72
76
 
73
77
  MCP:
74
78
  mcp [options] <json> Call an MCP server tool
@@ -470,27 +474,95 @@ class UniqueShell(cmd.Cmd):
470
474
  return
471
475
  self._print(cmd_cite_file(self.state, positional[0], pages))
472
476
 
477
+ def _parse_int(self, raw: str, flag: str) -> tuple[int | None, bool]:
478
+ """Parse an int option value, returning (value, ok). Prints on failure."""
479
+ try:
480
+ return int(raw), True
481
+ except ValueError:
482
+ self._print(f"Invalid {flag}: {raw} (expected an integer)")
483
+ return None, False
484
+
473
485
  def do_read(self, arg: str) -> None:
474
- """Read all indexed text chunks for a known content ID.
486
+ """Read indexed text chunks for a known content ID (optionally by page).
475
487
 
476
- Usage: read <cont_id>
488
+ Usage: read <cont_id> [--page N | --from-page N --to-page M] [--max-chars N]
477
489
 
478
490
  Retrieves every indexed chunk for the document directly from the
479
- database — no vector search, no query string needed.
491
+ database — no vector search, no query string needed. Use --page for a
492
+ single page or --from-page/--to-page for a range; a chunk spanning
493
+ pages 2-4 is returned for any overlapping request.
480
494
 
481
495
  Use `search` to find documents by topic; use `read` once you have
482
496
  the content ID and want the full text.
483
497
 
484
498
  Examples:
485
499
  /Reports> read cont_abc123
500
+ /Reports> read cont_abc123 --page 12
501
+ /Reports> read cont_abc123 --from-page 5 --to-page 9
486
502
  """
487
503
  from unique_sdk.cli.commands.read import cmd_read
488
504
 
489
505
  parts = shlex.split(arg)
506
+ usage = (
507
+ "Usage: read <cont_id> "
508
+ "[--page N | --from-page N --to-page M] [--max-chars N]"
509
+ )
490
510
  if not parts:
491
- self._print("Usage: read <cont_id>")
511
+ self._print(usage)
492
512
  return
493
- self._print(cmd_read(self.state, parts[0]))
513
+
514
+ cont_id: str | None = None
515
+ page: int | None = None
516
+ from_page: int | None = None
517
+ to_page: int | None = None
518
+ max_chars: int | None = None
519
+
520
+ int_flags = ("--page", "-p", "--from-page", "--to-page", "--max-chars")
521
+ i = 0
522
+ while i < len(parts):
523
+ tok = parts[i]
524
+ if tok in int_flags:
525
+ if i + 1 >= len(parts):
526
+ self._print(f"Missing value for {tok}")
527
+ return
528
+ value, ok = self._parse_int(parts[i + 1], tok)
529
+ if not ok:
530
+ return
531
+ if tok in ("--page", "-p"):
532
+ page = value
533
+ elif tok == "--from-page":
534
+ from_page = value
535
+ elif tok == "--to-page":
536
+ to_page = value
537
+ else: # --max-chars
538
+ max_chars = value
539
+ i += 2
540
+ elif cont_id is None:
541
+ cont_id = tok
542
+ i += 1
543
+ else:
544
+ self._print(f"Unknown argument: {tok}")
545
+ return
546
+
547
+ if cont_id is None:
548
+ self._print(usage)
549
+ return
550
+ if page is not None and (from_page is not None or to_page is not None):
551
+ self._print("read: use either --page or --from-page/--to-page, not both")
552
+ return
553
+ if page is not None:
554
+ from_page = page
555
+ to_page = page
556
+
557
+ self._print(
558
+ cmd_read(
559
+ self.state,
560
+ cont_id,
561
+ from_page=from_page,
562
+ to_page=to_page,
563
+ max_chars=max_chars,
564
+ )
565
+ )
494
566
 
495
567
  def do_rm(self, arg: str) -> None:
496
568
  """Delete a file.
@@ -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)