agent-brain-cli 10.0.7__tar.gz → 10.2.0__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.
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/PKG-INFO +3 -2
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/__init__.py +1 -1
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/cli.py +43 -3
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/client/api_client.py +89 -16
- agent_brain_cli-10.2.0/agent_brain_cli/client/transport.py +54 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/cache.py +16 -8
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/folders.py +23 -9
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/index.py +9 -12
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/inject.py +20 -12
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/jobs.py +8 -3
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/query.py +68 -8
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/reset.py +11 -6
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/start.py +75 -3
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/status.py +13 -5
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/config.py +73 -1
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/pyproject.toml +3 -2
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/README.md +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/client/__init__.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/__init__.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/config.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/doctor.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/init.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/install_agent.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/list_cmd.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/stop.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/types.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/uninstall.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/config_migrate.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/config_schema.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/diagnostics.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/migration.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/__init__.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/claude_converter.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/codex_converter.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/converter_base.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/gemini_converter.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/opencode_converter.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/parser.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/skill_runtime_converter.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/tool_maps.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/types.py +0 -0
- {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/xdg_paths.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: agent-brain-cli
|
|
3
|
-
Version: 10.0
|
|
3
|
+
Version: 10.2.0
|
|
4
4
|
Summary: Agent Brain CLI - Command-line interface for managing AI agent memory and knowledge retrieval
|
|
5
5
|
Home-page: https://github.com/SpillwaveSolutions/agent-brain
|
|
6
6
|
License: MIT
|
|
@@ -15,7 +15,8 @@ Classifier: Programming Language :: Python :: 3
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
-
Requires-Dist: agent-brain-rag (>=10.0
|
|
18
|
+
Requires-Dist: agent-brain-rag (>=10.2.0,<11.0.0)
|
|
19
|
+
Requires-Dist: agent-brain-uds (>=10.2.0,<11.0.0)
|
|
19
20
|
Requires-Dist: click (>=8.1.0,<9.0.0)
|
|
20
21
|
Requires-Dist: httpx (>=0.28.0,<0.29.0)
|
|
21
22
|
Requires-Dist: pydantic (>=2.10.0,<3.0.0)
|
|
@@ -30,7 +30,41 @@ from .commands import (
|
|
|
30
30
|
|
|
31
31
|
@click.group()
|
|
32
32
|
@click.version_option(version=__version__, prog_name="agent-brain")
|
|
33
|
-
|
|
33
|
+
@click.option(
|
|
34
|
+
"--transport",
|
|
35
|
+
"transport",
|
|
36
|
+
type=click.Choice(["auto", "http", "uds"], case_sensitive=False),
|
|
37
|
+
default=None,
|
|
38
|
+
help=(
|
|
39
|
+
"Transport to use: auto (default — UDS if available, HTTP "
|
|
40
|
+
"otherwise), http, or uds. Honors AGENT_BRAIN_TRANSPORT env."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--socket-path",
|
|
45
|
+
type=click.Path(),
|
|
46
|
+
default=None,
|
|
47
|
+
help="Override UDS socket path (only used with --transport=uds|auto).",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--base-url",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Override server base URL (only used with --transport=http|auto).",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--debug-transport",
|
|
56
|
+
is_flag=True,
|
|
57
|
+
default=False,
|
|
58
|
+
help="Log the resolved transport (http or uds) and target to stderr.",
|
|
59
|
+
)
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def cli(
|
|
62
|
+
ctx: click.Context,
|
|
63
|
+
transport: str | None,
|
|
64
|
+
socket_path: str | None,
|
|
65
|
+
base_url: str | None,
|
|
66
|
+
debug_transport: bool,
|
|
67
|
+
) -> None:
|
|
34
68
|
"""Agent Brain CLI - Manage and query the Agent Brain RAG server.
|
|
35
69
|
|
|
36
70
|
A command-line interface for interacting with the Agent Brain document
|
|
@@ -81,9 +115,15 @@ def cli() -> None:
|
|
|
81
115
|
|
|
82
116
|
\b
|
|
83
117
|
Environment Variables:
|
|
84
|
-
AGENT_BRAIN_URL
|
|
118
|
+
AGENT_BRAIN_URL Server URL (default: http://127.0.0.1:8000)
|
|
119
|
+
AGENT_BRAIN_TRANSPORT Transport hint: auto, http, or uds
|
|
120
|
+
AGENT_BRAIN_UDS_PATH Override UDS socket path
|
|
85
121
|
"""
|
|
86
|
-
|
|
122
|
+
ctx.ensure_object(dict)
|
|
123
|
+
ctx.obj["transport_hint"] = transport
|
|
124
|
+
ctx.obj["base_url_override"] = base_url
|
|
125
|
+
ctx.obj["socket_path_override"] = socket_path
|
|
126
|
+
ctx.obj["debug_transport"] = debug_transport
|
|
87
127
|
|
|
88
128
|
|
|
89
129
|
# Register project management commands
|
|
@@ -53,6 +53,22 @@ class IndexingStatus:
|
|
|
53
53
|
embedding_cache: dict[str, Any] | None = None
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
@dataclass
|
|
57
|
+
class ResultExplanation:
|
|
58
|
+
"""Structured per-result explanation (issue #159).
|
|
59
|
+
|
|
60
|
+
Populated only when the request set `explain=true`. All fields are
|
|
61
|
+
optional because their relevance depends on retrieval mode.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
reason: str
|
|
65
|
+
matched_terms: list[str] | None = None
|
|
66
|
+
fusion: dict[str, float] | None = None
|
|
67
|
+
graph_path: list[str] | None = None
|
|
68
|
+
rerank_movement: int | None = None
|
|
69
|
+
graph_fallback: bool | None = None
|
|
70
|
+
|
|
71
|
+
|
|
56
72
|
@dataclass
|
|
57
73
|
class QueryResult:
|
|
58
74
|
"""Single query result."""
|
|
@@ -64,6 +80,14 @@ class QueryResult:
|
|
|
64
80
|
metadata: dict[str, Any]
|
|
65
81
|
vector_score: float | None = None
|
|
66
82
|
bm25_score: float | None = None
|
|
83
|
+
graph_score: float | None = None
|
|
84
|
+
rerank_score: float | None = None
|
|
85
|
+
original_rank: int | None = None
|
|
86
|
+
relationship_path: list[str] | None = None
|
|
87
|
+
related_entities: list[str] | None = None
|
|
88
|
+
source_type: str = "doc"
|
|
89
|
+
language: str | None = None
|
|
90
|
+
explanation: ResultExplanation | None = None
|
|
67
91
|
|
|
68
92
|
|
|
69
93
|
@dataclass
|
|
@@ -95,6 +119,39 @@ class IndexResponse:
|
|
|
95
119
|
message: str | None
|
|
96
120
|
|
|
97
121
|
|
|
122
|
+
def _parse_query_result(payload: dict[str, Any]) -> QueryResult:
|
|
123
|
+
"""Build a QueryResult from a server response dict, including optional
|
|
124
|
+
explanation block (issue #159)."""
|
|
125
|
+
explanation_data = payload.get("explanation")
|
|
126
|
+
explanation: ResultExplanation | None = None
|
|
127
|
+
if explanation_data is not None:
|
|
128
|
+
explanation = ResultExplanation(
|
|
129
|
+
reason=explanation_data.get("reason", ""),
|
|
130
|
+
matched_terms=explanation_data.get("matched_terms"),
|
|
131
|
+
fusion=explanation_data.get("fusion"),
|
|
132
|
+
graph_path=explanation_data.get("graph_path"),
|
|
133
|
+
rerank_movement=explanation_data.get("rerank_movement"),
|
|
134
|
+
graph_fallback=explanation_data.get("graph_fallback"),
|
|
135
|
+
)
|
|
136
|
+
return QueryResult(
|
|
137
|
+
text=payload["text"],
|
|
138
|
+
source=payload["source"],
|
|
139
|
+
score=payload["score"],
|
|
140
|
+
chunk_id=payload["chunk_id"],
|
|
141
|
+
metadata=payload.get("metadata", {}),
|
|
142
|
+
vector_score=payload.get("vector_score"),
|
|
143
|
+
bm25_score=payload.get("bm25_score"),
|
|
144
|
+
graph_score=payload.get("graph_score"),
|
|
145
|
+
rerank_score=payload.get("rerank_score"),
|
|
146
|
+
original_rank=payload.get("original_rank"),
|
|
147
|
+
relationship_path=payload.get("relationship_path"),
|
|
148
|
+
related_entities=payload.get("related_entities"),
|
|
149
|
+
source_type=payload.get("source_type", "doc"),
|
|
150
|
+
language=payload.get("language"),
|
|
151
|
+
explanation=explanation,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
98
155
|
class DocServeClient:
|
|
99
156
|
"""HTTP client for Doc-Serve API."""
|
|
100
157
|
|
|
@@ -114,6 +171,29 @@ class DocServeClient:
|
|
|
114
171
|
self.timeout = timeout
|
|
115
172
|
self._client = httpx.Client(timeout=timeout)
|
|
116
173
|
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_httpx(cls, client: httpx.Client) -> "DocServeClient":
|
|
176
|
+
"""Build a DocServeClient that uses a pre-constructed httpx.Client.
|
|
177
|
+
|
|
178
|
+
Used by the transport selector to inject a UDS-backed client
|
|
179
|
+
(see ``agent_brain_cli.client.transport.open_client``). The
|
|
180
|
+
inner client's ``base_url`` is preserved; this wrapper sends
|
|
181
|
+
relative paths only.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
client: An already-configured ``httpx.Client``. The wrapper
|
|
185
|
+
takes ownership and will close it on ``__exit__``.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A DocServeClient backed by ``client``.
|
|
189
|
+
"""
|
|
190
|
+
instance = cls.__new__(cls)
|
|
191
|
+
instance.base_url = "" # inner client carries the real base_url
|
|
192
|
+
timeout = client.timeout
|
|
193
|
+
instance.timeout = timeout.read or 30.0
|
|
194
|
+
instance._client = client
|
|
195
|
+
return instance
|
|
196
|
+
|
|
117
197
|
def __enter__(self) -> "DocServeClient":
|
|
118
198
|
return self
|
|
119
199
|
|
|
@@ -229,6 +309,7 @@ class DocServeClient:
|
|
|
229
309
|
source_types: list[str] | None = None,
|
|
230
310
|
languages: list[str] | None = None,
|
|
231
311
|
file_paths: list[str] | None = None,
|
|
312
|
+
explain: bool = False,
|
|
232
313
|
) -> QueryResponse:
|
|
233
314
|
"""
|
|
234
315
|
Query indexed documents.
|
|
@@ -242,11 +323,14 @@ class DocServeClient:
|
|
|
242
323
|
source_types: Filter by source types (doc, code, test).
|
|
243
324
|
languages: Filter by programming languages.
|
|
244
325
|
file_paths: Filter by file path patterns.
|
|
326
|
+
explain: When True, include structured per-result explanations
|
|
327
|
+
(matched terms, fusion breakdown, graph path, rerank movement,
|
|
328
|
+
and a 'why this rank' summary). See issue #159.
|
|
245
329
|
|
|
246
330
|
Returns:
|
|
247
331
|
QueryResponse with matching results.
|
|
248
332
|
"""
|
|
249
|
-
request_data = {
|
|
333
|
+
request_data: dict[str, Any] = {
|
|
250
334
|
"query": query_text,
|
|
251
335
|
"top_k": top_k,
|
|
252
336
|
"similarity_threshold": similarity_threshold,
|
|
@@ -259,21 +343,12 @@ class DocServeClient:
|
|
|
259
343
|
request_data["languages"] = languages
|
|
260
344
|
if file_paths is not None:
|
|
261
345
|
request_data["file_paths"] = file_paths
|
|
346
|
+
if explain:
|
|
347
|
+
request_data["explain"] = True
|
|
262
348
|
|
|
263
349
|
data = self._request("POST", "/query/", json=request_data)
|
|
264
350
|
|
|
265
|
-
results = [
|
|
266
|
-
QueryResult(
|
|
267
|
-
text=r["text"],
|
|
268
|
-
source=r["source"],
|
|
269
|
-
score=r["score"],
|
|
270
|
-
chunk_id=r["chunk_id"],
|
|
271
|
-
metadata=r.get("metadata", {}),
|
|
272
|
-
vector_score=r.get("vector_score"),
|
|
273
|
-
bm25_score=r.get("bm25_score"),
|
|
274
|
-
)
|
|
275
|
-
for r in data.get("results", [])
|
|
276
|
-
]
|
|
351
|
+
results = [_parse_query_result(r) for r in data.get("results", [])]
|
|
277
352
|
|
|
278
353
|
return QueryResponse(
|
|
279
354
|
results=results,
|
|
@@ -295,7 +370,6 @@ class DocServeClient:
|
|
|
295
370
|
include_types: list[str] | None = None,
|
|
296
371
|
generate_summaries: bool = False,
|
|
297
372
|
force: bool = False,
|
|
298
|
-
allow_external: bool = False,
|
|
299
373
|
injector_script: str | None = None,
|
|
300
374
|
folder_metadata_file: str | None = None,
|
|
301
375
|
dry_run: bool = False,
|
|
@@ -318,7 +392,6 @@ class DocServeClient:
|
|
|
318
392
|
include_types: File type preset names (e.g., ["python", "docs"]).
|
|
319
393
|
generate_summaries: Generate LLM summaries for code chunks.
|
|
320
394
|
force: Bypass deduplication and force a new job.
|
|
321
|
-
allow_external: Allow paths outside the project directory.
|
|
322
395
|
injector_script: Path to Python script exporting process_chunk().
|
|
323
396
|
folder_metadata_file: Path to JSON file with static metadata.
|
|
324
397
|
dry_run: Validate injector against sample chunks without indexing.
|
|
@@ -358,7 +431,7 @@ class DocServeClient:
|
|
|
358
431
|
"POST",
|
|
359
432
|
"/index/",
|
|
360
433
|
json=body,
|
|
361
|
-
params={"force": force
|
|
434
|
+
params={"force": force},
|
|
362
435
|
)
|
|
363
436
|
|
|
364
437
|
return IndexResponse(
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Transport selector — builds a DocServeClient over HTTP or UDS.
|
|
2
|
+
|
|
3
|
+
Plan §4.4: every CLI command should call ``open_client(ctx)`` instead of
|
|
4
|
+
``DocServeClient(base_url=resolved_url)`` directly. The selector reads
|
|
5
|
+
transport-related state off the Click context, calls
|
|
6
|
+
:func:`agent_brain_cli.config.resolve_transport`, then constructs the
|
|
7
|
+
appropriate wrapper.
|
|
8
|
+
|
|
9
|
+
The selector is intentionally tiny — three branches and one
|
|
10
|
+
``from_httpx`` call. Resolution logic stays in ``config.py`` so it is
|
|
11
|
+
testable without a live ``httpx.Client``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from ..config import resolve_transport
|
|
21
|
+
from .api_client import DocServeClient
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def open_client(ctx: click.Context, *, timeout: float = 30.0) -> DocServeClient:
|
|
25
|
+
"""Construct a ``DocServeClient`` over the transport selected by ``ctx``.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
ctx: Click context. Reads optional keys from ``ctx.obj``:
|
|
29
|
+
``transport_hint`` (``"http"`` / ``"uds"`` / ``"auto"`` /
|
|
30
|
+
``None``), ``base_url_override``, ``socket_path_override``,
|
|
31
|
+
and ``debug_transport``.
|
|
32
|
+
timeout: HTTP request timeout in seconds. Defaults to 30.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A live ``DocServeClient``. The caller is responsible for closing
|
|
36
|
+
it (use as a context manager).
|
|
37
|
+
"""
|
|
38
|
+
obj = ctx.obj or {}
|
|
39
|
+
transport, target = resolve_transport(
|
|
40
|
+
transport_hint=obj.get("transport_hint"),
|
|
41
|
+
base_url_override=obj.get("base_url_override"),
|
|
42
|
+
socket_path_override=obj.get("socket_path_override"),
|
|
43
|
+
)
|
|
44
|
+
if obj.get("debug_transport"):
|
|
45
|
+
click.echo(f"[debug-transport] {transport} -> {target}", err=True)
|
|
46
|
+
|
|
47
|
+
if transport == "http":
|
|
48
|
+
return DocServeClient(base_url=target, timeout=timeout)
|
|
49
|
+
|
|
50
|
+
# UDS: import lazily so HTTP-only invocations don't pay the cost.
|
|
51
|
+
from agent_brain_uds import make_client
|
|
52
|
+
|
|
53
|
+
inner = make_client(socket_path=Path(target), timeout=timeout)
|
|
54
|
+
return DocServeClient.from_httpx(inner)
|
|
@@ -5,8 +5,8 @@ from rich.console import Console
|
|
|
5
5
|
from rich.prompt import Confirm
|
|
6
6
|
from rich.table import Table
|
|
7
7
|
|
|
8
|
-
from ..client import ConnectionError,
|
|
9
|
-
from ..
|
|
8
|
+
from ..client import ConnectionError, ServerError
|
|
9
|
+
from ..client.transport import open_client
|
|
10
10
|
|
|
11
11
|
console = Console()
|
|
12
12
|
|
|
@@ -25,11 +25,15 @@ def cache_group() -> None:
|
|
|
25
25
|
help="Agent Brain server URL (default: from config or http://127.0.0.1:8000)",
|
|
26
26
|
)
|
|
27
27
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
28
|
-
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def cache_status(ctx: click.Context, url: str | None, json_output: bool) -> None:
|
|
29
30
|
"""Show embedding cache statistics."""
|
|
30
|
-
|
|
31
|
+
if url:
|
|
32
|
+
ctx.ensure_object(dict)
|
|
33
|
+
ctx.obj["base_url_override"] = url
|
|
34
|
+
ctx.obj["transport_hint"] = "http"
|
|
31
35
|
try:
|
|
32
|
-
with
|
|
36
|
+
with open_client(ctx) as client:
|
|
33
37
|
data = client.cache_status()
|
|
34
38
|
|
|
35
39
|
if json_output:
|
|
@@ -91,14 +95,18 @@ def cache_status(url: str | None, json_output: bool) -> None:
|
|
|
91
95
|
is_flag=True,
|
|
92
96
|
help="Skip confirmation prompt",
|
|
93
97
|
)
|
|
94
|
-
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def cache_clear(ctx: click.Context, url: str | None, yes: bool) -> None:
|
|
95
100
|
"""Clear all cached embeddings from the cache.
|
|
96
101
|
|
|
97
102
|
Without --yes, shows the current entry count and prompts for confirmation.
|
|
98
103
|
"""
|
|
99
|
-
|
|
104
|
+
if url:
|
|
105
|
+
ctx.ensure_object(dict)
|
|
106
|
+
ctx.obj["base_url_override"] = url
|
|
107
|
+
ctx.obj["transport_hint"] = "http"
|
|
100
108
|
try:
|
|
101
|
-
with
|
|
109
|
+
with open_client(ctx) as client:
|
|
102
110
|
if not yes:
|
|
103
111
|
# Get current count before asking
|
|
104
112
|
try:
|
|
@@ -7,8 +7,8 @@ import click
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from rich.table import Table
|
|
9
9
|
|
|
10
|
-
from ..client import ConnectionError,
|
|
11
|
-
from ..
|
|
10
|
+
from ..client import ConnectionError, ServerError
|
|
11
|
+
from ..client.transport import open_client
|
|
12
12
|
|
|
13
13
|
console = Console()
|
|
14
14
|
|
|
@@ -34,7 +34,8 @@ def folders_group() -> None:
|
|
|
34
34
|
help="Agent Brain server URL (default: from config or http://127.0.0.1:8000)",
|
|
35
35
|
)
|
|
36
36
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
37
|
-
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def list_folders_cmd(ctx: click.Context, url: str | None, json_output: bool) -> None:
|
|
38
39
|
"""List all indexed folders with chunk counts and last indexed time.
|
|
39
40
|
|
|
40
41
|
\b
|
|
@@ -42,10 +43,13 @@ def list_folders_cmd(url: str | None, json_output: bool) -> None:
|
|
|
42
43
|
agent-brain folders list
|
|
43
44
|
agent-brain folders list --json
|
|
44
45
|
"""
|
|
45
|
-
|
|
46
|
+
if url:
|
|
47
|
+
ctx.ensure_object(dict)
|
|
48
|
+
ctx.obj["base_url_override"] = url
|
|
49
|
+
ctx.obj["transport_hint"] = "http"
|
|
46
50
|
|
|
47
51
|
try:
|
|
48
|
-
with
|
|
52
|
+
with open_client(ctx) as client:
|
|
49
53
|
folders = client.list_folders()
|
|
50
54
|
|
|
51
55
|
if json_output:
|
|
@@ -139,7 +143,9 @@ def list_folders_cmd(url: str | None, json_output: bool) -> None:
|
|
|
139
143
|
help="Debounce interval in seconds for file watching (default: 30)",
|
|
140
144
|
)
|
|
141
145
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
146
|
+
@click.pass_context
|
|
142
147
|
def add_folder_cmd(
|
|
148
|
+
ctx: click.Context,
|
|
143
149
|
folder_path: str,
|
|
144
150
|
url: str | None,
|
|
145
151
|
include_code: bool,
|
|
@@ -157,11 +163,14 @@ def add_folder_cmd(
|
|
|
157
163
|
agent-brain folders add ./src --include-code
|
|
158
164
|
agent-brain folders add ./src --watch auto --debounce 10
|
|
159
165
|
"""
|
|
160
|
-
|
|
166
|
+
if url:
|
|
167
|
+
ctx.ensure_object(dict)
|
|
168
|
+
ctx.obj["base_url_override"] = url
|
|
169
|
+
ctx.obj["transport_hint"] = "http"
|
|
161
170
|
folder = Path(folder_path).resolve()
|
|
162
171
|
|
|
163
172
|
try:
|
|
164
|
-
with
|
|
173
|
+
with open_client(ctx) as client:
|
|
165
174
|
response = client.index(
|
|
166
175
|
folder_path=str(folder),
|
|
167
176
|
include_code=include_code,
|
|
@@ -218,7 +227,9 @@ def add_folder_cmd(
|
|
|
218
227
|
help="Skip confirmation prompt",
|
|
219
228
|
)
|
|
220
229
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
230
|
+
@click.pass_context
|
|
221
231
|
def remove_folder_cmd(
|
|
232
|
+
ctx: click.Context,
|
|
222
233
|
folder_path: str,
|
|
223
234
|
url: str | None,
|
|
224
235
|
yes: bool,
|
|
@@ -234,7 +245,10 @@ def remove_folder_cmd(
|
|
|
234
245
|
agent-brain folders remove ./docs --yes
|
|
235
246
|
agent-brain folders remove /absolute/path/to/docs
|
|
236
247
|
"""
|
|
237
|
-
|
|
248
|
+
if url:
|
|
249
|
+
ctx.ensure_object(dict)
|
|
250
|
+
ctx.obj["base_url_override"] = url
|
|
251
|
+
ctx.obj["transport_hint"] = "http"
|
|
238
252
|
resolved_path = str(Path(folder_path).resolve())
|
|
239
253
|
|
|
240
254
|
if not yes:
|
|
@@ -244,7 +258,7 @@ def remove_folder_cmd(
|
|
|
244
258
|
)
|
|
245
259
|
|
|
246
260
|
try:
|
|
247
|
-
with
|
|
261
|
+
with open_client(ctx) as client:
|
|
248
262
|
result = client.delete_folder(folder_path=resolved_path)
|
|
249
263
|
|
|
250
264
|
chunks_deleted = result.get("chunks_deleted", 0)
|
|
@@ -5,8 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
import click
|
|
6
6
|
from rich.console import Console
|
|
7
7
|
|
|
8
|
-
from ..client import ConnectionError,
|
|
9
|
-
from ..
|
|
8
|
+
from ..client import ConnectionError, ServerError
|
|
9
|
+
from ..client.transport import open_client
|
|
10
10
|
from ..diagnostics import doctor_hint_message
|
|
11
11
|
|
|
12
12
|
console = Console()
|
|
@@ -79,13 +79,10 @@ console = Console()
|
|
|
79
79
|
is_flag=True,
|
|
80
80
|
help="Force re-indexing even if embedding provider has changed",
|
|
81
81
|
)
|
|
82
|
-
@click.option(
|
|
83
|
-
"--allow-external",
|
|
84
|
-
is_flag=True,
|
|
85
|
-
help="Allow indexing paths outside the project directory",
|
|
86
|
-
)
|
|
87
82
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
83
|
+
@click.pass_context
|
|
88
84
|
def index_command(
|
|
85
|
+
ctx: click.Context,
|
|
89
86
|
folder_path: str,
|
|
90
87
|
url: str | None,
|
|
91
88
|
chunk_size: int,
|
|
@@ -99,15 +96,16 @@ def index_command(
|
|
|
99
96
|
exclude_patterns: str | None,
|
|
100
97
|
generate_summaries: bool,
|
|
101
98
|
force: bool,
|
|
102
|
-
allow_external: bool,
|
|
103
99
|
json_output: bool,
|
|
104
100
|
) -> None:
|
|
105
101
|
"""Index documents from a folder.
|
|
106
102
|
|
|
107
103
|
FOLDER_PATH: Path to the folder containing documents to index.
|
|
108
104
|
"""
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
if url:
|
|
106
|
+
ctx.ensure_object(dict)
|
|
107
|
+
ctx.obj["base_url_override"] = url
|
|
108
|
+
ctx.obj["transport_hint"] = "http"
|
|
111
109
|
|
|
112
110
|
# Resolve to absolute path
|
|
113
111
|
folder = Path(folder_path).resolve()
|
|
@@ -131,7 +129,7 @@ def index_command(
|
|
|
131
129
|
)
|
|
132
130
|
|
|
133
131
|
try:
|
|
134
|
-
with
|
|
132
|
+
with open_client(ctx) as client:
|
|
135
133
|
response = client.index(
|
|
136
134
|
folder_path=str(folder),
|
|
137
135
|
chunk_size=chunk_size,
|
|
@@ -145,7 +143,6 @@ def index_command(
|
|
|
145
143
|
exclude_patterns=exclude_patterns_list,
|
|
146
144
|
generate_summaries=generate_summaries,
|
|
147
145
|
force=force,
|
|
148
|
-
allow_external=allow_external,
|
|
149
146
|
)
|
|
150
147
|
|
|
151
148
|
if json_output:
|
|
@@ -5,8 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
import click
|
|
6
6
|
from rich.console import Console
|
|
7
7
|
|
|
8
|
-
from ..client import ConnectionError,
|
|
9
|
-
from ..
|
|
8
|
+
from ..client import ConnectionError, ServerError
|
|
9
|
+
from ..client.transport import open_client
|
|
10
10
|
|
|
11
11
|
console = Console()
|
|
12
12
|
|
|
@@ -98,13 +98,10 @@ console = Console()
|
|
|
98
98
|
is_flag=True,
|
|
99
99
|
help="Force re-indexing even if embedding provider has changed",
|
|
100
100
|
)
|
|
101
|
-
@click.option(
|
|
102
|
-
"--allow-external",
|
|
103
|
-
is_flag=True,
|
|
104
|
-
help="Allow indexing paths outside the project directory",
|
|
105
|
-
)
|
|
106
101
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
102
|
+
@click.pass_context
|
|
107
103
|
def inject_command(
|
|
104
|
+
ctx: click.Context,
|
|
108
105
|
folder_path: str,
|
|
109
106
|
injector_script: str | None,
|
|
110
107
|
folder_metadata: str | None,
|
|
@@ -121,7 +118,6 @@ def inject_command(
|
|
|
121
118
|
exclude_patterns: str | None,
|
|
122
119
|
generate_summaries: bool,
|
|
123
120
|
force: bool,
|
|
124
|
-
allow_external: bool,
|
|
125
121
|
json_output: bool,
|
|
126
122
|
) -> None:
|
|
127
123
|
"""Index documents from a folder with content injection.
|
|
@@ -152,8 +148,10 @@ def inject_command(
|
|
|
152
148
|
)
|
|
153
149
|
raise SystemExit(2)
|
|
154
150
|
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
if url:
|
|
152
|
+
ctx.ensure_object(dict)
|
|
153
|
+
ctx.obj["base_url_override"] = url
|
|
154
|
+
ctx.obj["transport_hint"] = "http"
|
|
157
155
|
|
|
158
156
|
# Resolve to absolute paths
|
|
159
157
|
folder = Path(folder_path).resolve()
|
|
@@ -181,7 +179,7 @@ def inject_command(
|
|
|
181
179
|
)
|
|
182
180
|
|
|
183
181
|
try:
|
|
184
|
-
with
|
|
182
|
+
with open_client(ctx) as client:
|
|
185
183
|
response = client.index(
|
|
186
184
|
folder_path=str(folder),
|
|
187
185
|
chunk_size=chunk_size,
|
|
@@ -195,7 +193,6 @@ def inject_command(
|
|
|
195
193
|
exclude_patterns=exclude_patterns_list,
|
|
196
194
|
generate_summaries=generate_summaries,
|
|
197
195
|
force=force,
|
|
198
|
-
allow_external=allow_external,
|
|
199
196
|
injector_script=resolved_script,
|
|
200
197
|
folder_metadata_file=resolved_metadata,
|
|
201
198
|
dry_run=dry_run,
|
|
@@ -268,4 +265,15 @@ def inject_command(
|
|
|
268
265
|
"\n[dim]A conflict occurred. "
|
|
269
266
|
"Check 'agent-brain jobs' for queue status.[/]"
|
|
270
267
|
)
|
|
268
|
+
elif e.status_code == 403:
|
|
269
|
+
# Injector allowlist rejected the script (issue #181).
|
|
270
|
+
console.print(
|
|
271
|
+
"\n[dim]Injector scripts must be allowlisted in "
|
|
272
|
+
"[bold].agent-brain/config.yaml[/] before they can run on "
|
|
273
|
+
"the server. Add an entry under [bold]injector_scripts:[/] "
|
|
274
|
+
"with the script's path and the sha256 of its contents "
|
|
275
|
+
"(run [bold]sha256sum <script>[/] to get the hash). "
|
|
276
|
+
"See docs/USER_GUIDE.md 'Content Injection' for the full "
|
|
277
|
+
"schema.[/]"
|
|
278
|
+
)
|
|
271
279
|
raise SystemExit(1) from e
|
|
@@ -9,7 +9,7 @@ from rich.panel import Panel
|
|
|
9
9
|
from rich.table import Table
|
|
10
10
|
|
|
11
11
|
from ..client import ConnectionError, DocServeClient, ServerError
|
|
12
|
-
from ..
|
|
12
|
+
from ..client.transport import open_client
|
|
13
13
|
from ..diagnostics import doctor_hint_message
|
|
14
14
|
|
|
15
15
|
console = Console()
|
|
@@ -285,7 +285,9 @@ def _watch_jobs(client: DocServeClient, limit: int) -> None:
|
|
|
285
285
|
help="Agent Brain server URL (default: from config or http://127.0.0.1:8000)",
|
|
286
286
|
)
|
|
287
287
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
288
|
+
@click.pass_context
|
|
288
289
|
def jobs_command(
|
|
290
|
+
ctx: click.Context,
|
|
289
291
|
job_id: str | None,
|
|
290
292
|
watch: bool,
|
|
291
293
|
cancel: bool,
|
|
@@ -305,7 +307,10 @@ def jobs_command(
|
|
|
305
307
|
agent-brain jobs JOB_ID # Show job details
|
|
306
308
|
agent-brain jobs JOB_ID --cancel # Cancel a job
|
|
307
309
|
"""
|
|
308
|
-
|
|
310
|
+
if url:
|
|
311
|
+
ctx.ensure_object(dict)
|
|
312
|
+
ctx.obj["base_url_override"] = url
|
|
313
|
+
ctx.obj["transport_hint"] = "http"
|
|
309
314
|
|
|
310
315
|
# Validate options
|
|
311
316
|
if cancel and not job_id:
|
|
@@ -318,7 +323,7 @@ def jobs_command(
|
|
|
318
323
|
raise click.UsageError("--watch cannot be used with --json")
|
|
319
324
|
|
|
320
325
|
try:
|
|
321
|
-
with
|
|
326
|
+
with open_client(ctx) as client:
|
|
322
327
|
if cancel and job_id:
|
|
323
328
|
_cancel_job(client, job_id, json_output)
|
|
324
329
|
elif watch:
|
|
@@ -3,18 +3,59 @@
|
|
|
3
3
|
import click
|
|
4
4
|
from rich.console import Console
|
|
5
5
|
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
6
7
|
from rich.text import Text
|
|
7
8
|
|
|
8
|
-
from ..client import ConnectionError,
|
|
9
|
-
from ..
|
|
9
|
+
from ..client import ConnectionError, ServerError
|
|
10
|
+
from ..client.api_client import ResultExplanation
|
|
11
|
+
from ..client.transport import open_client
|
|
10
12
|
from ..diagnostics import doctor_hint_message
|
|
11
13
|
|
|
12
14
|
console = Console()
|
|
13
15
|
|
|
14
16
|
|
|
15
|
-
def
|
|
16
|
-
"""
|
|
17
|
-
|
|
17
|
+
def _render_explanation(explanation: ResultExplanation) -> None:
|
|
18
|
+
"""Render a ResultExplanation as a sub-panel below the main result.
|
|
19
|
+
|
|
20
|
+
Layout (only rows that have data are emitted):
|
|
21
|
+
Why: <reason string>
|
|
22
|
+
Matched: term1, term2, ...
|
|
23
|
+
Fusion: key=value | key=value | ...
|
|
24
|
+
Graph: subject -> predicate -> object
|
|
25
|
+
Rerank: moved up/down N places (if any)
|
|
26
|
+
Fallback: graph -> vector
|
|
27
|
+
"""
|
|
28
|
+
table = Table(show_header=False, box=None, padding=(0, 1), expand=False)
|
|
29
|
+
table.add_column("label", style="cyan", no_wrap=True)
|
|
30
|
+
table.add_column("value", style="dim")
|
|
31
|
+
|
|
32
|
+
table.add_row("Why:", explanation.reason)
|
|
33
|
+
|
|
34
|
+
if explanation.matched_terms:
|
|
35
|
+
highlighted = Text(", ".join(explanation.matched_terms))
|
|
36
|
+
highlighted.highlight_words(explanation.matched_terms, style="bold yellow")
|
|
37
|
+
table.add_row("Matched:", highlighted)
|
|
38
|
+
|
|
39
|
+
if explanation.fusion:
|
|
40
|
+
parts = [f"{k}={v:.4f}" for k, v in explanation.fusion.items()]
|
|
41
|
+
table.add_row("Fusion:", " | ".join(parts))
|
|
42
|
+
|
|
43
|
+
if explanation.graph_path:
|
|
44
|
+
table.add_row("Graph:", " -> ".join(explanation.graph_path))
|
|
45
|
+
|
|
46
|
+
if explanation.rerank_movement is not None:
|
|
47
|
+
if explanation.rerank_movement > 0:
|
|
48
|
+
arrow = f"+{explanation.rerank_movement} (moved up)"
|
|
49
|
+
elif explanation.rerank_movement < 0:
|
|
50
|
+
arrow = f"{explanation.rerank_movement} (moved down)"
|
|
51
|
+
else:
|
|
52
|
+
arrow = "0 (held position)"
|
|
53
|
+
table.add_row("Rerank:", arrow)
|
|
54
|
+
|
|
55
|
+
if explanation.graph_fallback:
|
|
56
|
+
table.add_row("Fallback:", "graph returned no hits -> vector")
|
|
57
|
+
|
|
58
|
+
console.print(table)
|
|
18
59
|
|
|
19
60
|
|
|
20
61
|
@click.command("query")
|
|
@@ -63,6 +104,15 @@ def _get_default_url() -> str:
|
|
|
63
104
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
64
105
|
@click.option("--full", is_flag=True, help="Show full text content")
|
|
65
106
|
@click.option("--scores", is_flag=True, help="Show individual vector/BM25 scores")
|
|
107
|
+
@click.option(
|
|
108
|
+
"--explain",
|
|
109
|
+
is_flag=True,
|
|
110
|
+
help=(
|
|
111
|
+
"Show structured 'why this rank' explanations under each result: "
|
|
112
|
+
"matched terms, fusion breakdown, graph path, and rerank movement "
|
|
113
|
+
"(issue #159)."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
66
116
|
@click.option(
|
|
67
117
|
"--source-types",
|
|
68
118
|
help="Comma-separated source types to filter by (doc,code,test)",
|
|
@@ -75,7 +125,9 @@ def _get_default_url() -> str:
|
|
|
75
125
|
"--file-paths",
|
|
76
126
|
help="Comma-separated file path patterns to filter by (wildcards supported)",
|
|
77
127
|
)
|
|
128
|
+
@click.pass_context
|
|
78
129
|
def query_command(
|
|
130
|
+
ctx: click.Context,
|
|
79
131
|
query_text: str,
|
|
80
132
|
url: str | None,
|
|
81
133
|
top_k: int,
|
|
@@ -85,13 +137,16 @@ def query_command(
|
|
|
85
137
|
json_output: bool,
|
|
86
138
|
full: bool,
|
|
87
139
|
scores: bool,
|
|
140
|
+
explain: bool,
|
|
88
141
|
source_types: str | None,
|
|
89
142
|
languages: str | None,
|
|
90
143
|
file_paths: str | None,
|
|
91
144
|
) -> None:
|
|
92
145
|
"""Search indexed documents with natural language or keyword query."""
|
|
93
|
-
|
|
94
|
-
|
|
146
|
+
if url:
|
|
147
|
+
ctx.ensure_object(dict)
|
|
148
|
+
ctx.obj["base_url_override"] = url
|
|
149
|
+
ctx.obj["transport_hint"] = "http"
|
|
95
150
|
|
|
96
151
|
# Parse comma-separated lists
|
|
97
152
|
source_types_list = (
|
|
@@ -105,7 +160,7 @@ def query_command(
|
|
|
105
160
|
)
|
|
106
161
|
|
|
107
162
|
try:
|
|
108
|
-
with
|
|
163
|
+
with open_client(ctx) as client:
|
|
109
164
|
response = client.query(
|
|
110
165
|
query_text=query_text,
|
|
111
166
|
top_k=top_k,
|
|
@@ -115,6 +170,7 @@ def query_command(
|
|
|
115
170
|
source_types=source_types_list,
|
|
116
171
|
languages=languages_list,
|
|
117
172
|
file_paths=file_paths_list,
|
|
173
|
+
explain=explain,
|
|
118
174
|
)
|
|
119
175
|
|
|
120
176
|
if json_output:
|
|
@@ -202,6 +258,10 @@ def query_command(
|
|
|
202
258
|
)
|
|
203
259
|
)
|
|
204
260
|
|
|
261
|
+
# Issue #159: optional structured explanation block.
|
|
262
|
+
if explain and result.explanation is not None:
|
|
263
|
+
_render_explanation(result.explanation)
|
|
264
|
+
|
|
205
265
|
except ConnectionError as e:
|
|
206
266
|
if json_output:
|
|
207
267
|
import json
|
|
@@ -4,8 +4,8 @@ import click
|
|
|
4
4
|
from rich.console import Console
|
|
5
5
|
from rich.prompt import Confirm
|
|
6
6
|
|
|
7
|
-
from ..client import ConnectionError,
|
|
8
|
-
from ..
|
|
7
|
+
from ..client import ConnectionError, ServerError
|
|
8
|
+
from ..client.transport import open_client
|
|
9
9
|
from ..diagnostics import doctor_hint_message
|
|
10
10
|
|
|
11
11
|
console = Console()
|
|
@@ -25,13 +25,18 @@ console = Console()
|
|
|
25
25
|
help="Skip confirmation prompt",
|
|
26
26
|
)
|
|
27
27
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
28
|
-
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def reset_command(
|
|
30
|
+
ctx: click.Context, url: str | None, yes: bool, json_output: bool
|
|
31
|
+
) -> None:
|
|
29
32
|
"""Reset the index by deleting all indexed documents.
|
|
30
33
|
|
|
31
34
|
WARNING: This permanently removes all indexed content.
|
|
32
35
|
"""
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
if url:
|
|
37
|
+
ctx.ensure_object(dict)
|
|
38
|
+
ctx.obj["base_url_override"] = url
|
|
39
|
+
ctx.obj["transport_hint"] = "http"
|
|
35
40
|
|
|
36
41
|
# Confirm unless --yes flag provided
|
|
37
42
|
if not yes and not json_output:
|
|
@@ -43,7 +48,7 @@ def reset_command(url: str | None, yes: bool, json_output: bool) -> None:
|
|
|
43
48
|
return
|
|
44
49
|
|
|
45
50
|
try:
|
|
46
|
-
with
|
|
51
|
+
with open_client(ctx) as client:
|
|
47
52
|
response = client.reset()
|
|
48
53
|
|
|
49
54
|
if json_output:
|
|
@@ -25,6 +25,7 @@ STATE_DIR_NAME = ".agent-brain"
|
|
|
25
25
|
LOCK_FILE = "agent-brain.lock"
|
|
26
26
|
PID_FILE = "agent-brain.pid"
|
|
27
27
|
RUNTIME_FILE = "runtime.json"
|
|
28
|
+
SOCKET_FILE_NAME = "agent-brain.sock"
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def read_config(state_dir: Path) -> dict[str, Any]:
|
|
@@ -105,6 +106,31 @@ def check_health(base_url: str, timeout: float = 3.0) -> bool:
|
|
|
105
106
|
return False
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
def _probe_uds(socket_path: str, *, timeout_s: float = 2.0) -> bool:
|
|
110
|
+
"""Probe ``socket_path`` with a UDS GET /health/ — True if 200.
|
|
111
|
+
|
|
112
|
+
Used by ``start --uds`` after the HTTP readiness probe to confirm
|
|
113
|
+
the UDS transport is actually live before advertising it in
|
|
114
|
+
runtime.json (Phase 7 reviewer #4). Local import keeps httpx out of
|
|
115
|
+
the start command's hot path when UDS isn't requested.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
import httpx
|
|
119
|
+
except ImportError:
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
transport = httpx.HTTPTransport(uds=str(socket_path))
|
|
123
|
+
with httpx.Client(
|
|
124
|
+
transport=transport,
|
|
125
|
+
base_url="http://agent-brain",
|
|
126
|
+
timeout=timeout_s,
|
|
127
|
+
) as client:
|
|
128
|
+
response = client.get("/health/")
|
|
129
|
+
return bool(response.status_code == 200)
|
|
130
|
+
except Exception:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
108
134
|
def find_available_port(host: str, start_port: int, end_port: int) -> int | None:
|
|
109
135
|
"""Find an available port in the given range."""
|
|
110
136
|
for port in range(start_port, end_port + 1):
|
|
@@ -174,6 +200,16 @@ def update_registry(project_root: Path, state_dir: Path) -> None:
|
|
|
174
200
|
is_flag=True,
|
|
175
201
|
help="Enable strict mode: fail on critical provider configuration errors",
|
|
176
202
|
)
|
|
203
|
+
@click.option(
|
|
204
|
+
"--uds",
|
|
205
|
+
is_flag=True,
|
|
206
|
+
help="Also bind a Unix Domain Socket alongside the HTTP listener.",
|
|
207
|
+
)
|
|
208
|
+
@click.option(
|
|
209
|
+
"--uds-only",
|
|
210
|
+
is_flag=True,
|
|
211
|
+
help="Bind only the Unix Domain Socket (no TCP listener). Implies --uds.",
|
|
212
|
+
)
|
|
177
213
|
def start_command(
|
|
178
214
|
path: str | None,
|
|
179
215
|
host: str | None,
|
|
@@ -182,6 +218,8 @@ def start_command(
|
|
|
182
218
|
timeout: int,
|
|
183
219
|
json_output: bool,
|
|
184
220
|
strict: bool,
|
|
221
|
+
uds: bool,
|
|
222
|
+
uds_only: bool,
|
|
185
223
|
) -> None:
|
|
186
224
|
"""Start an Agent Brain server for this project.
|
|
187
225
|
|
|
@@ -305,24 +343,39 @@ def start_command(
|
|
|
305
343
|
if not json_output:
|
|
306
344
|
console.print(f"[dim]Starting server on {base_url}...[/]")
|
|
307
345
|
|
|
308
|
-
# Build server command
|
|
346
|
+
# Build server command. We invoke `agent_brain_server.api.main`'s
|
|
347
|
+
# Click CLI (not raw `python -m uvicorn`) so the server's `run()`
|
|
348
|
+
# function actually fires — it's the only place that branches on
|
|
349
|
+
# AGENT_BRAIN_UDS / _UDS_ONLY env vars to delegate to uds_bind
|
|
350
|
+
# (Phase 7 fix for reviewer finding A1). Behaviour for HTTP-only
|
|
351
|
+
# users is identical because run() ends up calling uvicorn.run()
|
|
352
|
+
# with the same args.
|
|
309
353
|
server_cmd = [
|
|
310
354
|
sys.executable,
|
|
311
355
|
"-m",
|
|
312
|
-
"
|
|
313
|
-
"agent_brain_server.api.main:app",
|
|
356
|
+
"agent_brain_server.api.main",
|
|
314
357
|
"--host",
|
|
315
358
|
bind_host,
|
|
316
359
|
"--port",
|
|
317
360
|
str(bind_port),
|
|
318
361
|
]
|
|
319
362
|
|
|
363
|
+
# --uds-only implies --uds (plan §7)
|
|
364
|
+
enable_uds = uds or uds_only
|
|
365
|
+
socket_path = str(state_dir / SOCKET_FILE_NAME) if enable_uds else None
|
|
366
|
+
|
|
320
367
|
# Set environment variables for server
|
|
321
368
|
env = os.environ.copy()
|
|
322
369
|
env["AGENT_BRAIN_PROJECT_ROOT"] = str(project_root)
|
|
323
370
|
env["AGENT_BRAIN_STATE_DIR"] = str(state_dir)
|
|
324
371
|
if strict:
|
|
325
372
|
env["AGENT_BRAIN_STRICT_MODE"] = "true"
|
|
373
|
+
if enable_uds:
|
|
374
|
+
env["AGENT_BRAIN_UDS"] = "1"
|
|
375
|
+
assert socket_path is not None # for mypy
|
|
376
|
+
env["AGENT_BRAIN_UDS_PATH"] = socket_path
|
|
377
|
+
if uds_only:
|
|
378
|
+
env["AGENT_BRAIN_UDS_ONLY"] = "1"
|
|
326
379
|
|
|
327
380
|
if foreground:
|
|
328
381
|
# Write runtime state even in foreground mode so CLI can discover the URL
|
|
@@ -340,6 +393,7 @@ def start_command(
|
|
|
340
393
|
"pid": os.getpid(), # Current PID (will be replaced by exec)
|
|
341
394
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
342
395
|
"foreground": True, # Mark as foreground for cleanup detection
|
|
396
|
+
"socket_path": socket_path,
|
|
343
397
|
}
|
|
344
398
|
write_runtime(state_dir, runtime_state)
|
|
345
399
|
|
|
@@ -391,6 +445,7 @@ def start_command(
|
|
|
391
445
|
"port": bind_port,
|
|
392
446
|
"pid": process.pid,
|
|
393
447
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
448
|
+
"socket_path": socket_path,
|
|
394
449
|
}
|
|
395
450
|
write_runtime(state_dir, runtime_state)
|
|
396
451
|
|
|
@@ -409,6 +464,22 @@ def start_command(
|
|
|
409
464
|
break
|
|
410
465
|
time.sleep(0.5)
|
|
411
466
|
|
|
467
|
+
# If UDS was requested, probe the socket too and unset the
|
|
468
|
+
# runtime.json::socket_path field if it isn't actually live.
|
|
469
|
+
# Otherwise a runtime.json entry for socket_path misleads any
|
|
470
|
+
# client (MCP, CLI auto-mode) that prefers UDS (Phase 7
|
|
471
|
+
# reviewer #4).
|
|
472
|
+
if ready and enable_uds and socket_path:
|
|
473
|
+
if not _probe_uds(socket_path, timeout_s=2.0):
|
|
474
|
+
runtime_state["socket_path"] = None
|
|
475
|
+
write_runtime(state_dir, runtime_state)
|
|
476
|
+
if not json_output:
|
|
477
|
+
console.print(
|
|
478
|
+
"[yellow]UDS socket probe failed — "
|
|
479
|
+
"runtime.json::socket_path cleared. "
|
|
480
|
+
"Clients will use HTTP.[/]"
|
|
481
|
+
)
|
|
482
|
+
|
|
412
483
|
if ready:
|
|
413
484
|
if json_output:
|
|
414
485
|
click.echo(
|
|
@@ -419,6 +490,7 @@ def start_command(
|
|
|
419
490
|
"pid": process.pid,
|
|
420
491
|
"project_root": str(project_root),
|
|
421
492
|
"log_file": str(stdout_log),
|
|
493
|
+
"socket_path": runtime_state.get("socket_path"),
|
|
422
494
|
},
|
|
423
495
|
indent=2,
|
|
424
496
|
)
|
|
@@ -5,8 +5,8 @@ from rich.console import Console
|
|
|
5
5
|
from rich.panel import Panel
|
|
6
6
|
from rich.table import Table
|
|
7
7
|
|
|
8
|
-
from ..client import ConnectionError,
|
|
9
|
-
from ..
|
|
8
|
+
from ..client import ConnectionError, ServerError
|
|
9
|
+
from ..client.transport import open_client
|
|
10
10
|
from ..diagnostics import doctor_hint_message
|
|
11
11
|
|
|
12
12
|
console = Console()
|
|
@@ -21,11 +21,19 @@ console = Console()
|
|
|
21
21
|
)
|
|
22
22
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
23
23
|
@click.option("--verbose", "-v", is_flag=True, help="Show additional detail")
|
|
24
|
-
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def status_command(
|
|
26
|
+
ctx: click.Context, url: str | None, json_output: bool, verbose: bool
|
|
27
|
+
) -> None:
|
|
25
28
|
"""Check Agent Brain server status and health."""
|
|
26
|
-
|
|
29
|
+
# ``--url`` is a per-command HTTP override — promote it to the
|
|
30
|
+
# transport-selector context so ``open_client`` honors it.
|
|
31
|
+
if url:
|
|
32
|
+
ctx.ensure_object(dict)
|
|
33
|
+
ctx.obj["base_url_override"] = url
|
|
34
|
+
ctx.obj["transport_hint"] = "http"
|
|
27
35
|
try:
|
|
28
|
-
with
|
|
36
|
+
with open_client(ctx) as client:
|
|
29
37
|
health = client.health()
|
|
30
38
|
indexing = client.status()
|
|
31
39
|
|
|
@@ -8,7 +8,7 @@ import logging
|
|
|
8
8
|
import os
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any, Literal
|
|
12
12
|
|
|
13
13
|
import yaml
|
|
14
14
|
from pydantic import BaseModel, Field
|
|
@@ -412,3 +412,75 @@ def get_server_url(config: AgentBrainConfig | None = None) -> str:
|
|
|
412
412
|
config = load_config()
|
|
413
413
|
|
|
414
414
|
return config.server.url
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def resolve_transport(
|
|
418
|
+
*,
|
|
419
|
+
transport_hint: str | None = None,
|
|
420
|
+
base_url_override: str | None = None,
|
|
421
|
+
socket_path_override: Path | None = None,
|
|
422
|
+
config: AgentBrainConfig | None = None,
|
|
423
|
+
) -> tuple[Literal["http", "uds"], str]:
|
|
424
|
+
"""Resolve the active transport and its connection target.
|
|
425
|
+
|
|
426
|
+
Sibling to :func:`get_server_url` — shares the same precedence chain
|
|
427
|
+
for HTTP, layers UDS detection on top. See plan §4.4 and §12.3 #6.
|
|
428
|
+
|
|
429
|
+
Precedence:
|
|
430
|
+
1. ``transport_hint`` argument (from CLI ``--transport`` flag)
|
|
431
|
+
2. ``AGENT_BRAIN_TRANSPORT`` environment variable
|
|
432
|
+
3. ``"auto"`` (try UDS first, fall back to HTTP)
|
|
433
|
+
|
|
434
|
+
For ``"uds"``: uses ``socket_path_override`` → ``AGENT_BRAIN_UDS_PATH``
|
|
435
|
+
env → ``agent_brain_uds.resolve_socket_path``. Validates with
|
|
436
|
+
:func:`agent_brain_uds.validate_socket` and raises on failure (so
|
|
437
|
+
an explicit ``--transport uds`` without a valid socket exits loudly,
|
|
438
|
+
per plan §12.3 #7).
|
|
439
|
+
|
|
440
|
+
For ``"http"``: uses ``base_url_override`` → :func:`get_server_url`.
|
|
441
|
+
|
|
442
|
+
For ``"auto"``: tries UDS; on any validation failure, falls back to
|
|
443
|
+
HTTP transparently.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
A ``(transport, target)`` tuple where ``transport`` is
|
|
447
|
+
``"http"`` or ``"uds"`` and ``target`` is the URL or socket path
|
|
448
|
+
(as a string).
|
|
449
|
+
"""
|
|
450
|
+
chosen = (
|
|
451
|
+
transport_hint or os.environ.get("AGENT_BRAIN_TRANSPORT") or "auto"
|
|
452
|
+
).lower()
|
|
453
|
+
|
|
454
|
+
def _resolve_uds_target() -> str:
|
|
455
|
+
from agent_brain_uds import resolve_socket_path, validate_socket
|
|
456
|
+
|
|
457
|
+
if socket_path_override is not None:
|
|
458
|
+
path = Path(socket_path_override).expanduser()
|
|
459
|
+
elif env_path := os.environ.get("AGENT_BRAIN_UDS_PATH"):
|
|
460
|
+
path = Path(env_path).expanduser()
|
|
461
|
+
else:
|
|
462
|
+
path = resolve_socket_path(None)
|
|
463
|
+
validate_socket(path)
|
|
464
|
+
return str(path)
|
|
465
|
+
|
|
466
|
+
def _resolve_http_target() -> str:
|
|
467
|
+
if base_url_override:
|
|
468
|
+
return base_url_override
|
|
469
|
+
return get_server_url(config)
|
|
470
|
+
|
|
471
|
+
if chosen == "uds":
|
|
472
|
+
return ("uds", _resolve_uds_target())
|
|
473
|
+
if chosen == "http":
|
|
474
|
+
return ("http", _resolve_http_target())
|
|
475
|
+
|
|
476
|
+
# "auto" — try UDS, fall back to HTTP on any validation failure.
|
|
477
|
+
try:
|
|
478
|
+
from agent_brain_uds import AgentBrainUdsError
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
return ("uds", _resolve_uds_target())
|
|
482
|
+
except (AgentBrainUdsError, OSError, FileNotFoundError):
|
|
483
|
+
return ("http", _resolve_http_target())
|
|
484
|
+
except ImportError:
|
|
485
|
+
# agent_brain_uds not installed — HTTP is the only option.
|
|
486
|
+
return ("http", _resolve_http_target())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "agent-brain-cli"
|
|
3
|
-
version = "10.0
|
|
3
|
+
version = "10.2.0"
|
|
4
4
|
description = "Agent Brain CLI - Command-line interface for managing AI agent memory and knowledge retrieval"
|
|
5
5
|
authors = ["Spillwave Solutions"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -27,7 +27,8 @@ httpx = "^0.28.0"
|
|
|
27
27
|
rich = "^13.9.0"
|
|
28
28
|
pyyaml = "^6.0.0"
|
|
29
29
|
pydantic = "^2.10.0"
|
|
30
|
-
agent-brain-rag = "^10.0
|
|
30
|
+
agent-brain-rag = "^10.2.0"
|
|
31
|
+
agent-brain-uds = "^10.2.0"
|
|
31
32
|
|
|
32
33
|
[tool.poetry.group.dev.dependencies]
|
|
33
34
|
pytest = "^8.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/claude_converter.py
RENAMED
|
File without changes
|
{agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/codex_converter.py
RENAMED
|
File without changes
|
|
File without changes
|
{agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/gemini_converter.py
RENAMED
|
File without changes
|
{agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/opencode_converter.py
RENAMED
|
File without changes
|
|
File without changes
|
{agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/skill_runtime_converter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|