ndi-cli 0.21.0__tar.gz → 0.26.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndi-cli
3
- Version: 0.21.0
3
+ Version: 0.26.0
4
4
  Summary: NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools
5
5
  Keywords: ndi,cli,document-intelligence,coding-agents
6
6
  Author: Nace AI
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: ndi-sdk>=0.38,<1
19
+ Requires-Dist: ndi-sdk>=0.45,<1
20
20
  Requires-Dist: truststore>=0.10.4,<1
21
21
  Requires-Python: >=3.11
22
22
  Project-URL: Homepage, https://ndi-api.nace.ai
@@ -57,12 +57,13 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
57
57
  | `ndi workspace global-context [--set TEXT\|--file PATH\|--clear]` | Show, replace, or remove (`--clear`) the background every query without `--context` runs with; `--no-context` on a query runs with none |
58
58
  | `ndi files upload\|list\|get\|delete` | Workspace files (a directory walks nested files; `--ingest` then queues ingestion) |
59
59
  | `ndi ingest` | Queue ingestion |
60
- | `ndi automatic-search QUERY` | Service answers with fact search or an agent over files, tables and the catalog; prints the route, the output job id, and the answer (plus the catalog table when there is one) |
60
+ | `ndi automatic-search QUERY` | Service answers with fact search or an agent over files, tables and the catalog (`--mode fact\|agent` skips the routing step, `--path` scopes it to files); prints the route, the output job id, and the answer (plus the catalog table when there is one) |
61
61
  | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search (`--path` on `deep-search` scopes it to files); then `ndi job` on each printed `grounding_job_ids` entry |
62
- | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
62
+ | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--path` scopes it to files, `--cursor` pages an earlier run) |
63
63
  | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
64
64
  | `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
65
65
  | `ndi ask-file PATH QUESTION` | Cited answer over one document, image or recording |
66
+ | `ndi systemone --state TEXT --questions JSON` | Answer `noul`, `choice` and `score` questions about a state in one call, no workspace (`--state-json` for a JSON state; any value may be `@FILE` or `@-`) |
66
67
 
67
68
  ## Sources
68
69
 
@@ -33,12 +33,13 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
33
33
  | `ndi workspace global-context [--set TEXT\|--file PATH\|--clear]` | Show, replace, or remove (`--clear`) the background every query without `--context` runs with; `--no-context` on a query runs with none |
34
34
  | `ndi files upload\|list\|get\|delete` | Workspace files (a directory walks nested files; `--ingest` then queues ingestion) |
35
35
  | `ndi ingest` | Queue ingestion |
36
- | `ndi automatic-search QUERY` | Service answers with fact search or an agent over files, tables and the catalog; prints the route, the output job id, and the answer (plus the catalog table when there is one) |
36
+ | `ndi automatic-search QUERY` | Service answers with fact search or an agent over files, tables and the catalog (`--mode fact\|agent` skips the routing step, `--path` scopes it to files); prints the route, the output job id, and the answer (plus the catalog table when there is one) |
37
37
  | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search (`--path` on `deep-search` scopes it to files); then `ndi job` on each printed `grounding_job_ids` entry |
38
- | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
38
+ | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--path` scopes it to files, `--cursor` pages an earlier run) |
39
39
  | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
40
40
  | `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
41
41
  | `ndi ask-file PATH QUESTION` | Cited answer over one document, image or recording |
42
+ | `ndi systemone --state TEXT --questions JSON` | Answer `noul`, `choice` and `score` questions about a state in one call, no workspace (`--state-json` for a JSON state; any value may be `@FILE` or `@-`) |
42
43
 
43
44
  ## Sources
44
45
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.21.0"
3
+ version = "0.26.0"
4
4
  description = "NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Typing :: Typed",
25
25
  ]
26
26
  dependencies = [
27
- "ndi-sdk>=0.38,<1",
27
+ "ndi-sdk>=0.45,<1",
28
28
  "truststore>=0.10.4,<1",
29
29
  ]
30
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.21.0"
3
+ version = "0.26.0"
4
4
  description = "NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools"
5
5
  authors = [
6
6
  { name = "Nace AI", email = "engineering@nace.ai" }
@@ -27,7 +27,7 @@ classifiers = [
27
27
  "Typing :: Typed",
28
28
  ]
29
29
  dependencies = [
30
- "ndi-sdk>=0.38,<1",
30
+ "ndi-sdk>=0.45,<1",
31
31
  "truststore>=0.10.4,<1",
32
32
  ]
33
33
 
@@ -6,4 +6,4 @@ methods. Inside an agent sandbox the workspace tools (``folder-metadata``,
6
6
  are the whole surface; outside one only ``ask-file`` is.
7
7
  """
8
8
 
9
- __version__ = "0.21.0"
9
+ __version__ = "0.26.0"
@@ -8,16 +8,18 @@ when ``$NDI_CLI_SOCKET`` is set, before any client is built.
8
8
  from __future__ import annotations
9
9
 
10
10
  import argparse
11
+ import copy
11
12
  import os
12
13
  import sys
14
+ from concurrent.futures import ThreadPoolExecutor
13
15
 
14
16
  from ndi_sdk.client import NdiClient
15
17
  from ndi_sdk.errors import NdiError, NdiStatusError
16
18
  from ndi_sdk.resources.tools import SyncTools
17
19
 
18
- from ndi_cli import _docops, _jobs, _login, _tools, _workspace
20
+ from ndi_cli import _docops, _jobs, _login, _systemone, _tools, _workspace
19
21
  from ndi_cli._config import ConfigError, resolve_settings
20
- from ndi_cli._local import KG_ENV, SOCKET_ENV, TOKEN_ENV, LocalTransport
22
+ from ndi_cli._local import SOCKET_ENV, TOKEN_ENV, LocalTransport
21
23
  from ndi_cli._output import full_json, output_limit, print_full_or_summary
22
24
  from ndi_cli._tools import parse_pages
23
25
 
@@ -33,8 +35,7 @@ def main(argv: list[str] | None = None) -> int:
33
35
  # Presence, not truthiness: an empty $NDI_CLI_SOCKET still means "inside an
34
36
  # agent run", and must not silently unlock the full platform CLI.
35
37
  in_agent_run = socket_path is not None
36
- # An agent run gets kg-answer only when the worker granted it.
37
- parser = _build_parser(in_agent_run=in_agent_run, graph=in_agent_run and bool(os.environ.get(KG_ENV)))
38
+ parser = _build_parser(in_agent_run=in_agent_run)
38
39
  args = parser.parse_args(argv)
39
40
  if args.command is None:
40
41
  parser.print_help()
@@ -100,22 +101,35 @@ def main(argv: list[str] | None = None) -> int:
100
101
  def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, printer) -> int:
101
102
  """Run a tool once per positional target (``file-metadata a b``), each printed as its own call would print it.
102
103
 
103
- With several targets a failing one reports on stderr and the rest still run, so one
104
- bad path costs the caller that path's error line, not every other path's answer.
104
+ The targets are fetched concurrently and printed in the order given. With several
105
+ targets a failing one reports on stderr and the rest still run, so one bad path costs
106
+ the caller that path's error line, not every other path's answer.
105
107
  """
106
108
  targets = getattr(args, "targets", [None])
107
109
  if len(targets) == 1:
108
110
  args.target = targets[0]
109
111
  return printer(args, args.run(tools, workspace_id, args))
112
+
113
+ def fetch(target: str) -> tuple[argparse.Namespace, object]:
114
+ per_target = copy.copy(args)
115
+ per_target.target = target
116
+ try:
117
+ return per_target, per_target.run(tools, workspace_id, per_target)
118
+ except NdiError as exc:
119
+ return per_target, exc
120
+
121
+ with ThreadPoolExecutor(max_workers=len(targets)) as pool:
122
+ fetched = list(pool.map(fetch, targets))
110
123
  status = 0
111
- for index, target in enumerate(targets):
112
- args.target = target
124
+ for index, (per_target, response) in enumerate(fetched):
113
125
  if index:
114
126
  print()
115
- try:
116
- status = max(status, printer(args, args.run(tools, workspace_id, args)))
117
- except NdiError as exc:
118
- status = max(status, _report_error(exc, target=target))
127
+ if getattr(args, "label_targets", False):
128
+ print(f"==> {per_target.target} <==")
129
+ if isinstance(response, NdiError):
130
+ status = max(status, _report_error(response, target=per_target.target))
131
+ else:
132
+ status = max(status, printer(per_target, response))
119
133
  return status
120
134
 
121
135
 
@@ -173,7 +187,7 @@ def _validation_error_lines(detail: object) -> list[str]:
173
187
  return lines
174
188
 
175
189
 
176
- def _build_parser(*, in_agent_run: bool = False, graph: bool = False) -> argparse.ArgumentParser:
190
+ def _build_parser(*, in_agent_run: bool = False) -> argparse.ArgumentParser:
177
191
  parser = argparse.ArgumentParser(
178
192
  prog="ndi",
179
193
  description=(
@@ -196,10 +210,11 @@ def _build_parser(*, in_agent_run: bool = False, graph: bool = False) -> argpars
196
210
  )
197
211
  sub = parser.add_subparsers(dest="command")
198
212
  _login.add_parsers(sub)
199
- _tools.add_parsers(sub, common, in_agent_run=in_agent_run, graph=graph)
213
+ _tools.add_parsers(sub, common, in_agent_run=in_agent_run)
200
214
  _docops.add_parsers(sub, common)
201
215
  _jobs.add_parsers(sub, common)
202
216
  _workspace.add_parsers(sub, common)
217
+ _systemone.add_parsers(sub, default_timeout=DEFAULT_TIMEOUT_SECONDS)
203
218
  return parser
204
219
 
205
220
 
@@ -21,9 +21,6 @@ from ndi_cli.commands import WorkspaceCommand
21
21
 
22
22
  SOCKET_ENV = "NDI_CLI_SOCKET"
23
23
  TOKEN_ENV = "NDI_CLI_TOKEN"
24
- #: Set (to any non-empty value) by the worker for an agent run that has the graph
25
- #: surface; inside an agent run ``kg-answer`` is registered only when it is.
26
- KG_ENV = "NDI_CLI_KG"
27
24
  MAX_MESSAGE_BYTES = 16 * 1024 * 1024
28
25
  ModelT = TypeVar("ModelT", bound=BaseModel)
29
26
 
@@ -101,29 +98,8 @@ class SearchCommand(BaseModel):
101
98
  body: SearchRequest
102
99
 
103
100
 
104
- class KgAnswerRequest(BaseModel):
105
- """``kg-answer``'s question as a socket body.
106
-
107
- The agent passes its verbatim question; the server runs the graph pass and
108
- returns the rendered outcome (answered text + citations, or evidence + gap).
109
- """
110
-
111
- query: str
112
-
113
-
114
- class KgAnswerResponse(BaseModel):
115
- """The rendered outcome of one graph pass, ready to print or inject as a tool result."""
116
-
117
- text: str
118
-
119
-
120
- class KgAnswerCommand(BaseModel):
121
- command: Literal[WorkspaceCommand.KG_ANSWER] = WorkspaceCommand.KG_ANSWER
122
- body: KgAnswerRequest
123
-
124
-
125
101
  Command = Annotated[
126
- FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand | KgAnswerCommand,
102
+ FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand,
127
103
  Field(discriminator="command"),
128
104
  ]
129
105
  COMMAND = TypeAdapter(Command)
@@ -49,7 +49,8 @@ def shortened(full: str, args, *, get_full: str = "rerun with --json") -> bool:
49
49
  """Whether ``full`` is too long for stdout; if so, tell stderr how to get it.
50
50
 
51
51
  A file target is never shortened — the limit protects a reader's context,
52
- and ``--save`` / ``--out-dir`` asked for the whole thing. ``get_full`` names
52
+ and ``--save`` / ``--out-dir`` asked for the whole thing. A limit of ``0``
53
+ asked for the text rendering, so nothing goes to stderr. ``get_full`` names
53
54
  the command that prints it whole.
54
55
  """
55
56
  if getattr(args, "save", None) or getattr(args, "out_dir", None):
@@ -57,6 +58,8 @@ def shortened(full: str, args, *, get_full: str = "rerun with --json") -> bool:
57
58
  limit = output_limit()
58
59
  if len(full) <= limit:
59
60
  return False
61
+ if limit == 0:
62
+ return True
60
63
  print(
61
64
  f"note: output shortened — the full response is {len(full):,} chars, over the {limit:,} limit "
62
65
  f"(${OUTPUT_LIMIT_ENV}); {get_full} for all of it",
@@ -42,6 +42,7 @@ from ndi_sdk.models.ledger_understanding import (
42
42
  SelectionSummary,
43
43
  SourceCatalog,
44
44
  )
45
+ from ndi_sdk.models.systemone import ChoiceAnswer, NoulAnswer, ScoreAnswer, SystemOneResponse
45
46
  from ndi_sdk.models.workspaces import Workspace, WorkspaceStats
46
47
  from ndi_sdk.models.tools import (
47
48
  FileMetadataToolResponse,
@@ -591,6 +592,40 @@ def schema_validation(result: ExtractSchemaValidationResponse) -> str:
591
592
  return "\n".join(lines)
592
593
 
593
594
 
595
+ def _probability(value: float) -> str:
596
+ return f"{value:.3f}"
597
+
598
+
599
+ def _systemone_answer(answer) -> str:
600
+ if isinstance(answer, NoulAnswer):
601
+ return f"noul p(true)={_probability(answer.noul)}"
602
+ if isinstance(answer, ChoiceAnswer):
603
+ ranked = sorted(answer.probabilities.items(), key=lambda item: -item[1])
604
+ spread = " ".join(f"{option}={_probability(p)}" for option, p in ranked)
605
+ return f"choice {answer.choice} (confidence {_probability(answer.confidence)}) {spread}"
606
+ if isinstance(answer, ScoreAnswer):
607
+ # The score is an expected level index; name the level it rounds to.
608
+ named = ""
609
+ if answer.legend:
610
+ named = f" ≈ {answer.legend[min(max(round(answer.score), 0), len(answer.legend) - 1)]!r}"
611
+ spread = " ".join(
612
+ f"{name}={_probability(answer.probabilities[str(index)])}"
613
+ for index, name in enumerate(answer.legend)
614
+ if str(index) in answer.probabilities
615
+ )
616
+ return f"score {answer.score:.2f}{named} (confidence {_probability(answer.confidence)}) {spread}"
617
+ return f"{answer.type} (not known to this CLI) {json.dumps(answer.data, sort_keys=True)}"
618
+
619
+
620
+ def systemone(response: SystemOneResponse) -> str:
621
+ usage = response.usage
622
+ tokens = f" tokens in={usage.input_tokens} out={usage.output_tokens}" if usage.input_tokens is not None else ""
623
+ lines = [f"model {response.model} {response.evaluation_time_ms:.0f} ms{tokens}"]
624
+ width = max((len(key) for key in response.answers), default=0)
625
+ lines.extend(f"{key:<{width}} {_systemone_answer(answer)}" for key, answer in response.answers.items())
626
+ return "\n".join(lines)
627
+
628
+
594
629
  def workspace(item: Workspace) -> str:
595
630
  purpose = f" purpose={item.purpose}" if item.purpose else ""
596
631
  return f"{item.workspace_id} {item.name} {item.domain_slug}@{item.domain_version} {item.status}{purpose}"
@@ -0,0 +1,81 @@
1
+ """``ndi systemone``: answer typed questions about a state in one synchronous call. No workspace, no job."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ndi_sdk.client import NdiClient
11
+ from ndi_sdk.models.systemone import SystemOneRequest
12
+ from pydantic import JsonValue, ValidationError
13
+
14
+ from ndi_cli import _render
15
+ from ndi_cli._workspace import _dump_or_text
16
+
17
+
18
+ def add_parsers(sub: argparse._SubParsersAction, *, default_timeout: float) -> None:
19
+ systemone = sub.add_parser(
20
+ "systemone",
21
+ help="Answer noul, choice, and score questions about a state.",
22
+ description=(
23
+ "Answer typed questions about a state in one call. A VALUE is inline, @FILE reads a file, and @- reads stdin."
24
+ ),
25
+ )
26
+ systemone.add_argument(
27
+ "--json", action="store_true", help="Always print the full response JSON, never the shortened text rendering."
28
+ )
29
+ systemone.add_argument(
30
+ "--timeout",
31
+ type=float,
32
+ default=default_timeout,
33
+ metavar="SECONDS",
34
+ help=f"HTTP timeout for the call (default {default_timeout:.0f}s).",
35
+ )
36
+ state = systemone.add_mutually_exclusive_group(required=True)
37
+ state.add_argument("--state", default=None, metavar="VALUE", help="The state as text.")
38
+ state.add_argument("--state-json", default=None, metavar="VALUE", help="The state as any JSON value.")
39
+ systemone.add_argument(
40
+ "--questions",
41
+ required=True,
42
+ metavar="VALUE",
43
+ help='JSON object of named questions, e.g. {"approve": {"type": "noul", "instructions": "..."}}.',
44
+ )
45
+ systemone.set_defaults(run=_run, family="systemone", needs_workspace=False, needs_client=True)
46
+
47
+
48
+ def _read_value(value: str, flag: str) -> str:
49
+ if not value.startswith("@"):
50
+ return value
51
+ if value == "@-":
52
+ return sys.stdin.read()
53
+ try:
54
+ return Path(value[1:]).read_text(encoding="utf-8")
55
+ except OSError as exc:
56
+ raise ValueError(f"cannot read {flag} {value[1:]!r}: {exc.strerror or exc}") from None
57
+
58
+
59
+ def _read_json(value: str, flag: str) -> JsonValue:
60
+ try:
61
+ return json.loads(_read_value(value, flag))
62
+ except json.JSONDecodeError as exc:
63
+ raise ValueError(f"{flag} is not valid JSON: {exc}") from None
64
+
65
+
66
+ def _request(args: argparse.Namespace) -> SystemOneRequest:
67
+ state = _read_json(args.state_json, "--state-json") if args.state is None else _read_value(args.state, "--state")
68
+ questions = _read_json(args.questions, "--questions")
69
+ if not isinstance(questions, dict) or not questions:
70
+ raise ValueError("--questions must be a non-empty JSON object of named questions")
71
+ try:
72
+ return SystemOneRequest.model_validate({"state": state, "questions": questions})
73
+ except ValidationError as exc:
74
+ problems = "; ".join(f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in exc.errors())
75
+ raise ValueError(f"--questions: {problems}") from None
76
+
77
+
78
+ def _run(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
79
+ request = _request(args)
80
+ response = client.systemone.evaluate(request.state, request.questions)
81
+ return _dump_or_text(response, args, text=_render.systemone(response), stem="systemone")
@@ -1,4 +1,4 @@
1
- """The workspace-tool verbs: ``ask-file`` everywhere, and the agent run's file commands plus ``kg-answer``.
1
+ """The workspace-tool verbs: ``ask-file`` everywhere, and the agent run's file commands.
2
2
 
3
3
  The agent-only commands have no SDK method: their /v1 routes are unpublished, so
4
4
  they reach the run's socket through the tools' transport directly.
@@ -24,8 +24,6 @@ from ndi_cli import _render
24
24
  from ndi_cli._local import (
25
25
  FileMetadataRequest,
26
26
  FolderMetadataRequest,
27
- KgAnswerRequest,
28
- KgAnswerResponse,
29
27
  ReadFileRequest,
30
28
  RunSqlRequest,
31
29
  SearchRequest,
@@ -35,11 +33,11 @@ from ndi_cli.commands import WorkspaceCommand
35
33
  ModelT = TypeVar("ModelT", bound=BaseModel)
36
34
 
37
35
 
38
- def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser, *, in_agent_run: bool, graph: bool) -> None:
36
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser, *, in_agent_run: bool) -> None:
39
37
  """Register the tool verbs.
40
38
 
41
39
  Outside an agent run only ``ask-file`` is registered. Inside one the file
42
- commands are too, and ``kg-answer`` when ``graph`` says the worker granted it.
40
+ commands are too.
43
41
  """
44
42
  if in_agent_run:
45
43
  folder = sub.add_parser(
@@ -100,18 +98,29 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
100
98
  read = sub.add_parser(
101
99
  WorkspaceCommand.READ_FILE,
102
100
  parents=[common],
103
- help="Print the text of one uploaded file.",
101
+ help="Print the text of one or more uploaded files.",
104
102
  description=(
105
103
  "Content goes to stdout; a one-line footer (pages, components returned/omitted, truncation) goes to "
106
104
  "stderr. Scope with --component (names from file-metadata) or --pages; images are refused — use ask-file."
107
105
  ),
108
106
  )
109
- read.add_argument("path", help="Uploaded source path.")
107
+ read.add_argument(
108
+ "targets",
109
+ nargs="+",
110
+ metavar="path",
111
+ help="Uploaded source paths. Several are fetched together, each under a `==> path <==` header.",
112
+ )
110
113
  read.add_argument("--component", default=None, help="One tab or section, named as file-metadata lists it.")
111
114
  read.add_argument("--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8. Not for spreadsheets.")
112
115
  read.add_argument("--max-chars", type=int, default=None, help="Character budget for the returned text.")
113
116
  read.set_defaults(
114
- run=_run_read_file, render=_render_read_file, verbatim=True, family="tool", needs_workspace=True, needs_client=True
117
+ run=_run_read_file,
118
+ render=_render_read_file,
119
+ verbatim=True,
120
+ label_targets=True,
121
+ family="tool",
122
+ needs_workspace=True,
123
+ needs_client=True,
115
124
  )
116
125
 
117
126
  ask_file = sub.add_parser(
@@ -164,29 +173,11 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
164
173
  )
165
174
  search.add_argument("query", help="What to look for.")
166
175
  search.add_argument("-k", type=int, default=10, help="Number of hits (default 10).")
167
- search.add_argument("--path-prefix", default=None, help="Only files under this directory prefix.")
176
+ search.add_argument(
177
+ "--path-prefix", default=None, help="Only files under this directory prefix, or only the one file at this full path."
178
+ )
168
179
  search.add_argument("--category", action="append", default=None, dest="categories", help="Category filter; repeatable.")
169
180
  search.set_defaults(run=_run_search, render=_render.search, family="tool", needs_workspace=True, needs_client=True)
170
- if not graph:
171
- return
172
-
173
- kg_answer = sub.add_parser(
174
- WorkspaceCommand.KG_ANSWER,
175
- parents=[common],
176
- help="Retrieve from the workspace knowledge graph in one call — entities and pages, never an answer.",
177
- description=(
178
- "Runs the full graph pass: locates the entities the question names and reads the pages they cite. "
179
- "Prints the entity cards and the pages read: a page marked (full) is the document's text, quotable as "
180
- "printed; re-read only a page shown in part, for what lies past the cut. The graph only knows what its own build extracted — a question resting on material "
181
- "outside its scope (parquet, CSV, anything not yet built into it) gets nothing from this call, and "
182
- "even a covered entity's pages are a starting sample, not the workspace. "
183
- "Call this first, once, with the question verbatim."
184
- ),
185
- )
186
- kg_answer.add_argument("query", help="The question, verbatim.")
187
- kg_answer.set_defaults(
188
- run=_run_kg_answer, render=_render_kg_answer, verbatim=True, family="tool", needs_workspace=True, needs_client=True
189
- )
190
181
 
191
182
 
192
183
  def _agent_command(
@@ -245,7 +236,7 @@ def parse_pages(spec: str | None) -> list[int] | None:
245
236
 
246
237
 
247
238
  def _run_read_file(tools: SyncTools, workspace_id: str, args: argparse.Namespace) -> ReadFileResponse:
248
- body = ReadFileRequest(path=args.path, pages=parse_pages(args.pages), component=args.component, max_chars=args.max_chars)
239
+ body = ReadFileRequest(path=args.target, pages=parse_pages(args.pages), component=args.component, max_chars=args.max_chars)
249
240
  return _agent_command(tools, ReadFileResponse, workspace_id, WorkspaceCommand.READ_FILE, body)
250
241
 
251
242
 
@@ -271,11 +262,3 @@ def _run_run_sql(tools: SyncTools, workspace_id: str, args: argparse.Namespace)
271
262
  def _run_search(tools: SyncTools, workspace_id: str, args: argparse.Namespace) -> SearchResponse:
272
263
  body = SearchRequest(query=args.query, k=args.k, path_prefix=args.path_prefix, categories=args.categories)
273
264
  return _agent_command(tools, SearchResponse, workspace_id, WorkspaceCommand.HYBRID_SEARCH, body)
274
-
275
-
276
- def _run_kg_answer(tools: SyncTools, workspace_id: str, args: argparse.Namespace) -> KgAnswerResponse:
277
- return _agent_command(tools, KgAnswerResponse, workspace_id, WorkspaceCommand.KG_ANSWER, KgAnswerRequest(query=args.query))
278
-
279
-
280
- def _render_kg_answer(response: KgAnswerResponse) -> str:
281
- return response.text
@@ -8,11 +8,13 @@ import sys
8
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
9
  from dataclasses import dataclass
10
10
  from pathlib import Path
11
+ from typing import get_args
11
12
 
12
13
  from ndi_sdk.client import NdiClient
13
14
  from ndi_sdk.credentials import write_config
14
15
  from ndi_sdk.errors import ErrorCode, JobFailedError, JobTimeoutError, NdiError, NdiStatusError
15
16
  from ndi_sdk.models.jobs import Job, UploadFileResult
17
+ from ndi_sdk.models.tools import AutomaticSearchMode
16
18
  from ndi_sdk.resources.jobs import check_terminal
17
19
 
18
20
  from ndi_cli import _render
@@ -117,7 +119,6 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
117
119
 
118
120
  deep = sub.add_parser("deep-search", parents=[common], help="Run an agentic workspace search.")
119
121
  deep.add_argument("query")
120
- deep.add_argument("--effort", choices=("low", "medium", "high"), default=None)
121
122
  deep.add_argument("--prefix", default=None, dest="path_prefix")
122
123
  deep.add_argument(
123
124
  "--path",
@@ -139,8 +140,7 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
139
140
  add_job_output_args(fact)
140
141
  fact.set_defaults(run=_run_fact_search, family="workspace", needs_workspace=True, needs_client=True)
141
142
 
142
- # No --effort, --session-id, or --allow-clarification: the route is
143
- # single-shot and the service chooses the pipeline.
143
+ # No --effort, --session-id, or --allow-clarification: the route is single-shot.
144
144
  automatic = sub.add_parser(
145
145
  "automatic-search",
146
146
  parents=[common],
@@ -148,7 +148,14 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
148
148
  )
149
149
  automatic.add_argument("query")
150
150
  automatic.add_argument("--prefix", default=None, dest="path_prefix")
151
- automatic.add_argument("--top-k", type=int, default=None, help="Cap the citation list when the fact pipeline answers.")
151
+ automatic.add_argument("--path", action="append", dest="paths", default=None, help="Scope to this file; repeatable.")
152
+ automatic.add_argument("--top-k", type=int, default=None, help="Cap the citation list when fact search answers.")
153
+ automatic.add_argument(
154
+ "--mode",
155
+ choices=get_args(AutomaticSearchMode),
156
+ default=None,
157
+ help="auto (the default) lets the service choose; fact or agent runs that route directly.",
158
+ )
152
159
  _add_context_args(automatic)
153
160
  add_job_output_args(automatic)
154
161
  automatic.set_defaults(run=_run_automatic_search, family="workspace", needs_workspace=True, needs_client=True)
@@ -161,6 +168,7 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
161
168
  "--cursor", default=None, help="Page an earlier run: paste its result's next_cursor. A page is a new job."
162
169
  )
163
170
  filtered.add_argument("--prefix", default=None, dest="path_prefix")
171
+ filtered.add_argument("--path", action="append", dest="paths", default=None, help="Scope to this file; repeatable.")
164
172
  _add_context_args(filtered)
165
173
  # No --result-unit: the server deprecates the grouping override and returns
166
174
  # catalog entries. No --allow-clarification: a clarification is answered by a
@@ -534,7 +542,6 @@ def _run_deep_search(client: NdiClient, workspace_id: str, args: argparse.Namesp
534
542
  workspace_id,
535
543
  query=args.query,
536
544
  context=args.context,
537
- effort=args.effort,
538
545
  path_prefix=args.path_prefix,
539
546
  paths=args.paths,
540
547
  )
@@ -559,7 +566,9 @@ def _run_automatic_search(client: NdiClient, workspace_id: str, args: argparse.N
559
566
  query=args.query,
560
567
  context=args.context,
561
568
  path_prefix=args.path_prefix,
569
+ paths=args.paths,
562
570
  top_k=args.top_k,
571
+ mode=args.mode,
563
572
  )
564
573
  return _finish_job(client, job, args, _render.automatic_search_job, "automatic-search")
565
574
 
@@ -575,8 +584,11 @@ def _run_filtered_search(client: NdiClient, workspace_id: str, args: argparse.Na
575
584
  # A page re-executes the parent's own stored query, so the scoping flags
576
585
  # cannot apply to it. Accepting and dropping them would quietly answer a
577
586
  # different question than the one typed.
578
- if args.path_prefix or args.context is not None:
579
- print("error: --prefix, --context and --no-context belong to the initial query, not a page", file=sys.stderr)
587
+ if args.path_prefix or args.paths or args.context is not None:
588
+ print(
589
+ "error: --prefix, --path, --context and --no-context belong to the initial query, not a page",
590
+ file=sys.stderr,
591
+ )
580
592
  return 2
581
593
  job = client.search.filtered_page(workspace_id, cursor=args.cursor)
582
594
  else:
@@ -585,6 +597,7 @@ def _run_filtered_search(client: NdiClient, workspace_id: str, args: argparse.Na
585
597
  query=args.query,
586
598
  context=args.context,
587
599
  path_prefix=args.path_prefix,
600
+ paths=args.paths,
588
601
  )
589
602
  return _finish_job(client, job, args, _render.filtered_search_job, "filtered-search")
590
603
 
@@ -13,7 +13,6 @@ class WorkspaceCommand(StrEnum):
13
13
  KG_INFO = "kg-info"
14
14
  KG_SEARCH = "kg-search"
15
15
  KG_WALK = "kg-walk"
16
- KG_ANSWER = "kg-answer"
17
16
 
18
17
  @property
19
18
  def activity_label(self) -> str:
@@ -27,5 +26,4 @@ class WorkspaceCommand(StrEnum):
27
26
  self.KG_INFO: "Sizing up the knowledge graph",
28
27
  self.KG_SEARCH: "Searching the knowledge graph",
29
28
  self.KG_WALK: "Following graph relations",
30
- self.KG_ANSWER: "Reading the knowledge graph",
31
29
  }[self]
File without changes
File without changes
File without changes