Research Agent Memory
Durable, searchable long-term memory for an AI research assistant: per-user semantic recall, retrieval_udf lookups, tool calling and memory consolidation with custom aggregates
One app.py with 3 tables, 8 computed columns. Needs pixeltable>=0.7.16. Updated 2026-10-15.
app.py
"""Research Agent Memory: durable, searchable memory for an AI research assistant, in Pixeltable tables.Sources are the papers and pages the assistant has read (with an optional cover image). Memories arefacts, preferences and episodes per user, embedded with OpenAI text-embedding-3-small into anEmbeddingIndex. recall_memories (@pxt.query) is semantic recall scoped to one user; lookup_source(@pxt.query) is an exact-key lookup the LLM can call as a tool. Turns is the agent table: eachmessage recalls memories, calls gpt-4o-mini with tools, and proposes a memory to write back.run.py also builds pxt.retrieval_udf lookups on the live tables and consolidates memories with thesalience and memory_digest aggregates from udfs.py.Layout: UDFs and UDAs live in udfs.py; this file holds table models and @pxt.query functions."""from __future__ import annotationsimport pixeltable as pxtimport pixeltable.functions as pxtffrom pixeltable.functions import openaifrom udfs import (CHAT_MODEL,EMBED_MODEL,agent_messages,answer_messages,extract_memory,memory_block,pick_answer,reading_tier,)TableModel = pxt.model_base()class Sources(TableModel, name='sources', has_default_idxs=False):"""A paper, web page or note the assistant has read."""source_id = pxt.Column(type=pxt.String, primary_key=True)title: pxt.Stringkind: pxt.Stringyear: pxt.Inturl: pxt.String | Nonecover: pxt.Image | Nonecover_width = cover.width # type: ignore[attr-defined]class Memories(TableModel, name='memories', has_default_idxs=False):"""Long-term memory: one fact, preference, episode or summary per row, per user."""memory_id = pxt.Column(value=pxtf.uuid.uuid7(), primary_key=True)user_id: pxt.Stringkind: pxt.Stringcontent: pxt.Stringimportance: pxt.Intcreated_at: pxt.Timestampsource_id: pxt.String | None__indexes__ = [pxt.EmbeddingIndex(content, # type: ignore[name-defined] # noqa: F821string_embed=openai.embeddings.using(model=EMBED_MODEL),metric='cosine',name='memory_embed',),pxt.BtreeIndex(user_id), # type: ignore[name-defined] # noqa: F821]@pxt.querydef recall_memories(user_id: str, context: str, k: int = 4):"""Top-k memories of one user that are semantically closest to the context."""sim = Memories.content.similarity(string=context, idx='memory_embed')return (Memories.where(Memories.user_id == user_id).order_by(sim, asc=False).limit(k).select(Memories.kind, Memories.content, Memories.source_id, score=sim))@pxt.querydef lookup_source(source_id: str):"""Look up one source (title, kind, year, url) by its id, e.g. S-02."""return Sources.where(Sources.source_id == source_id).limit(1).select(Sources.source_id, Sources.title, Sources.kind, Sources.year, Sources.url)@pxt.querydef recent_memories(user_id: str, n: int = 5):"""The newest n memories of one user."""return (Memories.where(Memories.user_id == user_id).order_by(Memories.created_at, asc=False).limit(n).select(Memories.kind, Memories.content, Memories.created_at))# Keep at least one @pxt.udf in the toolset: with only @pxt.query tools, invoke_tools waits# forever on a response that has no tool calls (seen on pixeltable 0.7.16).TOOLS = pxt.tools(lookup_source, reading_tier)class Turns(TableModel, name='turns', has_default_idxs=False):"""Agent table: message -> recall -> tool-calling LLM -> answer + a proposed memory write-back."""turn_id = pxt.Column(value=pxtf.uuid.uuid7(), primary_key=True)user_id: pxt.Stringmessage: pxt.Stringrecalled = recall_memories(user_id, message, 4)memory_text = memory_block(recalled)response = openai.chat_completions(agent_messages(user_id, message, memory_text),model=CHAT_MODEL,model_kwargs={'max_tokens': 150, 'temperature': 0},tools=TOOLS,)tool_output = openai.invoke_tools(TOOLS, response)final = openai.chat_completions(answer_messages(message, memory_text, tool_output),model=CHAT_MODEL,model_kwargs={'max_tokens': 160, 'temperature': 0},)answer = pick_answer(response.choices[0].message.content, final.choices[0].message.content, tool_output)remember = extract_memory(message)
What app.py declares
Each row links to its line.
- runs on insert
- stored
- index
- route
- turn_iduser_idmessage
- recalled = recall_memories(user_id, message, 4)L112
- memory_text = memory_block(recalled)L113
- response = openai.chat_completions(agent_messages(user_id, message, memory_text), model=CHAT_MODEL...L114
- tool_output = openai.invoke_tools(TOOLS, response)L120
- final = openai.chat_completions(answer_messages(message, memory_text, tool_output), model=CHAT_...L121
- answer = pick_answer(response.choices[0].message.content, final.choices[0].message.content, tool...L126
- remember = extract_memory(message)L127
No HTTP routes: a pipeline you insert into and query from Python.
Run it
Its LLM calls need OPENAI_API_KEY. Export it before the first pxt command; on Cloud, store it with pxt secret set.
Needs uv.
terminal
git clone https://github.com/pierrebrunelle/pixeltable-research-agent-memorycd pixeltable-research-agent-memoryuv syncuv run pxt schema update app.py research
Related apps
- Apiary AdvisorRAG + tool-calling beekeeping advisor: custom embedding index, table-as-UDF agent pipeline, expr_udf mite mathShares: Embedding indexes, B-tree indexes, LLM calls, UDFs
- Harbor Radio SignalsHarbor radio signal media pipeline: audio normalize/fade, vision box overlays, and StringSplitter sentence viewsShares: B-tree indexes, Images, video and audio, UDFs
- Archery RangeMigrate archery-range scoring sessions from legacy JSON into Pixeltable, score with date-aware computed columns, and run as an incremental scheduled pipelineShares: B-tree indexes, UDFs