Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
1bb5e80
update docs/architecture/memory.md
basnijholt Nov 27, 2025
d79831a
Turn off ChromaDB telemetry
basnijholt Nov 27, 2025
9f3d6be
feat(memory): add output validation with ModelRetry for reconciliation
basnijholt Nov 27, 2025
fb1dde8
feat(memory): use PromptedOutput (JSON mode) for reconciliation
basnijholt Nov 27, 2025
62bafc3
feat(memory): add self-model features to AI journal POC
basnijholt Nov 27, 2025
aede425
docs: add detailed comparison between AI journal POC and aijournal
basnijholt Nov 27, 2025
70cf955
feat(memory): add adaptive summarization with hierarchical storage
basnijholt Nov 27, 2025
6bb6058
refactor(summarizer): improve code quality and add Letta-style features
basnijholt Nov 27, 2025
df000c0
refactor(summarizer): replace class with functional API
basnijholt Nov 27, 2025
cd4378e
refactor(summarizer): make internal modules private and simplify publ…
basnijholt Nov 27, 2025
023a714
refactor(memory): wire AdaptiveSummarizer into memory pipeline
basnijholt Nov 27, 2025
7f5aff3
docs: add summarizer spec and update memory docs for hierarchical sum…
basnijholt Nov 27, 2025
c35bc13
Add example script
basnijholt Nov 27, 2025
3e5fb4e
refactor(summarizer): YAGNI cleanup and fix prior_context bug
basnijholt Nov 27, 2025
01c67aa
feat(cli): add summarize command for adaptive hierarchical summarization
basnijholt Nov 27, 2025
2e7642a
refactor(memory): remove dead parent_group field and bundle metadata …
basnijholt Nov 27, 2025
2c5bf41
perf: lazy imports for pydantic_ai, sounddevice, and numpy
basnijholt Nov 27, 2025
18d02bd
refactor: reduce duplication in memory store and summarizer
basnijholt Nov 27, 2025
f18b366
refactor: simplify docstrings and remove unused upsert_hierarchical_s…
basnijholt Nov 27, 2025
1845640
fix(summarizer): strip special tokens from LLM output
basnijholt Nov 27, 2025
b3b1941
docs: correct Mem0 attribution in summarizer documentation
basnijholt Nov 27, 2025
734b43f
fix(memory): summarize raw conversation turns, not extracted facts
basnijholt Nov 27, 2025
f4d6b69
docs: clarify research foundations vs original design in summarizer
basnijholt Nov 27, 2025
484523f
refactor(summarizer): simplify to NONE/BRIEF/MAP_REDUCE levels
basnijholt Nov 27, 2025
6eff2f6
refactor(summarizer): consolidate shared code to reduce duplication
basnijholt Nov 27, 2025
ad376e3
refactor(summarizer): remove redundant _summarize_text and safety guard
basnijholt Nov 27, 2025
83390a3
refactor(summarizer): remove redundant exception re-wrapping
basnijholt Nov 27, 2025
4d25071
refactor(summarizer): remove defensive guards for impossible conditions
basnijholt Nov 27, 2025
f3f3c3b
feat(scripts): add summarizer comparison script with needle-in-haysta…
basnijholt Nov 27, 2025
527d06b
docs(summarizer): update architecture doc to reflect current implemen…
basnijholt Nov 27, 2025
e0262f4
docs: update memory.md for 3-level summarizer
basnijholt Nov 27, 2025
ca33813
refactor(summarizer): rename STANDARD_SUMMARY_PROMPT to GENERAL_SUMMA…
basnijholt Nov 27, 2025
ee4fea6
docs: clarify prompt comments to avoid confusion with level names
basnijholt Nov 27, 2025
5a26f01
Chunk memories
basnijholt Nov 29, 2025
c387cfa
Merge origin/main into poc/aijournal
basnijholt Nov 29, 2025
2a8085a
refactor(summarizer): remove dead code and reorganize models
basnijholt Nov 29, 2025
39a7703
refactor(memory): remove defensive code for impossible UPDATE/DELETE …
basnijholt Nov 29, 2025
effcd61
Merge remote-tracking branch 'origin/main' into poc/aijournal
basnijholt Nov 29, 2025
c701cdf
Merge remote-tracking branch 'origin/main' into poc/aijournal
basnijholt Nov 30, 2025
4a69cd4
Merge remote-tracking branch 'origin/main' into poc/aijournal
basnijholt Dec 4, 2025
9cae5b0
refactor(summarizer): simplify API with target_tokens/target_ratio pa…
basnijholt Dec 4, 2025
5c632b8
chore(summarizer): remove dead code
basnijholt Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion agent_cli/core/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, Any

import chromadb
from chromadb.config import Settings
from chromadb.utils import embedding_functions

from agent_cli.constants import DEFAULT_OPENAI_EMBEDDING_MODEL
Expand All @@ -29,7 +30,10 @@ def init_collection(
"""Initialize a Chroma collection with OpenAI-compatible embeddings."""
target_path = persistence_path / subdir if subdir else persistence_path
target_path.mkdir(parents=True, exist_ok=True)
client = chromadb.PersistentClient(path=str(target_path))
client = chromadb.PersistentClient(
path=str(target_path),
settings=Settings(anonymized_telemetry=False),
)
embed_fn = embedding_functions.OpenAIEmbeddingFunction(
api_base=openai_base_url,
api_key=openai_api_key or "dummy",
Expand Down
63 changes: 55 additions & 8 deletions agent_cli/memory/_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from uuid import uuid4

import httpx
from pydantic_ai import Agent
from pydantic_ai import Agent, ModelRetry, PromptedOutput
from pydantic_ai.exceptions import AgentRunError, UnexpectedModelBehavior
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
Expand Down Expand Up @@ -121,9 +121,10 @@ def process_reconciliation_decisions(
)
elif isinstance(dec, MemoryUpdate):
orig = id_map.get(dec.id)
if orig:
text = dec.text.strip()
if text:
text = dec.text.strip()
if text:
if orig:
# Update existing memory: delete old, add new
new_id = str(uuid4())
to_delete.append(orig)
to_add.append(
Expand All @@ -136,6 +137,17 @@ def process_reconciliation_decisions(
),
)
replacement_map[orig] = new_id
else:
# UPDATE with unknown ID = treat as ADD (model used wrong event)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this even happen still?

to_add.append(
Fact(
id=str(uuid4()),
conversation_id=conversation_id,
content=text,
source_id=source_id,
created_at=created_at,
),
)
elif isinstance(dec, MemoryDelete):
orig = id_map.get(dec.id)
if orig:
Expand Down Expand Up @@ -178,6 +190,7 @@ async def reconcile_facts(
return entries, [], {}
id_map: dict[int, str] = {idx: mem.id for idx, mem in enumerate(existing)}
existing_json = [{"id": idx, "text": mem.content} for idx, mem in enumerate(existing)]
existing_ids = set(id_map.keys())

provider = OpenAIProvider(api_key=api_key or "dummy", base_url=openai_base_url)
model_cfg = OpenAIChatModel(
Expand All @@ -188,13 +201,47 @@ async def reconcile_facts(
agent = Agent(
model=model_cfg,
system_prompt=UPDATE_MEMORY_PROMPT,
output_type=list[MemoryDecision],
output_type=PromptedOutput(list[MemoryDecision]), # JSON mode instead of tool calls
retries=3,
)

payload_obj = {"existing": existing_json, "new_facts": new_facts}
payload = json.dumps(payload_obj, ensure_ascii=False, indent=2)
LOGGER.info("Reconcile payload JSON: %s", payload)
@agent.output_validator
def validate_decisions(decisions: list[MemoryDecision]) -> list[MemoryDecision]:
"""Validate LLM decisions and provide feedback for retry."""
errors = []
for dec in decisions:
if (
isinstance(dec, (MemoryUpdate, MemoryDelete, MemoryIgnore))
and dec.id not in existing_ids
):
if isinstance(dec, MemoryUpdate):
errors.append(
f"UPDATE with id={dec.id} is invalid: that ID doesn't exist. "
f"Valid existing IDs are: {sorted(existing_ids)}. "
f"For NEW facts, use ADD with a new ID.",
)
elif isinstance(dec, MemoryDelete):
errors.append(f"DELETE with id={dec.id} is invalid: that ID doesn't exist.")
else: # MemoryIgnore (NONE)
errors.append(f"NONE with id={dec.id} is invalid: that ID doesn't exist.")
if errors:
msg = "Invalid memory decisions:\n" + "\n".join(f"- {e}" for e in errors)
raise ModelRetry(msg)
return decisions

# Format with separate sections for existing and new facts
existing_str = json.dumps(existing_json, ensure_ascii=False, indent=2)
new_facts_str = json.dumps(new_facts, ensure_ascii=False, indent=2)
payload = f"""Current memory:
```
{existing_str}
```

New facts to process:
```
{new_facts_str}
```"""
LOGGER.info("Reconcile payload: %s", payload)
try:
result = await agent.run(payload)
decisions = result.output
Expand Down
121 changes: 61 additions & 60 deletions agent_cli/memory/_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,67 +21,68 @@
Return only factual sentences grounded in the user text. No assistant acknowledgements or meta-text.
""".strip()

UPDATE_MEMORY_PROMPT = """
You are a smart memory manager which controls the memory of a system.
You can perform four operations: (1) ADD into memory, (2) UPDATE memory, (3) DELETE from memory, (4) NONE (no change).

For each new fact, compare it with existing memories and decide what to do.

Guidelines:

1. **ADD**: New fact contains information NOT present in any existing memory.
- Generate a new ID for added memories (next sequential integer).
- Existing unrelated memories remain unchanged (NONE).

2. **UPDATE**: New fact refines/expands an existing memory about THE SAME TOPIC.
UPDATE_MEMORY_PROMPT = """You are a smart memory manager which controls the memory of a system.
You can perform four operations: (1) ADD into the memory, (2) UPDATE the memory, (3) DELETE from the memory, and (4) NONE (no change).

Compare new facts with existing memory. For each new fact, decide whether to:
- ADD: Add it to the memory as a new element (new information not present in any existing memory)
- UPDATE: Update an existing memory element (only if facts are about THE SAME TOPIC, e.g., both about pizza preferences)
- DELETE: Delete an existing memory element (if new fact explicitly contradicts it)
- NONE: Make no change (if fact is already present, a duplicate, or the existing memory is unrelated to new facts)

**Guidelines:**

1. **ADD**: If the new fact contains new information not present in any existing memory, add it with a new ID.
- Existing unrelated memories should have event "NONE".
- **Example**:
- Current memory: [{"id": 0, "text": "User is a software engineer"}]
- New facts: ["Name is John"]
- Output: [
{"id": 0, "text": "User is a software engineer", "event": "NONE"},
{"id": 1, "text": "Name is John", "event": "ADD"}
]

2. **UPDATE**: Only if the new fact refines/expands an existing memory about THE SAME TOPIC.
- Keep the same ID, update the text.
- Only update if facts are about the same subject (e.g., both about pizza preferences).

3. **DELETE**: New fact explicitly contradicts an existing memory.
- Mark the old memory for deletion.

4. **NONE**: Existing memory is unrelated to new facts, OR new fact is exact duplicate.
- No change needed.

**CRITICAL**: You must return ALL memories (existing + new) in your response.
Each existing memory must have an event (NONE, UPDATE, or DELETE).
Each new unrelated fact must be ADDed with a new ID.

Examples:

1. UNRELATED new fact → ADD it, existing stays NONE
Existing: [{"id": 0, "text": "User is a software engineer"}]
New facts: ["Name is John"]
Output: [
{"id": 0, "text": "User is a software engineer", "event": "NONE"},
{"id": 1, "text": "Name is John", "event": "ADD"}
]

2. RELATED facts (same topic) → UPDATE existing
Existing: [{"id": 0, "text": "User likes pizza"}]
New facts: ["User loves pepperoni pizza"]
Output: [
{"id": 0, "text": "User loves pepperoni pizza", "event": "UPDATE"}
]

3. CONTRADICTING facts → DELETE old
Existing: [{"id": 0, "text": "Loves pizza"}, {"id": 1, "text": "Name is John"}]
New facts: ["Hates pizza"]
Output: [
{"id": 0, "text": "Loves pizza", "event": "DELETE"},
{"id": 1, "text": "Name is John", "event": "NONE"},
{"id": 2, "text": "Hates pizza", "event": "ADD"}
]

4. DUPLICATE → NONE for all
Existing: [{"id": 0, "text": "Name is John"}]
New facts: ["Name is John"]
Output: [
{"id": 0, "text": "Name is John", "event": "NONE"}
]

Return ONLY a JSON list. No prose or code fences.
""".strip()
- Example: "User likes pizza" + "User loves pepperoni pizza" → UPDATE (same topic: pizza)
- Example: "Met Sarah today" + "Went running" → NOT same topic, do NOT update!
- **Example**:
- Current memory: [{"id": 0, "text": "User likes pizza"}]
- New facts: ["User loves pepperoni pizza"]
- Output: [{"id": 0, "text": "User loves pepperoni pizza", "event": "UPDATE"}]

3. **DELETE**: If the new fact explicitly contradicts an existing memory.
- **Example**:
- Current memory: [{"id": 0, "text": "Loves pizza"}, {"id": 1, "text": "Name is John"}]
- New facts: ["Hates pizza"]
- Output: [
{"id": 0, "text": "Loves pizza", "event": "DELETE"},
{"id": 1, "text": "Name is John", "event": "NONE"},
{"id": 2, "text": "Hates pizza", "event": "ADD"}
]

4. **NONE**: If the new fact is already present or existing memory is unrelated to new facts.
- **Example**:
- Current memory: [{"id": 0, "text": "Name is John"}]
- New facts: ["Name is John"]
- Output: [{"id": 0, "text": "Name is John", "event": "NONE"}]

5. **IMPORTANT - Unrelated topics example**:
- Current memory: [{"id": 0, "text": "Met Sarah to discuss quantum computing"}]
- New facts: ["Went for a 5km run"]
- These are COMPLETELY DIFFERENT topics (meeting vs running). Do NOT use UPDATE!
- Output: [
{"id": 0, "text": "Met Sarah to discuss quantum computing", "event": "NONE"},
{"id": 1, "text": "Went for a 5km run", "event": "ADD"}
]

**CRITICAL RULES:**
- You MUST return ALL memories (existing + new) in your response.
- Each existing memory MUST have an event (NONE, UPDATE, or DELETE).
- Each genuinely NEW fact (not related to any existing memory) MUST be ADDed with a new ID.
- Do NOT use UPDATE for unrelated topics! "Met Sarah" and "Went running" are DIFFERENT topics → use NONE for existing + ADD for new.

Return ONLY a JSON list. No prose or code fences.""".strip()

SUMMARY_PROMPT = """
You are a concise conversation summarizer. Update the running summary with the new facts.
Expand Down
32 changes: 31 additions & 1 deletion agent_cli/memory/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from agent_cli.memory._ingest import extract_and_store_facts_and_summaries
from agent_cli.memory._persistence import evict_if_needed
from agent_cli.memory._retrieval import augment_chat_request
from agent_cli.memory._store import init_memory_collection
from agent_cli.memory._store import init_memory_collection, list_conversation_entries
from agent_cli.memory.engine import process_chat_request
from agent_cli.memory.models import ChatRequest, MemoryRetrieval, Message
from agent_cli.rag._retriever import get_reranker_model
Expand Down Expand Up @@ -185,6 +185,36 @@ async def search(
)
return retrieval or MemoryRetrieval(entries=[])

def list_all(
self,
conversation_id: str = "default",
include_summary: bool = False,
) -> list[dict[str, Any]]:
"""List all stored memories for a conversation.

Args:
conversation_id: Conversation scope.
include_summary: Whether to include summary entries.

Returns:
List of memory entries with id, content, and metadata.

"""
entries = list_conversation_entries(
self.collection,
conversation_id,
include_summary=include_summary,
)
return [
{
"id": e.id,
"content": e.content,
"role": e.metadata.role,
"created_at": e.metadata.created_at,
}
for e in entries
]

async def chat(
self,
messages: list[dict[str, str]] | list[Any],
Expand Down
Loading
Loading