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.
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/PKG-INFO +1 -1
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/pyproject.toml +1 -1
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/cli.py +66 -15
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/elicitation.py +31 -3
- unique_sdk-2026.26.0.dev11/unique_sdk/cli/commands/read.py +176 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/shell.py +93 -13
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +34 -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
- unique_sdk-2026.26.0.dev9/unique_sdk/cli/commands/read.py +0 -93
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/README.md +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/__init__.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_api_requestor.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_api_resource.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_api_version.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_error.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_http_client.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_list_object.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_object_classes.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_request_options.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_unique_object.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_unique_ql.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_unique_response.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_util.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_version.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/_webhook.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/__init__.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_acronyms.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_agentic_table.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_analytics_order.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_benchmarking.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_briefing.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_chat_completion.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_content.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_elicitation.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_embedding.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_event.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_folder.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_group.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_integrated.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_llm_models.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_mcp.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_assessment.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_execution.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_log.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_tool.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_module.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_scheduled_task.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_search.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_search_string.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_short_term_memory.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_space.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_user.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_web_search.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/__init__.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/__main__.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/__init__.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/cite_file.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/files.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/folders.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/mcp.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/navigation.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/search.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/subagent.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/web_search.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/web_search_config.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/config.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/formatting.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/state.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/analytics_order_run.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/benchmarking_run.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/chat_history.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/chat_in_space.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/file_io.py +0 -0
- {unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/sources.py +0 -0
- {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.
|
|
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>
|
|
@@ -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(
|
|
403
|
-
|
|
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
|
-
|
|
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=
|
|
1066
|
+
default=DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
1018
1067
|
show_default=True,
|
|
1019
|
-
help=
|
|
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=
|
|
1350
|
+
default=DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
1300
1351
|
show_default=True,
|
|
1301
1352
|
help="Max seconds to wait for a terminal state.",
|
|
1302
1353
|
)
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/elicitation.py
RENAMED
|
@@ -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 =
|
|
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: {
|
|
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>
|
|
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
|
-
--
|
|
87
|
-
|
|
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:
|
|
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
|
|
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(
|
|
512
|
+
self._print(usage)
|
|
492
513
|
return
|
|
493
|
-
|
|
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
|
|
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":
|
|
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
|
-
| `--
|
|
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
|
|
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
|
-
|
|
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.
|
|
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. **
|
|
207
|
-
9. **
|
|
208
|
-
10. **
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/__init__.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_acronyms.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_agentic_table.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_benchmarking.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_briefing.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_content.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_elicitation.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_embedding.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_folder.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_integrated.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_llm_models.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_log.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_message_tool.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_module.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_scheduled_task.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_search.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_search_string.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/api_resources/_web_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/cite_file.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/dynamic_frontend.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/navigation.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/scheduled_tasks.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/subagent.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/cli/commands/web_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/analytics_order_run.py
RENAMED
|
File without changes
|
{unique_sdk-2026.26.0.dev9 → unique_sdk-2026.26.0.dev11}/unique_sdk/utils/benchmarking_run.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|