hyperspell-brain 0.4.0__py3-none-any.whl
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.
- hyperbrain/__init__.py +3 -0
- hyperbrain/cli.py +141 -0
- hyperbrain/client.py +93 -0
- hyperbrain/commands/__init__.py +0 -0
- hyperbrain/commands/_query.py +55 -0
- hyperbrain/commands/api.py +89 -0
- hyperbrain/commands/ask.py +79 -0
- hyperbrain/commands/auth.py +43 -0
- hyperbrain/commands/brain.py +147 -0
- hyperbrain/commands/completion.py +70 -0
- hyperbrain/commands/config.py +116 -0
- hyperbrain/commands/connections.py +39 -0
- hyperbrain/commands/doctor.py +67 -0
- hyperbrain/commands/guide.py +132 -0
- hyperbrain/commands/integrations.py +25 -0
- hyperbrain/commands/login.py +145 -0
- hyperbrain/commands/memories.py +233 -0
- hyperbrain/commands/remember.py +91 -0
- hyperbrain/commands/search.py +48 -0
- hyperbrain/commands/structure.py +141 -0
- hyperbrain/commands/update.py +184 -0
- hyperbrain/config.py +139 -0
- hyperbrain/context.py +23 -0
- hyperbrain/mcp.py +361 -0
- hyperbrain/output.py +174 -0
- hyperspell_brain-0.4.0.dist-info/METADATA +217 -0
- hyperspell_brain-0.4.0.dist-info/RECORD +30 -0
- hyperspell_brain-0.4.0.dist-info/WHEEL +4 -0
- hyperspell_brain-0.4.0.dist-info/entry_points.txt +3 -0
- hyperspell_brain-0.4.0.dist-info/licenses/LICENSE +21 -0
hyperbrain/__init__.py
ADDED
hyperbrain/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""hyperbrain — agent-first CLI for the Hyperspell company brain.
|
|
2
|
+
|
|
3
|
+
Root command: wires global options into a per-invocation AppCtx, then dispatches
|
|
4
|
+
to the command modules. Designed so an agent can call any subcommand
|
|
5
|
+
non-interactively and parse JSON from stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import __version__, config
|
|
15
|
+
from .context import AppCtx
|
|
16
|
+
from .output import FORMAT_OPTION, Format, emit, pick, set_output_options
|
|
17
|
+
from .commands import api as api_cmd
|
|
18
|
+
from .commands import ask as ask_cmd
|
|
19
|
+
from .commands import auth as auth_cmd
|
|
20
|
+
from .commands import brain as brain_cmd
|
|
21
|
+
from .commands import completion as completion_cmd
|
|
22
|
+
from .commands import config as config_cmd
|
|
23
|
+
from .commands import connections as connections_cmd
|
|
24
|
+
from .commands import doctor as doctor_cmd
|
|
25
|
+
from .commands import guide as guide_cmd
|
|
26
|
+
from .commands import integrations as integrations_cmd
|
|
27
|
+
from .commands import login as login_cmd
|
|
28
|
+
from .commands import memories as memories_cmd
|
|
29
|
+
from .commands import remember as remember_cmd
|
|
30
|
+
from .commands import search as search_cmd
|
|
31
|
+
from .commands import structure as structure_cmd
|
|
32
|
+
from .commands import update as update_cmd
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="hyperbrain",
|
|
36
|
+
help="Agent-first CLI for the Hyperspell company brain. JSON by default; pipe-friendly.",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
add_completion=False,
|
|
39
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _version(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
typer.echo(__version__)
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def main(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
api_key: Optional[str] = typer.Option(
|
|
53
|
+
None, "--api-key", envvar="HYPERSPELL_API_KEY", help="API key (or device JWT)."
|
|
54
|
+
),
|
|
55
|
+
api_url: Optional[str] = typer.Option(
|
|
56
|
+
None, "--api-url", envvar="HYPERSPELL_BASE_URL", help="API base URL."
|
|
57
|
+
),
|
|
58
|
+
as_user: Optional[str] = typer.Option(
|
|
59
|
+
None, "--as-user", help="Act as this user (X-As-User; API-key auth only)."
|
|
60
|
+
),
|
|
61
|
+
output_format: Format = typer.Option(
|
|
62
|
+
Format.AUTO,
|
|
63
|
+
"--format",
|
|
64
|
+
"-o",
|
|
65
|
+
help="Output format: auto (table on TTY, else json), json, table.",
|
|
66
|
+
),
|
|
67
|
+
fields: Optional[str] = typer.Option(
|
|
68
|
+
None,
|
|
69
|
+
"--fields",
|
|
70
|
+
help="Comma-separated top-level keys to keep in the output (token economy).",
|
|
71
|
+
),
|
|
72
|
+
quiet: bool = typer.Option(
|
|
73
|
+
False,
|
|
74
|
+
"--quiet",
|
|
75
|
+
"-q",
|
|
76
|
+
help="Suppress the stdout data channel; branch on the exit code instead.",
|
|
77
|
+
),
|
|
78
|
+
_v: bool = typer.Option(
|
|
79
|
+
False, "--version", callback=_version, is_eager=True, help="Show version and exit."
|
|
80
|
+
),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Resolve credentials/endpoint once and stash them for subcommands."""
|
|
83
|
+
set_output_options(fields=fields, quiet=quiet)
|
|
84
|
+
ctx.obj = AppCtx(
|
|
85
|
+
resolved=config.resolve(api_key=api_key, api_url=api_url, as_user=as_user),
|
|
86
|
+
fmt=output_format,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Top-level verbs.
|
|
91
|
+
app.command()(login_cmd.login)
|
|
92
|
+
app.command()(ask_cmd.ask)
|
|
93
|
+
app.command()(search_cmd.search)
|
|
94
|
+
app.command()(remember_cmd.remember)
|
|
95
|
+
app.command(name="api")(api_cmd.api)
|
|
96
|
+
app.command()(update_cmd.update)
|
|
97
|
+
app.command()(doctor_cmd.doctor)
|
|
98
|
+
app.command()(completion_cmd.completion)
|
|
99
|
+
app.command(name="help")(guide_cmd.help)
|
|
100
|
+
|
|
101
|
+
# Grouped nouns.
|
|
102
|
+
app.add_typer(memories_cmd.app, name="memories")
|
|
103
|
+
app.add_typer(connections_cmd.app, name="connections")
|
|
104
|
+
app.add_typer(integrations_cmd.app, name="integrations")
|
|
105
|
+
app.add_typer(brain_cmd.app, name="brain")
|
|
106
|
+
app.add_typer(structure_cmd.app, name="structure")
|
|
107
|
+
app.add_typer(config_cmd.app, name="config")
|
|
108
|
+
app.add_typer(auth_cmd.app, name="auth")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _describe(cmd: Any, name: str) -> dict[str, Any]:
|
|
112
|
+
"""Recursively describe a click command tree using duck-typing (no click import)."""
|
|
113
|
+
node: dict[str, Any] = {"name": name, "help": (getattr(cmd, "help", "") or "").strip()}
|
|
114
|
+
params = []
|
|
115
|
+
for p in getattr(cmd, "params", []):
|
|
116
|
+
kind = getattr(p, "param_type_name", "parameter") # 'option' | 'argument'
|
|
117
|
+
entry: dict[str, Any] = {"name": p.name, "kind": kind, "required": bool(p.required)}
|
|
118
|
+
if kind == "option":
|
|
119
|
+
entry["flags"] = list(getattr(p, "opts", []))
|
|
120
|
+
entry["help"] = getattr(p, "help", "") or ""
|
|
121
|
+
params.append(entry)
|
|
122
|
+
if params:
|
|
123
|
+
node["params"] = params
|
|
124
|
+
subcommands = getattr(cmd, "commands", None) # dict on click Groups
|
|
125
|
+
if isinstance(subcommands, dict):
|
|
126
|
+
node["commands"] = [_describe(sub, sub_name) for sub_name, sub in subcommands.items()]
|
|
127
|
+
return node
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def schema(
|
|
132
|
+
ctx: typer.Context,
|
|
133
|
+
fmt: Optional[Format] = FORMAT_OPTION,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Dump the full command tree as JSON, so an agent can introspect capabilities."""
|
|
136
|
+
root = typer.main.get_command(app)
|
|
137
|
+
emit(_describe(root, "hyperbrain"), pick(ctx, fmt))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
app()
|
hyperbrain/client.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""SDK client construction + a raw-request escape hatch.
|
|
2
|
+
|
|
3
|
+
Most commands use the typed ``hyperspell`` SDK. A handful of endpoints the SDK
|
|
4
|
+
doesn't model yet — context-document *trees* and the ``/admin/*`` surface — are
|
|
5
|
+
reached through the SDK's own underlying httpx client via :func:`raw`, so they
|
|
6
|
+
share the same auth, base URL, retries, and timeouts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
from typing import Any, Iterator
|
|
13
|
+
|
|
14
|
+
from hyperspell import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIStatusError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
Hyperspell,
|
|
19
|
+
HyperspellError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
from .output import Exit, fail
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build(resolved: config.Resolved) -> Hyperspell:
|
|
28
|
+
"""Construct an authenticated SDK client, or exit cleanly if unauthenticated."""
|
|
29
|
+
if not resolved.credential:
|
|
30
|
+
raise fail(
|
|
31
|
+
"No credential found. Pass --api-key, set HYPERSPELL_API_KEY, "
|
|
32
|
+
"or run `hyperbrain login` to populate ~/.hyperspell/config.toml.",
|
|
33
|
+
Exit.AUTH,
|
|
34
|
+
)
|
|
35
|
+
return Hyperspell(
|
|
36
|
+
api_key=resolved.credential,
|
|
37
|
+
user_id=resolved.user_id,
|
|
38
|
+
base_url=resolved.base_url,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def raw(
|
|
43
|
+
client: Hyperspell,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
body: dict[str, Any] | None = None,
|
|
48
|
+
params: dict[str, Any] | None = None,
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""Call an endpoint the SDK doesn't model, reusing its auth + transport.
|
|
51
|
+
|
|
52
|
+
Returns parsed JSON (dict/list) and never raises for a non-2xx — callers get
|
|
53
|
+
the decoded error body so they can surface the API's own message.
|
|
54
|
+
"""
|
|
55
|
+
options: dict[str, Any] = {"cast_to": object}
|
|
56
|
+
if body is not None:
|
|
57
|
+
options["body"] = body
|
|
58
|
+
if params is not None:
|
|
59
|
+
options["options"] = {"params": params}
|
|
60
|
+
verb = method.lower()
|
|
61
|
+
fn = getattr(client, verb, None)
|
|
62
|
+
if fn is None:
|
|
63
|
+
raise fail(f"Unsupported HTTP method: {method}", Exit.USAGE)
|
|
64
|
+
return fn(path, **options)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def api_errors() -> Iterator[None]:
|
|
69
|
+
"""Translate SDK exceptions into structured stderr errors + stable exit codes."""
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
except AuthenticationError as exc:
|
|
73
|
+
raise fail(f"Authentication failed: {exc}", Exit.AUTH) from exc
|
|
74
|
+
except NotFoundError as exc:
|
|
75
|
+
raise fail(f"Not found: {exc}", Exit.NOT_FOUND) from exc
|
|
76
|
+
except APIStatusError as exc:
|
|
77
|
+
detail = _detail(exc)
|
|
78
|
+
raise fail(f"API error {exc.status_code}: {detail}", Exit.API) from exc
|
|
79
|
+
except APIConnectionError as exc:
|
|
80
|
+
raise fail(f"Could not reach the API: {exc}", Exit.API) from exc
|
|
81
|
+
except HyperspellError as exc:
|
|
82
|
+
raise fail(str(exc), Exit.ERROR) from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _detail(exc: APIStatusError) -> str:
|
|
86
|
+
"""Pull the API's own error message out of a non-2xx response when present."""
|
|
87
|
+
try:
|
|
88
|
+
body = exc.response.json()
|
|
89
|
+
except Exception:
|
|
90
|
+
return str(exc)
|
|
91
|
+
if isinstance(body, dict):
|
|
92
|
+
return str(body.get("detail") or body.get("error") or body)
|
|
93
|
+
return str(body)
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared retrieval logic behind `brain ask` and `brain search`.
|
|
2
|
+
|
|
3
|
+
Both hit ``POST /memories/query``; ``ask`` flips ``answer=true`` and dials up
|
|
4
|
+
effort, while ``search`` returns ranked documents only. ``effort`` is passed via
|
|
5
|
+
``extra_body`` because the pinned SDK predates that field — the API honors it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from hyperspell import Hyperspell
|
|
11
|
+
|
|
12
|
+
from .. import client as client_mod
|
|
13
|
+
|
|
14
|
+
VALID_EFFORT = {"minimal", "low", "medium", "high"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def default_sources(client: Hyperspell) -> list[str]:
|
|
18
|
+
"""Sources to query when the caller didn't pick any: everything this token has.
|
|
19
|
+
|
|
20
|
+
Reads the app's connected integrations from ``/auth/me`` (plus ``vault``,
|
|
21
|
+
which is always available) so "ask the brain" really means the whole brain.
|
|
22
|
+
The API drops any source it doesn't support, so a generous list is safe.
|
|
23
|
+
"""
|
|
24
|
+
me = client_mod.raw(client, "GET", "/auth/me")
|
|
25
|
+
integs = []
|
|
26
|
+
if isinstance(me, dict):
|
|
27
|
+
integs = me.get("available_integrations") or []
|
|
28
|
+
return sorted({*integs, "vault"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_query(
|
|
32
|
+
client: Hyperspell,
|
|
33
|
+
*,
|
|
34
|
+
query: str,
|
|
35
|
+
answer: bool,
|
|
36
|
+
effort: str,
|
|
37
|
+
sources: list[str] | None,
|
|
38
|
+
max_results: int,
|
|
39
|
+
collection: str | None,
|
|
40
|
+
):
|
|
41
|
+
"""Execute a query and return the SDK's QueryResult."""
|
|
42
|
+
options: dict[str, object] = {}
|
|
43
|
+
if collection:
|
|
44
|
+
options["filter"] = {"collection": collection}
|
|
45
|
+
|
|
46
|
+
kwargs: dict[str, object] = {
|
|
47
|
+
"query": query,
|
|
48
|
+
"answer": answer,
|
|
49
|
+
"max_results": max_results,
|
|
50
|
+
"sources": sources if sources else default_sources(client),
|
|
51
|
+
"extra_body": {"effort": effort},
|
|
52
|
+
}
|
|
53
|
+
if options:
|
|
54
|
+
kwargs["options"] = options
|
|
55
|
+
return client.memories.search(**kwargs)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`hyperbrain api` — a raw authenticated request to any Hyperspell endpoint.
|
|
2
|
+
|
|
3
|
+
The escape hatch that lets an agent reach anything the API exposes, not just the
|
|
4
|
+
verbs we've modeled. Reuses the resolved credential + transport, returns parsed
|
|
5
|
+
JSON, and never invents arguments — you give it the method, path, and body.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .. import client as client_mod
|
|
18
|
+
from .. import context, output
|
|
19
|
+
|
|
20
|
+
_METHODS = ("GET", "POST", "PATCH", "PUT", "DELETE")
|
|
21
|
+
# Methods that carry a request body (and may read it from stdin).
|
|
22
|
+
_BODY_METHODS = ("POST", "PATCH", "PUT", "DELETE")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def api(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
method: str = typer.Argument(..., help=f"HTTP method: {', '.join(_METHODS)}."),
|
|
28
|
+
path: str = typer.Argument(..., help="API path, e.g. /memories/list or /admin/apps/foo/..."),
|
|
29
|
+
data: Optional[str] = typer.Option(
|
|
30
|
+
None, "--data", "-d", help="JSON request body. Omit to read stdin (write methods)."
|
|
31
|
+
),
|
|
32
|
+
file: Optional[Path] = typer.Option(
|
|
33
|
+
None, "--file", "-f", help="Read the JSON request body from a file."
|
|
34
|
+
),
|
|
35
|
+
query: Optional[list[str]] = typer.Option(
|
|
36
|
+
None, "--query", "-q", help="Query param as key=value (repeatable)."
|
|
37
|
+
),
|
|
38
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Make a raw authenticated request to any endpoint and print the JSON response."""
|
|
41
|
+
verb = method.upper()
|
|
42
|
+
if verb not in _METHODS:
|
|
43
|
+
raise output.fail(f"Method must be one of {_METHODS}.", output.Exit.USAGE)
|
|
44
|
+
if not path.startswith("/"):
|
|
45
|
+
raise output.fail("Path must start with '/'.", output.Exit.USAGE)
|
|
46
|
+
|
|
47
|
+
body = _resolve_body(verb, data, file)
|
|
48
|
+
params = _parse_query(query)
|
|
49
|
+
|
|
50
|
+
app_ctx = context.get(ctx)
|
|
51
|
+
cli = client_mod.build(app_ctx.resolved)
|
|
52
|
+
with client_mod.api_errors():
|
|
53
|
+
result = client_mod.raw(cli, verb, path, body=body, params=params)
|
|
54
|
+
output.emit(result, output.pick(ctx, fmt))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_body(verb: str, data: Optional[str], file: Optional[Path]) -> Optional[dict[str, Any]]:
|
|
58
|
+
raw_text: Optional[str] = None
|
|
59
|
+
if file is not None:
|
|
60
|
+
if not file.exists():
|
|
61
|
+
raise output.fail(f"File not found: {file}", output.Exit.NOT_FOUND)
|
|
62
|
+
raw_text = file.read_text()
|
|
63
|
+
elif data is not None:
|
|
64
|
+
raw_text = data
|
|
65
|
+
elif verb in _BODY_METHODS and not sys.stdin.isatty():
|
|
66
|
+
piped = sys.stdin.read()
|
|
67
|
+
raw_text = piped if piped.strip() else None
|
|
68
|
+
|
|
69
|
+
if raw_text is None:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
parsed = json.loads(raw_text)
|
|
73
|
+
except json.JSONDecodeError as exc:
|
|
74
|
+
raise output.fail(f"Request body is not valid JSON: {exc}", output.Exit.USAGE) from exc
|
|
75
|
+
if not isinstance(parsed, dict):
|
|
76
|
+
raise output.fail("Request body must be a JSON object.", output.Exit.USAGE)
|
|
77
|
+
return parsed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_query(query: Optional[list[str]]) -> Optional[dict[str, str]]:
|
|
81
|
+
if not query:
|
|
82
|
+
return None
|
|
83
|
+
params: dict[str, str] = {}
|
|
84
|
+
for item in query:
|
|
85
|
+
key, sep, value = item.partition("=")
|
|
86
|
+
if not sep:
|
|
87
|
+
raise output.fail(f"--query '{item}' must be key=value.", output.Exit.USAGE)
|
|
88
|
+
params[key] = value
|
|
89
|
+
return params
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`brain ask` — ask the company brain a question and get a synthesized answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import client as client_mod
|
|
11
|
+
from .. import context, output
|
|
12
|
+
from ._query import VALID_EFFORT, run_query
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ask(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
query: str = typer.Argument(..., help="The question to ask the company brain."),
|
|
18
|
+
effort: str = typer.Option(
|
|
19
|
+
"high",
|
|
20
|
+
"--effort",
|
|
21
|
+
"-e",
|
|
22
|
+
help="Compute to spend: minimal | low | medium | high. Higher = better recall, more latency.",
|
|
23
|
+
),
|
|
24
|
+
source: Optional[list[str]] = typer.Option(
|
|
25
|
+
None,
|
|
26
|
+
"--source",
|
|
27
|
+
"-s",
|
|
28
|
+
help="Restrict to these sources (repeatable). Default: all connected sources.",
|
|
29
|
+
),
|
|
30
|
+
collection: Optional[str] = typer.Option(None, "--collection", help="Scope to a collection."),
|
|
31
|
+
max_results: int = typer.Option(10, "--max-results", "-n", help="Max source documents to use."),
|
|
32
|
+
answer_only: bool = typer.Option(
|
|
33
|
+
False, "--answer-only", help="Emit just the answer string, not the JSON envelope."
|
|
34
|
+
),
|
|
35
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Synthesize an answer from the brain, with the supporting documents."""
|
|
38
|
+
if effort not in VALID_EFFORT:
|
|
39
|
+
raise output.fail(
|
|
40
|
+
f"Invalid --effort {effort!r}; choose one of {sorted(VALID_EFFORT)}.",
|
|
41
|
+
output.Exit.USAGE,
|
|
42
|
+
)
|
|
43
|
+
app = context.get(ctx)
|
|
44
|
+
cli = client_mod.build(app.resolved)
|
|
45
|
+
with client_mod.api_errors():
|
|
46
|
+
result = run_query(
|
|
47
|
+
cli,
|
|
48
|
+
query=query,
|
|
49
|
+
answer=True,
|
|
50
|
+
effort=effort,
|
|
51
|
+
sources=source,
|
|
52
|
+
max_results=max_results,
|
|
53
|
+
collection=collection,
|
|
54
|
+
)
|
|
55
|
+
if answer_only:
|
|
56
|
+
typer.echo(getattr(result, "answer", None) or "")
|
|
57
|
+
return
|
|
58
|
+
resolved = output.pick(ctx, fmt)
|
|
59
|
+
if resolved is output.Format.TABLE or (resolved is output.Format.AUTO and sys.stdout.isatty()):
|
|
60
|
+
_render_answer(result)
|
|
61
|
+
else:
|
|
62
|
+
output.emit(result, resolved)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_answer(result: object) -> None:
|
|
66
|
+
"""Human-friendly terminal view: the prose answer plus its sources."""
|
|
67
|
+
from rich.console import Console
|
|
68
|
+
from rich.panel import Panel
|
|
69
|
+
|
|
70
|
+
console = Console()
|
|
71
|
+
answer = getattr(result, "answer", None) or "[dim](no answer returned)[/dim]"
|
|
72
|
+
console.print(Panel(answer, title="answer", border_style="cyan"))
|
|
73
|
+
docs = getattr(result, "documents", None) or []
|
|
74
|
+
if docs:
|
|
75
|
+
console.print("[bold]sources[/bold]")
|
|
76
|
+
for i, doc in enumerate(docs, 1):
|
|
77
|
+
title = getattr(doc, "title", None) or getattr(doc, "resource_id", "") or "?"
|
|
78
|
+
src = getattr(doc, "source", "")
|
|
79
|
+
console.print(f" {i}. [cyan]{src}[/cyan] {title}")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""`brain auth` — inspect the resolved credential and verify it against the API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import client as client_mod
|
|
10
|
+
from .. import context, output
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Authentication status.", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _redact(cred: str | None) -> str | None:
|
|
16
|
+
if not cred:
|
|
17
|
+
return None
|
|
18
|
+
return f"{cred[:6]}…{cred[-4:]}" if len(cred) > 12 else "set"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def status(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
check: bool = typer.Option(
|
|
25
|
+
True, "--check/--no-check", help="Verify the credential against the API (/auth/me)."
|
|
26
|
+
),
|
|
27
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Show where the credential came from and (by default) whether it works."""
|
|
30
|
+
app_ctx = context.get(ctx)
|
|
31
|
+
r = app_ctx.resolved
|
|
32
|
+
result: dict[str, object] = {
|
|
33
|
+
"authenticated": bool(r.credential),
|
|
34
|
+
"credential": _redact(r.credential),
|
|
35
|
+
"credential_source": r.source,
|
|
36
|
+
"base_url": r.base_url,
|
|
37
|
+
"user_id": r.user_id,
|
|
38
|
+
}
|
|
39
|
+
if check and r.credential:
|
|
40
|
+
cli = client_mod.build(r)
|
|
41
|
+
with client_mod.api_errors():
|
|
42
|
+
result["identity"] = client_mod.raw(cli, "GET", "/auth/me")
|
|
43
|
+
output.emit(result, output.pick(ctx, fmt))
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""`brain generate` and friends — the synthesized three-tier company brain.
|
|
2
|
+
|
|
3
|
+
These hit endpoints the SDK doesn't model yet, so they go through the raw escape
|
|
4
|
+
hatch. ``generate`` kicks off an async Temporal workflow and (by default) polls
|
|
5
|
+
progress to completion, streaming phase updates to stderr so stdout stays a
|
|
6
|
+
clean channel for the final tree JSON.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .. import client as client_mod
|
|
18
|
+
from .. import context, output
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Generate and fetch the synthesized company-brain tree.", no_args_is_help=True
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
TERMINAL = {"completed", "published", "failed"}
|
|
25
|
+
POLL_INTERVAL_S = 3.0
|
|
26
|
+
DEFAULT_TIMEOUT_S = 900 # 15 min — matches the documented worst-case synthesis time
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def generate(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
source: Optional[list[str]] = typer.Option(
|
|
33
|
+
None, "--source", "-s", help="Sources to include (repeatable). Default: all synced data."
|
|
34
|
+
),
|
|
35
|
+
workstream: Optional[str] = typer.Option(
|
|
36
|
+
None, "--workstream", help="Generate for this workstream only (skip auto-detection)."
|
|
37
|
+
),
|
|
38
|
+
user_id: Optional[str] = typer.Option(
|
|
39
|
+
None, "--user-id", help="Scope the personal tier to this user's data."
|
|
40
|
+
),
|
|
41
|
+
wait: bool = typer.Option(
|
|
42
|
+
True,
|
|
43
|
+
"--wait/--no-wait",
|
|
44
|
+
help="Poll to completion (default) or return the tree_id and exit.",
|
|
45
|
+
),
|
|
46
|
+
timeout: int = typer.Option(DEFAULT_TIMEOUT_S, "--timeout", help="Max seconds to wait."),
|
|
47
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Kick off a company-brain tree generation."""
|
|
50
|
+
app_ctx = context.get(ctx)
|
|
51
|
+
cli = client_mod.build(app_ctx.resolved)
|
|
52
|
+
resolved_fmt = output.pick(ctx, fmt)
|
|
53
|
+
|
|
54
|
+
body: dict[str, Any] = {}
|
|
55
|
+
if source:
|
|
56
|
+
body["sources"] = source
|
|
57
|
+
if workstream:
|
|
58
|
+
body["workstream_name"] = workstream
|
|
59
|
+
if user_id:
|
|
60
|
+
body["user_id"] = user_id
|
|
61
|
+
|
|
62
|
+
with client_mod.api_errors():
|
|
63
|
+
started = client_mod.raw(cli, "POST", "/context-documents/tree", body=body)
|
|
64
|
+
tree_id = _field(started, "tree_id")
|
|
65
|
+
if not wait or not tree_id:
|
|
66
|
+
output.emit(started, resolved_fmt)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
final = _poll(cli, tree_id, timeout)
|
|
70
|
+
status = (_field(final, "status") or "").lower()
|
|
71
|
+
if status == "failed":
|
|
72
|
+
raise output.fail(f"Tree {tree_id} generation failed.", output.Exit.API)
|
|
73
|
+
# Emit the finished tree itself, not just the progress record.
|
|
74
|
+
with client_mod.api_errors():
|
|
75
|
+
tree = client_mod.raw(cli, "GET", f"/context-documents/tree/by-id/{tree_id}")
|
|
76
|
+
output.emit(tree, resolved_fmt)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def latest(
|
|
81
|
+
ctx: typer.Context,
|
|
82
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Fetch the most recent completed/published tree."""
|
|
85
|
+
app_ctx = context.get(ctx)
|
|
86
|
+
cli = client_mod.build(app_ctx.resolved)
|
|
87
|
+
with client_mod.api_errors():
|
|
88
|
+
result = client_mod.raw(cli, "GET", "/context-documents/tree/latest")
|
|
89
|
+
output.emit(result, output.pick(ctx, fmt))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def get(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
tree_id: str = typer.Argument(..., help="The tree ID."),
|
|
96
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Fetch a tree by ID."""
|
|
99
|
+
app_ctx = context.get(ctx)
|
|
100
|
+
cli = client_mod.build(app_ctx.resolved)
|
|
101
|
+
with client_mod.api_errors():
|
|
102
|
+
result = client_mod.raw(cli, "GET", f"/context-documents/tree/by-id/{tree_id}")
|
|
103
|
+
output.emit(result, output.pick(ctx, fmt))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def progress(
|
|
108
|
+
ctx: typer.Context,
|
|
109
|
+
tree_id: str = typer.Argument(..., help="The tree ID."),
|
|
110
|
+
fmt: Optional[output.Format] = output.FORMAT_OPTION,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Check generation progress for a tree (single snapshot)."""
|
|
113
|
+
app_ctx = context.get(ctx)
|
|
114
|
+
cli = client_mod.build(app_ctx.resolved)
|
|
115
|
+
with client_mod.api_errors():
|
|
116
|
+
result = client_mod.raw(cli, "GET", f"/context-documents/tree/{tree_id}/progress")
|
|
117
|
+
output.emit(result, output.pick(ctx, fmt))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _poll(cli: Any, tree_id: str, timeout: int) -> Any:
|
|
121
|
+
"""Poll progress until terminal or timeout, narrating phase changes to stderr."""
|
|
122
|
+
deadline = time.monotonic() + timeout
|
|
123
|
+
last = None
|
|
124
|
+
while True:
|
|
125
|
+
with client_mod.api_errors():
|
|
126
|
+
snap = client_mod.raw(cli, "GET", f"/context-documents/tree/{tree_id}/progress")
|
|
127
|
+
status = (_field(snap, "status") or "").lower()
|
|
128
|
+
phase = _field(snap, "phase")
|
|
129
|
+
done, total = _field(snap, "completed_docs"), _field(snap, "total_docs")
|
|
130
|
+
line = f"[{status}] {phase or '...'}" + (f" {done}/{total}" if total else "")
|
|
131
|
+
if line != last:
|
|
132
|
+
print(line, file=sys.stderr, flush=True)
|
|
133
|
+
last = line
|
|
134
|
+
if status in TERMINAL:
|
|
135
|
+
return snap
|
|
136
|
+
if time.monotonic() >= deadline:
|
|
137
|
+
raise output.fail(
|
|
138
|
+
f"Timed out after {timeout}s waiting for tree {tree_id} (last status: {status}).",
|
|
139
|
+
output.Exit.API,
|
|
140
|
+
)
|
|
141
|
+
time.sleep(POLL_INTERVAL_S)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _field(obj: Any, key: str) -> Any:
|
|
145
|
+
if isinstance(obj, dict):
|
|
146
|
+
return obj.get(key)
|
|
147
|
+
return getattr(obj, key, None)
|