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