Status: Draft
Author: Josh Mock
Date: 2026-05-22
Related RFC: RFC.md
This POC validates a small slice of the RFC's "context engine" vision: surfacing useful, durable knowledge from past agent sessions. Rather than building a full graph-based crawler, this POC focuses on the memory tier — ingesting raw session logs into Elasticsearch and using a local LLM to distill them into searchable, agent-readable summaries.
Two standalone TypeScript scripts implement the full pipeline. No framework, no daemon, no watcher — just two scripts you run manually to prove the idea works.
- Confirm that pi session logs contain actionable signal worth remembering across sessions.
- Establish a simple
memory-raw→memory-summarypipeline as a concrete foundation for the broader RFC. - Produce a document schema that can later be extended with embeddings and semantic search.
- Real-time or continuous ingestion.
- Serving summaries back into an active agent session (retrieval is out of scope for this POC).
- Multi-user or multi-machine support.
- Building embeddings or vector search (addressed in recommendations below).
- An Elasticsearch instance is already running and accessible.
- Connection is configured via two environment variables:
ES_URL— full URL to the Elasticsearch instance (e.g.https://my-deployment.es.us-east-1.aws.elastic.cloud)ES_API_KEY— an API key with read/write access to both target indices
- A local llama.cpp server is running and accessible at
http://localhost:8080(or configurable viaLLAMA_URL), hosting a model capable of instruction following (e.g. Llama 3, Mistral, etc.). - Session logs are stored at
~/.pi/agent/sessions/in the format pi writes them. - Both scripts are run manually from the repo root via
npx ts-nodeor equivalent.
Reads every session log file in ~/.pi/agent/sessions/, transforms each message into a flat document, and bulk-indexes them into the memory-raw Elasticsearch index. Idempotent: re-running will upsert existing documents without duplication.
Session log files at ~/.pi/agent/sessions/. The script should handle whatever format pi writes (inspect a few live files before implementing to confirm structure).
One document per message (not per turn). Messages in the same conversational exchange share a turn_id.
| Field | Type | Description |
|---|---|---|
doc_id |
keyword |
Stable unique ID: {session_id}_{turn_id}_{message_index} |
session_id |
keyword |
Identifier for the session file (e.g. filename without extension) |
turn_id |
keyword |
Identifier grouping all messages in a single turn (e.g. {session_id}_{turn_index}) |
turn_index |
integer |
Zero-based position of this turn within the session |
message_index |
integer |
Zero-based position of this message within the turn |
role |
keyword |
user, assistant, tool, system, etc. |
content |
text |
Full message text content |
tool_name |
keyword |
If the message is a tool call/result, the tool name; otherwise null |
session_started_at |
date |
Timestamp of the first message in the session |
message_timestamp |
date |
Timestamp of this message, if available in the log |
model |
keyword |
Model identifier used in this session, if present in the log |
Index as keyword + text (multi-field) for any field you might want both to filter and to search.
- Walk
~/.pi/agent/sessions/and collect all session files. - Parse each file. Assign
session_idfrom the filename. - Assign
turn_idby grouping messages into turns. The grouping strategy depends on the session log format — a "turn" is typically one user message plus all the assistant/tool messages that respond to it. - Use Elasticsearch's bulk API (
@elastic/elasticsearchclient) to upsert documents, usingdoc_idas the_id. - Log progress to stdout: files processed, documents indexed, errors.
- Do not crash the entire run on a single malformed file — log the error and continue.
Iterates through all documents in memory-raw, uses a local LLM to identify ideas worth preserving as long-term agent memory, and writes summarized memory documents to the memory-summary index.
- Scroll through
memory-rawgrouped byturn_id(reconstruct full turns, not isolated messages). - Send each turn's content to the LLM with a prompt asking it to identify any ideas an agent would want to remember for future sessions.
- If the LLM identifies no memorable ideas in a turn, skip it.
- If it identifies one or more ideas, write one
memory-summarydocument per idea.
- Inference endpoint: local llama.cpp server at
http://localhost:8080/v1(OpenAI-compatible API), configurable viaLLAMA_URL. - Use the
/v1/chat/completionsendpoint with a structured output prompt. - Model ID is not hardcoded — pass whatever model name llama.cpp reports or configure via
LLAMA_MODEL.
The prompt should instruct the model to return a JSON array. Each element represents one memorable idea found in the turn, with:
summary: 1-3 sentence description of the ideatags: array of keywords (3-8), lowercase, hyphen-separated where multi-word (e.g.typescript,best-practice,repo-elasticsearch-js)
If there are no memorable ideas, return an empty array [].
The prompt must include the full reconstructed turn text (all messages concatenated with role labels), and request strict JSON output.
| Field | Type | Description |
|---|---|---|
doc_id |
keyword |
Stable unique ID: hash of {session_id}_{turn_id}_{summary_text} |
summary |
text |
The 1-3 sentence memory summary |
tags |
keyword |
Array of keyword tags assigned by the LLM |
source_refs |
object[] |
Array of { session_id, turn_id } pairs pointing back to the raw turn(s) in memory-raw |
established_at |
date |
Timestamp of the original turn's messages (use message_timestamp from the raw doc, falling back to session_started_at) |
summarized_at |
date |
Wall-clock time when this summary document was generated |
model |
keyword |
LLM model identifier used to generate the summary |
- Use the ES scroll API (or point-in-time + search_after) to page through all documents in
memory-raw. - Group documents by
turn_idbefore sending to LLM. - Use Elasticsearch bulk upsert with
doc_idas_idso re-runs are idempotent. - Concurrency: process turns sequentially to avoid overwhelming the local LLM server.
- Log progress: turns processed, memories written, turns skipped.
- Do not crash on LLM errors — log and continue.
dev-context-engine/
src/
ingest-sessions.ts
summarize-memories.ts
package.json
tsconfig.json
.env.example
@elastic/elasticsearch— official ES clienttypescript,ts-node— runtime- No additional dependencies; use Node's built-in
fetchfor LLM calls (Node 18+).
ES_URL=https://your-deployment.es.us-east-1.aws.elastic.cloud
ES_API_KEY=your_api_key_here
LLAMA_URL=http://localhost:8080
LLAMA_MODEL=llama3
-
ingest-sessions.tsruns to completion against a real~/.pi/agent/sessions/directory and documents appear inmemory-raw. - Each document in
memory-rawhas the correctturn_idgrouping — you can queryGET memory-raw/_searchfiltered byturn_idand see all messages from that turn. -
summarize-memories.tsruns to completion and writes at least a handful of summary documents tomemory-summary. - Summary documents include valid
source_refsthat link back to existingsession_id+turn_idpairs inmemory-raw. - Re-running either script does not create duplicate documents.
- Both scripts handle an empty sessions directory or empty
memory-rawindex gracefully (exit cleanly with a log message).
Once the POC works, these Elasticsearch features would meaningfully improve the pipeline:
Elastic hosts jina-embeddings-v5-text-small-clustering directly via the Elastic Inference Service (EIS). Rather than running the model locally via llama.cpp, you can register it as an inference endpoint in Elasticsearch and have ES generate embeddings automatically via an ingest pipeline. This eliminates the local llama.cpp dependency for embedding generation (though you would still need a local LLM for the summarization step).
PUT _inference/text_embedding/jina-clustering
{
"service": "jinaai",
"service_settings": {
"model_id": "jina-embeddings-v5-text-small-clustering",
"api_key": "<your-jina-key>"
}
}On Elasticsearch 8.11+ and Serverless, the semantic_text field type auto-generates embeddings at index time using a registered inference endpoint. Add a summary_embedding field of type semantic_text to the memory-summary mapping, point it at the Jina inference endpoint, and Elasticsearch will embed every summary automatically with no client-side code.
PUT memory-summary
{
"mappings": {
"properties": {
"summary": { "type": "text" },
"summary_embedding": {
"type": "semantic_text",
"inference_id": "jina-clustering"
}
}
}
}Once embeddings exist, you can retrieve the most semantically relevant memories for a given agent task using a knn query against the summary_embedding field. This is the retrieval step the RFC envisions — before a session starts, embed the user's first prompt and find the top-N matching memories to inject into context.
Serverless eliminates cluster sizing and index lifecycle management. For this use case (infrequent writes, occasional reads), the consumption-based billing model is more cost-efficient than a provisioned cluster. The inference API and semantic text features are fully available on Serverless.
For the raw ingest pipeline, an inference enrich processor can classify or tag messages at index time using a trained model — potentially auto-detecting "memorable" content without a separate summarization pass.
An embedding is a fixed-length array of floating point numbers (a "vector") that represents the semantic meaning of a piece of text. Two texts that mean similar things will have vectors that are close together in space, regardless of the exact words used. This is what powers semantic search: instead of matching keywords, you match meaning.
jina-embeddings-v5-text-small-clustering produces vectors of 1024 dimensions (or smaller via Matryoshka truncation: 32, 64, 128, 256, 512, 768, or 1024). It uses last-token pooling and supports sequences up to 32,768 tokens. It is optimized for clustering tasks — grouping related documents — which makes it a good fit for organizing memories.
Start the llama.cpp server with the embedding flag:
llama-server -hf jinaai/jina-embeddings-v5-text-small-clustering:F16 \
--embedding --pooling last -ub 32768Then call the OpenAI-compatible embeddings endpoint:
curl -X POST "http://127.0.0.1:8080/v1/embeddings" \
-H "Content-Type: application/json" \
-d '{
"input": ["Your text to embed goes here"]
}'The response contains an embedding array (the vector) for each input string. You store that array as a dense_vector field in Elasticsearch.
The model expects plain text. For this POC's use case (embedding summaries), no special prefix is needed. If you later embed longer documents, prepend "Document: " to the input text, as shown in the HuggingFace examples — the model was trained to distinguish document-type from query-type inputs.
Map the field as dense_vector with dims: 1024 (or your chosen Matryoshka dimension):
"summary_embedding": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine"
}Then at index time, generate the embedding via llama.cpp and include it in the document body.
POST memory-summary/_search
{
"knn": {
"field": "summary_embedding",
"query_vector": [0.032, -0.14, ...],
"k": 5,
"num_candidates": 50
}
}query_vector is the embedding of your search text (e.g. the user's first prompt for the new session). The result is the 5 most semantically similar memory summaries.
This model supports "Matryoshka" embeddings, meaning you can use only the first N dimensions of the vector and still get useful similarity results. For this POC, start with 1024 dims. If storage or query latency becomes a concern, you can truncate to 256 or 512 with only a modest accuracy tradeoff.