API Reference: Memory & Security
API Reference: Memory & Security
security/guard.py - DirectoryGuard
Purpose: Path sandbox for Core-owned I/O (runtime_data_dir, and optional workspace for skills)
DirectoryGuard(base_path: Path)
class DirectoryGuard:
def __init__(self, base_path: Path)
def resolve(self, path: str) -> Path
"""Resolve path to absolute and validate it's within the guard base.
Args:
path: Path string (relative or absolute)
Returns:
Resolved Path object
Raises:
PermissionError: If path escapes the base directory
"""
def is_safe_path(self, path: str) -> bool
"""Check if path is within the guard base.
Args:
path: Path string
Returns:
True if path is safe
"""
def list_dir(self, path: str) -> list[str]
"""List directory contents.
Args:
path: Directory path
Returns:
List of file/directory names
"""
Usage
guard = DirectoryGuard(Path("~/.local-ai").expanduser())
# Safe: within base
resolved = guard.resolve("sessions/u_s.json")
# Unsafe: escapes base
guard.resolve("../../etc/passwd") # Raises PermissionError
# Check safety
guard.is_safe_path("sessions/u_s.json") # True
guard.is_safe_path("../unsafe.txt") # False
session/store.py - SessionStore
Purpose: Persists session history to JSON files
SessionStore(guard: DirectoryGuard, sessions_dir: Path)
class SessionStore:
def __init__(self, guard: DirectoryGuard, sessions_dir: Path)
async def append(
self,
user_id: str,
session_id: str,
message: dict,
) -> None
"""Append message to session.
Args:
user_id: User identifier
session_id: Session identifier
message: {"role": "user|assistant", "content": str}
Raises:
ValueError: If message format invalid
"""
async def get_history(
self,
user_id: str,
session_id: str,
limit: int = 8,
) -> list[dict]
"""Get recent messages from session.
Args:
user_id: User identifier
session_id: Session identifier
limit: Number of messages to return
Returns:
List of messages from oldest to newest
"""
async def get_session(
self,
user_id: str,
session_id: str,
) -> dict | None
"""Get entire session.
Args:
user_id: User identifier
session_id: Session identifier
Returns:
Session dict with "messages" key or None
"""
File Format
{runtime_data_dir}/sessions/{user_id}_{session_id}.json
{
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"},
{"role": "assistant", "content": "I'm doing well..."}
]
}
Atomic Writes
Uses .tmp file + rename for atomicity:
# Write to temp file
tmp_path = path.with_suffix(".tmp")
await asyncio.to_thread(tmp_path.write_text, json.dumps(data))
# Atomic rename
await asyncio.to_thread(tmp_path.rename, path)
Concurrent Access
Per-file asyncio.Lock prevents concurrent modification:
self._locks = {}
async def append(self, user_id, session_id, message):
lock = self._locks.get(f"{user_id}_{session_id}")
if not lock:
lock = asyncio.Lock()
self._locks[f"{user_id}_{session_id}"] = lock
async with lock:
# ... write logic
packages/local-ai-memory/memory/manager.py — MemoryManager
Purpose: Multi-level runtime memory (L0–L3) under {runtime_data_dir}/memory/. Requires memory.enabled: true.
MemoryManager(guard: DirectoryGuard)
class MemoryManager:
def __init__(self, guard: DirectoryGuard) -> None
# L0 — state
async def get_state(self) -> dict[str, Any]
"""Return state dict. Missing/corrupt → {}."""
async def update_state(self, patch: dict[str, Any]) -> None
"""Shallow-merge patch into state under per-file lock."""
# L1 — working memory
async def get_working_memory(self) -> str
"""Return markdown summary. Missing/corrupt → ""."""
async def update_working_memory(self, content: str) -> None
"""Atomically overwrite working_memory.md."""
# L2 — plans
async def save_plan(self, plan_id: str, payload: dict[str, Any]) -> None
async def load_plan(self, plan_id: str) -> dict[str, Any]
"""Raises FileNotFoundError if missing or corrupt."""
# L3 — tool results
async def save_tool_result(self, result_id: str, payload: dict[str, Any]) -> None
async def load_tool_result(self, result_id: str) -> dict[str, Any]
"""Raises FileNotFoundError if missing or corrupt."""
RAG knowledge uses {runtime_data_dir}/memory_data/{category}/ (configured via memory.categories).
Storage paths
{runtime_data_dir}/memory/state/{scope}.json
{runtime_data_dir}/memory/working/{scope}.md
{runtime_data_dir}/memory/plans/{user}_{session}.json
{runtime_data_dir}/memory/tool_results/{result_id}.json
Versioned JSON envelope (L0, L2, L3)
{
"format_version": 1,
"updated_at": 1718123456.0,
"state": { "current_task": "...", "current_step": 2 }
}
Plans and tool results use "id", "saved_at", and "payload" instead of "state".
L3 tool result payload (written by ToolExecutor on large successful outputs):
{
"id": "a1b2c3d4e5f6...",
"tool": "mcp_filesystem_read_file",
"created_at": "2026-06-11T12:00:00+00:00",
"content": "<full tool output>"
}
The agent receives a compact reference instead of content when UTF-8 size exceeds app.tool_result_inline_limit (default 6144):
{
"result_id": "a1b2c3d4e5f6...",
"summary": "<first N chars>…",
"size": 128450
}
Summary length is app.tool_result_summary_max_chars (default 1000).
Atomic writes and corruption
Same pattern as SessionStore:
- Write to
{path}.tmp, thenPath.replace(target) - Corrupt JSON → backup
{name}.corrupt.bak, logMEMORY_CORRUPT, safe default - Per-path
asyncio.Lockfor concurrent access - Plan/result IDs validated with
[A-Za-z0-9_-]+only
Usage
from memory.manager import MemoryManager
manager = MemoryManager(guard)
await manager.update_state({"current_step": 2})
memory/compressor.py - WorkingMemoryCompressor
Purpose: LLM-summarize conversation state into L1 working_memory.md. Wired in app.py and orchestrated by AgentLoop (not part of MemoryManager storage I/O).
WorkingMemoryCompressor(memory_manager, llm_client, token_counter, target_tokens)
class WorkingMemoryCompressor:
def __init__(
self,
memory_manager: MemoryManager,
llm_client: LocalLLMClient,
token_counter: TokenCounter,
target_tokens: int,
) -> None
@property
def memory_manager(self) -> MemoryManager
async def compress(self, messages: list[dict[str, Any]]) -> str
"""Full rebuild from conversation messages. Writes L1; returns markdown.
On failure, returns existing L1 unchanged."""
async def update_summary(self, new_messages: list[dict[str, Any]]) -> str
"""Merge new turns with existing L1 via LLM. Enforces target_tokens.
On failure, returns existing L1 unchanged."""
L1 markdown schema
Required sections (in order): # Current Goal, # Completed, # Important Discoveries, # Open Issues, # Decisions.
Configuration
app key |
Default |
|---|---|
working_memory_target_tokens |
768 |
working_memory_enabled |
true |
working_memory_compact_keep_last |
6 |
Log event
WORKING_MEMORY_COMPRESSED — fields include mode=compress|update, tokens_before, tokens_after.
memory/store.py - MemoryStore
Purpose: Manages Markdown memory files
MemoryStore(guard: DirectoryGuard, memory_data_dir: Path)
class MemoryStore:
def __init__(self, guard: DirectoryGuard, memory_data_dir: Path)
@property
def category(self) -> str
"""Memory category (e.g., "knowledge", "personal")"""
@property
def base_path(self) -> Path
"""Base path for category"""
def list_user_files(self, user_id: str) -> list[str]
"""List all user files in category.
Returns:
List of filenames with user_id prefix
"""
def get_path(self, user_id: str, filename: str) -> Path
"""Get full path to file.
Args:
user_id: User identifier
filename: Filename with user_id prefix
Returns:
Path object
"""
def read_file(self, path: str) -> str
"""Read file content.
Args:
path: File path
Returns:
File content as string
"""
def write_file(self, path: str, content: str) -> str
"""Write file content.
Args:
path: File path
content: Content string
Returns:
Bytes written
"""
def fast_filter(self, query: str, top_n: int = 5) -> list[str] # alias for bm25_search
async def bm25_search(self, query: str, top_n: int = 5) -> list[str]
async def hybrid_search(self, query: str, top_n: int = 5, bm25_weight: float = 0.5) -> list[str]
"""Fast keyword-based filtering.
Args:
query: Search query
top_n: Number of results
Returns:
List of filenames ranked by relevance
"""
def index_exists(self) -> bool
"""Check if index file exists."""
def read_index(self) -> dict | None
"""Read index from disk."""
def write_index(self, index: dict) -> None
"""Write index to disk."""
def populate_index_from_files(
self,
user_id: str,
llm_client: LocalLLMClient,
) -> None
"""Build index from all files.
Scans files for title and description,
uses LLM to generate keywords.
"""
Index Structure
{
"index_version": 3,
"last_updated": 1686234567.123,
"files": [
{
"filename": "user1_notes.md",
"title": "Personal Notes",
"description": "Collection of personal notes...",
"keywords": ["notes", "personal", "reminders"]
}
]
}
File Organization
{runtime_data_dir}/memory_data/
├── knowledge/
│ ├── user1_notes.md
│ ├── user2_notes.md
│ └── index.json
├── personal/
│ ├── user1_diary.md
│ ├── user2_diary.md
│ └── index.json
└── work/
├── user1_projects.md
├── user2_projects.md
└── index.json
memory/retriever.py - MemoryRetriever
Purpose: 3-stage memory retrieval (BM25/hybrid → LLM select → load)
MemoryRetriever(memory_store: MemoryStore, llm_client: LocalLLMClient, token_counter: TokenCounter)
class MemoryRetriever:
def __init__(
self,
memory_store: MemoryStore,
llm_client: LocalLLMClient,
token_counter: TokenCounter,
)
async def retrieve(
self,
query: str,
user_id: str,
categories: list[str],
max_tokens: int = 2000,
) -> str
"""Retrieve relevant memory content using 3-stage retrieval.
Stage 1: Fast index filtering (no LLM)
Stage 2: LLM selects from filtered candidates
Stage 3: Load selected files within token budget
Args:
query: User query
user_id: User identifier
categories: Memory categories to search
max_tokens: Maximum tokens to load
Returns:
Concatenated memory content
Raises:
TimeoutError: If LLM call times out
"""
Retrieval Flow
# Stage 1: Fast filter
candidates = []
for category in categories:
store = get_memory_store(category)
filtered = await store.bm25_search(query, top_n=5)
candidates.extend(filtered)
# Stage 2: LLM selection
if not candidates:
return ""
# Send to LLM
prompt = f"""
You are a memory file selector.
Given a user query and available file structure metadata,
return ONLY a JSON array of filenames to load.
Query: {query}
Top {len(candidates)} candidate files:
- file1.md
- file2.md
...
Select the most relevant files (up to 5). Return ONLY a JSON array.
"""
selected_response = await llm.chat([
{"role": "user", "content": prompt}
])
selected = json.loads(selected_response)
selected = selected[:5] # Limit to 5
# Stage 3: Load content
chunks = []
used_tokens = 0
for filename in selected:
content = store.read_file(filename)
tokens = await token_counter.count(content)
if used_tokens + tokens > max_tokens:
break
chunks.append(content)
used_tokens += tokens
return "\n\n".join(chunks)
get_memory_store()
def get_memory_store(category: str) -> MemoryStore:
"""Get MemoryStore for category.
Args:
category: Category name
Returns:
MemoryStore instance
"""
return MemoryStore(guard, memory_data_dir / category)
Error Handling
DirectoryGuard
PermissionError:
guard.resolve("../../etc/passwd")
# Raises PermissionError: Path escapes DirectoryGuard base
SessionStore
ValueError:
await session_store.append(user_id, session_id, {"invalid": "format"})
# Raises ValueError: Invalid message format
MemoryRetriever
TimeoutError:
# LLM call times out
await retriever.retrieve(query, user_id, ["knowledge"])
# Raises TimeoutError
JSONDecodeError:
# LLM returns invalid JSON
selected = json.loads(llm_response)
# Raises JSONDecodeError