unique-sdk 2026.24.0.dev2__tar.gz → 2026.24.0.dev4__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.24.0.dev2 → unique_sdk-2026.24.0.dev4}/PKG-INFO +1 -1
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/pyproject.toml +1 -1
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_unique_response.py +4 -1
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/cli.py +11 -3
- unique_sdk-2026.24.0.dev4/unique_sdk/cli/commands/_citation_manifest.py +185 -0
- unique_sdk-2026.24.0.dev4/unique_sdk/cli/commands/search.py +262 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/web_search.py +134 -4
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +38 -8
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +55 -26
- unique_sdk-2026.24.0.dev2/unique_sdk/cli/commands/search.py +0 -114
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/README.md +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/__init__.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_api_requestor.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_api_resource.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_api_version.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_error.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_http_client.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_list_object.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_object_classes.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_request_options.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_unique_object.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_unique_ql.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_util.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_version.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/_webhook.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/__init__.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_acronyms.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_agentic_table.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_analytics_order.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_benchmarking.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_briefing.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_chat_completion.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_content.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_elicitation.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_embedding.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_event.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_folder.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_group.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_integrated.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_llm_models.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_mcp.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_message.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_message_assessment.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_message_execution.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_message_log.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_message_tool.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_module.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_scheduled_task.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_search.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_search_string.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_short_term_memory.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_space.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_user.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/api_resources/_web_search.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/__init__.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/__main__.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/__init__.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/elicitation.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/files.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/folders.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/mcp.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/navigation.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/subagent.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/web_search_config.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/config.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/formatting.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/shell.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/state.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/utils/analytics_order_run.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/utils/benchmarking_run.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/utils/chat_history.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/utils/chat_in_space.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/utils/file_io.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/utils/sources.py +0 -0
- {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/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.24.0.
|
|
3
|
+
Version: 2026.24.0.dev4
|
|
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>
|
|
@@ -19,4 +19,7 @@ class UniqueResponse(UniqueResponseBase):
|
|
|
19
19
|
def __init__(self, body: str, code: int, headers: Mapping[str, str]):
|
|
20
20
|
UniqueResponseBase.__init__(self, code, headers)
|
|
21
21
|
self.body = body
|
|
22
|
-
|
|
22
|
+
if not body or body.isspace():
|
|
23
|
+
self.data = OrderedDict()
|
|
24
|
+
else:
|
|
25
|
+
self.data = json.loads(body, object_pairs_hook=OrderedDict)
|
|
@@ -26,7 +26,12 @@ from unique_sdk.cli.commands.scheduled_tasks import (
|
|
|
26
26
|
cmd_schedule_list,
|
|
27
27
|
cmd_schedule_update,
|
|
28
28
|
)
|
|
29
|
-
from unique_sdk.cli.commands.search import
|
|
29
|
+
from unique_sdk.cli.commands.search import (
|
|
30
|
+
cmd_search,
|
|
31
|
+
)
|
|
32
|
+
from unique_sdk.cli.commands.search import (
|
|
33
|
+
is_error_output as _is_search_error_output,
|
|
34
|
+
)
|
|
30
35
|
from unique_sdk.cli.commands.subagent import cmd_subagent
|
|
31
36
|
from unique_sdk.cli.commands.subagent import (
|
|
32
37
|
is_error_output as _is_subagent_error_output,
|
|
@@ -390,9 +395,12 @@ def search(
|
|
|
390
395
|
k, v = kv.split("=", 1)
|
|
391
396
|
parsed_metadata.append((k, v))
|
|
392
397
|
|
|
393
|
-
|
|
394
|
-
|
|
398
|
+
output = cmd_search(
|
|
399
|
+
state, query, folder=folder, metadata=parsed_metadata, limit=limit
|
|
395
400
|
)
|
|
401
|
+
click.echo(output)
|
|
402
|
+
if _is_search_error_output(output):
|
|
403
|
+
ctx.exit(1)
|
|
396
404
|
|
|
397
405
|
|
|
398
406
|
@main.command()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Shared helpers for the per-turn citation refs manifest under ``.unique/``.
|
|
2
|
+
|
|
3
|
+
Both ``unique-cli search`` (KB) and ``unique-cli web-search`` (web) append
|
|
4
|
+
to a small per-turn JSONL file inside the current workspace's ``.unique``
|
|
5
|
+
directory so that the Swappable Intelligence runner can later substitute
|
|
6
|
+
``[sourceN]`` / ``[websourceN]`` markers in the LLM answer with footnotes
|
|
7
|
+
and reference chips.
|
|
8
|
+
|
|
9
|
+
The handshake is symmetric across the two callers — the file name, lock
|
|
10
|
+
file name, and on-disk layout differ, but the safety + locking +
|
|
11
|
+
read/append semantics are identical. This module owns that machinery so
|
|
12
|
+
neither caller has to maintain its own copy.
|
|
13
|
+
|
|
14
|
+
Each caller passes its own ``refs_log_path`` (absolute path) and lock
|
|
15
|
+
filename in. Nothing in this module is web- or KB-specific.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import fcntl
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from collections.abc import Generator
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"UnsafeRefsLogPathError",
|
|
30
|
+
"_append_turn_refs_manifest_entry",
|
|
31
|
+
"_locked_turn_refs_manifest",
|
|
32
|
+
"_read_turn_refs_manifest",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnsafeRefsLogPathError(OSError):
|
|
37
|
+
"""Raised when the internal refs log path would follow a symlink.
|
|
38
|
+
|
|
39
|
+
Callers should treat this as a hard failure for the current command —
|
|
40
|
+
refuse to render results, and surface the error via the caller's
|
|
41
|
+
usual ``<prefix>: <message>`` error string convention.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _assert_safe_refs_log_path(refs_log_path: Path) -> None:
|
|
46
|
+
"""Reject symlinks for the parent dir, the manifest, or any file at
|
|
47
|
+
the same path that is not a regular file.
|
|
48
|
+
|
|
49
|
+
This is intentionally strict: the manifest path is a fixed,
|
|
50
|
+
well-known handoff path used by another process (the runner) running
|
|
51
|
+
in the same workspace. Following symlinks here would let a malicious
|
|
52
|
+
workspace redirect writes elsewhere.
|
|
53
|
+
|
|
54
|
+
Writer-side counterpart to the reader-side check at
|
|
55
|
+
``swappable_intelligence.runner.CodingAgentRunner._is_safe_refs_log_path``
|
|
56
|
+
in ``monorepo``. The two run in different processes (this one in the
|
|
57
|
+
agent's bash sandbox, the reader in assistants-core) so they cannot
|
|
58
|
+
share code; keep the rejection policy aligned across the two when
|
|
59
|
+
either is edited.
|
|
60
|
+
"""
|
|
61
|
+
refs_log_dir = refs_log_path.parent
|
|
62
|
+
if refs_log_dir.is_symlink() or (
|
|
63
|
+
refs_log_dir.exists() and not refs_log_dir.is_dir()
|
|
64
|
+
):
|
|
65
|
+
raise UnsafeRefsLogPathError(
|
|
66
|
+
f"refusing unsafe refs log directory: {refs_log_dir}"
|
|
67
|
+
)
|
|
68
|
+
if refs_log_path.is_symlink():
|
|
69
|
+
raise UnsafeRefsLogPathError(f"refusing unsafe refs log file: {refs_log_path}")
|
|
70
|
+
if refs_log_path.exists() and not refs_log_path.is_file():
|
|
71
|
+
raise UnsafeRefsLogPathError(f"refusing unsafe refs log file: {refs_log_path}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_turn_refs_manifest(refs_log_path: Path) -> list[dict[str, Any]]:
|
|
75
|
+
"""Return all valid JSON object lines from the manifest, in order.
|
|
76
|
+
|
|
77
|
+
Blank lines and lines that fail to parse as a JSON object are
|
|
78
|
+
silently skipped — this matches the runner's tolerance for malformed
|
|
79
|
+
lines and keeps a stray partial write from breaking the next call.
|
|
80
|
+
"""
|
|
81
|
+
_assert_safe_refs_log_path(refs_log_path)
|
|
82
|
+
if not refs_log_path.is_file():
|
|
83
|
+
return []
|
|
84
|
+
try:
|
|
85
|
+
raw_lines = refs_log_path.read_text(encoding="utf-8").splitlines()
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise UnsafeRefsLogPathError(
|
|
88
|
+
f"failed to read refs log: {refs_log_path}"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
entries: list[dict[str, Any]] = []
|
|
92
|
+
for raw in raw_lines:
|
|
93
|
+
if not raw.strip():
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
payload = json.loads(raw)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
continue
|
|
99
|
+
if isinstance(payload, dict):
|
|
100
|
+
entries.append(payload)
|
|
101
|
+
return entries
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _append_turn_refs_manifest_entry(
|
|
105
|
+
refs_log_path: Path,
|
|
106
|
+
payload: dict[str, Any],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Append one JSON object as a single line, creating parents if needed.
|
|
109
|
+
|
|
110
|
+
Uses ``O_NOFOLLOW`` and a 0o600 mode so a symlink swap between the
|
|
111
|
+
safety check and the open call still fails closed.
|
|
112
|
+
"""
|
|
113
|
+
_assert_safe_refs_log_path(refs_log_path)
|
|
114
|
+
try:
|
|
115
|
+
refs_log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise UnsafeRefsLogPathError(
|
|
118
|
+
f"failed to create refs log directory: {refs_log_path.parent}"
|
|
119
|
+
) from exc
|
|
120
|
+
_assert_safe_refs_log_path(refs_log_path)
|
|
121
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
122
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
123
|
+
try:
|
|
124
|
+
fd = os.open(refs_log_path, flags, 0o600)
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
raise UnsafeRefsLogPathError(
|
|
127
|
+
f"failed to open refs log safely: {refs_log_path}"
|
|
128
|
+
) from exc
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(fd, "a", encoding="utf-8") as fp:
|
|
131
|
+
fp.write(json.dumps(payload, default=str, ensure_ascii=False) + "\n")
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
raise UnsafeRefsLogPathError(
|
|
134
|
+
f"failed to write refs log: {refs_log_path}"
|
|
135
|
+
) from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@contextmanager
|
|
139
|
+
def _locked_turn_refs_manifest(
|
|
140
|
+
refs_log_path: Path,
|
|
141
|
+
*,
|
|
142
|
+
lock_filename: str,
|
|
143
|
+
) -> Generator[None, None, None]:
|
|
144
|
+
"""Hold an ``fcntl.flock`` on a sibling lock file for the duration of
|
|
145
|
+
a read-existing → compute-next-number → append-new sequence.
|
|
146
|
+
|
|
147
|
+
The lock file lives in the same ``.unique`` directory as the manifest
|
|
148
|
+
so concurrent ``unique-cli`` invocations from the same workspace
|
|
149
|
+
serialise their appends and never race the source-number counter.
|
|
150
|
+
"""
|
|
151
|
+
lock_path = refs_log_path.parent / lock_filename
|
|
152
|
+
_assert_safe_refs_log_path(lock_path)
|
|
153
|
+
try:
|
|
154
|
+
refs_log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
raise UnsafeRefsLogPathError(
|
|
157
|
+
f"failed to create refs log directory: {refs_log_path.parent}"
|
|
158
|
+
) from exc
|
|
159
|
+
_assert_safe_refs_log_path(lock_path)
|
|
160
|
+
|
|
161
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
162
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
163
|
+
try:
|
|
164
|
+
fd = os.open(lock_path, flags, 0o600)
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
raise UnsafeRefsLogPathError(
|
|
167
|
+
f"failed to open refs lock safely: {lock_path}"
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
os.close(fd)
|
|
174
|
+
raise UnsafeRefsLogPathError(
|
|
175
|
+
f"failed to lock refs manifest: {lock_path}"
|
|
176
|
+
) from exc
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
yield
|
|
180
|
+
finally:
|
|
181
|
+
try:
|
|
182
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
os.close(fd)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Search command: combined KB search with folder/metadata filters.
|
|
2
|
+
|
|
3
|
+
Always emits results wrapped in ``<sourceN>...</sourceN>`` blocks and
|
|
4
|
+
appends a per-turn ContentChunk-shaped manifest at
|
|
5
|
+
``<cwd>/.unique/kb-search-refs.jsonl``. The Swappable Intelligence
|
|
6
|
+
runner reads that manifest after the turn to convert ``[sourceN]``
|
|
7
|
+
markers in the LLM answer into ``<sup>N</sup>`` footnotes plus
|
|
8
|
+
clickable reference chips on the Unique platform.
|
|
9
|
+
|
|
10
|
+
The manifest format is identical to the legacy ``bundled_skills/kb-search``
|
|
11
|
+
``search.py`` from the monorepo — that bundle is being retired in favor of
|
|
12
|
+
this CLI command.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import unique_sdk
|
|
21
|
+
from unique_sdk import UQLCombinator, UQLOperator
|
|
22
|
+
from unique_sdk.cli.commands._citation_manifest import (
|
|
23
|
+
UnsafeRefsLogPathError,
|
|
24
|
+
_append_turn_refs_manifest_entry,
|
|
25
|
+
_locked_turn_refs_manifest,
|
|
26
|
+
_read_turn_refs_manifest,
|
|
27
|
+
)
|
|
28
|
+
from unique_sdk.cli.state import ShellState
|
|
29
|
+
|
|
30
|
+
DEFAULT_LIMIT = 200
|
|
31
|
+
|
|
32
|
+
SEARCH_ERROR_PREFIX = "search:"
|
|
33
|
+
|
|
34
|
+
_REFS_LOG_RELATIVE_PATH = Path(".unique") / "kb-search-refs.jsonl"
|
|
35
|
+
_LOCK_FILENAME = "kb-search-refs.lock"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_metadata_filter(
|
|
39
|
+
folder_scope_id: str | None,
|
|
40
|
+
extra_metadata: list[tuple[str, str]] | None,
|
|
41
|
+
) -> dict[str, Any] | None:
|
|
42
|
+
"""Build a UniqueQL metadata filter from folder scope and key=value pairs."""
|
|
43
|
+
conditions: list[dict[str, Any]] = []
|
|
44
|
+
|
|
45
|
+
if folder_scope_id:
|
|
46
|
+
conditions.append(
|
|
47
|
+
{
|
|
48
|
+
"path": ["folderIdPath"],
|
|
49
|
+
"operator": UQLOperator.CONTAINS,
|
|
50
|
+
"value": f"uniquepathid://{folder_scope_id}",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if extra_metadata:
|
|
55
|
+
for key, value in extra_metadata:
|
|
56
|
+
conditions.append(
|
|
57
|
+
{
|
|
58
|
+
"path": [key],
|
|
59
|
+
"operator": UQLOperator.EQUALS,
|
|
60
|
+
"value": value,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not conditions:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if len(conditions) == 1:
|
|
68
|
+
return conditions[0]
|
|
69
|
+
|
|
70
|
+
return {UQLCombinator.AND: conditions}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_folder_to_scope_id(state: ShellState, folder: str) -> str:
|
|
74
|
+
"""Resolve a folder path or scope ID to a scope ID."""
|
|
75
|
+
if folder.startswith("scope_"):
|
|
76
|
+
return folder
|
|
77
|
+
|
|
78
|
+
if not folder.startswith("/"):
|
|
79
|
+
folder = f"{state.cwd.rstrip('/')}/{folder}"
|
|
80
|
+
|
|
81
|
+
info = unique_sdk.Folder.get_info(
|
|
82
|
+
user_id=state.config.user_id,
|
|
83
|
+
company_id=state.config.company_id,
|
|
84
|
+
folderPath=folder,
|
|
85
|
+
)
|
|
86
|
+
scope_id = info.get("id")
|
|
87
|
+
if not scope_id:
|
|
88
|
+
raise ValueError(f"Could not resolve folder: {folder}")
|
|
89
|
+
return scope_id
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_source_block(source_number: int, result: Any) -> str:
|
|
93
|
+
"""Render one search result as a ``<sourceN>...</sourceN>`` block.
|
|
94
|
+
|
|
95
|
+
Mirrors the legacy ``unique_ai`` source format that the Swappable
|
|
96
|
+
Intelligence runner's reference post-processor recognises — ``<sourceN>``
|
|
97
|
+
open/close tags with ``<|document|>``, ``<|page|>``, ``<|info|>``
|
|
98
|
+
sub-sections. The chunk-level metadata (startPage, endPage, url, etc.)
|
|
99
|
+
is captured in full in the JSONL manifest for the runner to consume;
|
|
100
|
+
only the human-relevant bits appear in the on-screen block.
|
|
101
|
+
"""
|
|
102
|
+
title = (
|
|
103
|
+
getattr(result, "title", None)
|
|
104
|
+
or getattr(result, "key", None)
|
|
105
|
+
or f"content {getattr(result, 'id', '?')}"
|
|
106
|
+
)
|
|
107
|
+
content_id = getattr(result, "id", "") or ""
|
|
108
|
+
text = getattr(result, "text", "") or ""
|
|
109
|
+
start_page = getattr(result, "startPage", 0) or 0
|
|
110
|
+
end_page = getattr(result, "endPage", 0) or 0
|
|
111
|
+
|
|
112
|
+
sections: list[str] = []
|
|
113
|
+
sections.append(f"<|document|>{title}</|document|>")
|
|
114
|
+
if start_page and end_page and start_page > 0 and end_page > 0:
|
|
115
|
+
page_range = (
|
|
116
|
+
str(start_page) if start_page == end_page else f"{start_page}-{end_page}"
|
|
117
|
+
)
|
|
118
|
+
sections.append(f"<|page|>{page_range}</|page|>")
|
|
119
|
+
if content_id:
|
|
120
|
+
sections.append(f"<|info|>{content_id}</|info|>")
|
|
121
|
+
sections.append(text.strip())
|
|
122
|
+
|
|
123
|
+
body = "\n".join(sections)
|
|
124
|
+
return f"<source{source_number}>\n{body}\n</source{source_number}>"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _result_to_chunk_payload(result: Any) -> dict[str, Any]:
|
|
128
|
+
"""Convert a ``unique_sdk.Search`` result into a ContentChunk-shaped dict.
|
|
129
|
+
|
|
130
|
+
Keys are camelCase aliases of ``unique_toolkit.content.ContentChunk``
|
|
131
|
+
fields so the runner can rehydrate them via
|
|
132
|
+
``ContentChunk.model_validate(...)``. All keys are emitted (with
|
|
133
|
+
``None`` when absent) to keep the per-line shape stable.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def _get(name: str, default: Any = None) -> Any:
|
|
137
|
+
return getattr(result, name, default)
|
|
138
|
+
|
|
139
|
+
metadata = _get("metadata")
|
|
140
|
+
return {
|
|
141
|
+
"id": _get("id", "") or "",
|
|
142
|
+
"chunkId": _get("chunkId"),
|
|
143
|
+
"text": _get("text", "") or "",
|
|
144
|
+
"order": _get("order", 0) or 0,
|
|
145
|
+
"key": _get("key"),
|
|
146
|
+
"url": _get("url"),
|
|
147
|
+
"title": _get("title"),
|
|
148
|
+
"startPage": _get("startPage"),
|
|
149
|
+
"endPage": _get("endPage"),
|
|
150
|
+
"metadata": metadata if isinstance(metadata, dict) else None,
|
|
151
|
+
"createdAt": _get("createdAt"),
|
|
152
|
+
"updatedAt": _get("updatedAt"),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _format_results_with_citations(
|
|
157
|
+
results: list[Any],
|
|
158
|
+
*,
|
|
159
|
+
refs_log_path: Path,
|
|
160
|
+
) -> str:
|
|
161
|
+
"""Number, format, and persist each result inside one locked critical section.
|
|
162
|
+
|
|
163
|
+
Reads the existing manifest under lock so ``sourceN`` numbering keeps
|
|
164
|
+
growing across multiple ``unique-cli search`` invocations within the
|
|
165
|
+
same turn — the runner truncates the manifest at turn start, so the
|
|
166
|
+
first call in a turn starts at 1.
|
|
167
|
+
"""
|
|
168
|
+
if not results:
|
|
169
|
+
return "No results found."
|
|
170
|
+
|
|
171
|
+
with _locked_turn_refs_manifest(refs_log_path, lock_filename=_LOCK_FILENAME):
|
|
172
|
+
existing_entries = _read_turn_refs_manifest(refs_log_path)
|
|
173
|
+
starting_number = len(existing_entries) + 1
|
|
174
|
+
|
|
175
|
+
blocks: list[str] = []
|
|
176
|
+
for offset, result in enumerate(results):
|
|
177
|
+
source_number = starting_number + offset
|
|
178
|
+
blocks.append(_format_source_block(source_number, result))
|
|
179
|
+
_append_turn_refs_manifest_entry(
|
|
180
|
+
refs_log_path,
|
|
181
|
+
_result_to_chunk_payload(result),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return f"Found {len(results)} result(s):\n\n" + "\n\n".join(blocks)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_search(
|
|
188
|
+
state: ShellState,
|
|
189
|
+
query: str,
|
|
190
|
+
folder: str | None = None,
|
|
191
|
+
metadata: list[tuple[str, str]] | None = None,
|
|
192
|
+
limit: int = DEFAULT_LIMIT,
|
|
193
|
+
*,
|
|
194
|
+
refs_log_path: Path | None = None,
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Execute a combined search with optional folder and metadata filters.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
state: Shell state carrying user/company credentials and cwd.
|
|
200
|
+
query: Search string.
|
|
201
|
+
folder: Optional folder path, name, or scope ID. Overrides
|
|
202
|
+
``state.scope_id`` and ``state.workspace_scope_ids``.
|
|
203
|
+
metadata: Optional ``[(key, value), ...]`` metadata filters
|
|
204
|
+
combined with AND.
|
|
205
|
+
limit: Maximum number of results.
|
|
206
|
+
refs_log_path: Override the per-turn citation manifest path —
|
|
207
|
+
for tests; production callers leave this ``None`` so the
|
|
208
|
+
manifest lives at ``<cwd>/.unique/kb-search-refs.jsonl`` and
|
|
209
|
+
the Swappable Intelligence runner can find it.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
folder_scope_id: str | None = None
|
|
213
|
+
if folder:
|
|
214
|
+
folder_scope_id = _resolve_folder_to_scope_id(state, folder)
|
|
215
|
+
elif state.scope_id:
|
|
216
|
+
folder_scope_id = state.scope_id
|
|
217
|
+
|
|
218
|
+
scope_ids: list[str] | None = None
|
|
219
|
+
if folder_scope_id:
|
|
220
|
+
scope_ids = [folder_scope_id]
|
|
221
|
+
elif not folder and state.workspace_scope_ids:
|
|
222
|
+
scope_ids = state.workspace_scope_ids
|
|
223
|
+
|
|
224
|
+
metadata_filter = _build_metadata_filter(
|
|
225
|
+
folder_scope_id if metadata else None,
|
|
226
|
+
metadata,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
search_params: dict[str, Any] = {
|
|
230
|
+
"searchString": query,
|
|
231
|
+
"searchType": "COMBINED",
|
|
232
|
+
"limit": limit,
|
|
233
|
+
}
|
|
234
|
+
if scope_ids:
|
|
235
|
+
search_params["scopeIds"] = scope_ids
|
|
236
|
+
if metadata_filter:
|
|
237
|
+
search_params["metaDataFilter"] = metadata_filter
|
|
238
|
+
|
|
239
|
+
results = unique_sdk.Search.create(
|
|
240
|
+
user_id=state.config.user_id,
|
|
241
|
+
company_id=state.config.company_id,
|
|
242
|
+
**search_params,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
except (ValueError, unique_sdk.APIError) as e:
|
|
246
|
+
return f"{SEARCH_ERROR_PREFIX} {e}"
|
|
247
|
+
|
|
248
|
+
log_path = refs_log_path or (Path.cwd() / _REFS_LOG_RELATIVE_PATH)
|
|
249
|
+
try:
|
|
250
|
+
return _format_results_with_citations(results, refs_log_path=log_path)
|
|
251
|
+
except UnsafeRefsLogPathError as exc:
|
|
252
|
+
return f"{SEARCH_ERROR_PREFIX} {exc}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def is_error_output(output: str) -> bool:
|
|
256
|
+
"""Return ``True`` when ``output`` is a CLI error message.
|
|
257
|
+
|
|
258
|
+
Mirrors :func:`unique_sdk.cli.commands.web_search.is_error_output` so
|
|
259
|
+
the Click layer can translate a returned error string into a non-zero
|
|
260
|
+
exit code without changing the existing string-returning contract.
|
|
261
|
+
"""
|
|
262
|
+
return output.startswith(SEARCH_ERROR_PREFIX)
|
{unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev4}/unique_sdk/cli/commands/web_search.py
RENAMED
|
@@ -18,9 +18,17 @@ Override precedence (highest first):
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import json
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
21
23
|
from typing import Any, cast
|
|
22
24
|
|
|
23
25
|
import unique_sdk
|
|
26
|
+
from unique_sdk.cli.commands._citation_manifest import (
|
|
27
|
+
UnsafeRefsLogPathError,
|
|
28
|
+
_append_turn_refs_manifest_entry,
|
|
29
|
+
_locked_turn_refs_manifest,
|
|
30
|
+
_read_turn_refs_manifest,
|
|
31
|
+
)
|
|
24
32
|
from unique_sdk.cli.commands.web_search_config import (
|
|
25
33
|
ConfigOverrides,
|
|
26
34
|
WebSearchCLIConfigError,
|
|
@@ -28,14 +36,124 @@ from unique_sdk.cli.commands.web_search_config import (
|
|
|
28
36
|
)
|
|
29
37
|
from unique_sdk.cli.state import ShellState
|
|
30
38
|
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
|
|
31
41
|
DEFAULT_PARALLEL = 10
|
|
32
42
|
_SNIPPET_PREVIEW_LIMIT = 200
|
|
33
43
|
_CONTENT_PREVIEW_LIMIT = 500
|
|
44
|
+
_WEB_REFS_LOG_RELATIVE_PATH = Path(".unique") / "web-refs.jsonl"
|
|
45
|
+
_WEB_REFS_LOCK_FILENAME = "web-refs.lock"
|
|
34
46
|
|
|
35
47
|
WEB_SEARCH_ERROR_PREFIX = "web-search:"
|
|
36
48
|
WEB_CRAWL_ERROR_PREFIX = "web-crawl:"
|
|
37
49
|
|
|
38
50
|
|
|
51
|
+
def _source_numbers_by_url(entries: list[dict[str, Any]]) -> dict[str, int]:
|
|
52
|
+
by_url: dict[str, int] = {}
|
|
53
|
+
for entry in entries:
|
|
54
|
+
url = entry.get("url")
|
|
55
|
+
source_number = entry.get("sourceNumber")
|
|
56
|
+
if isinstance(url, str) and isinstance(source_number, int):
|
|
57
|
+
by_url.setdefault(url.strip(), source_number)
|
|
58
|
+
return by_url
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _next_source_number(entries: list[dict[str, Any]]) -> int:
|
|
62
|
+
source_numbers = [
|
|
63
|
+
entry["sourceNumber"]
|
|
64
|
+
for entry in entries
|
|
65
|
+
if isinstance(entry.get("sourceNumber"), int)
|
|
66
|
+
]
|
|
67
|
+
return max(source_numbers, default=0) + 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _annotate_web_results_for_citations(
|
|
71
|
+
payload: dict[str, Any],
|
|
72
|
+
*,
|
|
73
|
+
refs_log_path: Path | None = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Add per-turn web citation numbers and append the refs manifest.
|
|
76
|
+
|
|
77
|
+
Web results are deduped by URL: the same URL keeps the same
|
|
78
|
+
``sourceNumber`` across consecutive ``search`` / ``crawl`` calls in
|
|
79
|
+
the same turn, so the crawled-content row carries the same citation
|
|
80
|
+
marker the search-snippet row already advertised.
|
|
81
|
+
"""
|
|
82
|
+
refs_log_path = refs_log_path or (Path.cwd() / _WEB_REFS_LOG_RELATIVE_PATH)
|
|
83
|
+
with _locked_turn_refs_manifest(
|
|
84
|
+
refs_log_path, lock_filename=_WEB_REFS_LOCK_FILENAME
|
|
85
|
+
):
|
|
86
|
+
entries = _read_turn_refs_manifest(refs_log_path)
|
|
87
|
+
source_numbers_by_url = _source_numbers_by_url(entries)
|
|
88
|
+
annotated = dict(payload)
|
|
89
|
+
annotated_results: list[dict[str, Any]] = []
|
|
90
|
+
|
|
91
|
+
for raw_result in payload.get("results") or []:
|
|
92
|
+
if not isinstance(raw_result, dict):
|
|
93
|
+
_LOGGER.warning(
|
|
94
|
+
"skipping non-dict web result while annotating citations: %r",
|
|
95
|
+
raw_result,
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
result = dict(raw_result)
|
|
99
|
+
url = str(result.get("url") or "").strip()
|
|
100
|
+
if not url:
|
|
101
|
+
annotated_results.append(result)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
source_number = source_numbers_by_url.get(url)
|
|
105
|
+
if source_number is None:
|
|
106
|
+
source_number = _next_source_number(entries)
|
|
107
|
+
source_numbers_by_url[url] = source_number
|
|
108
|
+
entries.append({"sourceNumber": source_number, "url": url})
|
|
109
|
+
|
|
110
|
+
result["sourceNumber"] = source_number
|
|
111
|
+
result["citation"] = f"websource{source_number}"
|
|
112
|
+
manifest_entry = {
|
|
113
|
+
"sourceNumber": source_number,
|
|
114
|
+
"url": url,
|
|
115
|
+
"title": result.get("title"),
|
|
116
|
+
"snippet": result.get("snippet"),
|
|
117
|
+
"content": result.get("content"),
|
|
118
|
+
"error": result.get("error"),
|
|
119
|
+
}
|
|
120
|
+
_append_turn_refs_manifest_entry(refs_log_path, manifest_entry)
|
|
121
|
+
annotated_results.append(result)
|
|
122
|
+
|
|
123
|
+
annotated["results"] = annotated_results
|
|
124
|
+
return annotated
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _row_label_for_result(result: dict[str, Any], fallback_index: int) -> int:
|
|
128
|
+
"""Return the human row label for a single result.
|
|
129
|
+
|
|
130
|
+
In the happy path ``_annotate_web_results_for_citations`` runs before
|
|
131
|
+
the formatter and stamps every dict result with an ``int``
|
|
132
|
+
``sourceNumber`` that matches the ``[websourceN]`` marker the LLM is
|
|
133
|
+
told to emit; the formatter then surfaces that same number as the row
|
|
134
|
+
label so the on-screen list and the citation namespace agree.
|
|
135
|
+
|
|
136
|
+
The ``fallback_index`` branch only fires when ``sourceNumber`` is
|
|
137
|
+
missing or non-int — i.e. a contract violation from the annotator
|
|
138
|
+
(or annotation was skipped). We never want the formatter to crash,
|
|
139
|
+
but the fallback row label deliberately *will not* match a
|
|
140
|
+
``[websourceN]`` marker, so the agent would cite a number that is
|
|
141
|
+
not in the manifest. Warn loudly so the bug is observable instead of
|
|
142
|
+
silently degrading citations.
|
|
143
|
+
"""
|
|
144
|
+
source_number = result.get("sourceNumber")
|
|
145
|
+
if isinstance(source_number, int):
|
|
146
|
+
return source_number
|
|
147
|
+
_LOGGER.warning(
|
|
148
|
+
"web result is missing a numeric `sourceNumber` after citation "
|
|
149
|
+
"annotation; falling back to row index %d. URL=%r — this row will "
|
|
150
|
+
"not be citable as [websourceN].",
|
|
151
|
+
fallback_index,
|
|
152
|
+
result.get("url"),
|
|
153
|
+
)
|
|
154
|
+
return fallback_index
|
|
155
|
+
|
|
156
|
+
|
|
39
157
|
def _format_search_results(payload: dict[str, Any]) -> str:
|
|
40
158
|
"""Render a /web-search-api/search response for terminal display."""
|
|
41
159
|
results: list[dict[str, Any]] = payload.get("results", [])
|
|
@@ -54,7 +172,10 @@ def _format_search_results(payload: dict[str, Any]) -> str:
|
|
|
54
172
|
snippet = (result.get("snippet") or "").replace("\n", " ").strip()
|
|
55
173
|
content = result.get("content") or ""
|
|
56
174
|
|
|
57
|
-
|
|
175
|
+
citation = result.get("citation")
|
|
176
|
+
citation_suffix = f" [{citation}]" if citation else ""
|
|
177
|
+
row_label = _row_label_for_result(result, i)
|
|
178
|
+
lines.append(f" {row_label}. {title}{citation_suffix}")
|
|
58
179
|
lines.append(f" {url}")
|
|
59
180
|
|
|
60
181
|
if snippet:
|
|
@@ -99,7 +220,10 @@ def _format_crawl_results(payload: dict[str, Any]) -> str:
|
|
|
99
220
|
content = entry.get("content") or ""
|
|
100
221
|
error = entry.get("error")
|
|
101
222
|
|
|
102
|
-
|
|
223
|
+
citation = entry.get("citation")
|
|
224
|
+
citation_suffix = f" [{citation}]" if citation else ""
|
|
225
|
+
row_label = _row_label_for_result(entry, i)
|
|
226
|
+
lines.append(f" {row_label}. {url}{citation_suffix}")
|
|
103
227
|
if error:
|
|
104
228
|
lines.append(f" ERROR: {error}")
|
|
105
229
|
elif content.strip():
|
|
@@ -254,7 +378,10 @@ def cmd_web_search(
|
|
|
254
378
|
except (ValueError, unique_sdk.APIError) as exc:
|
|
255
379
|
return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
|
|
256
380
|
|
|
257
|
-
|
|
381
|
+
try:
|
|
382
|
+
payload = _annotate_web_results_for_citations(_payload_from_resource(resource))
|
|
383
|
+
except UnsafeRefsLogPathError as exc:
|
|
384
|
+
return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
|
|
258
385
|
if output_json:
|
|
259
386
|
return _format_search_results_json(payload)
|
|
260
387
|
return _format_search_results(payload)
|
|
@@ -315,7 +442,10 @@ def cmd_web_crawl(
|
|
|
315
442
|
except (ValueError, unique_sdk.APIError) as exc:
|
|
316
443
|
return f"{WEB_CRAWL_ERROR_PREFIX} {exc}"
|
|
317
444
|
|
|
318
|
-
|
|
445
|
+
try:
|
|
446
|
+
payload = _annotate_web_results_for_citations(_payload_from_resource(resource))
|
|
447
|
+
except UnsafeRefsLogPathError as exc:
|
|
448
|
+
return f"{WEB_CRAWL_ERROR_PREFIX} {exc}"
|
|
319
449
|
if output_json:
|
|
320
450
|
return _format_crawl_results_json(payload)
|
|
321
451
|
return _format_crawl_results(payload)
|