unique-sdk 2026.24.0.dev2__tar.gz → 2026.24.0.dev3__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 (81) hide show
  1. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/PKG-INFO +1 -1
  2. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/pyproject.toml +1 -1
  3. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/cli.py +11 -3
  4. unique_sdk-2026.24.0.dev3/unique_sdk/cli/commands/_citation_manifest.py +185 -0
  5. unique_sdk-2026.24.0.dev3/unique_sdk/cli/commands/search.py +262 -0
  6. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/web_search.py +134 -4
  7. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +38 -8
  8. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +55 -26
  9. unique_sdk-2026.24.0.dev2/unique_sdk/cli/commands/search.py +0 -114
  10. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/README.md +0 -0
  11. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/__init__.py +0 -0
  12. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_api_requestor.py +0 -0
  13. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_api_resource.py +0 -0
  14. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_api_version.py +0 -0
  15. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_error.py +0 -0
  16. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_http_client.py +0 -0
  17. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_list_object.py +0 -0
  18. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_object_classes.py +0 -0
  19. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_request_options.py +0 -0
  20. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_unique_object.py +0 -0
  21. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_unique_ql.py +0 -0
  22. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_unique_response.py +0 -0
  23. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_util.py +0 -0
  24. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_version.py +0 -0
  25. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/_webhook.py +0 -0
  26. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/__init__.py +0 -0
  27. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_acronyms.py +0 -0
  28. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_agentic_table.py +0 -0
  29. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_analytics_order.py +0 -0
  30. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_benchmarking.py +0 -0
  31. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_briefing.py +0 -0
  32. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_chat_completion.py +0 -0
  33. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_content.py +0 -0
  34. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_elicitation.py +0 -0
  35. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_embedding.py +0 -0
  36. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_event.py +0 -0
  37. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_folder.py +0 -0
  38. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_group.py +0 -0
  39. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_integrated.py +0 -0
  40. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_llm_models.py +0 -0
  41. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_mcp.py +0 -0
  42. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_message.py +0 -0
  43. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_message_assessment.py +0 -0
  44. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_message_execution.py +0 -0
  45. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_message_log.py +0 -0
  46. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_message_tool.py +0 -0
  47. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_module.py +0 -0
  48. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  49. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_search.py +0 -0
  50. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_search_string.py +0 -0
  51. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  52. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_space.py +0 -0
  53. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_user.py +0 -0
  54. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/api_resources/_web_search.py +0 -0
  55. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/__init__.py +0 -0
  56. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/__main__.py +0 -0
  57. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/__init__.py +0 -0
  58. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/elicitation.py +0 -0
  59. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/files.py +0 -0
  60. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/folders.py +0 -0
  61. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/mcp.py +0 -0
  62. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/navigation.py +0 -0
  63. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  64. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/subagent.py +0 -0
  65. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/commands/web_search_config.py +0 -0
  66. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/config.py +0 -0
  67. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/formatting.py +0 -0
  68. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/shell.py +0 -0
  69. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  70. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  71. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  72. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  73. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  74. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/cli/state.py +0 -0
  75. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/utils/analytics_order_run.py +0 -0
  76. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/utils/benchmarking_run.py +0 -0
  77. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/utils/chat_history.py +0 -0
  78. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/utils/chat_in_space.py +0 -0
  79. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/utils/file_io.py +0 -0
  80. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/unique_sdk/utils/sources.py +0 -0
  81. {unique_sdk-2026.24.0.dev2 → unique_sdk-2026.24.0.dev3}/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.dev2
3
+ Version: 2026.24.0.dev3
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.24.0.dev2"
3
+ version = "2026.24.0.dev3"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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 cmd_search
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
- click.echo(
394
- cmd_search(state, query, folder=folder, metadata=parsed_metadata, limit=limit)
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)
@@ -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
- lines.append(f" {i}. {title}")
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
- lines.append(f" {i}. {url}")
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
- payload = _payload_from_resource(resource)
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
- payload = _payload_from_resource(resource)
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)