IPython extension for backtick-triggered AI prompts.
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.
Install in editable mode:
pip install -e .[dev]
Run tests:
pytest
This repo is configured for fastship releases:
ship-changelog
ship-release
Implemented:
ast and sent as <note> blocks in contextstartup.ipynb (nbformat v4.5 with cell IDs)_tool_refs()_parse_frontmatter() shared helper for extracting YAML frontmatter from skills, notes, and tool resultsallowed-tools frontmatter key in skills and notes for declaring tool dependenciesallowed-tools or eval: true) contribute tools.agents/skills/ (CWD + parents) and ~/.config/agents/skills/load_skill tool added to user_ns at init time, resolved via normal tool mechanism (not special-cased)🔧 f(x=1) => 2 formcompletion_model with session context, shows as prompt_toolkit suggestion; partial accept via M-f preserves remaining suggestion)mistletoe markdown parser (not regex) for correctness. prompts and %%ipyai cells (patches IPythonPTLexer at class level)SyntaxTB and inspect.getfileEach 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:
promptresponsehistory_lineStored 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:
history_line=1history_line=3So for the second prompt, ipyai knows:
x = 1, but not import mathFor each new prompt, ipyai reconstructs chat history as alternating user / assistant entries:
<context>...</context><user-request>...</user-request>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>
The extension lifecycle is:
%load_ext ipyai calls load_ipython_extension, which delegates to create_extension.IPyAIExtension.__init__ loads config, system prompt, discovers skills, and loads the startup file.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.. is rewritten by transform_dots() into get_ipython().run_cell_magic('ipyai', '', prompt).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)._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:
SyntaxTB.structured_traceback coerces non-string evalue.msg values to strinspect.getfile is wrapped to always return a stringThe 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:
?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.
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:
session_numberhistory_line is used to decide which code cells belong in the next prompt’s generated <context> blockai_prompts does not match the expected schema, ipyai drops and recreates it instead of migrating it%ipyai reset deletes only current-session rows and sets a reset baseline in user_nsstartup.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:
metadata.ipyai.kind="code")metadata.ipyai.source for round-trip replay)metadata.ipyai.prompt)On a fresh load:
run_cell(..., store_history=True)ai_prompts from metadataexecution_count is advanced for restored prompt events so later saves preserve orderingLegacy startup.json files (pre-notebook format) are still supported for loading.
Skills follow the Agent Skills specification. Discovery happens once at extension init time via _discover_skills():
.agents/skills/ in each~/.config/agents/skills/Each skill directory must contain a SKILL.md with YAML frontmatter (name, description). Frontmatter is parsed with PyYAML.
At runtime, if skills were discovered:
<skills> section listing all skill names, paths, and descriptionsload_skill tool is added to the tools list (reads SKILL.md and returns as FullResponse)user_ns (does not pollute the user’s namespace)The skills list is frozen at load time to prevent the LLM from creating and loading skills during a session.
code_context(start, stop) pulls normal IPython history with:
history_manager.get_range(session=0, start=start, stop=stop, raw=True, output=True)
Rules:
ipyai commands (starting with . or %ipyai) are skipped_is_note via ast.parse) become <note> tags containing the string value<code>...</code><output>...</output>Tool references are written in prompts as &name``.
Tools are discovered from multiple sources via _tool_refs():
&name`` in the current prompt and prior prompts in dialog historyallowed-tools frontmatter and &name`` mentions in skills&name`` mentions and allowed-tools frontmatter in notes (string-literal cells)allowed-tools or eval: trueShared helpers:
_parse_frontmatter(text) extracts YAML frontmatter from any text (reused by skills, notes, and tool results)_allowed_tools(text) combines frontmatter allowed-tools and &name`` mentions into a set of tool names_tool_results(response) scans stored response <details> blocks for qualifying tool resultsresolve_tools():
NameError/TypeError for missing or non-callable)_tool_refs()user_nsget_schema_nm(...) so the exposed tool name matches the namespace symbol instead of __call__ for callable objectslisette.AsyncChat(..., 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 storage are deliberately separated.
astream_to_stdout():
lisette.AsyncStreamFormatter to iterate the response streamrich.live.Live view with Markdown(...) as chunks arriveDisplay processing (_display_text):
_strip_thinking removes 🧠emoji lines once actual content follows (shows them as a progress indicator during thinking, strips them from the final display)compact_tool_display rewrites lisette tool detail blocks to a short 🔧 f(x=1) => 2 formipyai 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.
Registered via prompt_toolkit on shell.pt_app.key_bindings during load():
escape, . (Alt-.): AI inline completion — calls _ai_complete() as a background task, which builds a prompt from session context plus the current prefix/suffix and calls the configured completion_model. The result is set as buffer.suggestion (prompt_toolkit’s auto-suggest display), accepted with right-arrow or word-at-a-time with M-f. IPython’s existing auto-suggest get_suggestion is patched to remember the AI target text so partial accepts regenerate the remainder. Cancels safely if the buffer text changes before the response arrives.escape, up / escape, down (Alt-Up/Down): jump through complete history entries, bypassing line-by-line navigation in multiline inputs (calls buffer.history_backward() / history_forward())escape, W (Alt-Shift-W): insert all Python code blocks from _ai_last_responseescape, ! through escape, ( (Alt-Shift-1 through Alt-Shift-9): insert the Nth code blockescape, s-up / escape, s-down (Alt-Shift-Up/Down): cycle through code blocks one at a time, replacing the buffer contents; prompt_toolkit swaps A/B for modifier-4 (Alt+Shift) arrows, so the bindings are intentionally invertedCode blocks are extracted using mistletoe.Document and CodeFence — only blocks tagged python or py are included.
XDG-backed module globals are defined at import time:
CONFIG_PATH: model, think, search, Rich code theme, and the exact-log flagSYSP_PATH: system prompt passed as sp= to lisette.AsyncChatSTARTUP_PATH: saved startup snapshot (.ipynb format)LOG_PATH: optional raw prompt/response log outputCreation behavior:
model defaults from IPYAI_MODEL if present%ipyai model ... and similar commands change only the live extension object, not the config fileWhen 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.
The test suite uses dummy shell, history, chat, formatter, console, and markdown objects.
Coverage currently focuses on:
<note> tags)_parse_frontmatter) and allowed-tools extractionload_skill_run_promptWhen changing behavior in ipyai/core.py, update or add the narrowest possible test in tests/test_core.py.
If you want to change prompt parsing or magic routing:
is_dot_prompt(), prompt_from_lines(), or transform_dots()If you want to change the XML or history sent to the model:
_prompt_template, code_context(), format_prompt(), or dialog_history()If you want to change notes behavior:
_is_note(), _note_str(), and the note handling in code_context()If you want to change tool behavior:
_tool_names(), _tool_refs(), _parse_frontmatter(), _allowed_tools(), _tool_results(), or resolve_tools()If you want to change skills:
_parse_skill(), _discover_skills(), _skills_xml(), load_skill(), and the skills/tool collection in _run_prompt()If you want to change terminal rendering:
_display_text(), _strip_thinking(), compact_tool_display(), _astream_to_live_markdown(), _markdown_renderable(), or astream_to_stdout()If you want to change persistence:
ensure_prompt_table(), prompt_records(), save_prompt(), save_startup(), apply_startup(), and reset_session_history()If you want to change the startup notebook format:
_event_to_cell(), _cell_to_event(), _default_startup(), load_startup(), and save_startup()If you want to change keybindings:
_register_keybindings() and _extract_code_blocks()If you want to change AI inline completion:
_ai_complete(), _COMPLETION_SP, and the escape, . binding in _register_keybindings()If you want to change syntax highlighting:
_patch_lexer()