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
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,33 @@ Tests live in `tests/`. The test suite (`tests/e2e_test.py`) calls the **real Op
1. `make format`
2. `make lint` — must pass cleanly

## Architecture principles

### `core.py` is client-agnostic

`core.py` is a reusable library. It must not reference or special-case any specific caller. New clients (web frontends, backend libraries, …) will be added over time.

Follow **SOLID**, especially:
- **Single Responsibility** — `core.py` handles AI interaction only. I/O, presentation, and session management belong to the caller.
- **Open/Closed** — extend behaviour through the defined callback and slot protocol; never add branches that check which kind of caller is in use.

### Caller protocol for `main()` and `one_shot()`

Both methods drive a conversation through two instance callbacks:
- `input()` — returns the next prompt string, or a falsy value to end the session
- `output(content, info)` — receives each response

**Per-message attachments** are communicated via instance slots that the caller populates *before* unblocking `input()`. `main()` initializes these slots from their keyword parameters, so all callers — regardless of their concurrency model — use the same mechanism with no branching:

| Slot | Cleared after |
|---|---|
| `_next_image_path` | every `send()` |
| `_next_file_paths` | every `send()` |
| `_next_vectorize_paths` | vector store creation |

A synchronous caller sets these through keyword arguments.
An event-driven caller sets them on the instance before signalling `input()` to unblock — safe because the consumer is always blocked on `input()` at that point.

## Code conventions

- No `pyproject.toml` or `setup.py` — this is a flat script-based repo, not a package.
Expand All @@ -66,6 +93,10 @@ Tests live in `tests/`. The test suite (`tests/e2e_test.py`) calls the **real Op

You may be running in a git worktree (e.g. `.claude/worktrees/...`). Your cwd is already set correctly — run `git`, `gh`, and other commands directly without `-C` or `cd`.

## Code style

- **DRY** — don't repeat yourself. Extract helpers rather than duplicating logic.

## Things to avoid

- Don't add type stubs, docstrings, or comments to code you didn't change.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Quit with `q`, `x`, `exit`, `quit`, `Ctrl+C`, or `Ctrl+D`.
| `-i`, `--image` | Image file to include |
| `-f`, `--file` | Document(s) to include |
| `-vf`, `--vectorize-file` | Document(s) to upload to a vector store for semantic file search |
| `-vs`, `--vector-store` | Use a pre-existing vector store by ID for semantic file search |
| `-vs`, `--vector-store` | Use a pre-existing vector store by ID for semantic file search; can be combined with `-vf` to upload additional files into it (files are kept after the session) |
| `-r`, `--rich` | Render Markdown with rich text formatting in the terminal |
| `-d`, `--debug` | Pretty-print raw API responses to stderr |
| `-l`, `--list-known` | List models with known pricing |
Expand Down Expand Up @@ -95,6 +95,7 @@ Quit with `q`, `x`, `exit`, `quit`, `Ctrl+C`, or `Ctrl+D`.
./cli.py -b -vf contracts/*.pdf <<< "Which contracts mention arbitration?"
./cli.py -vf docs/*.pdf # interactive Q&A session across a document collection
./cli.py -vs vs_abc123 # interactive Q&A using a pre-existing vector store
./cli.py -vs vs_abc123 -vf extra.pdf # same, but also upload extra.pdf into it (file kept after session)
```

The tool is quite powerful with `guake` drop-down terminal and alias like this:
Expand Down
9 changes: 3 additions & 6 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,12 +371,8 @@ def main():
"cannot be combined with each other or other options."
)

if args.vectorize_file and args.vector_store:
parser.error("--vectorize-file and --vector-store cannot be used together.")

if args.list_known:
known_models = sorted(core.USD_PER_INPUT_TOKEN.keys())
for m in known_models:
for m in core.KNOWN_MODELS:
print(m)
return

Expand Down Expand Up @@ -411,11 +407,12 @@ def batch_output(msg, info):
args.model,
web_search=args.web_search,
debug=args.debug,
).one_shot(
).main(
image_path=args.image,
file_paths=args.file,
vectorize_file_paths=args.vectorize_file,
vector_store_id=args.vector_store,
one_shot=True,
)
return

Expand Down
128 changes: 82 additions & 46 deletions core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,27 @@

import openai

# Best in non-agentic coding per https://livebench.ai/ (2026-01-08)
DEFAULT_MODEL = "gpt-5.4"

IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
USER_DATA_EXTENSIONS = (
".csv",
".doc",
".docx",
".html",
".json",
".md",
".odt",
".pdf",
".ppt",
".pptx",
".rtf",
".txt",
".xls",
".xlsx",
".xml",
)

# Prices in USD, source: https://openai.com/api/pricing/
USD_PER_INPUT_TOKEN = {
"o1": 15e-6,
Expand All @@ -24,6 +42,8 @@
"gpt-5.2": 1.75e-6,
"gpt-5.2-codex": 1.75e-6,
"gpt-5.3-codex": 1.75e-6,
"gpt-5.4-nano": 0.2e-6,
"gpt-5.4-mini": 0.75e-6,
"gpt-5.4": 2.5e-6,
"gpt-5.4-pro": 30e-6,
}
Expand All @@ -37,11 +57,15 @@
"gpt-5.2": 14e-6,
"gpt-5.2-codex": 14e-6,
"gpt-5.3-codex": 14e-6,
"gpt-5.4-nano": 1.25e-6,
"gpt-5.4-mini": 4.5e-6,
"gpt-5.4": 15e-6,
"gpt-5.4-pro": 180e-6,
}
assert set(USD_PER_INPUT_TOKEN.keys()) == set(USD_PER_OUTPUT_TOKEN.keys())

KNOWN_MODELS = sorted(USD_PER_INPUT_TOKEN.keys())

USD_PER_WEB_SEARCH_CALL = 0.01

DATA_DIRECTORY = Path(
Expand Down Expand Up @@ -95,7 +119,14 @@ class GptCore:
The main loop to interact with the model.
"""

def __init__(self, input, output, model, web_search=False, debug=False): # noqa: A002 (input is a callback, not the builtin)
def __init__(
self,
input=None,
output=None,
model=DEFAULT_MODEL,
web_search=False,
debug=False,
): # noqa: A002 (input is a callback, not the builtin)
self.input = input
self.output = output
self.model = model
Expand All @@ -114,6 +145,8 @@ def __init__(self, input, output, model, web_search=False, debug=False): # noqa
os.makedirs(DATA_DIRECTORY, exist_ok=True)
self.file = DATA_DIRECTORY / f"{self.conversation_id}.json"

self.save_callback = None

self.client = openai.OpenAI()

def _compute_price(self, input_tokens, output_tokens, web_search_calls=0):
Expand Down Expand Up @@ -179,6 +212,12 @@ def _teardown(self):
self.delete_file(file_id)
self._teardown_vector_store()

def _save(self):
with open(self.file, "w") as f:
json.dump([dict(m) for m in self.messages], f, sort_keys=True, indent=4)
if self.save_callback:
self.save_callback()

def send(self, prompt, image_path=None, file_paths=None):
"""Send a message and get response. Returns (content, Info)."""
content = []
Expand All @@ -194,6 +233,7 @@ def send(self, prompt, image_path=None, file_paths=None):
content.append({"type": "input_file", "file_id": file_id})
content.append({"type": "input_text", "text": prompt})
self.messages.append({"role": "user", "content": content})
self._save()

tools = []
includes = []
Expand Down Expand Up @@ -225,9 +265,7 @@ def send(self, prompt, image_path=None, file_paths=None):
content += "\n\n**Sources:**\n" + "\n".join(
f"- [{s['title']}]({s['url']})" for s in sources
)
serialized = [dict(m) for m in self.messages]
with open(self.file, "w") as f:
json.dump(serialized, f, sort_keys=True, indent=4)
self._save()

usage = response.usage
input_tokens, output_tokens = usage.input_tokens, usage.output_tokens
Expand All @@ -240,31 +278,54 @@ def send(self, prompt, image_path=None, file_paths=None):

return content, Info(input_tokens, output_tokens, web_search_calls, step_price)

def _init_session(
self, image_path, file_paths, vectorize_file_paths, vector_store_id
):
"""Reset per-session state and populate the attachment slots."""
self._images = []
self._files = []
self._vector_store_id = vector_store_id or None
self._vector_store_owned = False
self._vector_files = []
self._next_image_path = image_path
self._next_file_paths = file_paths
self._next_vectorize_paths = vectorize_file_paths

def _consume_attachments(self):
"""Set up any pending vector store, then return and clear the per-message slots."""
if self._next_vectorize_paths:
if not self._vector_store_id:
self._setup_vector_store(self._next_vectorize_paths)
else:
for path in self._next_vectorize_paths:
file_id = self.upload_file(path, "assistants")
self.add_vector_store_file(self._vector_store_id, file_id)
self.wait_for_vector_store(self._vector_store_id)
self._next_vectorize_paths = None
image_path, file_paths = self._next_image_path, self._next_file_paths
self._next_image_path = None
self._next_file_paths = None
return image_path, file_paths

def main(
self,
image_path=None,
file_paths=None,
vectorize_file_paths=None,
vector_store_id=None,
one_shot=False,
):
self._images = []
self._files = []
self._vector_store_id = None
self._vector_store_owned = False
self._vector_files = []
if not self.input or not self.output:
raise RuntimeError("Calling main without input/output callback set.")
self._init_session(
image_path, file_paths, vectorize_file_paths, vector_store_id
)
price = 0.0
total_web_search_calls = 0
try:
if vector_store_id:
self._vector_store_id = vector_store_id
elif vectorize_file_paths:
self._setup_vector_store(vectorize_file_paths)
while prompt := self.input():
content, info = self.send(
prompt, image_path=image_path, file_paths=file_paths
)
image_path = None # only attach files to the first message
file_paths = None
img, fps = self._consume_attachments()
content, info = self.send(prompt, image_path=img, file_paths=fps)
if price is not None and info.price is not None:
price += info.price
else:
Expand All @@ -279,33 +340,8 @@ def main(
price,
),
)
finally:
self._teardown()

def one_shot(
self,
image_path=None,
file_paths=None,
vectorize_file_paths=None,
vector_store_id=None,
):
self._images = []
self._files = []
self._vector_store_id = None
self._vector_store_owned = False
self._vector_files = []
prompt = self.input()
if not prompt:
return
try:
if vector_store_id:
self._vector_store_id = vector_store_id
elif vectorize_file_paths:
self._setup_vector_store(vectorize_file_paths)
content, info = self.send(
prompt, image_path=image_path, file_paths=file_paths
)
self.output(content, info)
if one_shot:
break
finally:
self._teardown()

Expand Down
Loading
Loading