9.4 KiB
Agent Memory System — Implementation Plan
Overview
Add short-term and long-term memory to the Didactyl agent, stored as Nostr kind 30078 (addressable app data) events. Both memory types use freeform markdown content, NIP-44 encrypted (the agent encrypts to itself for privacy).
- Short-term memory — injected into every LLM prompt automatically; the agent's scratchpad for facts, context, and notes that should persist across conversations
- Long-term memory — retrieved on demand via a tool call; a larger store for reference material the agent doesn't need in every prompt
Both are global to the agent (not per-user), stored as single replaceable events keyed by d tag. Content is NIP-44 encrypted to the agent's own public key, ensuring only the agent can read its own memories.
Nostr Event Structure
Short-Term Memory Event
{
"kind": 30078,
"content": "<NIP-44 encrypted markdown>",
"tags": [
["d", "short_term_memory"],
["app", "didactyl"]
]
}
Decrypted content example:
## Current Context
- Working on project Alpha...
- Admin prefers concise responses...
Long-Term Memory Event
{
"kind": 30078,
"content": "<NIP-44 encrypted markdown>",
"tags": [
["d", "long_term_memory"],
["app", "didactyl"]
]
}
Decrypted content example:
## Project Notes
### Alpha
- Started 2026-01-15...
## Preferences
- Admin timezone: UTC-3...
Encryption Details
Memory content is NIP-44 encrypted using the agent's own keypair (self-encryption):
- Encrypt:
nostr_nip44_encrypt(agent_private_key, agent_public_key, plaintext, ciphertext, size) - Decrypt:
nostr_nip44_decrypt(agent_private_key, agent_public_key, ciphertext, plaintext, size)
This follows the existing pattern in tool_nostr_dm.c (execute_nostr_encrypt/execute_nostr_decrypt). The d tags remain unencrypted (they must be for addressable event replacement to work), but the actual memory content is private.
Size Limits
| Memory Type | Max Content Size | Rationale |
|---|---|---|
| Short-term | ~8,000 chars | In every prompt; ~2K tokens budget |
| Long-term | ~32,000 chars | On-demand only; well within 64KB relay limit |
Architecture
flowchart TD
A[Agent Startup] --> B[Fetch kind 30078 from nostr]
B --> B2[NIP-44 decrypt content with agent keys]
B2 --> C[Cache short_term_memory plaintext in-memory]
B2 --> D[Cache long_term_memory plaintext in-memory]
E[Every Prompt Build] --> F[Soul template section: short_term_memory]
F --> G[tool: memory_short_term_read]
G --> H[Return cached plaintext STM content]
H --> I[Injected as system message in prompt]
J[Agent wants to recall LTM] --> K[Calls my_memory tool]
K --> L{Cached?}
L -->|Yes| M[Return cached plaintext LTM]
L -->|No| N[Query nostr for kind 30078 d=long_term_memory]
N --> N2[NIP-44 decrypt with agent keys]
N2 --> O[Cache plaintext result]
O --> M
P[Agent wants to save memory] --> Q[Calls memory_save tool]
Q --> R{type param}
R -->|short_term| S[NIP-44 encrypt + publish kind 30078]
S --> S2[Update STM cache with plaintext]
R -->|long_term| T[NIP-44 encrypt + publish kind 30078]
T --> T2[Update LTM cache with plaintext]
New Tools
1. memory_save — Write memory
Description: Save content to agent short-term or long-term memory. Publishes as kind 30078 to nostr and updates the local cache.
Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
type |
string | yes | "short_term" or "long_term" |
content |
string | yes | Markdown content to store |
Behavior:
- Validate
typeis"short_term"or"long_term" - Enforce size limit based on type (on the plaintext, before encryption)
- Determine d-tag:
"short_term_memory"or"long_term_memory" - NIP-44 encrypt the content (agent encrypts to own public key)
- Publish kind 30078 event with encrypted content,
["d", <d_tag>]and["app", "didactyl"]tags vianostr_handler_publish_kind_event() - Update the in-memory cache (stores plaintext for fast access)
- Return success with event_id
2. my_memory — Read long-term memory
Description: Retrieve the agent's long-term memory. Returns cached content or fetches from nostr if not yet loaded.
Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
| (none) | — | — | No parameters needed |
Behavior:
- Check if LTM is cached in-memory (plaintext)
- If cached, return the content
- If not cached, query nostr for kind 30078 with
d=long_term_memoryauthored by self - NIP-44 decrypt the content (agent decrypts with own keypair)
- Cache the plaintext result
- Return the content (or empty string if no memory exists yet)
3. memory_short_term_read — Internal tool for prompt injection
Description: Internal tool (not exposed to LLM as a callable tool) used by the soul template to inject short-term memory into every prompt.
Behavior:
- Return cached short-term memory content
- If cache is empty, return empty string (skip_if_empty will omit the section)
Files to Modify/Create
New File: src/tools/tool_memory.c
Contains implementations for:
execute_memory_save()— handles both STM and LTM writes (NIP-44 encrypts before publishing)execute_my_memory()— handles LTM reads (NIP-44 decrypts on cache miss)execute_memory_short_term_read()— internal tool for prompt template injection (returns cached plaintext)- Static cache variables for STM and LTM plaintext content with mutex protection
memory_init()— called at startup to fetch existing memories from nostr and NIP-44 decrypt them into cachememory_cleanup()— free cached memory on shutdown- Helper functions for NIP-44 self-encrypt/decrypt using agent's own keypair (pattern from
tool_nostr_dm.c)
Modified: src/tools/tools_internal.h
Add function declarations:
char* execute_memory_save(tools_context_t* ctx, const char* args_json);char* execute_my_memory(tools_context_t* ctx, const char* args_json);char* execute_memory_short_term_read(tools_context_t* ctx, const char* args_json);int memory_init(tools_context_t* ctx);— fetches from nostr, NIP-44 decrypts, caches plaintextvoid memory_cleanup(void);
Modified: src/tools/tools_dispatch.c
Add dispatch entries:
"memory_save"→execute_memory_save()"my_memory"→execute_my_memory()"memory_short_term_read"→execute_memory_short_term_read()
Modified: src/tools/tools_schema.c
Add OpenAI function schemas for:
memory_save— exposed to LLM (type + content params)my_memory— exposed to LLM (no params)memory_short_term_readis NOT added to schema (internal only, called by template)
Modified: src/agent.c
- Call
memory_init()duringagent_init()to load existing memories from nostr on startup - Call
memory_cleanup()duringagent_cleanup() - Add
"short_term_memory"to context section detection indetect_context_section()
Modified: Soul template in config.jsonc.example
Add a new template section for short-term memory injection:
- section: short_term_memory
role: system
tool: memory_short_term_read
skip_if_empty: true
This goes after the existing context sections (admin identity, profile, etc.) and before the DM history expand section.
Modified: Makefile
Add src/tools/tool_memory.c to the build.
Startup Flow
agent_init()callsmemory_init()memory_init()queries nostr for kind 30078 events authored by self with d-tagsshort_term_memoryandlong_term_memory- For each event found, NIP-44 decrypt the content using the agent's own keypair
- Decrypted plaintext results are cached in static variables protected by mutex
- If no events found, caches remain empty (agent starts with blank memory)
- If decryption fails (e.g., key mismatch from a previous agent identity), log a warning and start with blank memory
Prompt Injection Flow (Short-Term Memory)
prompt_template_build_messages()encounters theshort_term_memorysection- Section has
tool: memory_short_term_read— callsexecute_memory_short_term_read() - Tool returns cached STM content as the
contentfield - If empty and
skip_if_empty: true, section is omitted from prompt - If non-empty, injected as a system message like:
## Short-Term Memory <agent's markdown notes here>
Cache Design
// In tool_memory.c
static char* g_short_term_memory = NULL; // cached STM content
static char* g_long_term_memory = NULL; // cached LTM content
static int g_stm_loaded = 0; // whether STM has been fetched
static int g_ltm_loaded = 0; // whether LTM has been fetched
static pthread_mutex_t g_memory_mutex = PTHREAD_MUTEX_INITIALIZER;
#define MEMORY_STM_MAX_CHARS 8000
#define MEMORY_LTM_MAX_CHARS 32000
Implementation Order
- Create
src/tools/tool_memory.cwith cache, init, cleanup, and all three tool functions - Add declarations to
tools_internal.h - Add dispatch entries to
tools_dispatch.c - Add schemas for
memory_saveandmy_memorytotools_schema.c - Wire up
memory_init()/memory_cleanup()inagent.c - Add
short_term_memorytemplate section to soul inconfig.jsonc.example - Update
Makefilebuild - Update soul template in system prompt to mention memory capabilities
- Test: save STM → verify prompt injection; save LTM → verify recall via my_memory