unique-sdk 2026.30.0.dev0__tar.gz → 2026.30.0.dev2__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 (87) hide show
  1. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/PKG-INFO +1 -1
  2. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/pyproject.toml +1 -1
  3. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_unique_ql.py +2 -0
  4. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/mcp.py +312 -37
  5. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/metadata_filter.py +1 -1
  6. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/state.py +34 -0
  7. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/README.md +0 -0
  8. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/__init__.py +0 -0
  9. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_api_requestor.py +0 -0
  10. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_api_resource.py +0 -0
  11. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_api_version.py +0 -0
  12. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_error.py +0 -0
  13. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_http_client.py +0 -0
  14. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_list_object.py +0 -0
  15. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_object_classes.py +0 -0
  16. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_request_options.py +0 -0
  17. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_unique_object.py +0 -0
  18. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_unique_response.py +0 -0
  19. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_util.py +0 -0
  20. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_version.py +0 -0
  21. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/_webhook.py +0 -0
  22. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/__init__.py +0 -0
  23. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_acronyms.py +0 -0
  24. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_agentic_table.py +0 -0
  25. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_analytics_order.py +0 -0
  26. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_benchmarking.py +0 -0
  27. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_briefing.py +0 -0
  28. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_chat_completion.py +0 -0
  29. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_content.py +0 -0
  30. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  31. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_elicitation.py +0 -0
  32. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_embedding.py +0 -0
  33. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_event.py +0 -0
  34. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_folder.py +0 -0
  35. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_group.py +0 -0
  36. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_integrated.py +0 -0
  37. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_llm_models.py +0 -0
  38. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_mcp.py +0 -0
  39. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_message.py +0 -0
  40. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_message_assessment.py +0 -0
  41. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_message_execution.py +0 -0
  42. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_message_log.py +0 -0
  43. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_message_tool.py +0 -0
  44. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_module.py +0 -0
  45. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  46. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_search.py +0 -0
  47. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_search_string.py +0 -0
  48. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  49. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_space.py +0 -0
  50. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_user.py +0 -0
  51. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/api_resources/_web_search.py +0 -0
  52. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/__init__.py +0 -0
  53. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/__main__.py +0 -0
  54. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/cli.py +0 -0
  55. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/__init__.py +0 -0
  56. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  57. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/browser.py +0 -0
  58. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/cite_file.py +0 -0
  59. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  60. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/elicitation.py +0 -0
  61. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/files.py +0 -0
  62. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/folders.py +0 -0
  63. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/navigation.py +0 -0
  64. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/read.py +0 -0
  65. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  66. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/search.py +0 -0
  67. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/subagent.py +0 -0
  68. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/web_search.py +0 -0
  69. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/commands/web_search_config.py +0 -0
  70. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/config.py +0 -0
  71. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/formatting.py +0 -0
  72. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/shell.py +0 -0
  73. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
  74. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  75. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  76. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  77. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  78. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  79. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  80. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  81. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/utils/analytics_order_run.py +0 -0
  82. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/utils/benchmarking_run.py +0 -0
  83. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/utils/chat_history.py +0 -0
  84. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/utils/chat_in_space.py +0 -0
  85. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/utils/file_io.py +0 -0
  86. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/unique_sdk/utils/sources.py +0 -0
  87. {unique_sdk-2026.30.0.dev0 → unique_sdk-2026.30.0.dev2}/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.30.0.dev0
3
+ Version: 2026.30.0.dev2
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.30.0.dev0"
3
+ version = "2026.30.0.dev2"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -46,6 +46,8 @@ class UQLOperator:
46
46
  LESS_THAN_OR_EQUAL = "lessThanOrEqual"
47
47
  IN = "in"
48
48
  NOT_IN = "notIn"
49
+ OVERLAPS = "overlaps"
50
+ NOT_OVERLAPS = "notOverlaps"
49
51
  CONTAINS = "contains"
50
52
  NOT_CONTAINS = "notContains"
51
53
  IS_NULL = "isNull"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
+ import re
7
8
  import sys
8
9
  from pathlib import Path
9
10
  from typing import Any
@@ -27,8 +28,13 @@ _LOGGER = logging.getLogger(__name__)
27
28
  # (referencing is UN-21285, tracked separately).
28
29
  _MCP_OUTPUT_LOG_RELATIVE_PATH = Path(".unique") / "mcp-output.jsonl"
29
30
  # Writer-side cap so a single huge/raw tool result cannot bloat the manifest.
30
- # The evaluator applies its own per-source cap as well.
31
- _MCP_OUTPUT_TEXT_CHAR_LIMIT = 50_000
31
+ # This manifest is the groundedness check's source of truth, so the cap is
32
+ # sized against the eval model's context window rather than kept minimal:
33
+ # GPT-4o's 128k-token input fits ~400k chars, and the runner bounds the
34
+ # combined per-turn payload separately (UN-22309). At the previous 50k, a
35
+ # large list result (e.g. a 115k-char Jira search) lost most of its items
36
+ # before the judge saw them, flagging well-grounded answers as hallucinations.
37
+ _MCP_OUTPUT_TEXT_CHAR_LIMIT = 200_000
32
38
 
33
39
  # Per-turn manifest of citable MCP sources, consumed by the runner to stitch
34
40
  # ``[mcpsourceN]`` markers into ``<sup>N</sup>`` footnotes + reference chips
@@ -39,7 +45,6 @@ _MCP_OUTPUT_TEXT_CHAR_LIMIT = 50_000
39
45
  _MCP_REFS_LOG_RELATIVE_PATH = Path(".unique") / "mcp-refs.jsonl"
40
46
  _MCP_REFS_LOCK_FILENAME = "mcp-refs.lock"
41
47
  _MCP_SNIPPET_CHAR_LIMIT = 300
42
- _MCP_MAX_ITEMS_PER_CALL = 8
43
48
 
44
49
  # Keys an MCP tool's JSON result commonly uses for a record's human title.
45
50
  _TITLE_KEYS = ("title", "name", "displayName", "subject", "summary", "key")
@@ -152,28 +157,271 @@ def _details_from_json(obj: dict[str, Any]) -> str | None:
152
157
  return " - ".join(parts)[:_MCP_DETAILS_CHAR_LIMIT]
153
158
 
154
159
 
155
- def _titles_from_json(text: str) -> list[dict[str, Any]]:
156
- """Best-effort: pull a human title out of a JSON result (object or list of
157
- objects), e.g. an Atlassian page/issue returned as JSON-in-text. Returns []
158
- when the text is not JSON or carries no recognizable title.
160
+ # Keys under which a JSON object commonly wraps the actual list of retrieved
161
+ # records, e.g. Jira's ``{"issues": [...]}`` or a generic ``{"results": [...]}``.
162
+ # When a result wraps its records this way, we iterate the list so each record
163
+ # becomes its own reference instead of collapsing to one title-less chip.
164
+ _CONTAINER_KEYS = (
165
+ "issues",
166
+ "results",
167
+ "items",
168
+ "values",
169
+ "data",
170
+ "hits",
171
+ "records",
172
+ "entries",
173
+ "elements",
174
+ "content",
175
+ )
176
+
177
+ # Subset of ``_CONTAINER_KEYS`` that are also plausible fields of a *single*
178
+ # titled record — e.g. a page/document payload ``{"title": ..., "content": [...]}``
179
+ # whose ``content`` is the body, not a list of separate results. When one of
180
+ # these matches on an object that carries its own top-level title, we keep the
181
+ # object as one record rather than splitting it into title-less inner rows.
182
+ _AMBIGUOUS_CONTAINER_KEYS = ("data", "content")
183
+
184
+
185
+ def _loads_embedded_json(text: str) -> Any:
186
+ """Parse a JSON value from a tool-result string, tolerating a non-JSON
187
+ preamble.
188
+
189
+ Some MCP servers (e.g. Atlassian) prefix the JSON body with a human-readable
190
+ notice like ``[IMPORTANT: ...]\\n{...}``, so a direct ``json.loads`` of the
191
+ whole string fails. Try a direct parse first, then fall back to decoding the
192
+ first JSON object/array that appears. Returns ``None`` when no JSON is found.
159
193
  """
160
194
  try:
161
- parsed = json.loads(text)
195
+ return json.loads(text)
162
196
  except (ValueError, TypeError):
197
+ pass
198
+ decoder = json.JSONDecoder()
199
+ for index, char in enumerate(text):
200
+ if char in "{[":
201
+ try:
202
+ value, _ = decoder.raw_decode(text, index)
203
+ except ValueError:
204
+ continue
205
+ return value
206
+ return None
207
+
208
+
209
+ def _records_from_parsed(parsed: Any) -> list[dict[str, Any]]:
210
+ """Normalize a parsed JSON value to a flat list of record dicts, unwrapping
211
+ a single container key (``{"issues": [...]}``) when present."""
212
+ if isinstance(parsed, list):
213
+ return [entry for entry in parsed if isinstance(entry, dict)]
214
+ if isinstance(parsed, dict):
215
+ for key in _CONTAINER_KEYS:
216
+ value = parsed.get(key)
217
+ if isinstance(value, list):
218
+ records = [entry for entry in value if isinstance(entry, dict)]
219
+ if records:
220
+ # A single titled record must not be split apart by an
221
+ # incidental ambiguous key (a page body under ``content`` /
222
+ # ``data``) — keep the object as one record so its title
223
+ # survives instead of collapsing to a title-less chip.
224
+ if key in _AMBIGUOUS_CONTAINER_KEYS and _title_from_json(parsed):
225
+ return [parsed]
226
+ return records
227
+ return [parsed]
228
+ return []
229
+
230
+
231
+ def _titles_from_json(text: str) -> list[dict[str, Any]]:
232
+ """Best-effort: pull a human title out of a JSON result, e.g. an Atlassian
233
+ page/issue returned as JSON-in-text. Tolerates a non-JSON preamble before
234
+ the JSON body and unwraps a container key (``{"issues": [...]}``) so each
235
+ retrieved record yields its own item. Returns [] when the text carries no
236
+ JSON or no record has a recognizable title.
237
+ """
238
+ parsed = _loads_embedded_json(text)
239
+ if parsed is None:
163
240
  return []
164
- candidates = parsed if isinstance(parsed, list) else [parsed]
165
241
  items: list[dict[str, Any]] = []
166
- for entry in candidates:
167
- if isinstance(entry, dict):
168
- title = _title_from_json(entry)
242
+ for entry in _records_from_parsed(parsed):
243
+ title = _title_from_json(entry)
244
+ if title:
245
+ items.append(
246
+ {
247
+ "title": title,
248
+ "snippet": None,
249
+ "details": _details_from_json(entry),
250
+ }
251
+ )
252
+ return items
253
+
254
+
255
+ def _get_by_dotted_path(obj: Any, path: str) -> Any:
256
+ """Walk a dotted path over dicts and lists (numeric segments index lists),
257
+ e.g. ``"issues"``, ``"fields.summary"``, ``"result.items.0.key"``. Returns
258
+ ``None`` when any segment is missing."""
259
+ current = obj
260
+ for segment in path.split("."):
261
+ if isinstance(current, dict):
262
+ current = current.get(segment)
263
+ elif isinstance(current, list) and segment.lstrip("-").isdigit():
264
+ index = int(segment)
265
+ try:
266
+ current = current[index]
267
+ except IndexError:
268
+ return None
269
+ else:
270
+ return None
271
+ if current is None:
272
+ return None
273
+ return current
274
+
275
+
276
+ _TEMPLATE_TOKEN = re.compile(r"\{([^{}]+)\}")
277
+
278
+
279
+ def _render_title_template(template: str, record: dict[str, Any]) -> str | None:
280
+ """Substitute ``{dotted.path}`` tokens from ``record`` into ``template``.
281
+ Missing tokens render empty; returns ``None`` if the result is blank."""
282
+
283
+ def _sub(match: re.Match[str]) -> str:
284
+ value = _get_by_dotted_path(record, match.group(1).strip())
285
+ return str(value).strip() if value is not None else ""
286
+
287
+ rendered = _TEMPLATE_TOKEN.sub(_sub, template).strip()
288
+ # Collapse artifacts left by empty tokens (e.g. a dangling " — ").
289
+ rendered = rendered.strip(" -—–|:").strip()
290
+ return rendered or None
291
+
292
+
293
+ def _mapped_records(response: Any, list_path: str | None) -> list[Any]:
294
+ """Locate the records a reference mapping applies to, preferring the
295
+ MCP-native ``structuredContent`` then any JSON-in-text block (preamble
296
+ tolerant). ``list_path`` (dotted) points at the array; when unset the parsed
297
+ value itself is used. List entries may be objects (field/template titles) or
298
+ plain strings (text titles), so both are kept; a single object is wrapped.
299
+ An empty object/list is ignored (not treated as a record) so the caller can
300
+ still fall back to ``titleFromText`` / the generic heuristic — e.g. an empty
301
+ ``structuredContent`` alongside a Markdown doc."""
302
+ sources: list[Any] = []
303
+ structured = getattr(response, "structuredContent", None) or getattr(
304
+ response, "structured_content", None
305
+ )
306
+ if structured is not None:
307
+ sources.append(structured)
308
+ for block in getattr(response, "content", None) or []:
309
+ if isinstance(block, dict) and block.get("type") == "text":
310
+ parsed = _loads_embedded_json(block.get("text") or "")
311
+ if parsed is not None:
312
+ sources.append(parsed)
313
+
314
+ for parsed in sources:
315
+ target = _get_by_dotted_path(parsed, list_path) if list_path else parsed
316
+ if isinstance(target, list):
317
+ records = [entry for entry in target if isinstance(entry, (dict, str))]
318
+ if records:
319
+ return records
320
+ elif isinstance(target, dict) and target:
321
+ return [target]
322
+ return []
323
+
324
+
325
+ _MCP_TEXT_TITLE_DEFAULT_CHARS = 120
326
+
327
+
328
+ def _leading_title_line(text: str | None, max_chars: int) -> str | None:
329
+ """First non-empty line of ``text`` with leading Markdown heading/list
330
+ markers (``# * - >``) stripped, capped at ``max_chars``. Used to title a
331
+ plain-text document (or a plain-text list item) by its heading. ``None``
332
+ when ``text`` is not a non-empty string."""
333
+ if not isinstance(text, str):
334
+ return None
335
+ for line in text.splitlines():
336
+ stripped = line.strip().lstrip("#*->").strip()
337
+ if stripped:
338
+ return stripped[:max_chars]
339
+ return None
340
+
341
+
342
+ def _first_text_title(response: Any, max_chars: int) -> str | None:
343
+ """Title from the first non-empty line of the first text block — for a
344
+ non-JSON result such as a fetched Markdown document (e.g. ``read_doc``)."""
345
+ for block in getattr(response, "content", None) or []:
346
+ if isinstance(block, dict) and block.get("type") == "text":
347
+ title = _leading_title_line(block.get("text"), max_chars)
169
348
  if title:
170
- items.append(
171
- {
172
- "title": title,
173
- "snippet": None,
174
- "details": _details_from_json(entry),
175
- }
176
- )
349
+ return title
350
+ return None
351
+
352
+
353
+ def _extract_with_reference_mapping(
354
+ response: Any, mapping: dict[str, Any]
355
+ ) -> list[dict[str, Any]]:
356
+ """Destructure a result into ``{title, snippet, details}`` items using an
357
+ admin-configured reference mapping. For a list result, one item per record;
358
+ for a non-JSON text result with ``titleFromText`` set, a single item titled
359
+ from the leading text. Returns [] when the mapping locates nothing, so the
360
+ caller can fall back to the generic heuristic."""
361
+ list_path = mapping.get("listPath") or mapping.get("list_path")
362
+ title_path = mapping.get("titlePath") or mapping.get("title_path")
363
+ title_template = mapping.get("titleTemplate") or mapping.get("title_template")
364
+ details_path = mapping.get("detailsPath") or mapping.get("details_path")
365
+ title_from_text = mapping.get("titleFromText") or mapping.get("title_from_text")
366
+ # When titling from text and the list items are objects, this dotted path
367
+ # points at the text field within each item (e.g. ``content``); leave unset
368
+ # when each item is itself a plain string.
369
+ title_text_path = mapping.get("titleTextPath") or mapping.get("title_text_path")
370
+ try:
371
+ title_max_chars = int(
372
+ mapping.get("titleMaxChars")
373
+ or mapping.get("title_max_chars")
374
+ or _MCP_TEXT_TITLE_DEFAULT_CHARS
375
+ )
376
+ except (TypeError, ValueError):
377
+ title_max_chars = _MCP_TEXT_TITLE_DEFAULT_CHARS
378
+
379
+ records = _mapped_records(response, list_path)
380
+ items: list[dict[str, Any]] = []
381
+ for record in records:
382
+ is_dict = isinstance(record, dict)
383
+ if title_from_text:
384
+ # Title from the item's text: the item itself when it's a plain
385
+ # string, else its ``title_text_path`` field.
386
+ if isinstance(record, str):
387
+ text = record
388
+ elif is_dict and title_text_path:
389
+ text = _get_by_dotted_path(record, title_text_path)
390
+ else:
391
+ text = None
392
+ title = _leading_title_line(text, title_max_chars)
393
+ elif title_template and is_dict:
394
+ title = _render_title_template(title_template, record)
395
+ elif title_path and is_dict:
396
+ value = _get_by_dotted_path(record, title_path)
397
+ title = str(value).strip() if isinstance(value, (str, int, float)) else None
398
+ else:
399
+ title = None
400
+ if not title:
401
+ continue
402
+ details = (
403
+ _get_by_dotted_path(record, details_path)
404
+ if details_path and is_dict
405
+ else None
406
+ )
407
+ items.append(
408
+ {
409
+ "title": title,
410
+ "snippet": None,
411
+ "details": str(details).strip() if details else None,
412
+ }
413
+ )
414
+ if items:
415
+ return items
416
+ # Non-list / non-JSON result (e.g. a fetched Markdown doc): title the single
417
+ # chip from the leading text when the tool opts in via ``titleFromText``.
418
+ # Only when *no* records were located — if list records were found but
419
+ # yielded no usable title, defer to the generic heuristic (e.g. per-issue
420
+ # references) instead of a bogus text-derived chip.
421
+ if not records and title_from_text:
422
+ title = _first_text_title(response, title_max_chars)
423
+ if title:
424
+ return [{"title": title, "snippet": None, "details": None}]
177
425
  return items
178
426
 
179
427
 
@@ -182,15 +430,23 @@ def _extract_mcp_citation_items(
182
430
  *,
183
431
  tool_name: str,
184
432
  server_name: str | None,
433
+ reference_mapping: dict[str, Any] | None = None,
185
434
  ) -> list[dict[str, Any]]:
186
435
  """Context for what the tool retrieved: ``{title, snippet}`` per item.
187
436
 
188
- Titles come from MCP ``resource_link`` names (spec-native) or a best-effort
437
+ An optional admin ``reference_mapping`` is applied first (deterministic
438
+ destructuring of a list result); when it yields nothing we fall back to the
439
+ generic heuristic: MCP ``resource_link`` names (spec-native) or a best-effort
189
440
  JSON-title heuristic over text blocks (for tools like Atlassian that return
190
441
  JSON-in-text). No URLs are extracted — the chip is display-only. Falls back
191
442
  to a single title-less item (the runner names it after the tool) when the
192
443
  result carries no recognizable title.
193
444
  """
445
+ if reference_mapping:
446
+ mapped = _extract_with_reference_mapping(response, reference_mapping)
447
+ if mapped:
448
+ return mapped
449
+
194
450
  content = getattr(response, "content", None) or []
195
451
  items: list[dict[str, Any]] = []
196
452
 
@@ -213,7 +469,7 @@ def _extract_mcp_citation_items(
213
469
  # No recognizable title — one chip named after the tool itself.
214
470
  items.append({"title": None, "snippet": None})
215
471
 
216
- return items[:_MCP_MAX_ITEMS_PER_CALL]
472
+ return items
217
473
 
218
474
 
219
475
  def _next_mcp_source_number(entries: list[dict[str, Any]]) -> int:
@@ -239,19 +495,23 @@ def _annotate_mcp_results_for_citations(
239
495
  tool_name: str,
240
496
  server_name: str | None,
241
497
  refs_log_path: Path | None = None,
498
+ reference_mapping: dict[str, Any] | None = None,
242
499
  ) -> list[tuple[int, dict[str, Any]]]:
243
500
  """Assign per-turn ``[mcpsourceN]`` numbers to each retrieved item and append
244
- the refs manifest. Returns ``[(sourceNumber, item)]`` for the footer.
501
+ the refs manifest. Returns ``[(sourceNumber, item)]`` for the Sources block.
245
502
 
246
503
  Items dedup by title across the turn (same item keeps one number), or by
247
504
  tool for the title-less fallback. Best-effort — returns ``[]`` on any failure
248
- (the tool result is unaffected; only the citation footer is skipped).
505
+ (the tool result is unaffected; only the citation Sources block is skipped).
249
506
  """
250
507
  refs_log_path = refs_log_path or (Path.cwd() / _MCP_REFS_LOG_RELATIVE_PATH)
251
508
  annotated: list[tuple[int, dict[str, Any]]] = []
252
509
  try:
253
510
  items = _extract_mcp_citation_items(
254
- response, tool_name=tool_name, server_name=server_name
511
+ response,
512
+ tool_name=tool_name,
513
+ server_name=server_name,
514
+ reference_mapping=reference_mapping,
255
515
  )
256
516
  with _locked_turn_refs_manifest(
257
517
  refs_log_path, lock_filename=_MCP_REFS_LOCK_FILENAME
@@ -320,15 +580,20 @@ def _annotate_mcp_results_for_citations(
320
580
  return annotated
321
581
 
322
582
 
323
- def _citation_footer(annotated: list[tuple[int, dict[str, Any]]]) -> str:
324
- """Tell the agent which marker to cite each retrieved item with."""
583
+ def _citation_sources_block(annotated: list[tuple[int, dict[str, Any]]]) -> str:
584
+ """Tell the agent which marker to cite each retrieved item with.
585
+
586
+ Rendered *before* the tool output (leading block, not a trailing footer):
587
+ the agent harness spills oversized tool results to a file wholesale, and a
588
+ partial or programmatic read of that file only reliably sees the head — a
589
+ trailing marker list would be exactly what such reads miss (UN-22309).
590
+ """
325
591
  if not annotated:
326
592
  return ""
327
593
  lines = [
328
- "",
329
594
  "Sources — MANDATORY: every fact you take from this result MUST be "
330
- "cited inline with its [mcpsourceN] marker below, or it will not be "
331
- "referenced in the answer:",
595
+ "cited inline with its [mcpsourceN] marker from this list, or it "
596
+ "will not be referenced in the answer:",
332
597
  ]
333
598
  for source_number, item in annotated:
334
599
  label = item.get("title") or "this MCP tool result"
@@ -375,14 +640,18 @@ def record_mcp_citations(
375
640
  server_name: str | None,
376
641
  unique_dir: Path,
377
642
  formatted_text: str,
643
+ reference_mapping: dict[str, Any] | None = None,
378
644
  ) -> str:
379
645
  """Write both per-turn MCP manifests under ``unique_dir`` and return the
380
- ``[mcpsourceN]`` citation footer for the agent.
646
+ ``[mcpsourceN]`` citation Sources block for the agent.
381
647
 
382
- Shared by the ``unique-cli mcp`` skills flow (``cmd_mcp``) and the
383
- in-process tools-mode proxy in assistants-core, so both write identical
384
- manifests and footers. ``unique_dir`` is the workspace ``.unique`` directory
385
- (its filenames are joined directly here — do not pass the workspace root).
648
+ Callers must place the block *before* the tool output (leading block, not
649
+ a trailing footer) so the markers survive harness-side spilling/truncation
650
+ of large results (UN-22309). Shared by the ``unique-cli mcp`` skills flow
651
+ (``cmd_mcp``) and the in-process tools-mode proxy in assistants-core, so
652
+ both write identical manifests and blocks. ``unique_dir`` is the workspace
653
+ ``.unique`` directory (its filenames are joined directly here — do not
654
+ pass the workspace root).
386
655
 
387
656
  - ``response`` is the raw ``unique_sdk.MCP`` result, used for citation
388
657
  extraction (titles from ``resource_link`` names / JSON bodies).
@@ -405,8 +674,9 @@ def record_mcp_citations(
405
674
  tool_name=tool_name,
406
675
  server_name=server_name,
407
676
  refs_log_path=unique_dir / _MCP_REFS_LOG_RELATIVE_PATH.name,
677
+ reference_mapping=reference_mapping,
408
678
  )
409
- return _citation_footer(annotated)
679
+ return _citation_sources_block(annotated)
410
680
 
411
681
 
412
682
  def _read_payload(
@@ -490,11 +760,16 @@ def cmd_mcp(
490
760
  formatted = f"mcp: formatter error ({fmt_exc}); raw response:\n{fallback}"
491
761
 
492
762
  server_name = _server_name_from_tool(name) or getattr(response, "mcpServerId", None)
493
- footer = record_mcp_citations(
763
+ sources_block = record_mcp_citations(
494
764
  response,
495
765
  tool_name=name,
496
766
  server_name=server_name,
497
767
  unique_dir=Path.cwd() / ".unique",
498
768
  formatted_text=formatted,
769
+ reference_mapping=state.mcp_tool_reference_mappings.get(name),
499
770
  )
500
- return formatted + footer
771
+ # Sources block FIRST: large outputs get spilled/truncated tail-first by
772
+ # the agent harness, and a trailing block is what partial reads miss.
773
+ if sources_block:
774
+ return sources_block + "\n\n" + formatted
775
+ return formatted
@@ -32,7 +32,7 @@ _SCOPE_ID_RE = re.compile(r"scope_[a-z0-9_]+", re.IGNORECASE)
32
32
  # ``startswith("not")`` heuristic: that heuristic would also catch a future
33
33
  # ``notEmpty``-style operator and invert it wrongly. Mirrors the Operator enum
34
34
  # in unique_toolkit.content.smart_rules. See UN-21780.
35
- _NEGATED_OPERATORS = frozenset({"notequals", "notin", "notcontains"})
35
+ _NEGATED_OPERATORS = frozenset({"notequals", "notin", "notcontains", "notoverlaps"})
36
36
 
37
37
  ScopePathResolver = Callable[[str], "str | None"]
38
38
  ContentOwnerPathResolver = Callable[[str], "str | None"]
@@ -13,6 +13,7 @@ from unique_sdk.cli.metadata_filter import MetadataFilter
13
13
 
14
14
  _SEARCH_CONFIG_FILENAME = ".unique-search.json"
15
15
  _UPLOADED_CONFIG_FILENAME = ".unique-uploaded.json"
16
+ _MCP_TOOLS_CONFIG_FILENAME = ".unique-mcp-tools.json"
16
17
  _CHAT_FILES_MANIFEST_PATH = Path(".unique") / "chat-files.json"
17
18
 
18
19
 
@@ -48,6 +49,33 @@ def _load_uploaded_config() -> dict[str, Any]:
48
49
  return data if isinstance(data, dict) else {}
49
50
 
50
51
 
52
+ def _load_mcp_tool_configs() -> dict[str, dict[str, Any]]:
53
+ """Load per-MCP-tool reference mappings from ``.unique-mcp-tools.json``.
54
+
55
+ Written by the Swappable Intelligence runner from each configured tool's
56
+ ``referenceMapping`` (a Unique admin override describing how to destructure
57
+ a list-returning result into individual citations). Shape:
58
+ ``{"toolConfigs": {<toolName>: {listPath, titlePath, titleTemplate, ...}}}``.
59
+ Keyed by the same namespaced tool name the agent passes to ``unique-cli
60
+ mcp``. Returns ``{}`` when absent or invalid.
61
+ """
62
+ config_path = Path.cwd() / _MCP_TOOLS_CONFIG_FILENAME
63
+ if not config_path.is_file():
64
+ return {}
65
+ try:
66
+ data = json.loads(config_path.read_text(encoding="utf-8"))
67
+ except (json.JSONDecodeError, OSError):
68
+ return {}
69
+ configs = data.get("toolConfigs") if isinstance(data, dict) else None
70
+ if not isinstance(configs, dict):
71
+ return {}
72
+ return {
73
+ name: mapping
74
+ for name, mapping in configs.items()
75
+ if isinstance(name, str) and isinstance(mapping, dict)
76
+ }
77
+
78
+
51
79
  def _load_uploaded_content_ids(data: dict[str, Any]) -> list[str]:
52
80
  content_ids = data.get("contentIds")
53
81
  if isinstance(content_ids, list) and all(isinstance(c, str) for c in content_ids):
@@ -145,6 +173,12 @@ class ShellState:
145
173
  self.uploaded_search_content_ids: list[str] = _load_uploaded_content_ids(
146
174
  _uploaded_config
147
175
  )
176
+ # Per-MCP-tool reference mappings (``.unique-mcp-tools.json``), keyed by
177
+ # the namespaced tool name the agent passes to ``unique-cli mcp``. Used
178
+ # by ``cmd_mcp`` to destructure list results into individual citations.
179
+ self.mcp_tool_reference_mappings: dict[str, dict[str, Any]] = (
180
+ _load_mcp_tool_configs()
181
+ )
148
182
 
149
183
  @property
150
184
  def uploaded_search_available(self) -> bool: