unique-sdk 2026.44.0.dev4__tar.gz → 2026.44.0.dev6__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 (92) hide show
  1. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/PKG-INFO +1 -1
  2. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/pyproject.toml +1 -1
  3. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/cli.py +28 -13
  4. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/files.py +168 -38
  5. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/web_search.py +174 -20
  6. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +4 -0
  7. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +52 -2
  8. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/README.md +0 -0
  9. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/__init__.py +0 -0
  10. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_api_requestor.py +0 -0
  11. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_api_resource.py +0 -0
  12. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_api_version.py +0 -0
  13. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_error.py +0 -0
  14. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_http_client.py +0 -0
  15. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_list_object.py +0 -0
  16. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_object_classes.py +0 -0
  17. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_request_options.py +0 -0
  18. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_unique_object.py +0 -0
  19. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_unique_ql.py +0 -0
  20. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_unique_response.py +0 -0
  21. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_util.py +0 -0
  22. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_version.py +0 -0
  23. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/_webhook.py +0 -0
  24. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/__init__.py +0 -0
  25. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_acronyms.py +0 -0
  26. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_agentic_table.py +0 -0
  27. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_analytics_order.py +0 -0
  28. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_benchmarking.py +0 -0
  29. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_briefing.py +0 -0
  30. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_chat_completion.py +0 -0
  31. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_content.py +0 -0
  32. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_context_memory.py +0 -0
  33. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_elicitation.py +0 -0
  34. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_embedding.py +0 -0
  35. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_event.py +0 -0
  36. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_folder.py +0 -0
  37. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_group.py +0 -0
  38. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_integrated.py +0 -0
  39. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_llm_models.py +0 -0
  40. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_mcp.py +0 -0
  41. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_message.py +0 -0
  42. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_message_assessment.py +0 -0
  43. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_message_execution.py +0 -0
  44. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_message_log.py +0 -0
  45. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_message_tool.py +0 -0
  46. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_module.py +0 -0
  47. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  48. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_search.py +0 -0
  49. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_search_string.py +0 -0
  50. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  51. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_space.py +0 -0
  52. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_user.py +0 -0
  53. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/api_resources/_web_search.py +0 -0
  54. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/__init__.py +0 -0
  55. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/__main__.py +0 -0
  56. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/__init__.py +0 -0
  57. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  58. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/agentic_table.py +0 -0
  59. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  60. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/browser.py +0 -0
  61. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/cite_file.py +0 -0
  62. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/elicitation.py +0 -0
  63. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/folders.py +0 -0
  64. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/mcp.py +0 -0
  65. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/navigation.py +0 -0
  66. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/read.py +0 -0
  67. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  68. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/search.py +0 -0
  69. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/subagent.py +0 -0
  70. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/commands/web_search_config.py +0 -0
  71. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/config.py +0 -0
  72. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/formatting.py +0 -0
  73. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/identity.py +0 -0
  74. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/metadata_filter.py +0 -0
  75. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/paths.py +0 -0
  76. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/shell.py +0 -0
  77. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  78. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  79. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  82. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  83. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  84. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/state.py +0 -0
  85. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.44.0.dev4 → unique_sdk-2026.44.0.dev6}/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.44.0.dev4
3
+ Version: 2026.44.0.dev6
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.44.0.dev4"
3
+ version = "2026.44.0.dev6"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -105,6 +105,7 @@ from unique_sdk.cli.commands.subagent import (
105
105
  from unique_sdk.cli.commands.web_search import (
106
106
  cmd_web_crawl,
107
107
  cmd_web_search,
108
+ cmd_web_search_many,
108
109
  )
109
110
  from unique_sdk.cli.commands.web_search import (
110
111
  is_error_output as _is_web_search_error_output,
@@ -2127,6 +2128,7 @@ Examples:
2127
2128
  unique-cli web-search search "quarterly earnings 2026"
2128
2129
  unique-cli web-search search "AI regulation" -n 10
2129
2130
  unique-cli web-search search "python tutorial" --json
2131
+ unique-cli web-search search "git history" "redis history" "docker history"
2130
2132
  unique-cli web-search search "sustainability" --include-content --json
2131
2133
  unique-cli web-search search "tax reform" \\
2132
2134
  --engine-config '{"searchEngineName":"Google","fetchSize":3}'
@@ -2135,7 +2137,7 @@ Examples:
2135
2137
 
2136
2138
 
2137
2139
  @web_search_group.command("search", help=_SEARCH_HELP)
2138
- @click.argument("query")
2140
+ @click.argument("queries", nargs=-1, required=True)
2139
2141
  @click.option(
2140
2142
  "--fetch-size",
2141
2143
  "-n",
@@ -2191,7 +2193,7 @@ Examples:
2191
2193
  @click.pass_context
2192
2194
  def web_search_search_cmd(
2193
2195
  ctx: click.Context,
2194
- query: str,
2196
+ queries: tuple[str, ...],
2195
2197
  fetch_size: int | None,
2196
2198
  include_content: bool,
2197
2199
  engine_config_raw: str | None,
@@ -2202,17 +2204,30 @@ def web_search_search_cmd(
2202
2204
  ) -> None:
2203
2205
  """Search the web via the Unique public API."""
2204
2206
  resolved_config = _resolve_web_search_config_path(ctx, config_path)
2205
- output = cmd_web_search(
2206
- LazyState.get(ctx),
2207
- query,
2208
- fetch_size=fetch_size,
2209
- include_content=include_content,
2210
- engine_config_raw=engine_config_raw,
2211
- crawler_config_raw=crawler_config_raw,
2212
- output_json=output_json,
2213
- config_path=resolved_config,
2214
- chat_id=_resolve_cli_chat_id(ctx, chat_id),
2215
- )
2207
+ if len(queries) == 1:
2208
+ output = cmd_web_search(
2209
+ LazyState.get(ctx),
2210
+ queries[0],
2211
+ fetch_size=fetch_size,
2212
+ include_content=include_content,
2213
+ engine_config_raw=engine_config_raw,
2214
+ crawler_config_raw=crawler_config_raw,
2215
+ output_json=output_json,
2216
+ config_path=resolved_config,
2217
+ chat_id=_resolve_cli_chat_id(ctx, chat_id),
2218
+ )
2219
+ else:
2220
+ output = cmd_web_search_many(
2221
+ LazyState.get(ctx),
2222
+ list(queries),
2223
+ fetch_size=fetch_size,
2224
+ include_content=include_content,
2225
+ engine_config_raw=engine_config_raw,
2226
+ crawler_config_raw=crawler_config_raw,
2227
+ output_json=output_json,
2228
+ config_path=resolved_config,
2229
+ chat_id=_resolve_cli_chat_id(ctx, chat_id),
2230
+ )
2216
2231
  _emit_web_search(ctx, output)
2217
2232
 
2218
2233
 
@@ -38,6 +38,12 @@ _SUPPORTED_UPLOAD_MIME_TYPES = {
38
38
  ".md": "text/markdown",
39
39
  }
40
40
 
41
+ # Bounds so one name resolve can never page through the whole knowledge base.
42
+ _MAX_RESOLVE_MATCH_CANDIDATES = 100
43
+ _MAX_RESOLVE_INFOS_PAGES = 10
44
+ _RESOLVE_INFOS_PAGE_SIZE = 100
45
+ _MAX_RESOLVE_INFOS_ENTRIES = _MAX_RESOLVE_INFOS_PAGES * _RESOLVE_INFOS_PAGE_SIZE
46
+
41
47
 
42
48
  def _normalize_unique_file_path(cwd: str, path: str) -> str:
43
49
  """Normalize a Unique file path without allowing traversal above root."""
@@ -79,6 +85,7 @@ def _resolve_content_id(
79
85
  return name_or_id, name_or_id
80
86
 
81
87
  lookup_name = name_or_id
88
+ folder_path = state.cwd
82
89
  scope_id = state.scope_id
83
90
  if "/" in name_or_id:
84
91
  unique_path = _normalize_unique_file_path(state.cwd, name_or_id)
@@ -124,16 +131,10 @@ def _resolve_content_id(
124
131
  if not scope_id:
125
132
  raise ValueError(f"folder not found: {folder_path}")
126
133
  elif scope_id is None:
127
- # A bare file name with no folder context resolves via an *unparented*
128
- # Content.get_infos scan across the whole knowledge base.
134
+ # Resolving a bare name at the root would page the whole knowledge base.
129
135
  if state.workspace_metadata_filter is not None:
130
- # With a per-message filter active, a whole-KB filename scan would
131
- # discover documents outside the task scope before the per-id gate
132
- # runs — and the deny-vs-not-found distinction is an existence/title
133
- # oracle across the task boundary. That is broader than the
134
- # ls/read-at-root gating intent, so require a bounded context
135
- # instead. The cont_ fast-path, an explicit in-scope path, or a
136
- # ``cd`` into an in-scope folder all still work. See UN-21780.
136
+ # With a per-message filter active that is also a cross-scope
137
+ # existence/title oracle. See UN-21780.
137
138
  raise ValueError(
138
139
  f"permission denied: {name_or_id} can't be resolved by name at "
139
140
  f"the knowledge-base root while a task scope is active "
@@ -141,51 +142,180 @@ def _resolve_content_id(
141
142
  "by content id, or 'cd' into an in-scope folder."
142
143
  )
143
144
  if not state.is_within_workspace():
144
- # No per-message filter: defer to the static scopeIds boundary.
145
145
  raise ValueError("permission denied (outside workspace scope)")
146
+ raise ValueError(
147
+ f"{name_or_id} can't be resolved by name at the knowledge-base "
148
+ "root. Use 'unique-cli search', pass a content id (cont_...), "
149
+ "or 'cd' into a folder first."
150
+ )
151
+
152
+ return _resolve_content_id_in_folder(
153
+ state=state,
154
+ name_or_id=name_or_id,
155
+ lookup_name=lookup_name,
156
+ folder_path=folder_path,
157
+ scope_id=scope_id,
158
+ allow_chat_files=allow_chat_files,
159
+ )
160
+
161
+
162
+ def _resolve_content_id_in_folder(
163
+ *,
164
+ state: ShellState,
165
+ name_or_id: str,
166
+ lookup_name: str,
167
+ folder_path: str,
168
+ scope_id: str,
169
+ allow_chat_files: bool,
170
+ ) -> tuple[str, str]:
171
+ """Resolve ``lookup_name`` inside one folder without paging the whole KB."""
172
+ match = _pick_match(
173
+ hits=_hits_by_file_path(
174
+ state=state,
175
+ folder_path=folder_path,
176
+ lookup_name=lookup_name,
177
+ scope_id=scope_id,
178
+ ),
179
+ lookup_name=lookup_name,
180
+ )
181
+ capped = False
182
+ if match is None:
183
+ hits, capped = _hits_by_search(
184
+ state=state, lookup_name=lookup_name, scope_id=scope_id
185
+ )
186
+ match = _pick_match(hits=hits, lookup_name=lookup_name)
187
+ if match is None:
188
+ if capped:
189
+ raise ValueError(
190
+ f"File not found in the first {_MAX_RESOLVE_INFOS_ENTRIES} "
191
+ f"entries of this folder: {name_or_id}. Use 'unique-cli "
192
+ "search' or pass a content id (cont_...)."
193
+ )
194
+ raise ValueError(f"File not found: {name_or_id}")
195
+
196
+ resolved_id, display_name = match
197
+ # Gate the *resolved* id too: resolving by file name or path must not
198
+ # bypass the per-message metadata-filter scope that the cont_ fast-path
199
+ # above enforces, and must honour the same read-only chat-file
200
+ # exemption (so rm/mv by name can't reach an out-of-scope attachment
201
+ # either). See UN-21780.
202
+ if not state.is_content_within_workspace(
203
+ resolved_id, allow_chat_files=allow_chat_files
204
+ ):
205
+ raise ValueError(
206
+ f"permission denied: {name_or_id} is outside your "
207
+ f"task scope ({state.scope_denial_hint()}). Use "
208
+ "'unique-cli search' or 'ls' within that scope "
209
+ "instead."
210
+ )
211
+ return resolved_id, display_name
212
+
213
+
214
+ def _hits_by_file_path(
215
+ *, state: ShellState, folder_path: str, lookup_name: str, scope_id: str
216
+ ) -> Sequence[Any]:
217
+ # One indexed lookup that returns no chunks and also finds files whose
218
+ # ingestion failed.
219
+ try:
220
+ result = unique_sdk.Content.get_info(
221
+ user_id=state.config.user_id,
222
+ company_id=state.config.company_id,
223
+ filePath=f"{folder_path.rstrip('/')}/{lookup_name}",
224
+ )
225
+ except unique_sdk.UniqueError:
226
+ return []
227
+ # A server that ignores filePath returns unrelated content.
228
+ return [
229
+ info
230
+ for info in result.get("contentInfo", [])
231
+ if info.get("ownerId") == scope_id
232
+ ]
233
+
234
+
235
+ def _hits_by_search(
236
+ *, state: ShellState, lookup_name: str, scope_id: str
237
+ ) -> tuple[Sequence[Any], bool]:
238
+ """Return the hits, and whether the get_infos fallback hit its page cap."""
239
+ try:
240
+ hits = unique_sdk.Content.search(
241
+ user_id=state.config.user_id,
242
+ company_id=state.config.company_id,
243
+ where={
244
+ "AND": [
245
+ {"ownerId": {"equals": scope_id}},
246
+ {
247
+ "OR": [
248
+ {"title": {"equals": lookup_name}},
249
+ {"key": {"equals": lookup_name}},
250
+ ]
251
+ },
252
+ ]
253
+ },
254
+ includeFailedContent=True,
255
+ )
256
+ except unique_sdk.UniqueError:
257
+ return _resolve_content_id_via_infos_fallback(
258
+ state=state,
259
+ lookup_name=lookup_name,
260
+ scope_id=scope_id,
261
+ )
262
+ return hits, False
263
+
146
264
 
147
- params: dict[str, Any] = {}
148
- if scope_id:
149
- params["parentId"] = scope_id
265
+ def _pick_match(*, hits: Sequence[Any], lookup_name: str) -> tuple[str, str] | None:
266
+ for content in hits[:_MAX_RESOLVE_MATCH_CANDIDATES]:
267
+ info = _as_content_fields(content)
268
+ resolved_id = info.get("id")
269
+ if not isinstance(resolved_id, str) or not resolved_id:
270
+ continue
271
+ title = info.get("title") or ""
272
+ key = info.get("key") or ""
273
+ if lookup_name in {title, key}:
274
+ return resolved_id, title or key
275
+ return None
150
276
 
151
- take = 100
277
+
278
+ def _as_content_fields(content: Any) -> Mapping[str, Any]:
279
+ if isinstance(content, Mapping):
280
+ return content
281
+ return {
282
+ "id": getattr(content, "id", None),
283
+ "title": getattr(content, "title", None),
284
+ "key": getattr(content, "key", None),
285
+ }
286
+
287
+
288
+ def _resolve_content_id_via_infos_fallback(
289
+ *,
290
+ state: ShellState,
291
+ lookup_name: str,
292
+ scope_id: str,
293
+ ) -> tuple[list[Mapping[str, Any]], bool]:
294
+ """Folder-scoped get_infos fallback with a hard page cap.
295
+
296
+ Returns the match (if any), and whether the cap stopped the scan early.
297
+ """
152
298
  skip = 0
153
- while True:
299
+ for _ in range(_MAX_RESOLVE_INFOS_PAGES):
154
300
  result = unique_sdk.Content.get_infos(
155
301
  user_id=state.config.user_id,
156
302
  company_id=state.config.company_id,
303
+ parentId=scope_id,
157
304
  skip=skip,
158
- take=take,
159
- **params,
305
+ take=_RESOLVE_INFOS_PAGE_SIZE,
160
306
  )
161
307
  content_infos = result.get("contentInfos", [])
162
308
  if not content_infos:
163
- break
164
-
309
+ return [], False
165
310
  for info in content_infos:
166
311
  title = info.get("title") or ""
167
312
  key = info.get("key") or ""
168
313
  if lookup_name in {title, key}:
169
- resolved_id = info["id"]
170
- # Gate the *resolved* id too: resolving by file name or path
171
- # must not bypass the per-message metadata-filter scope that
172
- # the cont_ fast-path above enforces, and must honour the same
173
- # read-only chat-file exemption (so rm/mv by name can't reach
174
- # an out-of-scope attachment either). See UN-21780.
175
- if not state.is_content_within_workspace(
176
- resolved_id, allow_chat_files=allow_chat_files
177
- ):
178
- raise ValueError(
179
- f"permission denied: {name_or_id} is outside your "
180
- f"task scope ({state.scope_denial_hint()}). Use "
181
- "'unique-cli search' or 'ls' within that scope "
182
- "instead."
183
- )
184
- return resolved_id, title or key
185
-
314
+ return [info], False
315
+ if len(content_infos) < _RESOLVE_INFOS_PAGE_SIZE:
316
+ return [], False
186
317
  skip += len(content_infos)
187
-
188
- raise ValueError(f"File not found: {name_or_id}")
318
+ return [], True
189
319
 
190
320
 
191
321
  def _detect_upload_mime_type(path: Path) -> str:
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  import json
21
21
  import logging
22
+ from concurrent.futures import ThreadPoolExecutor
22
23
  from pathlib import Path
23
24
  from typing import Any, cast
24
25
 
@@ -40,6 +41,8 @@ from unique_sdk.cli.workspace import manifest_path as workspace_manifest_path
40
41
  _LOGGER = logging.getLogger(__name__)
41
42
 
42
43
  DEFAULT_PARALLEL = 10
44
+ MAX_SEARCH_QUERIES = 8
45
+ SEARCH_CONCURRENCY = 4
43
46
  _SNIPPET_PREVIEW_LIMIT = 200
44
47
  _CONTENT_PREVIEW_LIMIT = 500
45
48
  _WEB_REFS_LOG_RELATIVE_PATH = Path(".unique") / "web-refs.jsonl"
@@ -231,14 +234,18 @@ def _format_search_results(payload: dict[str, Any]) -> str:
231
234
  return "\n".join(lines).rstrip()
232
235
 
233
236
 
237
+ def _search_results_projection(payload: dict[str, Any]) -> dict[str, Any]:
238
+ """Stable projection — drops the SDK envelope ``object`` discriminator."""
239
+ return {
240
+ "engine": payload.get("engine"),
241
+ "query": payload.get("query"),
242
+ "results": payload.get("results", []),
243
+ }
244
+
245
+
234
246
  def _format_search_results_json(payload: dict[str, Any]) -> str:
235
- """Stable JSON projection — drops the SDK envelope ``object`` discriminator."""
236
247
  return json.dumps(
237
- {
238
- "engine": payload.get("engine"),
239
- "query": payload.get("query"),
240
- "results": payload.get("results", []),
241
- },
248
+ _search_results_projection(payload),
242
249
  indent=2,
243
250
  ensure_ascii=False,
244
251
  )
@@ -385,6 +392,40 @@ def cmd_web_search(
385
392
  chat_id: Optional chat id this call is made on behalf of. When
386
393
  set, the space's Web Search toggle is enforced server-side.
387
394
  """
395
+ options = _search_request_options(
396
+ fetch_size=fetch_size,
397
+ include_content=include_content,
398
+ engine_config_raw=engine_config_raw,
399
+ crawler_config_raw=crawler_config_raw,
400
+ config_path=config_path,
401
+ chat_id=chat_id,
402
+ )
403
+ if isinstance(options, str):
404
+ return options
405
+
406
+ payload = _fetch_search_payload(state, query, options)
407
+ if isinstance(payload, str):
408
+ return payload
409
+
410
+ try:
411
+ payload = _annotate_web_results_for_citations(payload)
412
+ except UnsafeRefsLogPathError as exc:
413
+ return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
414
+ if output_json:
415
+ return _format_search_results_json(payload)
416
+ return _format_search_results(payload)
417
+
418
+
419
+ def _search_request_options(
420
+ *,
421
+ fetch_size: int | None,
422
+ include_content: bool,
423
+ engine_config_raw: str | None,
424
+ crawler_config_raw: str | None,
425
+ config_path: str | None,
426
+ chat_id: str | None,
427
+ ) -> dict[str, Any] | str:
428
+ """Build the request fields shared by every query, or return an error string."""
388
429
  file_overrides = _load_file_overrides(config_path)
389
430
  if isinstance(file_overrides, str):
390
431
  return file_overrides
@@ -402,34 +443,147 @@ def cmd_web_search(
402
443
  if fetch_size is None:
403
444
  fetch_size = file_overrides.fetch_size
404
445
 
405
- params: dict[str, Any] = {"query": query}
446
+ options: dict[str, Any] = {}
406
447
  if fetch_size is not None:
407
- params["fetchSize"] = fetch_size
448
+ options["fetchSize"] = fetch_size
408
449
  if include_content:
409
- params["includeContent"] = True
450
+ options["includeContent"] = True
410
451
  if engine_override is not None:
411
- params["searchEngineConfig"] = engine_override
452
+ options["searchEngineConfig"] = engine_override
412
453
  if crawler_override is not None:
413
- params["crawlerConfig"] = crawler_override
454
+ options["crawlerConfig"] = crawler_override
414
455
  if chat_id:
415
- params["chatId"] = chat_id
456
+ options["chatId"] = chat_id
457
+ return options
416
458
 
459
+
460
+ def _fetch_search_payload(
461
+ state: ShellState,
462
+ query: str,
463
+ options: dict[str, Any],
464
+ ) -> dict[str, Any] | str:
465
+ """Run one search request; return the payload, or an error string."""
417
466
  try:
418
467
  resource = unique_sdk.WebSearch.search(
419
468
  user_id=state.config.user_id,
420
469
  company_id=state.config.company_id,
421
- **params,
470
+ query=query,
471
+ **options,
422
472
  )
423
- except (ValueError, unique_sdk.APIError) as exc:
473
+ except (ValueError, unique_sdk.UniqueError) as exc:
424
474
  return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
475
+ return _payload_from_resource(resource)
476
+
477
+
478
+ def cmd_web_search_many(
479
+ state: ShellState,
480
+ queries: list[str],
481
+ fetch_size: int | None = None,
482
+ include_content: bool = False,
483
+ engine_config_raw: str | None = None,
484
+ crawler_config_raw: str | None = None,
485
+ output_json: bool = False,
486
+ config_path: str | None = None,
487
+ chat_id: str | None = None,
488
+ ) -> str:
489
+ """Run several web searches in one call and return one block per query.
490
+
491
+ Requests run concurrently, at most ``SEARCH_CONCURRENCY`` at a time.
492
+ Citation numbers are assigned afterwards, one query at a time in the
493
+ order given, so ``[websourceN]`` is the same on every run. A URL found
494
+ by several queries keeps one number; the manifest gets one row per search.
495
+
496
+ A failed query shows its own error and does not drop the others. The
497
+ output is a ``web-search:`` error only when every query failed.
498
+ ``output_json`` returns a list with one entry per query: the usual
499
+ ``{engine, query, results}`` object, or ``{query, error}`` on failure.
500
+ """
501
+ if not queries:
502
+ return f"{WEB_SEARCH_ERROR_PREFIX} no queries provided."
503
+ if len(queries) > MAX_SEARCH_QUERIES:
504
+ return (
505
+ f"{WEB_SEARCH_ERROR_PREFIX} too many queries ({len(queries)}); "
506
+ f"the limit is {MAX_SEARCH_QUERIES} per call. "
507
+ "Split them across several calls."
508
+ )
509
+
510
+ options = _search_request_options(
511
+ fetch_size=fetch_size,
512
+ include_content=include_content,
513
+ engine_config_raw=engine_config_raw,
514
+ crawler_config_raw=crawler_config_raw,
515
+ config_path=config_path,
516
+ chat_id=chat_id,
517
+ )
518
+ if isinstance(options, str):
519
+ return options
520
+
521
+ with ThreadPoolExecutor(
522
+ max_workers=min(SEARCH_CONCURRENCY, len(queries))
523
+ ) as executor:
524
+ fetched = list(
525
+ executor.map(
526
+ lambda query: _fetch_search_payload(state, query, options), queries
527
+ )
528
+ )
529
+
530
+ outcomes: list[dict[str, Any] | str] = []
531
+ for item in fetched:
532
+ if isinstance(item, str):
533
+ outcomes.append(item)
534
+ continue
535
+ try:
536
+ outcomes.append(_annotate_web_results_for_citations(item))
537
+ except UnsafeRefsLogPathError as exc:
538
+ return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
539
+
540
+ failures = [
541
+ f"{query!r}: {outcome}"
542
+ for query, outcome in zip(queries, outcomes, strict=True)
543
+ if isinstance(outcome, str)
544
+ ]
545
+ if len(failures) == len(queries):
546
+ return (
547
+ f"{WEB_SEARCH_ERROR_PREFIX} all {len(queries)} queries failed.\n"
548
+ + "\n".join(failures)
549
+ )
425
550
 
426
- try:
427
- payload = _annotate_web_results_for_citations(_payload_from_resource(resource))
428
- except UnsafeRefsLogPathError as exc:
429
- return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
430
551
  if output_json:
431
- return _format_search_results_json(payload)
432
- return _format_search_results(payload)
552
+ return _format_many_search_results_json(queries, outcomes)
553
+ return _format_many_search_results(queries, outcomes)
554
+
555
+
556
+ def _format_many_search_results(
557
+ queries: list[str],
558
+ outcomes: list[dict[str, Any] | str],
559
+ ) -> str:
560
+ blocks: list[str] = []
561
+ failed = 0
562
+ for position, (query, outcome) in enumerate(
563
+ zip(queries, outcomes, strict=True), start=1
564
+ ):
565
+ header = f"=== query {position}/{len(queries)} ==="
566
+ if isinstance(outcome, str):
567
+ failed += 1
568
+ blocks.append(f"{header}\nquery: {query!r}\nERROR: {outcome}")
569
+ continue
570
+ blocks.append(f"{header}\n{_format_search_results(outcome)}")
571
+ if failed:
572
+ blocks.append(f"{failed} of {len(queries)} queries failed.")
573
+ return "\n\n".join(blocks)
574
+
575
+
576
+ def _format_many_search_results_json(
577
+ queries: list[str],
578
+ outcomes: list[dict[str, Any] | str],
579
+ ) -> str:
580
+ entries = [
581
+ {"query": query, "error": outcome}
582
+ if isinstance(outcome, str)
583
+ else _search_results_projection(outcome)
584
+ for query, outcome in zip(queries, outcomes, strict=True)
585
+ ]
586
+ return json.dumps(entries, indent=2, ensure_ascii=False)
433
587
 
434
588
 
435
589
  def cmd_web_crawl(
@@ -99,6 +99,10 @@ unique-cli mv report.pdf "Annual Report 2025.pdf"
99
99
  | Content ID | `cont_abc123` | File directly by ID |
100
100
  | File path | `/Reports/Q1/report.pdf` | File in a folder |
101
101
 
102
+ A bare file name such as `report.pdf` resolves in the current folder. At the
103
+ root (`/`) a bare file name is an error: pass a full path, a content ID, or `cd`
104
+ into the folder first.
105
+
102
106
  ## Upload Destination Resolution
103
107
 
104
108
  The `upload` command always enables immutable content versioning. It does not expose an unversioned upload mode. Its destination works like Linux `cp`:
@@ -58,6 +58,31 @@ unique-cli web-search search "python tutorial" --json
58
58
  unique-cli web-search search "sustainability reports" --include-content --json
59
59
  ```
60
60
 
61
+ ### Several searches: pass every query in ONE call
62
+
63
+ When you need more than one search (several topics, companies or
64
+ questions), pass all queries as separate arguments of a single `search`
65
+ command. Do **not** run one `unique-cli web-search search` call per query,
66
+ and do not start several in the background: the queries run concurrently
67
+ inside the one call and the answer comes back in one round.
68
+
69
+ ```bash
70
+ unique-cli web-search search "Git first release" "Redis first release" "Docker first release" --json
71
+ ```
72
+
73
+ - Up to 8 queries per call (4 run at the same time). More than 8 is an
74
+ error: split them across calls.
75
+ - Options such as `-n`, `--include-content` and `--json` apply to every
76
+ query. With `--include-content`, prefer fewer queries per call: the output
77
+ grows with every query.
78
+ - Output has one block per query, in the order you gave them, each with
79
+ its query. `--json` returns a list with one entry per query.
80
+ - `[websourceN]` numbers follow the query order. A URL found by two
81
+ queries has one number.
82
+ - A query that fails shows its own error and the other queries still
83
+ return. The command exits `1` only when every query failed.
84
+ - A single query behaves exactly as before.
85
+
61
86
  ### Per-call engine override
62
87
 
63
88
  Force a specific engine (or any engine-specific knob) without changing server config:
@@ -141,7 +166,7 @@ discriminator:
141
166
 
142
167
  ## Command Reference
143
168
 
144
- ### `unique-cli web-search search <query>`
169
+ ### `unique-cli web-search search <query> [<query>...]`
145
170
 
146
171
  | Option | Short | Default | Description |
147
172
  |--------|-------|---------|-------------|
@@ -217,6 +242,28 @@ Found 3 result(s):
217
242
  > | unique-cli web-search crawl --stdin
218
243
  > ```
219
244
 
245
+ ### `search` with several queries (text)
246
+
247
+ ```
248
+ === query 1/2 ===
249
+ engine: Google query: 'Git first release'
250
+ Found 1 result(s):
251
+
252
+ 1. Git history [websource1]
253
+ https://example.com/git
254
+ ...
255
+
256
+ === query 2/2 ===
257
+ query: 'Redis first release'
258
+ ERROR: web-search: upstream failed
259
+
260
+ 1 of 2 queries failed.
261
+ ```
262
+
263
+ With `--json` the output is a list. Each entry is the usual
264
+ `{engine, query, results}` object, or `{"query": "...", "error": "..."}`
265
+ for a query that failed.
266
+
220
267
  ### `crawl` (text)
221
268
 
222
269
  ```
@@ -261,7 +308,10 @@ In one-shot mode, the CLI exits **`0`** on success and **`1`** on
261
308
  failures (config-file errors, invalid JSON overrides, API errors,
262
309
  missing URLs for `crawl`, and citation-manifest I/O failures — e.g.
263
310
  when `.unique/web-refs.jsonl` cannot be written or its parent
264
- directory is unsafe). Error messages are prefixed with
311
+ directory is unsafe). With several queries in one `search` call, a failed
312
+ query alone does not change the exit code: it exits `1` only when every
313
+ query failed, or when the call is invalid (more than 8 queries, bad
314
+ override JSON). Error messages are prefixed with
265
315
  `web-search:` or `web-crawl:` so they're easy to grep.
266
316
 
267
317
  ```bash