Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,11 @@ ffmpeg -i talk.mp4 -f wav - | assembly transcribe -
git log --oneline -30 | assembly llm "write release notes grouped by feature/fix"
```

Pass files straight to `llm` instead of building the pipeline yourself — each is read, prefixed with a `===== name =====` header, and concatenated as the prompt's context (so the answer can cite which note it came from):
Pass files — or a whole directory — straight to `llm` instead of building the pipeline yourself — each is read, prefixed with a `===== name =====` header, and concatenated as the prompt's context (so the answer can cite which note it came from). A directory argument recurses for its `.md`/`.txt` files:

```sh
assembly llm "answer using only these notes: who owns the deploy?" notes/*.md
assembly llm "summarize the key decisions" transcripts/
```

## 📚 Documentation
Expand Down
9 changes: 5 additions & 4 deletions aai_cli/commands/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def _list_models(output_field: choices.TextOrJson | None, json_mode: bool) -> No
),
("Pipe any text in", 'echo "meeting notes" | assembly llm "turn into action items"'),
(
"Read one or more files as context",
'assembly llm "answer using only these notes: who owns the deploy?" notes/*.md',
"Read files or a whole directory as context",
'assembly llm "summarize the key decisions" transcripts/',
),
(
"Pick a model and add a system prompt",
Expand All @@ -60,8 +60,9 @@ def llm(
prompt: str | None = typer.Argument(None, help="The prompt to send to the model"),
files: list[Path] | None = typer.Argument(
None,
help="Optional input files to read as the prompt's context (each is header-prefixed "
"with its name and concatenated; takes priority over piped stdin)",
help="Optional input files or directories to read as the prompt's context "
"(a directory recurses for .md/.txt files; each is header-prefixed with its "
"name and concatenated; takes priority over piped stdin)",
),
# Note: text piped on stdin is injected into the prompt (e.g. `cat notes | assembly llm "summarize"`).
model: str = typer.Option(
Expand Down
44 changes: 38 additions & 6 deletions aai_cli/commands/llm/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
'`assembly stream -o text | assembly llm -f "summarize action items as I talk"`.'
)

# Suffixes a directory argument recurses for (matched case-insensitively).
_DIR_SUFFIXES = (".md", ".txt")


@dataclass(frozen=True)
class LlmOptions:
Expand Down Expand Up @@ -82,17 +85,46 @@ def _validate_follow_args(
return prompt


def _expand_paths(files: tuple[Path, ...]) -> tuple[Path, ...]:
"""Expand any directory argument into the ``.md``/``.txt`` files it holds.

A directory recurses (depth-first via ``rglob``, path-sorted so the header
order is deterministic) for files whose suffix is in ``_DIR_SUFFIXES``; a plain
file passes through unchanged. A directory holding no matching files is a usage
error — the empty-check the justfile's ``transcripts/**/*.md(.N)`` glob used to
carry, now that the CLI can. This lets `assembly llm "…" transcripts/` stand in
for the glob-and-guard the caller would otherwise hand-roll.
"""
expanded: list[Path] = []
for path in files:
if not path.is_dir():
expanded.append(path)
continue
matches = sorted(
p for p in path.rglob("*") if p.is_file() and p.suffix.lower() in _DIR_SUFFIXES
)
if not matches:
raise UsageError(
f"No {' or '.join(_DIR_SUFFIXES)} files found in {path}.",
suggestion="Point at a directory containing .md or .txt notes, "
"or pass files directly.",
)
expanded.extend(matches)
return tuple(expanded)


def _read_files(files: tuple[Path, ...]) -> str:
"""Read each file and join them, each prefixed with a ``===== name =====`` header.

The header names each source (the file's stem) so a multi-file prompt can cite
which note an answer came from; it's applied uniformly, even for a single file,
so the format the model sees is predictable. A missing or unreadable path is a
usage error raised before any auth or network — the same fail-fast ordering as
the --transcript-id check.
A directory argument is first expanded into its ``.md``/``.txt`` files
(see ``_expand_paths``). The header names each source (the file's stem) so a
multi-file prompt can cite which note an answer came from; it's applied
uniformly, even for a single file, so the format the model sees is predictable.
A missing or unreadable path is a usage error raised before any auth or
network — the same fail-fast ordering as the --transcript-id check.
"""
sections: list[str] = []
for path in files:
for path in _expand_paths(files):
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
Expand Down
13 changes: 7 additions & 6 deletions tests/__snapshots__/test_snapshots_help_run.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -592,9 +592,11 @@

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ prompt [PROMPT] The prompt to send to the model │
│ files [FILES]... Optional input files to read as the prompt's │
│ context (each is header-prefixed with its name and │
│ concatenated; takes priority over piped stdin) │
│ files [FILES]... Optional input files or directories to read as the │
│ prompt's context (a directory recurses for │
│ .md/.txt files; each is header-prefixed with its │
│ name and concatenated; takes priority over piped │
│ stdin) │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --model TEXT LLM Gateway model │
Expand Down Expand Up @@ -634,9 +636,8 @@
$ assembly llm "summarize the key decisions" --transcript-id 5551234-abcd
Pipe any text in
$ echo "meeting notes" | assembly llm "turn into action items"
Read one or more files as context
$ assembly llm "answer using only these notes: who owns the deploy?"
notes/*.md
Read files or a whole directory as context
$ assembly llm "summarize the key decisions" transcripts/
Pick a model and add a system prompt
$ assembly llm "draft a follow-up email" --model claude-opus-4-7 --system "Be
concise."
Expand Down
124 changes: 0 additions & 124 deletions tests/test_llm_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,130 +128,6 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, e
assert seen["transcript_id"] is None


def test_llm_reads_file_argument_as_context(monkeypatch, tmp_path):
_auth()
seen = {}

def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
seen["content"] = messages[0]["content"]
seen["transcript_id"] = transcript_id
return _payload("done")

monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
note = tmp_path / "alpha.md"
note.write_text("bob owns the deploy")
result = runner.invoke(app, ["llm", "who owns the deploy?", str(note), "--json"])
assert result.exit_code == 0
# The file content is injected, under a header naming the file's stem.
assert "who owns the deploy?" in seen["content"]
assert "bob owns the deploy" in seen["content"]
assert "===== alpha =====" in seen["content"]
assert seen["transcript_id"] is None


def test_llm_concatenates_multiple_files_with_headers_in_order(monkeypatch, tmp_path):
_auth()
seen = {}

def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
seen["content"] = messages[0]["content"]
return _payload("done")

monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
first = tmp_path / "first.md"
first.write_text("ship friday")
second = tmp_path / "second.md"
second.write_text("freeze monday")
result = runner.invoke(app, ["llm", "summarize", str(first), str(second), "--json"])
assert result.exit_code == 0
content = seen["content"]
assert "===== first =====" in content
assert "===== second =====" in content
assert "ship friday" in content
assert "freeze monday" in content
# Both note bodies appear under their own header, in the order passed.
assert content.index("===== first =====") < content.index("===== second =====")
assert content.index("ship friday") < content.index("freeze monday")


def test_llm_files_take_priority_over_stdin(monkeypatch, tmp_path):
_auth()
seen = {}

def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
seen["content"] = messages[0]["content"]
return _payload("done")

monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
note = tmp_path / "note.md"
note.write_text("from the file")
result = runner.invoke(
app, ["llm", "summarize", str(note)], input="from stdin, should be ignored"
)
assert result.exit_code == 0
assert "from the file" in seen["content"]
assert "from stdin, should be ignored" not in seen["content"]
assert "Ignoring piped stdin; file arguments take priority." in result.output


def test_llm_missing_file_exits_2_without_network(monkeypatch, tmp_path):
# A bad path (e.g. an unmatched shell glob passed through literally) is a usage
# error raised before auth or the gateway, not a crash.
_auth()
monkeypatch.setattr(
"aai_cli.commands.llm.gateway.complete",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call the gateway")),
)
missing = tmp_path / "nope.md"
result = runner.invoke(app, ["llm", "summarize", str(missing)])
assert result.exit_code == 2
assert "Couldn't read" in result.output
# The clean OS reason (errno's strerror) is shown, not the raw exception repr —
# so no "[Errno N] …: '/path'" bracket leaks into the message.
assert "[Errno" not in result.output


def test_llm_files_with_terminal_stdin_emits_no_warning(monkeypatch, tmp_path):
# With files given and stdin a terminal (not piped), there's nothing being
# ignored, so the "Ignoring piped stdin" warning must not fire.
_auth()
monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False)
seen = {}

def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
seen["content"] = messages[0]["content"]
return _payload("done")

monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
note = tmp_path / "note.md"
note.write_text("only the file")
result = runner.invoke(app, ["llm", "summarize", str(note)])
assert result.exit_code == 0
assert "only the file" in seen["content"]
assert "Ignoring piped stdin" not in result.output


def test_llm_transcript_id_takes_priority_over_files(monkeypatch, tmp_path):
_auth()
seen = {}
# Pin stdin to a terminal so only the file argument is the ignored source.
monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False)

def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
seen["content"] = messages[0]["content"]
seen["transcript_id"] = transcript_id
return _payload("s")

monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
note = tmp_path / "note.md"
note.write_text("file content here")
result = runner.invoke(app, ["llm", "summarize", str(note), "--transcript-id", "t_9"])
assert result.exit_code == 0
assert seen["transcript_id"] == "t_9"
assert "file content here" not in seen["content"]
assert "Ignoring file arguments; --transcript-id takes priority." in result.output


def test_llm_transcript_id_takes_priority_over_stdin(monkeypatch):
_auth()
seen = {}
Expand Down
Loading
Loading