diff --git a/demo/ap-memory-agent/.env.example b/demo/ap-memory-agent/.env.example
new file mode 100644
index 00000000..55dc2f17
--- /dev/null
+++ b/demo/ap-memory-agent/.env.example
@@ -0,0 +1,5 @@
+ANTHROPIC_API_KEY=your_anthropic_key
+EVERMEM_BASE_URL=http://localhost:1995
+EVERMEM_USER_ID=ap_agent_system
+# Required for seeded vendor history (matches ap_agent_system's group)
+EVERMEM_GROUP_ID=eb6618c4d52d3bf9_group
diff --git a/demo/ap-memory-agent/README.md b/demo/ap-memory-agent/README.md
new file mode 100644
index 00000000..5116a9be
--- /dev/null
+++ b/demo/ap-memory-agent/README.md
@@ -0,0 +1,126 @@
+# AP Memory Agent
+
+Accounts Payable automation agent built with **LangGraph + EverMemOS + Claude** for the [Memory Genesis Competition 2026](https://luma.com/n88icl03?tk=aawakR).
+
+**Core demo moment:** An agent catches a fraudulent or duplicate invoice because it remembers what happened weeks ago.
+
+## Demo Video
+
+
+
+
+
+[Watch the full demo on Loom](https://www.loom.com/share/9834fe7b5fca45c78c304f7101b15ac6)
+
+## Architecture
+
+```
+Streamlit UI (invoice form)
+ ↓
+LangGraph Orchestrator
+ ├── Node 1: invoice_agent → normalise invoice, write episodic_memory, fetch vendor context
+ ├── Node 2: risk_agent → Claude reasons over invoice + memory context, returns risk flags
+ ├── Node 3: approval_agent → Claude makes approve/hold/reject decision with reasoning
+ └── Node 4: memory_updater → write resolution to episodic_memory, update profile if risk ≥ 40
+ ↕ ↕ ↕ ↕
+ EverMemOS (Shared Memory Bus)
+```
+
+### Memory Strategy
+
+- **Invoice Agent:** Writes each invoice event as episodic memory; searches episodic + event_log (agentic retrieval); fetches profile separately; merges into `memory_context`.
+- **Memory Updater:** Always writes resolution as episodic memory; conditionally writes profile when risk_score ≥ 40 or decision is hold/reject
+- **Profile** is not searchable — fetched via GET /memories and merged manually
+
+## Setup
+
+1. **Prerequisites**
+ - Python 3.11+
+ - EverMemOS running locally (default: `http://localhost:1995`)
+
+2. **Install dependencies**
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+3. **Configure environment**
+ ```bash
+ cp .env.example .env
+ # Edit .env with your ANTHROPIC_API_KEY
+ # EVERMEM_GROUP_ID is required for seeded vendor history
+ ```
+
+4. **Seed vendor history**
+ ```bash
+ cd demo/ap-memory-agent
+ python seed_memory.py
+ # Or: python seed_memory.py --clear # Clear and re-seed
+ ```
+ From EverMemOS root: `
+ `
+
+ **Extraction timing:** EverMemOS uses boundary detection — messages are extracted into episodic memories when a "conversation episode" is complete. The seed sends user+assistant pairs with `sync_mode` to trigger extraction. Expect 1–2 minutes for the full seed. Episodic memories appear after boundary detection runs.
+
+5. **Run the UI**
+ ```bash
+ cd demo/ap-memory-agent
+ streamlit run streamlit_app.py
+ ```
+ From EverMemOS root: `uv run streamlit run demo/ap-memory-agent/streamlit_app.py`
+
+## Demo Script (for judges)
+
+1. Run `seed_memory.py` to populate vendor history
+2. Open Streamlit UI
+3. **Acme Corp** — Submit invoice `INV-2025-0042` for $500 → should **APPROVE** (clean history)
+4. **Globex Supplies** — Submit invoice `INV-2025-0105` for $1,200 → should **HOLD** (prior dispute)
+5. **Shadow LLC** — Submit invoice `INV-777` for $3,000 → should **REJECT** (duplicate caught by memory)
+
+## Test Examples
+
+### Streamlit UI (manual form)
+
+| Test | Vendor Name | Invoice Number | Amount | Expected |
+|------|-------------|----------------|--------|----------|
+| 1 | Acme Corp | INV-2025-0042 | 500 | APPROVE |
+| 2 | Globex Supplies | INV-2025-0105 | 1200 | HOLD |
+| 3 | Shadow LLC | INV-777 | 3000 | REJECT |
+| 4 | Acme Corp | INV-2025-0099 | 550 | APPROVE |
+
+### CLI test script
+
+```bash
+uv run python demo/ap-memory-agent/test_demo.py
+```
+
+Runs all 4 test invoices through the graph and prints decisions.
+
+## Project Structure
+
+```
+ap-memory-agent/
+├── .env # API keys (never commit)
+├── .env.example # Template for env vars
+├── requirements.txt # Dependencies
+├── ap_agent_graph.py # LangGraph pipeline (4 nodes)
+├── evermem_client.py # EverMemOS API wrapper
+├── seed_memory.py # Pre-populate vendor history
+├── streamlit_app.py # Streamlit UI
+└── README.md
+```
+
+## Environment Variables
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `ANTHROPIC_API_KEY` | Claude API key | required |
+| `EVERMEM_BASE_URL` | EverMemOS URL | `http://localhost:1995` |
+| `EVERMEM_USER_ID` | Memory scope | `ap_agent_system` |
+| `EVERMEM_GROUP_ID` | Group for seeded data | `eb6618c4d52d3bf9_group` |
+
+## EverMemOS API Notes
+
+- **POST /api/v1/memories** — Store message (MemorizeMessageRequest: message_id, create_time, sender, content, role)
+- **GET /api/v1/memories** — Fetch by memory_type (profile, episodic_memory, etc.)
+- **GET /api/v1/memories/search** — Search episodic_memory, event_log, foresight (profile NOT supported)
+- **retrieve_method="agentic"** — Use for agent queries (LLM-guided retrieval)
diff --git a/demo/ap-memory-agent/ap_agent_graph.py b/demo/ap-memory-agent/ap_agent_graph.py
new file mode 100644
index 00000000..d1fb9aab
--- /dev/null
+++ b/demo/ap-memory-agent/ap_agent_graph.py
@@ -0,0 +1,369 @@
+"""
+AP Automation Agent - LangGraph + EverMemOS + Claude
+=====================================================
+Multi-agent accounts payable pipeline with shared episodic memory.
+
+Architecture:
+ UI (Streamlit) → LangGraph Orchestrator → [Invoice Agent → Risk Agent → Approval Agent → Memory Updater]
+ ↕ ↕ ↕ ↕
+ EverMemOS (Shared Memory Bus)
+"""
+
+import os
+import json
+import re
+import uuid
+from datetime import datetime
+from typing import Any, TypedDict
+from dotenv import load_dotenv
+
+from langgraph.graph import StateGraph, END
+from langchain_anthropic import ChatAnthropic
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from evermem_client import EverMemClient
+
+load_dotenv()
+
+
+def _extract_json(text: str) -> dict[str, Any] | None:
+ """
+ Extract JSON from Claude response. Handles markdown code blocks and extra text.
+ """
+ if not text or not isinstance(text, str):
+ return None
+ text = text.strip()
+ # Strip markdown code blocks (```json ... ``` or ``` ... ```)
+ match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
+ if match:
+ text = match.group(1).strip()
+ # Find first { and last } to extract JSON object
+ start = text.find("{")
+ end = text.rfind("}")
+ if start != -1 and end != -1 and end > start:
+ try:
+ return json.loads(text[start : end + 1])
+ except json.JSONDecodeError:
+ pass
+ # Fallback: try parsing the whole string
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ return None
+
+
+# ─────────────────────────────────────────────
+# GRAPH STATE
+# ─────────────────────────────────────────────
+
+class APState(TypedDict):
+ invoice: dict
+ memory_context: str
+ risk_flags: list[str]
+ risk_score: int
+ decision: str
+ reasoning: str
+ invoice_id: str
+ processed_at: str
+
+
+# ─────────────────────────────────────────────
+# INITIALISE SHARED SERVICES
+# ─────────────────────────────────────────────
+
+llm = ChatAnthropic(
+ model="claude-opus-4-6",
+ api_key=os.getenv("ANTHROPIC_API_KEY"),
+ temperature=0,
+)
+
+evermem = EverMemClient()
+
+
+# ─────────────────────────────────────────────
+# NODE 1: INVOICE AGENT
+# ─────────────────────────────────────────────
+
+def invoice_agent(state: APState) -> APState:
+ invoice = state["invoice"]
+ invoice_id = str(uuid.uuid4())[:8]
+ vendor = invoice.get("vendor_name", "Unknown Vendor")
+
+ print(f"\n[Invoice Agent] Processing invoice from {vendor}...")
+
+ memory_content = (
+ f"Invoice received from vendor '{vendor}'. "
+ f"Invoice number: {invoice.get('invoice_number')}. "
+ f"Amount: ${invoice.get('amount')}. "
+ f"Payment terms: {invoice.get('payment_terms', 'net-30')}. "
+ f"Due date: {invoice.get('due_date', 'not specified')}. "
+ f"Line items: {invoice.get('line_items', 'not provided')}."
+ )
+ evermem.write_memory(
+ content=memory_content,
+ role="assistant",
+ memory_type="episodic_memory",
+ )
+
+ vendor_memories = evermem.search_memories(
+ query=f"{vendor} invoice payment dispute late rejected duplicate",
+ retrieve_method="keyword",
+ memory_types=["episodic_memory", "event_log"],
+ top_k=20,
+ )
+
+ # Filter search results to this vendor (keyword search returns all groups)
+ vendor_lower = vendor.lower()
+ vendor_memories = [
+ m for m in vendor_memories
+ if vendor_lower in str(m.get("content", m.get("episode", m.get("summary", m.get("subject", m.get("title", "")))))).lower()
+ ]
+
+ vendor_profile = evermem.fetch_memories_by_type(
+ memory_types=["profile"],
+ top_k=3,
+ )
+
+ all_memories = vendor_profile + vendor_memories
+ memory_context = evermem.format_memory_context(all_memories)
+ print(
+ f"[Invoice Agent] Retrieved {len(vendor_profile)} profile + "
+ f"{len(vendor_memories)} episodic records for {vendor}"
+ )
+
+ return {
+ **state,
+ "invoice_id": invoice_id,
+ "memory_context": memory_context,
+ "processed_at": datetime.now().isoformat(),
+ }
+
+
+# ─────────────────────────────────────────────
+# NODE 2: RISK AGENT
+# ─────────────────────────────────────────────
+
+def risk_agent(state: APState) -> APState:
+ invoice = state["invoice"]
+ memory_context = state["memory_context"]
+
+ print(f"\n[Risk Agent] Analysing risk for invoice {invoice.get('invoice_number')}...")
+
+ system_prompt = """You are an AP Risk Agent for a finance team.
+Your job is to analyse incoming invoices against vendor payment history and flag anomalies.
+Be precise, concise, and always ground your findings in the memory context provided.
+
+CRITICAL: Return ONLY a raw JSON object. No markdown, no code blocks, no preamble, no explanation.
+Start your response with { and end with }."""
+
+ user_prompt = f"""
+CURRENT INVOICE:
+{json.dumps(invoice, indent=2)}
+
+VENDOR MEMORY (past interactions retrieved from shared memory system):
+{memory_context}
+
+Analyse this invoice and return a JSON object with exactly this structure:
+{{
+ "risk_flags": ["list of specific risk flags, or empty list if clean"],
+ "risk_score": ,
+ "summary": "one sentence summary of your finding"
+}}
+
+Check for:
+- Duplicate invoice numbers vs history
+- Amount significantly higher than vendor average
+- Unusual or changed payment terms
+- Prior disputes or late payments with this vendor
+- Any other anomalies in the memory context
+"""
+
+ response = llm.invoke([
+ SystemMessage(content=system_prompt),
+ HumanMessage(content=user_prompt),
+ ])
+
+ content = response.content if isinstance(response.content, str) else str(response.content)
+ result = _extract_json(content)
+ if result is not None:
+ risk_flags = result.get("risk_flags", [])
+ risk_score = int(result.get("risk_score", 0))
+ print(f"[Risk Agent] Score: {risk_score}/100 | Flags: {risk_flags or 'None'}")
+ else:
+ risk_flags = ["Unable to parse risk analysis — manual review required"]
+ risk_score = 50
+ print("[Risk Agent] Warning: Could not parse Claude response as JSON")
+
+ return {
+ **state,
+ "risk_flags": risk_flags,
+ "risk_score": risk_score,
+ }
+
+
+# ─────────────────────────────────────────────
+# NODE 3: APPROVAL AGENT
+# ─────────────────────────────────────────────
+
+def approval_agent(state: APState) -> APState:
+ invoice = state["invoice"]
+ risk_flags = state["risk_flags"]
+ risk_score = state["risk_score"]
+ memory_context = state["memory_context"]
+
+ print(f"\n[Approval Agent] Making decision (risk score: {risk_score})...")
+
+ system_prompt = """You are a senior AP approver. You make final payment decisions
+based on invoice data, risk analysis, and vendor history.
+Be decisive and explain your reasoning in plain English for the finance team.
+
+CRITICAL: Return ONLY a raw JSON object. No markdown, no code blocks, no preamble.
+Start your response with { and end with }."""
+
+ user_prompt = f"""
+INVOICE: {json.dumps(invoice, indent=2)}
+
+RISK ANALYSIS:
+- Risk Score: {risk_score}/100
+- Flags Raised: {risk_flags if risk_flags else "None"}
+
+VENDOR HISTORY CONTEXT:
+{memory_context}
+
+Decision rules:
+- risk_score 0-30 → "approve" (unless a critical flag overrides)
+- risk_score 31-60 → "hold" (needs human review)
+- risk_score 61+ → "reject" (do not process)
+- Any duplicate invoice number → always "reject"
+- Any unresolved prior dispute → always "hold"
+
+Return JSON:
+{{
+ "decision": "approve" | "hold" | "reject",
+ "reasoning": "2-3 sentence plain English explanation citing specific memory evidence"
+}}
+"""
+
+ response = llm.invoke([
+ SystemMessage(content=system_prompt),
+ HumanMessage(content=user_prompt),
+ ])
+
+ content = response.content if isinstance(response.content, str) else str(response.content)
+ result = _extract_json(content)
+ if result is not None:
+ decision = result.get("decision", "hold")
+ reasoning = result.get("reasoning", "No reasoning provided.")
+ if decision not in ("approve", "hold", "reject"):
+ decision = "hold"
+ else:
+ decision = "hold"
+ reasoning = "Could not parse approval decision — defaulting to hold for manual review."
+
+ print(f"[Approval Agent] Decision: {decision.upper()}")
+ print(f"[Approval Agent] Reasoning: {reasoning}")
+
+ return {
+ **state,
+ "decision": decision,
+ "reasoning": reasoning,
+ }
+
+
+# ─────────────────────────────────────────────
+# NODE 4: MEMORY UPDATER
+# ─────────────────────────────────────────────
+
+def memory_updater(state: APState) -> APState:
+ invoice = state["invoice"]
+ vendor = invoice.get("vendor_name", "Unknown Vendor")
+ decision = state["decision"]
+ reasoning = state["reasoning"]
+ risk_flags = state["risk_flags"]
+
+ print(f"\n[Memory Updater] Writing outcome to EverMemOS...")
+
+ outcome_memory = (
+ f"Invoice {invoice.get('invoice_number')} from vendor '{vendor}' "
+ f"for ${invoice.get('amount')} was {decision.upper()}. "
+ f"Risk score: {state['risk_score']}/100. "
+ f"Flags: {', '.join(risk_flags) if risk_flags else 'none'}. "
+ f"Reasoning: {reasoning}"
+ )
+ evermem.write_memory(
+ content=outcome_memory,
+ role="assistant",
+ memory_type="episodic_memory",
+ )
+
+ if state["risk_score"] >= 40 or decision in ["hold", "reject"]:
+ profile_update = (
+ f"Vendor '{vendor}' has a flagged invoice history. "
+ f"Most recent outcome: {decision.upper()} on invoice "
+ f"{invoice.get('invoice_number')} (${invoice.get('amount')}). "
+ f"Risk flags observed: {', '.join(risk_flags) if risk_flags else 'none'}."
+ )
+ evermem.write_vendor_profile(vendor=vendor, profile_content=profile_update)
+ print(f"[Memory Updater] Vendor profile updated due to risk score {state['risk_score']}")
+
+ print("[Memory Updater] Resolution written to shared memory ✓")
+ return state
+
+
+# ─────────────────────────────────────────────
+# ROUTING & GRAPH
+# ─────────────────────────────────────────────
+
+def route_after_approval(state: APState) -> str:
+ return "memory_updater"
+
+
+def build_ap_graph() -> StateGraph:
+ graph = StateGraph(APState)
+
+ graph.add_node("invoice_agent", invoice_agent)
+ graph.add_node("risk_agent", risk_agent)
+ graph.add_node("approval_agent", approval_agent)
+ graph.add_node("memory_updater", memory_updater)
+
+ graph.set_entry_point("invoice_agent")
+ graph.add_edge("invoice_agent", "risk_agent")
+ graph.add_edge("risk_agent", "approval_agent")
+ graph.add_conditional_edges("approval_agent", route_after_approval, {
+ "memory_updater": "memory_updater",
+ })
+ graph.add_edge("memory_updater", END)
+
+ return graph.compile()
+
+
+# ─────────────────────────────────────────────
+# CLI ENTRY POINT
+# ─────────────────────────────────────────────
+
+if __name__ == "__main__":
+ print("Running CLI test...")
+ graph = build_ap_graph()
+ test_invoice = {
+ "vendor_name": "Acme Corp",
+ "invoice_number": "INV-2025-0042",
+ "amount": 12500.00,
+ "payment_terms": "net-30",
+ "due_date": "2025-03-30",
+ "line_items": "Software licenses x5",
+ }
+ initial_state: APState = {
+ "invoice": test_invoice,
+ "memory_context": "",
+ "risk_flags": [],
+ "risk_score": 0,
+ "decision": "",
+ "reasoning": "",
+ "invoice_id": "",
+ "processed_at": "",
+ }
+ result = graph.invoke(initial_state)
+ print(f"\n{'='*50}")
+ print(f"FINAL DECISION: {result['decision'].upper()}")
+ print(f"REASONING: {result['reasoning']}")
+ print(f"RISK SCORE: {result['risk_score']}/100")
diff --git a/demo/ap-memory-agent/data/ap_agent_seed.json b/demo/ap-memory-agent/data/ap_agent_seed.json
new file mode 100644
index 00000000..f5c7bb59
--- /dev/null
+++ b/demo/ap-memory-agent/data/ap_agent_seed.json
@@ -0,0 +1,38 @@
+{
+ "version": "1.0.0",
+ "conversation_meta": {
+ "scene": "assistant",
+ "scene_desc": {"description": "AP Agent vendor history for demo"},
+ "name": "AP Agent Vendor History",
+ "description": "Vendor invoice history: Acme Corp (clean), Globex Supplies (dispute), Shadow LLC (duplicate).",
+ "group_id": "eb6618c4d52d3bf9_group",
+ "created_at": "2025-01-15T00:00:00Z",
+ "default_timezone": "UTC",
+ "user_details": {
+ "ap_agent_user": {"full_name": "AP User", "role": "user", "extra": {}},
+ "ap_agent_system": {"full_name": "AP Agent", "role": "assistant", "extra": {}}
+ },
+ "tags": ["AP", "Vendor", "Invoice"]
+ },
+ "conversation_list": [
+ {"message_id": "ap_seed_001", "create_time": "2025-01-15T10:00:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "What was the outcome of invoice INV-001 from Acme Corp?", "refer_list": []},
+ {"message_id": "ap_seed_002", "create_time": "2025-01-15T10:00:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-001 from Acme Corp for $500 approved. Net 30 terms. Paid on time. Line items: Office supplies.", "refer_list": []},
+ {"message_id": "ap_seed_003", "create_time": "2025-01-15T10:01:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "And invoice INV-002?", "refer_list": []},
+ {"message_id": "ap_seed_004", "create_time": "2025-01-15T10:01:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-002 from Acme Corp for $450 approved. Net 30. Paid within terms. Software licenses.", "refer_list": []},
+ {"message_id": "ap_seed_005", "create_time": "2025-01-15T10:02:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "INV-003, INV-004, INV-005 from Acme?", "refer_list": []},
+ {"message_id": "ap_seed_006", "create_time": "2025-01-15T10:02:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-003 from Acme Corp for $550 approved. INV-004 for $480 approved. INV-005 for $520 approved. Net 30. Clean payment history. Acme Corp is low-risk: 5 invoices approved in past 6 months, amounts $400-$600, no disputes.", "refer_list": []},
+
+ {"message_id": "ap_seed_007", "create_time": "2025-01-16T10:05:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "What about Globex Supplies invoices INV-101 and INV-102?", "refer_list": []},
+ {"message_id": "ap_seed_008", "create_time": "2025-01-16T10:05:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-101 from Globex Supplies for $1,200 approved. INV-102 for $980 approved. Net 30. Paid on time.", "refer_list": []},
+ {"message_id": "ap_seed_009", "create_time": "2025-01-16T10:06:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "Was there a dispute with Globex?", "refer_list": []},
+ {"message_id": "ap_seed_010", "create_time": "2025-01-16T10:06:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-103 from Globex Supplies disputed March 2025 — shipment delay. Resolution: partial credit issued. Amount $2,000.", "refer_list": []},
+ {"message_id": "ap_seed_011", "create_time": "2025-01-16T10:07:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "Any late payments from Globex?", "refer_list": []},
+ {"message_id": "ap_seed_012", "create_time": "2025-01-16T10:07:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-104 from Globex Supplies for $1,500 paid 15 days late. Net 30 terms. Globex has prior dispute and late payment. Moderate risk.", "refer_list": []},
+
+ {"message_id": "ap_seed_013", "create_time": "2025-01-17T10:10:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "What happened with Shadow LLC invoice INV-777?", "refer_list": []},
+ {"message_id": "ap_seed_014", "create_time": "2025-01-17T10:10:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-777 from Shadow LLC for $3,000 rejected — duplicate invoice number. Same invoice number submitted twice. Shadow LLC flagged for duplicate attempt. High risk. Always verify invoice numbers against history.", "refer_list": []},
+
+ {"message_id": "ap_seed_015", "create_time": "2025-01-18T09:00:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "I need to switch to a different task now. Can you pull up the Q1 2025 departmental budget summary?", "refer_list": []},
+ {"message_id": "ap_seed_016", "create_time": "2025-01-18T09:00:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Sure, here is the Q1 2025 departmental budget summary. Total allocated: $2.4M across 8 departments. Engineering has the largest allocation at $680K, followed by Sales at $520K. All departments are currently within budget. Let me know if you need a detailed breakdown.", "refer_list": []}
+ ]
+}
diff --git a/demo/ap-memory-agent/evermem_client.py b/demo/ap-memory-agent/evermem_client.py
new file mode 100644
index 00000000..2d4beb0b
--- /dev/null
+++ b/demo/ap-memory-agent/evermem_client.py
@@ -0,0 +1,221 @@
+"""
+EverMemClient - Thin wrapper around the EverMemOS REST API.
+
+Core endpoints:
+ POST /api/v1/memories → Store a message (MemorizeMessageRequest format)
+ GET /api/v1/memories → Fetch memories by type
+ GET /api/v1/memories/search → Search memories
+ DELETE /api/v1/memories → Delete memories
+
+All requests use Content-Type: application/json.
+GET requests use JSON body (server merges with query params).
+"""
+
+import os
+import uuid
+import requests
+from datetime import datetime, timezone
+from typing import Any
+
+
+class EverMemClient:
+ """
+ Thin wrapper around the EverMemOS REST API.
+
+ Key parameters:
+ retrieve_method : keyword | vector | hybrid | rrf | agentic
+ memory_types : episodic_memory | foresight | event_log (profile NOT on search)
+ role : user | assistant
+ """
+
+ def __init__(self):
+ self.base_url = os.getenv("EVERMEM_BASE_URL", "http://localhost:1995")
+ self.user_id = os.getenv("EVERMEM_USER_ID", "ap_agent_system")
+ self.group_id = os.getenv("EVERMEM_GROUP_ID") # For group-scoped memories (seed data)
+ self.headers = {"Content-Type": "application/json"}
+
+ def write_memory(
+ self,
+ content: str,
+ role: str = "assistant",
+ memory_type: str = "episodic_memory",
+ metadata: dict | None = None,
+ *,
+ group_id: str | None = None,
+ group_name: str | None = None,
+ sender: str | None = None,
+ sync_mode: bool = False,
+ ) -> dict:
+ """
+ Store a memory in EverMemOS.
+
+ EverMemOS expects MemorizeMessageRequest format. memory_type and metadata
+ are for internal use only — fold context into content. The system extracts
+ memories from the message content.
+
+ role: "assistant" for agent-generated memories, "user" for user inputs.
+ sync_mode: If True, add ?sync_mode=false to wait for extraction (slower but ensures memories are indexed).
+ """
+ del memory_type, metadata # Not sent to API; structure content instead
+ effective_sender = sender or self.user_id
+ payload = {
+ "message_id": f"ap_{uuid.uuid4().hex[:12]}",
+ "create_time": datetime.now(timezone.utc).isoformat(),
+ "sender": effective_sender,
+ "content": content,
+ "role": role,
+ }
+ if group_id:
+ payload["group_id"] = group_id
+ if group_name:
+ payload["group_name"] = group_name
+
+ url = f"{self.base_url}/api/v1/memories"
+ if sync_mode:
+ url += "?sync_mode=false"
+
+ response = requests.post(url, json=payload, headers=self.headers, timeout=120)
+ response.raise_for_status()
+ return response.json()
+
+ def write_vendor_profile(self, vendor: str, profile_content: str) -> dict:
+ """
+ Write or update stable vendor profile. Folds vendor into content
+ since EverMemOS has no metadata field on MemorizeMessageRequest.
+ """
+ content = f"[Vendor profile for {vendor}] {profile_content}"
+ return self.write_memory(
+ content=content,
+ role="assistant",
+ memory_type="profile",
+ )
+
+ def search_memories(
+ self,
+ query: str,
+ retrieve_method: str = "agentic",
+ memory_types: list[str] | None = None,
+ top_k: int = 10,
+ ) -> list[dict]:
+ """
+ Search vendor memory. memory_types: episodic_memory | foresight | event_log.
+ Profile is NOT supported on /search — use fetch_memories_by_type for profile.
+ """
+ if memory_types is None:
+ memory_types = ["episodic_memory", "event_log"]
+ payload = {
+ "query": query,
+ "retrieve_method": retrieve_method,
+ "memory_types": memory_types,
+ "top_k": top_k,
+ }
+ if self.group_id:
+ payload["group_id"] = self.group_id
+ else:
+ payload["user_id"] = self.user_id
+ response = requests.get(
+ f"{self.base_url}/api/v1/memories/search",
+ json=payload,
+ headers=self.headers,
+ )
+ response.raise_for_status()
+ data = response.json()
+ raw = data.get("result", data)
+ memories = raw.get("memories", raw.get("results", []))
+ return self._flatten_search_results(memories)
+
+ def _flatten_search_results(self, memories: Any) -> list[dict]:
+ """Flatten nested {group_id: [records]} into a single list."""
+ if not memories or not isinstance(memories, list):
+ return []
+ flat: list[dict] = []
+ for item in memories:
+ if isinstance(item, dict):
+ for group_id, records in item.items():
+ if isinstance(records, list):
+ for r in records:
+ rec = dict(r) if isinstance(r, dict) else {"content": str(r)}
+ rec.setdefault("group_id", group_id)
+ flat.append(rec)
+ break
+ else:
+ flat.append(item)
+ return flat if flat else list(memories)
+
+ def fetch_memories_by_type(
+ self,
+ memory_types: list[str] | None = None,
+ top_k: int = 20,
+ ) -> list[dict]:
+ """
+ Fetch memories by type (profile, episodic_memory, etc.).
+ EverMemOS uses memory_type (singular) and limit per request.
+ """
+ if memory_types is None:
+ memory_types = ["episodic_memory"]
+ all_memories: list[dict] = []
+ for memory_type in memory_types:
+ payload = {
+ "memory_type": memory_type,
+ "limit": top_k,
+ "offset": 0,
+ }
+ if self.group_id:
+ payload["group_id"] = self.group_id
+ else:
+ payload["user_id"] = self.user_id
+ response = requests.get(
+ f"{self.base_url}/api/v1/memories",
+ json=payload,
+ headers=self.headers,
+ )
+ response.raise_for_status()
+ data = response.json()
+ raw = data.get("result", data)
+ memories = raw.get("memories", raw.get("results", []))
+ if isinstance(memories, list):
+ for m in memories:
+ rec = dict(m) if isinstance(m, dict) else {"content": str(m)}
+ rec.setdefault("memory_type", memory_type)
+ all_memories.append(rec)
+ return all_memories
+
+ def format_memory_context(self, memories: list[dict]) -> str:
+ """Convert raw memory results into a clean string for Claude's context."""
+ if not memories:
+ return "No prior history found for this vendor."
+ lines = []
+ for i, mem in enumerate(memories, 1):
+ content = (
+ mem.get("episode")
+ or mem.get("content")
+ or mem.get("memory")
+ or mem.get("summary")
+ or mem.get("atomic_fact")
+ or mem.get("subject")
+ or str(mem)
+ )
+ if isinstance(content, dict):
+ content = str(content)
+ profile_data = mem.get("profile_data")
+ if profile_data and isinstance(profile_data, dict):
+ content = str(profile_data) if not content else content
+ mem_type = mem.get("memory_type", mem.get("type", "memory"))
+ timestamp = mem.get("created_at", mem.get("timestamp", "unknown date"))
+ lines.append(f"[{str(mem_type).upper()} {i} | {timestamp}]: {content}")
+ return "\n".join(lines)
+
+ def delete_memories(self, user_id: str | None = None, group_id: str | None = None) -> dict:
+ """Delete memories. Uses group_id if set, else user_id."""
+ payload: dict[str, str] = {}
+ if group_id or self.group_id:
+ payload["group_id"] = group_id or self.group_id or ""
+ else:
+ payload["user_id"] = user_id or self.user_id
+ response = requests.delete(
+ f"{self.base_url}/api/v1/memories",
+ json=payload,
+ headers=self.headers,
+ )
+ response.raise_for_status()
+ return response.json()
diff --git a/demo/ap-memory-agent/requirements.txt b/demo/ap-memory-agent/requirements.txt
new file mode 100644
index 00000000..d15f3a52
--- /dev/null
+++ b/demo/ap-memory-agent/requirements.txt
@@ -0,0 +1,6 @@
+langgraph>=0.2.0
+langchain-anthropic>=0.3.0
+langchain-core>=0.3.0
+streamlit>=1.40.0
+requests>=2.32.0
+python-dotenv>=1.0.0
diff --git a/demo/ap-memory-agent/seed_memory.py b/demo/ap-memory-agent/seed_memory.py
new file mode 100644
index 00000000..acc3bf18
--- /dev/null
+++ b/demo/ap-memory-agent/seed_memory.py
@@ -0,0 +1,434 @@
+"""
+Seed mock vendor history into EverMemOS for the AP Memory Agent demo.
+
+Two modes:
+ --direct (default): Insert pre-built episodic memories directly into MongoDB + ES.
+ No LLM credits needed. Fast and reliable.
+ --via-api: POST messages through the EverMemOS API pipeline (requires LLM).
+
+Usage:
+ python seed_memory.py # Direct seed (recommended)
+ python seed_memory.py --clear # Clear first, then seed
+ python seed_memory.py --via-api # Seed through API pipeline (needs LLM credits)
+
+Requires: EVERMEM_GROUP_ID=eb6618c4d52d3bf9_group in .env (matches ap_agent_system)
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import re
+from pathlib import Path
+from datetime import datetime, timezone
+
+import httpx
+from dotenv import load_dotenv
+
+load_dotenv()
+
+BASE_URL = os.getenv("EVERMEM_BASE_URL", "http://localhost:1995")
+SEED_JSON = Path(__file__).parent / "data" / "ap_agent_seed.json"
+GROUP_ID = "eb6618c4d52d3bf9_group" # hash(ap_agent_system)_group
+GROUP_NAME = "AP Agent Vendor History"
+
+# Delay between messages (seconds) - for --via-api mode
+MESSAGE_DELAY_SEC = 0.5
+
+# Common English stopwords to filter from search_content
+_STOPWORDS = {
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
+ "of", "with", "by", "from", "as", "is", "was", "are", "were", "be",
+ "been", "being", "have", "has", "had", "do", "does", "did", "will",
+ "would", "shall", "should", "may", "might", "must", "can", "could",
+ "not", "no", "nor", "so", "if", "then", "than", "that", "this",
+ "these", "those", "it", "its", "he", "she", "they", "them", "his",
+ "her", "their", "we", "our", "you", "your", "my", "am", "up", "out",
+}
+
+
+def _tokenize_for_search(*texts: str) -> list[str]:
+ """Tokenize text into search terms (mimics jieba + stopword filtering for English)."""
+ combined = " ".join(t for t in texts if t)
+ words = re.findall(r"[a-zA-Z0-9]+(?:[-][a-zA-Z0-9]+)*", combined)
+ return [w.lower() for w in words if len(w) >= 2 and w.lower() not in _STOPWORDS]
+
+
+# ─────────────────────────────────────────────
+# PRE-BUILT EPISODIC MEMORIES (no LLM needed)
+# ─────────────────────────────────────────────
+
+EPISODIC_MEMORIES = [
+ {
+ "subject": "Acme Corp Invoices INV-001 to INV-005 Approved — Clean Payment History January 2025",
+ "episode": (
+ "On January 15, 2025 at 10:00 AM UTC, the user inquired about the status of "
+ "multiple Acme Corp invoices. Invoice INV-001 for $500 was approved with Net 30 terms "
+ "and paid on time, covering office supplies. Invoice INV-002 for $450 was approved "
+ "with Net 30 terms and paid within terms for software licenses. Invoices INV-003 ($550), "
+ "INV-004 ($480), and INV-005 ($520) were all approved with Net 30 terms and clean "
+ "payment history. Acme Corp is assessed as low-risk: 5 invoices approved in the past "
+ "6 months, amounts ranging $400-$600, with no disputes or late payments."
+ ),
+ "timestamp": "2025-01-15T10:02:30",
+ "participants": ["ap_agent_user", "ap_agent_system"],
+ },
+ {
+ "subject": "Globex Supplies Invoices INV-101 to INV-104 — Dispute and Late Payment History January 2025",
+ "episode": (
+ "On January 16, 2025 at 10:05 AM UTC, the user inquired about Globex Supplies "
+ "invoices INV-101 and INV-102. The assistant confirmed INV-101 for $1,200 was approved "
+ "with Net 30 terms and paid on time, and INV-102 for $980 was similarly approved and "
+ "paid on time. The user then asked about any disputes with Globex Supplies. The "
+ "assistant reported that Invoice INV-103 for $2,000 was disputed in March 2025 due to "
+ "shipment delay, resolved by issuing partial credit. The user asked about late payments "
+ "from Globex Supplies. The assistant stated that INV-104 for $1,500 was paid 15 days "
+ "late under Net 30 terms. Globex Supplies has a prior dispute and late payment history, "
+ "assessed as moderate risk."
+ ),
+ "timestamp": "2025-01-16T10:07:30",
+ "participants": ["ap_agent_user", "ap_agent_system"],
+ },
+ {
+ "subject": "Shadow LLC Duplicate Invoice INV-777 Rejected — High Risk Vendor January 2025",
+ "episode": (
+ "On January 17, 2025 at 10:10 AM UTC, the user inquired about the status of "
+ "invoice INV-777 from Shadow LLC. The assistant reported that invoice INV-777 for "
+ "$3,000 was rejected due to a duplicate invoice number — the same invoice number was "
+ "submitted twice. Shadow LLC was flagged for the duplicate attempt and assessed as "
+ "high risk. The assistant advised to always verify invoice numbers against history "
+ "when processing Shadow LLC invoices."
+ ),
+ "timestamp": "2025-01-17T10:10:30",
+ "participants": ["ap_agent_user", "ap_agent_system"],
+ },
+]
+
+
+def clear_group_data():
+ """Clear all MongoDB and ES data for the group."""
+ from pymongo import MongoClient as _MongoClient
+
+ print(" Clearing MongoDB...")
+ _client = _MongoClient(
+ "mongodb://admin:memsys123@localhost:27017/memsys?authSource=admin"
+ )
+ _db = _client["memsys"]
+ for coll_name in _db.list_collection_names():
+ r = _db[coll_name].delete_many({"group_id": GROUP_ID})
+ if r.deleted_count > 0:
+ print(f" {coll_name}: {r.deleted_count} deleted")
+ _client.close()
+
+ # Clear ES
+ try:
+ import requests
+ resp = requests.post(
+ "http://localhost:19200/episodic-memory-memsys-*/_delete_by_query",
+ json={"query": {"term": {"group_id": GROUP_ID}}},
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ if resp.ok:
+ deleted = resp.json().get("deleted", 0)
+ if deleted:
+ print(f" Elasticsearch: {deleted} deleted")
+ except Exception:
+ pass
+ print(" Cleared.")
+
+
+def seed_direct(clear_first: bool):
+ """Insert pre-built episodic memories directly into MongoDB + Elasticsearch."""
+ from pymongo import MongoClient as _MongoClient
+
+ if clear_first:
+ clear_group_data()
+
+ # 1. Save conversation meta via API
+ print(" Saving conversation-meta via API...")
+ import requests
+ meta_payload = {
+ "version": "1.0",
+ "scene": "assistant",
+ "scene_desc": {"description": "AP Agent vendor history for demo"},
+ "name": GROUP_NAME,
+ "description": "Vendor invoice history: Acme Corp (clean), Globex Supplies (dispute), Shadow LLC (duplicate).",
+ "group_id": GROUP_ID,
+ "created_at": "2025-01-15T00:00:00Z",
+ "default_timezone": "UTC",
+ "user_details": {
+ "ap_agent_user": {"full_name": "AP User", "role": "user", "extra": {}},
+ "ap_agent_system": {"full_name": "AP Agent", "role": "assistant", "extra": {}},
+ },
+ "tags": ["AP", "Vendor", "Invoice"],
+ }
+ try:
+ resp = requests.post(
+ f"{BASE_URL}/api/v1/memories/conversation-meta",
+ json=meta_payload,
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ print(" Conversation-meta saved.")
+ else:
+ print(f" Warning: conversation-meta HTTP {resp.status_code}")
+ except Exception as e:
+ print(f" Warning: conversation-meta failed: {e}")
+
+ # 2. Insert episodic memories directly into MongoDB
+ print(" Inserting episodic memories into MongoDB...")
+ _client = _MongoClient(
+ "mongodb://admin:memsys123@localhost:27017/memsys?authSource=admin"
+ )
+ _db = _client["memsys"]
+ now = datetime.now(timezone.utc)
+
+ inserted = 0
+ for mem in EPISODIC_MEMORIES:
+ ts = datetime.fromisoformat(mem["timestamp"].replace("Z", "+00:00"))
+ # Insert group episode + personal episodes (for each participant)
+ for user_id in [None] + mem["participants"]:
+ doc = {
+ "created_at": now,
+ "updated_at": now,
+ "deleted_at": None,
+ "deleted_by": None,
+ "deleted_id": 0,
+ "user_id": user_id,
+ "memory_type": "episodic_memory",
+ "subject": mem["subject"],
+ "summary": mem["episode"][:200],
+ "episode": mem["episode"],
+ "timestamp": ts,
+ "group_id": GROUP_ID,
+ "group_name": GROUP_NAME,
+ "participants": mem["participants"],
+ "type": "Conversation",
+ "extend": None,
+ "memcell_event_id_list": [],
+ "ori_event_id_list": [],
+ "keywords": [],
+ }
+ _db.episodic_memories.insert_one(doc)
+ inserted += 1
+ _client.close()
+ print(f" Inserted {inserted} episodic memories ({len(EPISODIC_MEMORIES)} episodes x 3 copies).")
+
+ # 3. Index into Elasticsearch
+ print(" Indexing into Elasticsearch...")
+ try:
+ _client2 = _MongoClient(
+ "mongodb://admin:memsys123@localhost:27017/memsys?authSource=admin"
+ )
+ _db2 = _client2["memsys"]
+ es_docs = []
+ for doc in _db2.episodic_memories.find({"group_id": GROUP_ID}):
+ doc_id = str(doc["_id"])
+ subject = doc.get("subject", "")
+ summary = doc.get("summary", "")
+ episode = doc.get("episode", "")
+ search_content = _tokenize_for_search(subject, summary, episode)
+ es_doc = {
+ "_id": doc_id,
+ "event_id": doc_id,
+ "search_content": search_content,
+ "title": subject,
+ "subject": subject,
+ "summary": summary,
+ "episode": episode,
+ "group_id": doc.get("group_id", ""),
+ "group_name": doc.get("group_name", ""),
+ "user_id": doc.get("user_id"),
+ "participants": doc.get("participants", []),
+ "timestamp": doc.get("timestamp").isoformat() if doc.get("timestamp") else None,
+ "created_at": doc.get("created_at").isoformat() if doc.get("created_at") else None,
+ "type": doc.get("type", "Conversation"),
+ }
+ es_docs.append(es_doc)
+
+ # Find the ES index name
+ resp = requests.get("http://localhost:19200/_cat/indices?format=json", timeout=5)
+ es_index = None
+ if resp.ok:
+ for idx in resp.json():
+ if "episodic-memory-memsys" in idx.get("index", ""):
+ es_index = idx["index"]
+ break
+
+ if es_index and es_docs:
+ # Bulk index
+ bulk_body = ""
+ for es_doc in es_docs:
+ doc_id = es_doc.pop("_id")
+ bulk_body += json.dumps({"index": {"_index": es_index, "_id": doc_id}}) + "\n"
+ bulk_body += json.dumps(es_doc) + "\n"
+ resp = requests.post(
+ f"http://localhost:19200/_bulk",
+ data=bulk_body,
+ headers={"Content-Type": "application/x-ndjson"},
+ timeout=10,
+ )
+ if resp.ok:
+ result = resp.json()
+ errors = result.get("errors", False)
+ print(f" ES indexed {len(es_docs)} docs (errors={errors}).")
+ else:
+ print(f" ES bulk index failed: HTTP {resp.status_code}")
+ else:
+ print(f" Warning: ES index not found or no docs to index.")
+ _client2.close()
+ except Exception as e:
+ print(f" Warning: ES indexing failed: {e}")
+
+ print(f"\nDone. {len(EPISODIC_MEMORIES)} vendor episodes seeded.")
+ print("Run: uv run python demo/ap-memory-agent/test_demo.py")
+
+
+# ─────────────────────────────────────────────
+# ORIGINAL API-BASED SEEDING (requires LLM)
+# ─────────────────────────────────────────────
+
+def load_conversation() -> tuple:
+ """Load conversation from JSON. Returns (messages, group_id, group_name, meta)."""
+ if not SEED_JSON.exists():
+ raise FileNotFoundError(f"Seed data not found: {SEED_JSON}")
+ with open(SEED_JSON, encoding="utf-8") as f:
+ data = json.load(f)
+ messages = data.get("conversation_list", [])
+ meta = data.get("conversation_meta", {})
+ group_id = meta.get("group_id", GROUP_ID)
+ group_name = meta.get("name", "AP Agent Vendor History")
+ user_details = meta.get("user_details", {})
+ for msg in messages:
+ msg["group_id"] = group_id
+ msg["group_name"] = group_name
+ sender = msg.get("sender")
+ if sender and sender in user_details:
+ msg["role"] = user_details[sender].get("role", "user")
+ else:
+ msg["role"] = "assistant" if "system" in str(sender or "").lower() else "user"
+ return messages, group_id, group_name, meta
+
+
+async def upsert_conversation_meta(
+ client: httpx.AsyncClient,
+ meta: dict,
+ messages: list,
+ group_id: str,
+ group_name: str,
+) -> None:
+ """Save conversation-meta (required for extraction scene)."""
+ created_at = meta.get("created_at") or (
+ messages[0].get("create_time") if messages else None
+ ) or datetime.now(timezone.utc).isoformat()
+
+ user_details = meta.get("user_details") or {}
+ if not user_details:
+ for m in messages:
+ s = m.get("sender")
+ if s:
+ user_details[s] = {
+ "full_name": m.get("sender_name", s),
+ "role": "user" if m.get("role") == "user" else "assistant",
+ "extra": {},
+ }
+
+ payload = {
+ "version": meta.get("version", "1.0"),
+ "scene": "assistant",
+ "scene_desc": meta.get("scene_desc", {}),
+ "name": group_name,
+ "description": meta.get("description", ""),
+ "group_id": group_id,
+ "created_at": created_at,
+ "default_timezone": meta.get("default_timezone", "UTC"),
+ "user_details": user_details,
+ "tags": meta.get("tags", []),
+ }
+
+ resp = await client.post(
+ f"{BASE_URL}/api/v1/memories/conversation-meta",
+ json=payload,
+ headers={"Content-Type": "application/json"},
+ )
+ if resp.status_code != 200:
+ print(f" Warning: conversation-meta failed HTTP {resp.status_code}")
+ else:
+ print(" Conversation-meta saved.")
+
+
+async def seed_messages(
+ client: httpx.AsyncClient,
+ messages: list,
+ clear_first: bool,
+) -> None:
+ """POST each message through the API pipeline (requires LLM credits)."""
+ if clear_first:
+ clear_group_data()
+
+ url = f"{BASE_URL}/api/v1/memories?sync_mode=false"
+ extracted = 0
+ accumulated = 0
+
+ for idx, msg in enumerate(messages, 1):
+ try:
+ resp = await client.post(
+ url, json=msg, headers={"Content-Type": "application/json"}, timeout=600
+ )
+ if resp.status_code == 200:
+ result = resp.json()
+ status = result.get("result", {}).get("status_info", "")
+ count = result.get("result", {}).get("count", 0)
+ if count > 0 or "extracted" in status.lower():
+ extracted += 1
+ print(f" [{idx}/{len(messages)}] Extracted")
+ else:
+ accumulated += 1
+ print(f" [{idx}/{len(messages)}] Queued")
+ elif resp.status_code == 202:
+ extracted += 1
+ print(f" [{idx}/{len(messages)}] Processing (202)")
+ else:
+ print(f" [{idx}/{len(messages)}] HTTP {resp.status_code}")
+ except httpx.ReadTimeout:
+ print(f" [{idx}/{len(messages)}] Timeout (continuing)")
+ except Exception as e:
+ print(f" [{idx}/{len(messages)}] Error: {e}")
+
+ if idx < len(messages):
+ await asyncio.sleep(MESSAGE_DELAY_SEC)
+
+ print(f"\n Summary: {extracted} extracted, {accumulated} queued")
+
+
+async def main_api(clear: bool) -> None:
+ messages, group_id, group_name, meta = load_conversation()
+ print(f"Loaded {len(messages)} messages from {SEED_JSON}")
+ print(f"Group ID: {group_id}")
+
+ async with httpx.AsyncClient(timeout=600) as client:
+ await upsert_conversation_meta(client, meta, messages, group_id, group_name)
+ await seed_messages(client, messages, clear)
+
+ print("\nDone.")
+ print("Run: uv run python demo/ap-memory-agent/test_demo.py")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Seed vendor history for AP Memory Agent demo")
+ parser.add_argument("--clear", action="store_true", help="Delete group memories before seeding")
+ parser.add_argument("--via-api", action="store_true", help="Seed via API pipeline (requires LLM credits)")
+ args = parser.parse_args()
+
+ if args.via_api:
+ print("Seeding via API pipeline (requires LLM credits)...")
+ asyncio.run(main_api(args.clear))
+ else:
+ print("Seeding directly into MongoDB + Elasticsearch (no LLM needed)...")
+ seed_direct(args.clear)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/demo/ap-memory-agent/streamlit_app.py b/demo/ap-memory-agent/streamlit_app.py
new file mode 100644
index 00000000..619deb88
--- /dev/null
+++ b/demo/ap-memory-agent/streamlit_app.py
@@ -0,0 +1,100 @@
+"""
+AP Memory Agent - Streamlit UI
+
+Run with: streamlit run streamlit_app.py
+(From demo/ap-memory-agent/ directory)
+"""
+
+import streamlit as st
+
+from ap_agent_graph import build_ap_graph, APState
+
+
+def run_streamlit_ui() -> None:
+ st.set_page_config(page_title="AP Memory Agent", page_icon="🧾", layout="wide")
+ st.title("🧾 AP Automation Agent")
+ st.caption(
+ "Powered by LangGraph + EverMemOS + Claude — "
+ "Multi-agent invoice processing with shared memory"
+ )
+
+ st.subheader("Submit Invoice")
+ with st.form("invoice_form"):
+ col1, col2 = st.columns(2)
+ with col1:
+ vendor_name = st.text_input("Vendor Name", placeholder="e.g. Acme Corp")
+ invoice_number = st.text_input(
+ "Invoice Number", placeholder="e.g. INV-2025-0042"
+ )
+ amount = st.number_input("Amount ($)", min_value=0.0, step=100.0)
+ with col2:
+ payment_terms = st.selectbox(
+ "Payment Terms",
+ ["net-30", "net-15", "net-60", "due-on-receipt"],
+ )
+ due_date = st.date_input("Due Date")
+ line_items = st.text_area(
+ "Line Items (optional)",
+ placeholder="e.g. Software licenses x5, Support hours x10",
+ )
+
+ submitted = st.form_submit_button("🚀 Process Invoice", use_container_width=True)
+
+ if submitted and vendor_name and invoice_number:
+ invoice = {
+ "vendor_name": vendor_name,
+ "invoice_number": invoice_number,
+ "amount": amount,
+ "payment_terms": payment_terms,
+ "due_date": str(due_date),
+ "line_items": line_items,
+ }
+
+ with st.spinner("Agents processing invoice..."):
+ graph = build_ap_graph()
+ initial_state: APState = {
+ "invoice": invoice,
+ "memory_context": "",
+ "risk_flags": [],
+ "risk_score": 0,
+ "decision": "",
+ "reasoning": "",
+ "invoice_id": "",
+ "processed_at": "",
+ }
+ final_state = graph.invoke(initial_state)
+
+ st.divider()
+ st.subheader("Agent Decision")
+
+ decision = final_state["decision"]
+ decision_colors = {"approve": "🟢", "hold": "🟡", "reject": "🔴"}
+ emoji = decision_colors.get(decision, "⚪")
+ st.markdown(f"## {emoji} {decision.upper()}")
+ st.info(final_state["reasoning"])
+
+ col1, col2 = st.columns(2)
+ with col1:
+ st.metric("Risk Score", f"{final_state['risk_score']}/100")
+ with col2:
+ flags = final_state["risk_flags"]
+ st.metric("Risk Flags", len(flags))
+
+ if flags:
+ st.subheader("⚠️ Risk Flags")
+ for flag in flags:
+ st.warning(flag)
+
+ with st.expander("🧠 Vendor Memory Used in Decision", expanded=True):
+ st.caption(
+ "This is what EverMemOS retrieved from shared agent memory "
+ "before making the decision:"
+ )
+ st.text(final_state["memory_context"])
+
+ elif submitted:
+ st.error("Please fill in at least Vendor Name and Invoice Number.")
+
+
+if __name__ == "__main__":
+ run_streamlit_ui()
diff --git a/demo/ap-memory-agent/test_demo.py b/demo/ap-memory-agent/test_demo.py
new file mode 100644
index 00000000..e0699f3e
--- /dev/null
+++ b/demo/ap-memory-agent/test_demo.py
@@ -0,0 +1,113 @@
+"""
+Test script for the AP Memory Agent demo.
+
+Run from EverMemOS root:
+ uv run python demo/ap-memory-agent/test_demo.py
+
+Or from demo/ap-memory-agent/:
+ uv run python test_demo.py
+
+Prerequisites:
+ - EverMemOS running at http://localhost:1995
+ - seed_memory.py already run (vendor history populated)
+ - ANTHROPIC_API_KEY in .env
+"""
+
+import sys
+from pathlib import Path
+
+# Ensure demo/ap-memory-agent is on path when run from project root
+if str(Path(__file__).parent) not in sys.path:
+ sys.path.insert(0, str(Path(__file__).parent))
+
+from ap_agent_graph import build_ap_graph, APState
+
+
+# Example invoices to test (matches the demo script from README)
+TEST_INVOICES = [
+ {
+ "name": "Acme Corp (clean) — expect APPROVE",
+ "invoice": {
+ "vendor_name": "Acme Corp",
+ "invoice_number": "INV-2025-0042",
+ "amount": 500.0,
+ "payment_terms": "net-30",
+ "due_date": "2025-03-30",
+ "line_items": "Office supplies",
+ },
+ },
+ {
+ "name": "Globex Supplies (prior dispute) — expect HOLD",
+ "invoice": {
+ "vendor_name": "Globex Supplies",
+ "invoice_number": "INV-2025-0105",
+ "amount": 1200.0,
+ "payment_terms": "net-30",
+ "due_date": "2025-04-15",
+ "line_items": "Industrial equipment",
+ },
+ },
+ {
+ "name": "Shadow LLC (duplicate INV-777) — expect REJECT",
+ "invoice": {
+ "vendor_name": "Shadow LLC",
+ "invoice_number": "INV-777",
+ "amount": 3000.0,
+ "payment_terms": "net-30",
+ "due_date": "2025-04-01",
+ "line_items": "Consulting services",
+ },
+ },
+ {
+ "name": "Acme Corp (new invoice) — expect APPROVE",
+ "invoice": {
+ "vendor_name": "Acme Corp",
+ "invoice_number": "INV-2025-0099",
+ "amount": 550.0,
+ "payment_terms": "net-30",
+ "due_date": "2025-05-01",
+ "line_items": "Software licenses",
+ },
+ },
+]
+
+
+def run_test(invoice: dict, name: str) -> dict:
+ """Run the graph with a single invoice and return the result."""
+ graph = build_ap_graph()
+ initial_state: APState = {
+ "invoice": invoice,
+ "memory_context": "",
+ "risk_flags": [],
+ "risk_score": 0,
+ "decision": "",
+ "reasoning": "",
+ "invoice_id": "",
+ "processed_at": "",
+ }
+ return graph.invoke(initial_state)
+
+
+def main() -> None:
+ print("=" * 60)
+ print("AP Memory Agent — Demo Test Script")
+ print("=" * 60)
+ print("\nEnsure EverMemOS is running and seed_memory.py has been run.\n")
+
+ for i, test in enumerate(TEST_INVOICES, 1):
+ print(f"\n--- Test {i}: {test['name']} ---")
+ result = run_test(test["invoice"], test["name"])
+ print(f" Decision: {result['decision'].upper()}")
+ print(f" Risk Score: {result['risk_score']}/100")
+ if result["risk_flags"]:
+ print(f" Flags: {result['risk_flags']}")
+ print(f" Reasoning: {result['reasoning'][:80]}...")
+ print()
+
+ print("=" * 60)
+ print("Tests complete.")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 5e5a6aaf..2643f255 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,6 +72,8 @@ dependencies = [
"nltk>=3.9.2",
"tiktoken>=0.12.0",
"prometheus-client>=0.20.0",
+ # Demo: AP Memory Agent (Streamlit UI)
+ "streamlit>=1.40.0",
]
[build-system]
diff --git a/src/biz_layer/mem_memorize.py b/src/biz_layer/mem_memorize.py
index e951a13c..e0237c6d 100644
--- a/src/biz_layer/mem_memorize.py
+++ b/src/biz_layer/mem_memorize.py
@@ -523,11 +523,18 @@ async def _timed_extract_event_logs():
return result
if state.is_assistant_scene:
- _, foresight_memories, event_logs = await asyncio.gather(
+ results = await asyncio.gather(
_timed_extract_episodes(),
_timed_extract_foresights(),
_timed_extract_event_logs(),
+ return_exceptions=True,
)
+ for i, result in enumerate(results):
+ if isinstance(result, Exception):
+ stage_name = ['episodes', 'foresights', 'event_logs'][i]
+ logger.warning(f"[mem_memorize] {stage_name} extraction failed (non-fatal): {result}")
+ foresight_memories = results[1] if not isinstance(results[1], Exception) else []
+ event_logs = results[2] if not isinstance(results[2], Exception) else []
else:
await _timed_extract_episodes()
record_extraction_stage(
diff --git a/uv.lock b/uv.lock
index 26d94d33..87e94b01 100644
--- a/uv.lock
+++ b/uv.lock
@@ -95,6 +95,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
+[[package]]
+name = "altair"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "jsonschema" },
+ { name = "narwhals" },
+ { name = "packaging" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" },
+]
+
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -298,6 +314,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" },
]
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
[[package]]
name = "blockbuster"
version = "1.5.25"
@@ -694,6 +719,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.46"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
+]
+
[[package]]
name = "google-ai-generativelanguage"
version = "0.9.0"
@@ -1080,6 +1129,18 @@ version = "0.42.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" }
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
[[package]]
name = "jiter"
version = "0.12.0"
@@ -1135,6 +1196,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
]
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
[[package]]
name = "jsonschema-rs"
version = "0.29.1"
@@ -1150,6 +1226,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/89/3f89de071920208c0eb64b827a878d2e587f6a3431b58c02f63c3468b76e/jsonschema_rs-0.29.1-cp312-cp312-win_amd64.whl", hash = "sha256:a414c162d687ee19171e2d8aae821f396d2f84a966fd5c5c757bd47df0954452", size = 1871774, upload-time = "2025-02-08T21:24:30.824Z" },
]
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
[[package]]
name = "langchain"
version = "1.1.2"
@@ -1400,6 +1488,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+]
+
[[package]]
name = "matplotlib-inline"
version = "0.2.1"
@@ -1492,6 +1599,7 @@ dependencies = [
{ name = "redis" },
{ name = "scikit-learn" },
{ name = "sqlmodel" },
+ { name = "streamlit" },
{ name = "tiktoken" },
{ name = "tqdm" },
{ name = "tzlocal" },
@@ -1584,6 +1692,7 @@ requires-dist = [
{ name = "redis", specifier = ">=5.0.0" },
{ name = "scikit-learn", specifier = ">=1.3.0" },
{ name = "sqlmodel", specifier = ">=0.0.19" },
+ { name = "streamlit", specifier = ">=1.40.0" },
{ name = "tiktoken", specifier = ">=0.12.0" },
{ name = "tqdm", specifier = ">=4.65.0" },
{ name = "tzlocal", specifier = ">=5.3.0" },
@@ -1679,6 +1788,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
+[[package]]
+name = "narwhals"
+version = "2.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/59/81d0f4cad21484083466f278e6b392addd9f4205b48d45b5c8771670ebf8/narwhals-2.17.0.tar.gz", hash = "sha256:ebd5bc95bcfa2f8e89a8ac09e2765a63055162837208e67b42d6eeb6651d5e67", size = 620306, upload-time = "2026-02-23T09:44:34.142Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/27/20770bd6bf8fbe1e16f848ba21da9df061f38d2e6483952c29d2bb5d1d8b/narwhals-2.17.0-py3-none-any.whl", hash = "sha256:2ac5307b7c2b275a7d66eeda906b8605e3d7a760951e188dcfff86e8ebe083dd", size = 444897, upload-time = "2026-02-23T09:44:32.006Z" },
+]
+
[[package]]
name = "nest-asyncio"
version = "1.6.0"
@@ -1864,6 +1982,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
]
+[[package]]
+name = "pillow"
+version = "12.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
+ { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
+ { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.5.0"
@@ -2082,6 +2219,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/da/fcc9a9fcd4ca946ff402cff20348e838b051d69f50f5d1f5dca4cd3c5eb8/py_spy-0.4.1-py2.py3-none-win_amd64.whl", hash = "sha256:d92e522bd40e9bf7d87c204033ce5bb5c828fca45fa28d970f58d71128069fdc", size = 1818784, upload-time = "2025-07-31T19:33:23.802Z" },
]
+[[package]]
+name = "pyarrow"
+version = "23.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" },
+ { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" },
+ { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" },
+]
+
[[package]]
name = "pyasn1"
version = "0.6.1"
@@ -2175,6 +2327,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
+[[package]]
+name = "pydeck"
+version = "0.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" },
+]
+
[[package]]
name = "pygments"
version = "2.19.2"
@@ -2431,6 +2596,20 @@ hiredis = [
{ name = "hiredis" },
]
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
[[package]]
name = "regex"
version = "2025.11.3"
@@ -2493,6 +2672,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+]
+
[[package]]
name = "rsa"
version = "4.9.1"
@@ -2572,6 +2774,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
+[[package]]
+name = "smmap"
+version = "5.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -2665,6 +2876,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
+[[package]]
+name = "streamlit"
+version = "1.54.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altair" },
+ { name = "blinker" },
+ { name = "cachetools" },
+ { name = "click" },
+ { name = "gitpython" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pandas" },
+ { name = "pillow" },
+ { name = "protobuf" },
+ { name = "pyarrow" },
+ { name = "pydeck" },
+ { name = "requests" },
+ { name = "tenacity" },
+ { name = "toml" },
+ { name = "tornado" },
+ { name = "typing-extensions" },
+ { name = "watchdog", marker = "sys_platform != 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/66/d887ee80ea85f035baee607c60af024994e17ae9b921277fca9675e76ecf/streamlit-1.54.0.tar.gz", hash = "sha256:09965e6ae7eb0357091725de1ce2a3f7e4be155c2464c505c40a3da77ab69dd8", size = 8662292, upload-time = "2026-02-04T16:37:54.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/1d/40de1819374b4f0507411a60f4d2de0d620a9b10c817de5925799132b6c9/streamlit-1.54.0-py3-none-any.whl", hash = "sha256:a7b67d6293a9f5f6b4d4c7acdbc4980d7d9f049e78e404125022ecb1712f79fc", size = 9119730, upload-time = "2026-02-04T16:37:52.199Z" },
+]
+
[[package]]
name = "structlog"
version = "25.5.0"
@@ -2720,6 +2960,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
]
+[[package]]
+name = "toml"
+version = "0.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
+ { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
+ { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
+ { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
+ { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
+ { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
+]
+
[[package]]
name = "tqdm"
version = "4.67.1"
@@ -2909,6 +3177,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
[[package]]
name = "watchfiles"
version = "1.1.1"