ipyai

IPython extension for backtick-triggered AI prompts.

View the Project on GitHub AnswerDotAI/ipyai

DEV

This project is small. Nearly all runtime behavior lives in ipyai/core.py, so getting productive mainly means understanding that file and the tests in tests/test_core.py.

Setup

Install in editable mode:

pip install -e .[dev]

Run tests:

pytest

This repo is configured for fastship releases:

ship-changelog
ship-release

Current Scope

Implemented:

File Map

Prompt History And Context

Each AI prompt is saved in an ai_prompts table inside IPython’s history SQLite database. Rows are keyed by the current IPython session_number and include:

Stored rows contain only the user prompt, full AI response, and the line where the code context for that prompt stops.

Example:

In [1]: import math
In [2]: .first prompt
In [3]: x = 1
In [4]: .second prompt

The stored rows are roughly:

So for the second prompt, ipyai knows:

For each new prompt, ipyai reconstructs chat history as alternating user / assistant entries:

The <context> block contains all non-ipyai code run since the previous AI prompt in the current session, plus Out[...] history when IPython has it. String-literal-only cells are sent as <note> instead of <code> (detected via ast). The XML is intentionally simple:

<context><code>a = 1</code><note>This is a note</note><code>a</code><output>1</output></context>

Runtime Flow

The extension lifecycle is:

  1. %load_ext ipyai calls load_ipython_extension, which delegates to create_extension.
  2. IPyAIExtension.__init__ loads config, system prompt, discovers skills, and loads the startup file.
  3. IPyAIExtension.load() registers %ipyai / %%ipyai, inserts a cleanup transform into IPython’s input_transformer_manager.cleanup_transforms, registers keybindings, and applies startup.ipynb if the session is still fresh.
  4. Any cell whose first character is . is rewritten by transform_dots() into get_ipython().run_cell_magic('ipyai', '', prompt).
  5. AIMagics.ipyai() routes line input to handle_line() and cell input directly to the _run_prompt() coroutine (returned to the async run_cell_magic patch for awaiting).
  6. _run_prompt() reconstructs conversation history, resolves tools, adds skills tools/system prompt if skills were discovered, runs lisette.AsyncChat, streams the response, optionally writes an exact log entry, and stores the full response.

At import time, ipyai also applies two small global IPython bugfixes borrowed from ipykernel_helper:

Why Cleanup Transforms

The period rewrite happens in cleanup_transforms, not in a later input transformer. That matters because IPython’s own parsing for help syntax and similar features can interfere with raw prompts if the rewrite happens too late.

This is the mechanism that makes these cases work correctly:

Prompt Construction

The stored prompt text is not the exact user message sent to the model. The actual user entry is built dynamically with:

{context}<user-request>{prompt}</user-request>

context is empty when there has been no intervening code. Otherwise it is:

<context><code>...</code><note>...</note><output>...</output>...</context>

Important detail: only the raw prompt and raw response are stored in SQLite. Context is regenerated on each run from normal IPython history. That keeps the table small and avoids baking transient context into stored rows.

SQLite Storage

ipyai uses IPython’s existing history database connection at shell.history_manager.db.

Table schema:

CREATE TABLE IF NOT EXISTS ai_prompts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session INTEGER NOT NULL,
  prompt TEXT NOT NULL,
  response TEXT NOT NULL,
  history_line INTEGER NOT NULL DEFAULT 0
)

Notes:

Startup Snapshot

startup.ipynb is stored as a Jupyter notebook (nbformat v4.5 with cell IDs) next to the other XDG files.

%ipyai save writes a merged event stream for the current session as notebook cells:

On a fresh load:

Legacy startup.json files (pre-notebook format) are still supported for loading.

Skills

Skills follow the Agent Skills specification. Discovery happens once at extension init time via _discover_skills():

  1. Walk from CWD up through all parent directories, scanning .agents/skills/ in each
  2. Scan ~/.config/agents/skills/
  3. Deduplicate by resolved path; closer-to-CWD skills take priority

Each skill directory must contain a SKILL.md with YAML frontmatter (name, description). Frontmatter is parsed with PyYAML.

At runtime, if skills were discovered:

The skills list is frozen at load time to prevent the LLM from creating and loading skills during a session.

Code Context Reconstruction

code_context(start, stop) pulls normal IPython history with:

history_manager.get_range(session=0, start=start, stop=stop, raw=True, output=True)

Rules:

Tool Resolution

Tool references are written in prompts as &name``.

Tools are discovered from multiple sources via _tool_refs():

Shared helpers:

resolve_tools():

The load_skill tool is added to user_ns at extension init time when skills are discovered. It is resolved through the normal tool mechanism (skills always contribute load_skill to the tool name set) rather than being special-cased in _run_prompt.

The tool lookup is intentionally live against the active namespace, so changing a function in the IPython session changes the tool used by subsequent prompts. Async callables are handled by lisette.AsyncChat, so tool results are awaited correctly.

Streaming And Display

Streaming and storage are deliberately separated.

astream_to_stdout():

  1. uses lisette.AsyncStreamFormatter to iterate the response stream
  2. in a TTY, updates a rich.live.Live view with Markdown(...) as chunks arrive
  3. outside a TTY, writes raw chunks to stdout
  4. returns the full original text for storage

Display processing (_display_text):

ipyai wraps the streaming phase in a small guard that temporarily marks shell.display_pub._is_publishing = True. That keeps terminal-visible AI output out of IPython’s normal stdout capture and therefore out of output_history, while still allowing ipyai to store the full response in ai_prompts.

Keybindings

Registered via prompt_toolkit on shell.pt_app.key_bindings during load():

Code blocks are extracted using mistletoe.Document and CodeFence — only blocks tagged python or py are included.

Config And System Prompt

XDG-backed module globals are defined at import time:

Creation behavior:

When log_exact is enabled, the log file contains the exact fully-expanded prompt passed to the model and the exact raw response returned from the stream.

Tests

The test suite uses dummy shell, history, chat, formatter, console, and markdown objects.

Coverage currently focuses on:

When changing behavior in ipyai/core.py, update or add the narrowest possible test in tests/test_core.py.

Common Change Points

If you want to change prompt parsing or magic routing:

If you want to change the XML or history sent to the model:

If you want to change notes behavior:

If you want to change tool behavior:

If you want to change skills:

If you want to change terminal rendering:

If you want to change persistence:

If you want to change the startup notebook format:

If you want to change keybindings:

If you want to change AI inline completion:

If you want to change syntax highlighting:

Working Assumptions