ndi-cli 0.29.0__tar.gz → 0.30.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.29.0
3
+ Version: 0.30.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.50,<1
19
+ Requires-Dist: ndi-sdk>=0.51,<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
@@ -47,11 +47,12 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
47
47
  | `ndi login` | Authorize and save the API key |
48
48
  | `ndi version` | CLI + SDK versions |
49
49
  | `ndi upload FILE` | Stage bytes; prints `ndi://upload/<id>` |
50
+ | `ndi upload-delete UPLOAD_ID` | Delete an unused upload; used uploads require deleting their jobs first |
50
51
  | `ndi parse SOURCE` | Markdown / text / blocks |
51
52
  | `ndi extract SOURCE -s SCHEMA` | Structured extract (`--validate` checks a schema with no job) |
52
53
  | `ndi split SOURCE --class id:label` | Logical sections |
53
54
  | `ndi classify SOURCE --class id:label` | Labels (refuses `jobid://`) |
54
- | `ndi ground SOURCE --target id=TEXT` | Locate quoted text (standalone quotes only; after search, poll `grounding_job_ids`) |
55
+ | `ndi ground SOURCE --target id=TEXT` | Locate quoted text (after search, poll any `grounding_job_ids`; when none are planned, use `ndi ground` explicitly) |
55
56
  | `ndi job ID` / `ndi jobs` / `ndi cancel ID` | Inspect or cancel jobs |
56
57
  | `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
57
58
  | `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 |
@@ -124,3 +125,5 @@ conflicts, and treats the multipart `Content-Type` as advisory.
124
125
 
125
126
  See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
126
127
  reference. `SKILL.md` is the agent-facing guide to reading a workspace with `ndi`.
128
+
129
+ To select existing customer storage, run `ndi storage-connections list`, then `ndi workspace create "Customer workspace" --storage-connection CONNECTION_ID`. Customer storage is not generally available; disabled deployments return `not_implemented` for every connection operation (including `storage-connections list`) and for creating a workspace with a connection, retries included; enabled deployments with incomplete configuration return `storage_unavailable`. Set up connections and rotate credentials through the console or Python/TypeScript SDK. The CLI does not accept cloud secrets as command-line arguments.
@@ -23,11 +23,12 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
23
23
  | `ndi login` | Authorize and save the API key |
24
24
  | `ndi version` | CLI + SDK versions |
25
25
  | `ndi upload FILE` | Stage bytes; prints `ndi://upload/<id>` |
26
+ | `ndi upload-delete UPLOAD_ID` | Delete an unused upload; used uploads require deleting their jobs first |
26
27
  | `ndi parse SOURCE` | Markdown / text / blocks |
27
28
  | `ndi extract SOURCE -s SCHEMA` | Structured extract (`--validate` checks a schema with no job) |
28
29
  | `ndi split SOURCE --class id:label` | Logical sections |
29
30
  | `ndi classify SOURCE --class id:label` | Labels (refuses `jobid://`) |
30
- | `ndi ground SOURCE --target id=TEXT` | Locate quoted text (standalone quotes only; after search, poll `grounding_job_ids`) |
31
+ | `ndi ground SOURCE --target id=TEXT` | Locate quoted text (after search, poll any `grounding_job_ids`; when none are planned, use `ndi ground` explicitly) |
31
32
  | `ndi job ID` / `ndi jobs` / `ndi cancel ID` | Inspect or cancel jobs |
32
33
  | `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
33
34
  | `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 |
@@ -100,3 +101,5 @@ conflicts, and treats the multipart `Content-Type` as advisory.
100
101
 
101
102
  See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
102
103
  reference. `SKILL.md` is the agent-facing guide to reading a workspace with `ndi`.
104
+
105
+ To select existing customer storage, run `ndi storage-connections list`, then `ndi workspace create "Customer workspace" --storage-connection CONNECTION_ID`. Customer storage is not generally available; disabled deployments return `not_implemented` for every connection operation (including `storage-connections list`) and for creating a workspace with a connection, retries included; enabled deployments with incomplete configuration return `storage_unavailable`. Set up connections and rotate credentials through the console or Python/TypeScript SDK. The CLI does not accept cloud secrets as command-line arguments.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.29.0"
3
+ version = "0.30.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.50,<1",
27
+ "ndi-sdk>=0.51,<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.29.0"
3
+ version = "0.30.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.50,<1",
30
+ "ndi-sdk>=0.51,<1",
31
31
  "truststore>=0.10.4,<1",
32
32
  ]
33
33
 
@@ -0,0 +1,14 @@
1
+ """``ndi`` — the NDI platform CLI.
2
+
3
+ Document operations, jobs, and workspace lifecycle wrap ``ndi-sdk`` ``/v1``
4
+ methods. Inside an agent sandbox the workspace tools (``folder-metadata``,
5
+ ``file-metadata``, ``read-file``, ``ask-file``, ``run-sql``, ``hybrid-search``, ``grep``)
6
+ are the whole surface; outside one only ``ask-file`` is.
7
+ """
8
+
9
+ __version__ = "0.30.0"
10
+
11
+ #: Set (even empty) only inside an agent run: every command then goes to the worker over this socket.
12
+ SOCKET_ENV = "NDI_CLI_SOCKET"
13
+ #: Set by the worker when the deployment enables ``ndi grep`` (``NDI_GREP``); without it the verb does not exist.
14
+ GREP_ENV = "NDI_GREP"
@@ -36,7 +36,15 @@ def main(argv: list[str] | None = None) -> int:
36
36
  # agent run", and must not silently unlock the full platform CLI.
37
37
  in_agent_run = socket_path is not None
38
38
  parser = _build_parser(in_agent_run=in_agent_run)
39
- args = parser.parse_args(argv)
39
+ args, extras = parser.parse_known_args(_bind_regexp(sys.argv[1:] if argv is None else list(argv)))
40
+ # argparse leaves a positional that follows an option unparsed (`grep PATTERN -i PATH`); a command whose
41
+ # positionals end in a list takes such strays into it, as grep and rg take paths after their flags.
42
+ extra_into = getattr(args, "extra_positionals", None)
43
+ if extras and extra_into and not any(extra.startswith("-") for extra in extras):
44
+ setattr(args, extra_into, [*(getattr(args, extra_into) or []), *extras])
45
+ args.folded_positionals = extras
46
+ elif extras:
47
+ parser.error(f"unrecognized arguments: {' '.join(extras)}")
40
48
  if args.command is None:
41
49
  parser.print_help()
42
50
  return 2
@@ -98,6 +106,21 @@ def main(argv: list[str] | None = None) -> int:
98
106
  return 2
99
107
 
100
108
 
109
+ def _bind_regexp(argv: list[str]) -> list[str]:
110
+ """``grep -e PATTERN``: the word after -e is the pattern even when it starts with "-" (that is what -e is for);
111
+ argparse would read ``-e -5%`` as two options."""
112
+ if "grep" not in argv:
113
+ return argv
114
+ bound: list[str] = []
115
+ words = iter(argv)
116
+ for word in words:
117
+ if word in ("-e", "--regexp") and (pattern := next(words, None)) is not None:
118
+ bound.append(f"--regexp={pattern}")
119
+ else:
120
+ bound.append(word)
121
+ return bound
122
+
123
+
101
124
  def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, printer) -> int:
102
125
  """Run a tool once per positional target (``file-metadata a b``), each printed as its own call would print it.
103
126
 
@@ -106,6 +129,10 @@ def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, pri
106
129
  the caller that path's error line, not every other path's answer.
107
130
  """
108
131
  targets = getattr(args, "targets", [None])
132
+ names = getattr(args, "name_contains", None)
133
+ if isinstance(names, list) and len(names) > 1: # folder-metadata --name-contains A --name-contains B
134
+ targets = [(target, name) for target in targets for name in names]
135
+ args.label_targets = True
109
136
  if len(targets) == 1:
110
137
  args.target = targets[0]
111
138
  return printer(args, args.run(tools, workspace_id, args))
@@ -125,7 +152,8 @@ def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, pri
125
152
  if index:
126
153
  print()
127
154
  if getattr(args, "label_targets", False):
128
- print(f"==> {per_target.target} <==")
155
+ target = per_target.target
156
+ print(f"==> {f'{target[0]} --name-contains {target[1]}' if isinstance(target, tuple) else target} <==")
129
157
  if isinstance(response, NdiError):
130
158
  status = max(status, _report_error(response, target=per_target.target))
131
159
  else:
@@ -37,10 +37,13 @@ DEFAULT_PARALLEL = 4
37
37
  def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
38
38
  upload = sub.add_parser("upload", parents=[common], help="Stage a file for a document operation.")
39
39
  upload.add_argument("file", help="Local file to stage.")
40
- upload.add_argument("--ttl", type=int, default=None, dest="ttl_seconds", metavar="SECONDS")
41
40
  upload.add_argument("--file-name", default=None, help="Override the uploaded name.")
42
41
  upload.set_defaults(run=_run_upload, family="docops", needs_workspace=False, needs_client=True)
43
42
 
43
+ delete_upload = sub.add_parser("upload-delete", parents=[common], help="Erase an unused upload.")
44
+ delete_upload.add_argument("upload_id", help="Upload ID returned by upload; used uploads require deleting their jobs.")
45
+ delete_upload.set_defaults(run=_run_delete_upload, family="docops", needs_workspace=False, needs_client=True)
46
+
44
47
  parse = sub.add_parser("parse", parents=[common], help="Turn a document into markdown, text, and blocks.")
45
48
  _add_source_args(parse)
46
49
  parse.add_argument("-p", "--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8.")
@@ -88,7 +91,9 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
88
91
 
89
92
 
90
93
  def _add_source_args(parser: argparse.ArgumentParser, *, required: bool = True) -> None:
91
- parser.add_argument("source", nargs=None if required else "?", help="File, directory, URL, ndi://, jobid://, ws://, or -.")
94
+ parser.add_argument(
95
+ "source", nargs=None if required else "?", help="File, directory, URL, ndi://, jobid://, ws://FILE_ID, or -."
96
+ )
92
97
  parser.add_argument("--file-name", default=None, help="Name for stdin bytes or a URL without a filename.")
93
98
  parser.add_argument("-j", type=int, default=DEFAULT_PARALLEL, dest="parallel", help="Parallel jobs for a directory.")
94
99
  add_job_output_args(parser)
@@ -99,12 +104,17 @@ def _add_class_args(parser: argparse.ArgumentParser) -> None:
99
104
  parser.add_argument("--classes", dest="classes_file", default=None, help="JSON array of classes.")
100
105
 
101
106
 
107
+ def _run_delete_upload(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
108
+ client.documents.delete_upload(args.upload_id)
109
+ return 0
110
+
111
+
102
112
  def _run_upload(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
103
113
  path = Path(args.file)
104
114
  if not path.is_file():
105
115
  print(f"error: file not found: {args.file}", file=sys.stderr)
106
116
  return 2
107
- upload = client.documents.create_upload(path, file_name=args.file_name or path.name, ttl_seconds=args.ttl_seconds)
117
+ upload = client.documents.create_upload(path, file_name=args.file_name or path.name)
108
118
  print(f"ndi://upload/{upload.upload_id}")
109
119
  print(f"upload {upload.upload_id} {upload.file_name} {upload.size_bytes}B", file=sys.stderr)
110
120
  return 0
@@ -3,14 +3,20 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import io
6
+ import os
6
7
  import sys
7
8
 
8
- import truststore
9
+ from ndi_cli import SOCKET_ENV
9
10
 
10
11
 
11
12
  def main(argv: list[str] | None = None) -> int:
12
13
  """Enable native certificate verification and UTF-8 stdio before loading the CLI and SDK."""
13
- truststore.inject_into_ssl()
14
+ # An agent run's commands reach the worker over the local socket, never TLS, and the
15
+ # trust-store patch (it also loads urllib3 and requests) would slow every one of them.
16
+ if SOCKET_ENV not in os.environ:
17
+ import truststore # noqa: PLC0415
18
+
19
+ truststore.inject_into_ssl()
14
20
  # Piped stdio otherwise follows the Windows ANSI code page, which cannot encode every character a result may hold.
15
21
  for stream in (sys.stdout, sys.stderr):
16
22
  if isinstance(stream, io.TextIOWrapper):
@@ -17,9 +17,9 @@ from ndi_sdk.errors import ErrorBody, NdiConnectionError, NdiTimeoutError, error
17
17
  from ndi_sdk.models.tools import QaFileRequest
18
18
  from pydantic import BaseModel, Field, TypeAdapter
19
19
 
20
+ from ndi_cli import SOCKET_ENV as SOCKET_ENV # re-exported: the worker imports it from here
20
21
  from ndi_cli.commands import WorkspaceCommand
21
22
 
22
- SOCKET_ENV = "NDI_CLI_SOCKET"
23
23
  TOKEN_ENV = "NDI_CLI_TOKEN"
24
24
  MAX_MESSAGE_BYTES = 16 * 1024 * 1024
25
25
  ModelT = TypeVar("ModelT", bound=BaseModel)
@@ -55,6 +55,8 @@ class ReadFileRequest(BaseModel):
55
55
  pages: list[int] | None = None
56
56
  component: str | None = None
57
57
  max_chars: int | None = None
58
+ line_start: int | None = None
59
+ line_end: int | None = None
58
60
 
59
61
 
60
62
  class RunSqlRequest(BaseModel):
@@ -68,6 +70,18 @@ class SearchRequest(BaseModel):
68
70
  categories: list[str] | None = None
69
71
 
70
72
 
73
+ class GrepRequest(BaseModel):
74
+ pattern: str
75
+ path_prefix: str | None = None
76
+ paths: list[str] | None = None
77
+ before: int = 0
78
+ after: int = 0
79
+ files_only: bool = False
80
+ ignore_case: bool = False
81
+ fixed_strings: bool = False
82
+ max_matches: int = 50
83
+
84
+
71
85
  class FolderCommand(BaseModel):
72
86
  command: Literal[WorkspaceCommand.FOLDER_METADATA] = WorkspaceCommand.FOLDER_METADATA
73
87
  body: FolderMetadataRequest
@@ -98,8 +112,13 @@ class SearchCommand(BaseModel):
98
112
  body: SearchRequest
99
113
 
100
114
 
115
+ class GrepCommand(BaseModel):
116
+ command: Literal[WorkspaceCommand.GREP] = WorkspaceCommand.GREP
117
+ body: GrepRequest
118
+
119
+
101
120
  Command = Annotated[
102
- FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand,
121
+ FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand | GrepCommand,
103
122
  Field(discriminator="command"),
104
123
  ]
105
124
  COMMAND = TypeAdapter(Command)
@@ -10,8 +10,10 @@ output limit (``_output.FULL_OUTPUT_LIMIT``); ``--json`` always prints it whole.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import os
13
14
  import sys
14
15
  import uuid
16
+ from collections import Counter
15
17
 
16
18
  import html
17
19
  import re
@@ -47,6 +49,8 @@ from ndi_sdk.models.workspaces import Workspace, WorkspaceStats
47
49
  from ndi_sdk.models.tools import (
48
50
  FileMetadataToolResponse,
49
51
  FolderMetadataResponse,
52
+ GrepMatch,
53
+ GrepResponse,
50
54
  QaFileResponse,
51
55
  ReadFileResponse,
52
56
  RunSqlResponse,
@@ -56,6 +60,19 @@ from ndi_sdk.models.tools import (
56
60
  _SNIPPET_LIMIT = 500
57
61
 
58
62
 
63
+ #: What a document's rows table is and what it is for, printed once per table by file-metadata.
64
+ _ROWS_TABLE_NOTE = (
65
+ "every row this document's tables print, one SQL table for the document (a section's rows are the ones "
66
+ "printed on its pages). `page`, `table_no`, `row_no` place a row; the other columns are its cells, left to "
67
+ "right (`c1`, `c2`, … unless the tables repeat a header); a header printed once is among a table's first rows, "
68
+ "and `context` is the text printed above the table, followed by any system alignment warning. "
69
+ "Only the fixed Parse alignment sentence marks unverified columns; other lookalikes are printed source text. "
70
+ "Keep the warning separate from printed headings. "
71
+ "For counting, totalling or ranking rows; to read one "
72
+ "printed table, read-file its page or lines"
73
+ )
74
+
75
+
59
76
  def _size(num_bytes: int) -> str:
60
77
  value = float(num_bytes)
61
78
  for unit in ("B", "KB", "MB", "GB"):
@@ -143,22 +160,97 @@ def _locator(locator) -> str:
143
160
  return locator.kind
144
161
 
145
162
 
163
+ #: Hits whose snippets agree on this many leading characters (digits masked) are one passage printed in several
164
+ #: files: a serial corpus restates its headings, tables of contents and boilerplate in every issue.
165
+ _SAME_PASSAGE_CHARS = 120
166
+ _DIGITS = re.compile(r"\d")
167
+
168
+
169
+ def _passage_key(hit: Evidence) -> str | None:
170
+ text = _flatten_markup(hit.snippet or "")
171
+ return _DIGITS.sub("#", " ".join(text.split()))[:_SAME_PASSAGE_CHARS] or None
172
+
173
+
174
+ def _same_passage_groups(hits: list[Evidence]) -> dict[int, list[Evidence]]:
175
+ """For each hit (by position) that leads a run of hits sharing its passage, the later hits it stands for.
176
+
177
+ Only hits in **other files** fold under a leader: two hits in one file are two places to read. Folded hits
178
+ keep their rank order; the leader is the best-ranked."""
179
+ folded: dict[int, list[Evidence]] = {}
180
+ taken: set[int] = set()
181
+ for i, hit in enumerate(hits):
182
+ if i in taken or (key := _passage_key(hit)) is None:
183
+ continue
184
+ same = [
185
+ other
186
+ for j, other in enumerate(hits[i + 1 :], start=i + 1)
187
+ if j not in taken and other.path != hit.path and _passage_key(other) == key
188
+ ]
189
+ if same:
190
+ folded[i] = same
191
+ taken.update(j for j, other in enumerate(hits) if other in same)
192
+ return folded
193
+
194
+
146
195
  def _evidence_lines(hits: list[Evidence]) -> list[str]:
147
196
  lines: list[str] = []
197
+ folded = _same_passage_groups(hits)
198
+ hidden = {id(other) for same in folded.values() for other in same}
148
199
  for index, hit in enumerate(hits, start=1):
200
+ if id(hit) in hidden:
201
+ continue
149
202
  where = f" [{hit.component}]" if hit.component else ""
150
203
  page = f", page {hit.page}" if hit.page is not None else ""
151
204
  at = f" @ {_locator(hit.locator)}" if hit.locator is not None else ""
152
- lines.append(f"{index}. {hit.path}{where} (score {hit.relevance_score:.3f}{page}){at}")
205
+ span = ""
206
+ if hit.line_start is not None and hit.line_end is not None:
207
+ span = f" · lines {hit.line_start}-{hit.line_end}" if hit.line_end != hit.line_start else f" · line {hit.line_start}"
208
+ lines.append(f"{index}. {hit.path}{where} (score {hit.relevance_score:.3f}{page}){at}{span}")
153
209
  if hit.sql_table:
154
210
  lines.append(f" sql table: {hit.sql_table}")
155
211
  if hit.snippet:
156
212
  lines.append(f" {_clip(_flatten_markup(hit.snippet))}")
157
213
  if hit.why:
158
214
  lines.append(f" why: {_clip(hit.why, 300)}")
215
+ if same := folded.get(index - 1):
216
+ where = ", ".join(f"{other.path}" + (f" p.{other.page}" if other.page is not None else "") for other in same)
217
+ lines.append(f" same passage in {len(same)} more file(s): {where}")
159
218
  return lines
160
219
 
161
220
 
221
+ def folder_names(response: FolderMetadataResponse) -> str:
222
+ """``folder-metadata --names-only``: the paths, one per line, and a last line only when the list was cut."""
223
+ lines = [entry.path for entry in response.file_entries]
224
+ if response.files_truncated:
225
+ lines.append(f"... more files: narrow with a directory or --name-contains, or --start-after {lines[-1]}")
226
+ elif not lines:
227
+ lines.append("no files")
228
+ return "\n".join(lines)
229
+
230
+
231
+ #: A truncated page is digested only when its names share this much of a common prefix: fewer and the names
232
+ #: are unrelated, and a row each says more than a prefix would.
233
+ _DIGEST_MIN_PREFIX = 4
234
+ #: ...and holds at least this many entries: a short page reads faster as rows.
235
+ _DIGEST_MIN_ENTRIES = 20
236
+
237
+
238
+ def _listing_digest(entries) -> str | None:
239
+ """One line for a page of same-shaped names: ``treasury_bulletin_*.pdf, treasury_bulletin_1939_01.pdf ...
240
+ treasury_bulletin_1947_05.pdf, 2.2MB-7.5MB, ingested x100`` — or None when the names share no prefix."""
241
+ names = [entry.path.rsplit("/", 1)[-1] for entry in entries]
242
+ prefix = os.path.commonprefix(names)
243
+ if len(names) < _DIGEST_MIN_ENTRIES or len(prefix) < _DIGEST_MIN_PREFIX:
244
+ return None
245
+ suffixes = {name.rsplit(".", 1)[-1] for name in names if "." in name}
246
+ shape = prefix + "*" + (f".{suffixes.pop()}" if len(suffixes) == 1 else "")
247
+ sizes = [entry.size_bytes for entry in entries if entry.size_bytes is not None]
248
+ span = f", {_size(min(sizes))}-{_size(max(sizes))}" if sizes else ""
249
+ statuses = Counter(entry.ingestion_status for entry in entries)
250
+ status = ", ".join(f"{name} x{count}" for name, count in sorted(statuses.items()))
251
+ return f"({shape}): {entries[0].path} ... {entries[-1].path}{span}, {status}"
252
+
253
+
162
254
  def folder_metadata(response: FolderMetadataResponse) -> str:
163
255
  lines = [f"directory: {response.directory}", f"files in scope: {response.overall_files}"]
164
256
  if response.categories:
@@ -172,7 +264,14 @@ def folder_metadata(response: FolderMetadataResponse) -> str:
172
264
  failed = f" ({sub.failed_files} failed)" if sub.failed_files else ""
173
265
  cats = f" categories: {_categories(sub.top_categories, limit=3)}" if sub.top_categories else ""
174
266
  lines.append(f" {sub.name}/ {sub.overall_files} files{failed}{cats}")
175
- if response.file_entries:
267
+ if response.file_entries and response.files_truncated and (digest := _listing_digest(response.file_entries)):
268
+ # A page of a larger listing: the agent cannot act on a hundred rows that stop short of the file it
269
+ # needs, so the page reads as what the names have in common, and how to list or narrow them.
270
+ lines.append(f"files listed ({len(response.file_entries)} of {response.overall_files}, truncated) {digest}")
271
+ lines.append(
272
+ " every path: --names-only (pipe to grep); by name: --name-contains TEXT; by content: hybrid-search or grep"
273
+ )
274
+ elif response.file_entries:
176
275
  cut = " (truncated — resume with --start-after, or narrow)" if response.files_truncated else ""
177
276
  lines.append(f"files listed ({len(response.file_entries)}){cut}:")
178
277
  for entry in response.file_entries:
@@ -358,10 +457,7 @@ def file_metadata(response: FileMetadataToolResponse) -> str:
358
457
  shared = comp.kind == "section" and comp.columns and any(col.name == "origin" for col in comp.columns)
359
458
  shown = comp.table_name in shown_tables
360
459
  if shared and not shown:
361
- lines.append(
362
- f" {comp.table_name}: one SQL table for every section that lists it; "
363
- "a section's rows are the ones printed on its pages"
364
- )
460
+ lines.append(f" {comp.table_name}: {_ROWS_TABLE_NOTE}")
365
461
  if comp.columns and not (shared and shown):
366
462
  lines.extend(_column_lines(comp.columns, bool(comp.columns_truncated)))
367
463
  if comp.table_preamble and not shown:
@@ -371,6 +467,11 @@ def file_metadata(response: FileMetadataToolResponse) -> str:
371
467
  shown_tables.add(comp.table_name)
372
468
  if comp.error:
373
469
  lines.append(f" error: {comp.error}")
470
+ if response.figures:
471
+ lines.append(f'figures ({len(response.figures)}) — ask one with: ask-file PATH "question" --component "<name>":')
472
+ for figure in response.figures:
473
+ seen = figure.description or (f"text: {figure.text}" if figure.text else "not described")
474
+ lines.append(f" [{figure.name}] page {figure.page} — {' '.join(seen.split())}")
374
475
  return "\n".join(lines)
375
476
 
376
477
 
@@ -429,6 +530,11 @@ def _column_lines(columns: list, truncated: bool) -> list[str]:
429
530
 
430
531
  def read_file_footer(response: ReadFileResponse) -> str:
431
532
  parts = [f"read {response.path}"]
533
+ if response.line_count is not None:
534
+ if response.line_end is None:
535
+ parts.append(f"no lines (the text has {response.line_count})")
536
+ else:
537
+ parts.append(f"lines {response.line_start}-{response.line_end} of {response.line_count}")
432
538
  if response.pages_returned:
433
539
  parts.append(f"pages {_page_ranges(response.pages_returned)}")
434
540
  if response.components_returned:
@@ -516,6 +622,90 @@ def search(response: SearchResponse) -> str:
516
622
  return "\n".join(lines)
517
623
 
518
624
 
625
+ def grep(response: GrepResponse) -> str:
626
+ """Matches grouped by file and component, ``LINE[ p.PAGE]: text`` like grep -n, with the follow-up names.
627
+
628
+ A tab, image or recording match has no line: it is printed bare, and a closing note says where its answer is.
629
+ A grep that found nothing shows its near misses the same way, under a line saying what they are.
630
+ """
631
+ lines = _grep_lines(response.matches)
632
+ if not response.matches:
633
+ lines.append("no matches")
634
+ if response.near_misses:
635
+ lines.append("nearest lines (most of the pattern's words, not the pattern as written):")
636
+ lines += _grep_lines(response.near_misses)
637
+ tail = (
638
+ f"{len(response.matches)} matching lines shown; {response.files_matched} of {response.files_scanned} files read matched"
639
+ )
640
+ # Index mode reads every file whose indexed text holds the pattern's words; say so, or an agent takes the answer
641
+ # for a sample and searches again.
642
+ tail += " (the files holding the pattern's words; the rest of the scope lacks them)" if response.mode == "index" else ""
643
+ if response.truncated:
644
+ tail += f"; STOPPED at the limit — {response.files_unscanned} more files may match: raise -m or narrow the PATHs"
645
+ lines.append(tail)
646
+ lines += [f"note: {note}" for note in response.notes]
647
+ kinds = {match.kind for match in response.matches}
648
+ if "tab" in kinds:
649
+ note = (
650
+ "Tab matches are pointers: a tab's stored text is a preview of its rows (a parquet's, a column profile), "
651
+ "so the answer — and any row missing here — very likely comes from run-sql on the sql table shown."
652
+ )
653
+ if any(match.kind == "tab" and match.sql_table is None for match in response.matches):
654
+ note += " A tab shown without one has no table: read it with read-file --component."
655
+ lines.append(note)
656
+ if kinds & {"image", "media"}:
657
+ lines.append("Image and recording matches are pointers (an image's text or summary matched): read them with ask-file.")
658
+ return "\n".join(lines)
659
+
660
+
661
+ def _grep_lines(matches: list[GrepMatch]) -> list[str]:
662
+ lines: list[str] = []
663
+ current: tuple[str, str | None] | None = None
664
+ current_table: tuple[str, str | None, int] | None = None
665
+ shown = 0 # last line printed in the current file/component, so overlapping context prints once
666
+ for match in matches:
667
+ if (match.path, match.component) != current:
668
+ current, shown = (match.path, match.component), 0
669
+ label = match.component or (match.kind if match.kind in {"image", "media"} else None)
670
+ where = f" [{label}]" if label else ""
671
+ table = f" (sql table: {match.sql_table})" if match.sql_table else ""
672
+ lines.append(f"{match.path}{where}{table}")
673
+ if match.line is None:
674
+ lines.append(f" {match.text}")
675
+ continue
676
+ page = f" p.{match.page}" if match.page is not None else ""
677
+ table = match.table
678
+ if table is not None and (match.path, match.component, table.first_line) != current_table:
679
+ # A row's cells mean nothing without their column names: each table's lines, title and header, once.
680
+ current_table, shown = (match.path, match.component, table.first_line), 0 # the block separates
681
+ if table.rows is not None: # a table stored as one HTML line: its header once, then each matching row
682
+ lines.append(
683
+ f" [table on line {table.first_line}, {table.rows} rows]" + (f" {table.title}" if table.title else "")
684
+ )
685
+ if table.header and match.text != table.header: # a match on the header itself is the header
686
+ lines.append(f" header: {table.header}")
687
+ lines.append(f" {match.line}{page}: {match.text}")
688
+ continue
689
+ span = f" [table lines {table.first_line}-{table.last_line}]"
690
+ if match.line < table.first_line: # the match is the table's title
691
+ lines += [f" {match.line}{page}: {match.text}", f"{span} {table.first_line}: {table.header}"]
692
+ continue
693
+ lines.append(span + (f" {table.title}" if table.title else ""))
694
+ if match.line != table.first_line and table.first_line < match.line - len(match.context_before):
695
+ lines.append(f" {table.first_line}: {table.header}")
696
+ if match.context_before or match.context_after: # grep -A/-B/-C: context lines print as N- text
697
+ first = match.line - len(match.context_before)
698
+ if shown and first > shown + 1:
699
+ lines.append(" --")
700
+ lines += [f" {n}- {text}" for n, text in enumerate(match.context_before, start=first) if n > shown]
701
+ lines.append(f" {match.line}{page}: {match.text}")
702
+ lines += [f" {n}- {text}" for n, text in enumerate(match.context_after, start=match.line + 1) if n > match.line]
703
+ shown = match.line + len(match.context_after)
704
+ continue
705
+ lines.append(f" {match.line}{page}: {match.text}")
706
+ return lines
707
+
708
+
519
709
  def parse_job(job: Job) -> str:
520
710
  result = job.result
521
711
  if not isinstance(result, ParseResult):
@@ -741,7 +931,10 @@ def automatic_search_job(job: Job) -> str:
741
931
  body = filtered_search_job(job.model_copy(update={"result": output}))
742
932
  else:
743
933
  body = search_job(job.model_copy(update={"result": output}))
744
- return f"route: {result.routing.final} — {result.routing.reason}\noutput job: {result.output_job_id}\n{body}"
934
+ routing = result.routing
935
+ # The reason explains the router's choice, so a hand-over names both routes.
936
+ route = routing.final if routing.selected == routing.final else f"{routing.selected} → {routing.final} (no fact answer)"
937
+ return f"route: {route} — {routing.reason}\noutput job: {result.output_job_id}\n{body}"
745
938
 
746
939
 
747
940
  def search_job(job: Job) -> str:
@@ -18,6 +18,7 @@ from ndi_sdk.models.common import DocumentSource, ParseResultSource, UploadSourc
18
18
  JOBID_PREFIX = "jobid://"
19
19
  UPLOAD_PREFIX = "ndi://upload/"
20
20
  WS_PREFIX = "ws://"
21
+ _WS_FILE_ID_HINT = "workspace files are ws://FILE_ID; find the id with `ndi files list`"
21
22
  _UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
22
23
  SUPPORTED_SUFFIXES = frozenset(
23
24
  {
@@ -165,9 +166,10 @@ def resolve_source(
165
166
  if spec.startswith(WS_PREFIX):
166
167
  if not workspace_id:
167
168
  raise SourceError("ws:// sources need a workspace: set $NDI_WORKSPACE_ID or pass --workspace")
168
- return WorkspaceFileSource(
169
- workspace_id=_uuid(workspace_id, "workspace"), file_id=_uuid(spec.removeprefix(WS_PREFIX), "ws://")
170
- )
169
+ file_id = spec.removeprefix(WS_PREFIX)
170
+ if not _UUID.match(file_id):
171
+ raise SourceError(f"bad ws:// id: {file_id!r}; {_WS_FILE_ID_HINT}")
172
+ return WorkspaceFileSource(workspace_id=_uuid(workspace_id, "workspace"), file_id=uuid.UUID(file_id))
171
173
  if spec.startswith(("https://", "http://")):
172
174
  name = file_name or Path(spec.split("?", 1)[0]).name or "document"
173
175
  return UrlSource(url=spec, file_name=name)
@@ -175,7 +177,8 @@ def resolve_source(
175
177
  return _from_stdin(client, file_name=file_name, allow_parse_result=allow_parse_result, stdin=stdin)
176
178
  path = Path(spec)
177
179
  if not path.is_file():
178
- raise SourceError(f"file not found: {spec}")
180
+ hint = f"; {_WS_FILE_ID_HINT}" if spec.startswith("ws:") else ""
181
+ raise SourceError(f"file not found: {spec}{hint}")
179
182
  try:
180
183
  upload = client.documents.create_upload(path, file_name=file_name or path.name)
181
184
  except OSError as exc: