diff --git a/demos/customer_support_voice_agent/.env.example b/demos/customer_support_voice_agent/.env.example
new file mode 100644
index 00000000..a182344b
--- /dev/null
+++ b/demos/customer_support_voice_agent/.env.example
@@ -0,0 +1,3 @@
+OPENAI_API_KEY=your openai api key here
+FIRECRAWL_API_KEY=your firecrawl api key here
+MEMORI_API_KEY=your memori api key here
\ No newline at end of file
diff --git a/demos/customer_support_voice_agent/.streamlit/config.toml b/demos/customer_support_voice_agent/.streamlit/config.toml
new file mode 100644
index 00000000..f0cf6164
--- /dev/null
+++ b/demos/customer_support_voice_agent/.streamlit/config.toml
@@ -0,0 +1,10 @@
+[theme]
+base = "light"
+
+# Optional overrides:
+# primaryColor = "#0b57d0"
+# backgroundColor = "#ffffff"
+# secondaryBackgroundColor = "#f5f7fb"
+# textColor = "#000000"
+
+
diff --git a/demos/customer_support_voice_agent/README.md b/demos/customer_support_voice_agent/README.md
new file mode 100644
index 00000000..25a79ac7
--- /dev/null
+++ b/demos/customer_support_voice_agent/README.md
@@ -0,0 +1,46 @@
+## Customer Support Voice Agent
+
+Customer-support assistant powered by **OpenAI GPT‑4o**, **OpenAI TTS**, **Memori v3**, and **Firecrawl**.
+Paste your docs/FAQ URLs, ingest them into Memori, and chat with a voice-enabled support agent on top of that knowledge.
+
+### Features
+
+- **Company‑agnostic**: Works for any product/company docs you point it at.
+- **Memori v3 knowledge base**: Docs are crawled with Firecrawl and stored in a Memori‑backed SQLite DB.
+- **Chat + Voice UI**: Streamlit chat interface with optional audio playback.
+- **Persistent memory**: Conversations and ingested docs are stored for future questions.
+
+### Setup
+
+Install dependencies (use any Python 3.11+ environment you like):
+
+```bash
+cd demos/customer_support_voice_agent
+python -m pip install -r requirements.txt
+```
+
+Create a `.env` file (copy from `.env.example`) and set:
+
+- `OPENAI_API_KEY` – required (chat + TTS).
+- `FIRECRAWL_API_KEY` – required to ingest docs via Firecrawl.
+- `MEMORI_API_KEY` – optional, for Memori Advanced Augmentation / higher quotas.
+- `SQLITE_DB_PATH` – optional, defaults to `./memori.sqlite`.
+
+### Run
+
+```bash
+streamlit run app.py
+```
+
+In the **sidebar**:
+
+1. Enter your **Firecrawl**, **Memori** (optional), and **OpenAI** API keys.
+2. (Optionally) set a **Company Name**.
+3. Paste one or more documentation URLs (one per line) under **“Ingest Docs into Memori”** and click **“Extract & store to Memori”**.
+
+Then use the main chat box to ask customer‑support questions about your product.
+
+> Note: `ingest_studio1.py` is kept as an example script for the original Studio1 demo.
+> For most use cases, you can ignore it and use the sidebar‑based ingestion instead.
+
+
diff --git a/demos/customer_support_voice_agent/app.py b/demos/customer_support_voice_agent/app.py
new file mode 100644
index 00000000..489a25b0
--- /dev/null
+++ b/demos/customer_support_voice_agent/app.py
@@ -0,0 +1,444 @@
+"""
+Customer Support Voice Agent with Memori v3
+
+Streamlit app:
+- Single chat interface for customer support on top of your own docs/FAQs.
+- Uses Memori v3 + OpenAI GPT-4o for grounded answers.
+- Uses OpenAI TTS for optional voice responses.
+
+Prereqs:
+- Set OPENAI_API_KEY (and optional SQLITE_DB_PATH, e.g. ./memori.sqlite).
+- Set FIRECRAWL_API_KEY to ingest documentation URLs into Memori.
+"""
+
+import base64
+import os
+from io import BytesIO
+from typing import Optional
+
+import streamlit as st
+from dotenv import load_dotenv
+from firecrawl import FirecrawlApp
+from memori import Memori
+from openai import OpenAI
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import sessionmaker
+
+
+load_dotenv()
+
+
+def _load_inline_image(path: str, height_px: int) -> str:
+ """Return an inline
tag for a local PNG, or empty string on failure."""
+ try:
+ with open(path, "rb") as f:
+ encoded = base64.b64encode(f.read()).decode()
+ return (
+ f"
"
+ )
+ except Exception:
+ return ""
+
+
+def _init_memori_with_openai() -> Optional[Memori]:
+ """Initialize Memori v3 + OpenAI client, mirroring ai_consultant_agent."""
+ openai_key = os.getenv("OPENAI_API_KEY", "")
+ if not openai_key:
+ st.warning("OPENAI_API_KEY is not set – Memori v3 will not be active.")
+ return None
+
+ try:
+ db_path = os.getenv("SQLITE_DB_PATH", "./memori.sqlite")
+ database_url = f"sqlite:///{db_path}"
+ engine = create_engine(
+ database_url,
+ pool_pre_ping=True,
+ connect_args={"check_same_thread": False},
+ )
+
+ # Optional DB connectivity check
+ with engine.connect() as conn:
+ conn.execute(text("SELECT 1"))
+
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+ client = OpenAI(api_key=openai_key)
+ mem = Memori(conn=SessionLocal).openai.register(client)
+ # Generic attribution for customer-support use-cases
+ mem.attribution(
+ entity_id="customer-support-user", process_id="customer-support"
+ )
+ mem.config.storage.build()
+
+ st.session_state.memori = mem
+ st.session_state.openai_client = client
+ return mem
+ except Exception as e:
+ st.warning(f"Memori v3 initialization note: {e}")
+ return None
+
+
+def _synth_audio(text: str, client: OpenAI) -> Optional[BytesIO]:
+ """Call OpenAI TTS to synthesize speech for the given text."""
+ try:
+ # Using audio.speech.create (high-level helper) if available
+ result = client.audio.speech.create(
+ model="gpt-4o-mini-tts",
+ voice="alloy",
+ input=text,
+ )
+ audio_bytes = result.read() if hasattr(result, "read") else result
+ if isinstance(audio_bytes, bytes):
+ return BytesIO(audio_bytes)
+ return None
+ except Exception as e:
+ st.warning(f"TTS error: {e}")
+ return None
+
+
+def _ingest_urls_with_firecrawl(mem: Memori, client: OpenAI, urls: list[str]) -> int:
+ """Ingest one or more documentation base URLs into Memori using Firecrawl."""
+ firecrawl_key = os.getenv("FIRECRAWL_API_KEY", "")
+ if not firecrawl_key:
+ raise RuntimeError("FIRECRAWL_API_KEY is not set – cannot ingest docs.")
+
+ app = FirecrawlApp(api_key=firecrawl_key)
+ all_pages = []
+
+ for base_url in urls:
+ try:
+ job = app.crawl(
+ base_url,
+ limit=50,
+ scrape_options={
+ "formats": ["markdown", "html"],
+ "onlyMainContent": True,
+ },
+ )
+
+ # Normalize Firecrawl response into a list of page dicts (mirrors ingest_studio1).
+ if isinstance(job, dict):
+ pages = job.get("data") or job.get("pages") or job
+ else:
+ pages = (
+ getattr(job, "data", None)
+ or getattr(job, "pages", None)
+ or getattr(job, "results", None)
+ )
+ if pages is None:
+ if hasattr(job, "model_dump"):
+ data = job.model_dump()
+ elif hasattr(job, "dict"):
+ data = job.dict()
+ else:
+ data = job
+ pages = (
+ data.get("data")
+ or data.get("pages")
+ or data.get("results")
+ or data
+ )
+
+ if isinstance(pages, list):
+ all_pages.extend(pages)
+ elif isinstance(pages, dict):
+ all_pages.append(pages)
+ except Exception as e:
+ st.warning(f"Firecrawl issue while crawling {base_url}: {e}")
+
+ # Deduplicate by URL
+ dedup_pages = []
+ seen_urls = set()
+ for page in all_pages:
+ url = None
+ if isinstance(page, dict):
+ meta = page.get("metadata") or {}
+ url = page.get("url") or meta.get("sourceURL")
+ key = url or id(page)
+ if key in seen_urls:
+ continue
+ seen_urls.add(key)
+ dedup_pages.append(page)
+
+ company_name = st.session_state.get("company_name") or "the company"
+
+ # Ingest pages into Memori by sending them through the registered OpenAI client.
+ ingested = 0
+ for idx, page in enumerate(dedup_pages, start=1):
+ if isinstance(page, dict):
+ page_dict = page
+ else:
+ if hasattr(page, "model_dump"):
+ page_dict = page.model_dump()
+ elif hasattr(page, "dict"):
+ page_dict = page.dict()
+ else:
+ continue
+
+ metadata = page_dict.get("metadata") or {}
+ url = page_dict.get("url") or metadata.get("sourceURL") or urls[0]
+
+ markdown = (
+ page_dict.get("markdown")
+ or page_dict.get("text")
+ or page_dict.get("content")
+ or ""
+ )
+ if not markdown:
+ continue
+
+ title = page_dict.get("title") or metadata.get("title") or f"Page {idx}"
+
+ doc_text = f"""{company_name} Documentation Page
+Title: {title}
+URL: {url}
+
+Content:
+{markdown}
+"""
+
+ try:
+ _ = client.chat.completions.create(
+ model="gpt-4o-mini",
+ messages=[
+ {
+ "role": "user",
+ "content": (
+ "Store the following documentation page in memory for "
+ "future customer-support conversations. Respond with a "
+ "short acknowledgement only.\n\n"
+ f"{doc_text}"
+ ),
+ }
+ ],
+ )
+ ingested += 1
+ except Exception as e:
+ st.warning(f"Memori/OpenAI issue ingesting {url}: {e}")
+
+ # Flush writes to storage without closing the adapter (app keeps running).
+ try:
+ adapter = getattr(mem.config.storage, "adapter", None)
+ if adapter is not None:
+ adapter.commit()
+ except Exception as e:
+ st.warning(f"Memori commit note: {e}")
+
+ return ingested
+
+
+def main():
+ # Page config
+ st.set_page_config(
+ page_title="Customer Support Voice Agent",
+ layout="wide",
+ )
+
+ # Initialize session state
+ if "memori" not in st.session_state or "openai_client" not in st.session_state:
+ _init_memori_with_openai()
+
+ if "messages" not in st.session_state:
+ st.session_state.messages = []
+
+ if "company_name" not in st.session_state:
+ st.session_state.company_name = ""
+
+ # Inline title logos (reuse existing assets from other agents)
+ memori_img_inline = _load_inline_image(
+ "../job_search_agent/assets/Memori_Logo.png", height_px=90
+ )
+
+ title_html = f"""
+
+
+ Customer Support Voice Agent with
+ {memori_img_inline}
+
+
+"""
+ st.markdown(title_html, unsafe_allow_html=True)
+
+ # Sidebar
+ with st.sidebar:
+ st.subheader("🔑 API & Storage")
+
+ firecrawl_api_key_input = st.text_input(
+ "Firecrawl API Key",
+ value=os.getenv("FIRECRAWL_API_KEY", ""),
+ type="password",
+ help="Used to crawl/scrape your documentation URLs into Memori.",
+ )
+
+ memori_api_key_input = st.text_input(
+ "Memori API Key (optional)",
+ value=os.getenv("MEMORI_API_KEY", ""),
+ type="password",
+ help="Used for Memori Advanced Augmentation and higher quotas.",
+ )
+
+ openai_api_key_input = st.text_input(
+ "OpenAI API Key",
+ value=os.getenv("OPENAI_API_KEY", ""),
+ type="password",
+ help="Your OpenAI API key for GPT-4o and TTS.",
+ )
+
+ company_name_input = st.text_input(
+ "Company Name (optional)",
+ value=st.session_state.company_name,
+ help="Used to personalize prompts and titles.",
+ )
+ st.session_state.company_name = company_name_input.strip()
+
+ if st.button("Save Settings"):
+ if openai_api_key_input:
+ os.environ["OPENAI_API_KEY"] = openai_api_key_input
+ if firecrawl_api_key_input:
+ os.environ["FIRECRAWL_API_KEY"] = firecrawl_api_key_input
+ if memori_api_key_input:
+ os.environ["MEMORI_API_KEY"] = memori_api_key_input
+ st.success("✅ Settings saved for this session. Re-initializing Memori...")
+ _init_memori_with_openai()
+
+ st.markdown("---")
+ st.markdown("### 📚 Ingest Docs into Memori")
+ ingest_urls_text = st.text_area(
+ "Documentation URLs (one per line)",
+ placeholder="https://docs.yourcompany.com\nhttps://yourcompany.com/help",
+ height=140,
+ )
+ if st.button("Extract & store to Memori"):
+ urls = [u.strip() for u in ingest_urls_text.splitlines() if u.strip()]
+ if not urls:
+ st.warning("Please enter at least one URL to ingest.")
+ elif (
+ "memori" not in st.session_state
+ or "openai_client" not in st.session_state
+ ):
+ st.warning(
+ "Memori / OpenAI client not initialized yet – check your API key above."
+ )
+ else:
+ try:
+ count = _ingest_urls_with_firecrawl(
+ st.session_state.memori,
+ st.session_state.openai_client,
+ urls,
+ )
+ st.success(
+ f"✅ Ingested {count} documentation page(s) into Memori."
+ )
+ except Exception as e:
+ st.error(f"❌ Ingestion error: {e}")
+
+ st.markdown("---")
+ st.markdown("### 💡 About the Agent")
+ st.markdown(
+ """
+ This agent answers customer-support questions for **your own product or company**:
+ - Docs, FAQs, services, pricing, and onboarding flows
+ - Product capabilities and common troubleshooting steps
+
+ Knowledge is built from whatever documentation URLs you ingest
+ (e.g. `https://docs.yourcompany.com`) via **Firecrawl** and stored in **Memori v3**.
+
+ Responses are powered by **OpenAI GPT-4o** and can optionally be read aloud using **OpenAI TTS**.
+ """
+ )
+
+ if "openai_client" not in st.session_state:
+ st.warning(
+ "⚠️ OPENAI_API_KEY missing or Memori v3 failed to initialize – "
+ "LLM responses will not work."
+ )
+ st.stop()
+
+ client: OpenAI = st.session_state.openai_client
+ mem: Memori = st.session_state.memori
+
+ # Toggle for voice output
+ col_voice, _ = st.columns([1, 3])
+ with col_voice:
+ enable_voice = st.checkbox("🔊 Enable voice responses", value=True)
+
+ # Chat history
+ for message in st.session_state.messages:
+ with st.chat_message(message["role"]):
+ st.markdown(message["content"])
+
+ user_input = st.chat_input("Ask a customer-support question…")
+
+ if user_input:
+ # Display user message
+ st.session_state.messages.append({"role": "user", "content": user_input})
+ with st.chat_message("user"):
+ st.markdown(user_input)
+
+ # Assistant response
+ with st.chat_message("assistant"):
+ with st.spinner("🤔 Thinking with your ingested knowledge…"):
+ try:
+ # (Optional) Use Memori search to fetch relevant Studio1 context
+ kb_snippets = []
+ try:
+ # Only call .search if this Memori instance actually exposes it.
+ if hasattr(mem, "search"):
+ # Limit to most relevant 5 items
+ kb_snippets = mem.search(user_input, limit=5) or []
+ except Exception as search_err:
+ # Non-fatal – the assistant can still answer without KB snippets.
+ st.warning(f"Memori search issue: {search_err}")
+
+ kb_context = ""
+ if kb_snippets:
+ kb_context = "Here are some relevant snippets from the company knowledge base:\n"
+ for snip in kb_snippets:
+ kb_context += f"- {snip}\n"
+
+ # Resolve company name for this request
+ company_name = (
+ st.session_state.get("company_name") or "your company"
+ )
+
+ system_prompt = f"""You are a helpful customer support assistant for {company_name}.
+
+Use ONLY the company's documentation and prior stored content in Memori to answer.
+If something is unclear or not covered, say that it isn't in the docs instead of hallucinating.
+
+Context from the knowledge base (may be partial):
+{kb_context}
+"""
+
+ response = client.chat.completions.create(
+ model="gpt-4o-mini",
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_input},
+ ],
+ )
+ answer = response.choices[0].message.content or ""
+
+ st.session_state.messages.append(
+ {"role": "assistant", "content": answer}
+ )
+ st.markdown(answer)
+
+ # Optional voice output
+ if enable_voice and answer.strip():
+ audio_buf = _synth_audio(answer, client)
+ if audio_buf is not None:
+ audio_bytes = audio_buf.getvalue()
+ st.audio(audio_bytes, format="audio/mp3")
+ except Exception as e:
+ err = f"❌ Error generating answer: {e}"
+ st.session_state.messages.append(
+ {"role": "assistant", "content": err}
+ )
+ st.error(err)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/demos/customer_support_voice_agent/assets/Memori_Logo.png b/demos/customer_support_voice_agent/assets/Memori_Logo.png
new file mode 100644
index 00000000..4b4416b8
Binary files /dev/null and b/demos/customer_support_voice_agent/assets/Memori_Logo.png differ
diff --git a/demos/customer_support_voice_agent/ingest_studio1.py b/demos/customer_support_voice_agent/ingest_studio1.py
new file mode 100644
index 00000000..fb026b98
--- /dev/null
+++ b/demos/customer_support_voice_agent/ingest_studio1.py
@@ -0,0 +1,319 @@
+"""
+Example ingestion script for the original Studio1 Customer Support demo.
+
+Today, the recommended way to ingest docs is via the **Streamlit sidebar**
+(paste URLs and click "Extract & store to Memori"). This script is kept as a
+convenience for rebuilding the Studio1 knowledge base from its public
+docs/marketing site.
+
+It uses Firecrawl to crawl https://www.studio1hq.com/ and ingests the content
+into Memori v3 as a searchable knowledge base.
+"""
+
+import os
+from typing import List
+
+from dotenv import load_dotenv
+from firecrawl import FirecrawlApp
+from memori import Memori
+from openai import OpenAI
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import sessionmaker
+
+
+load_dotenv()
+
+# Primary docs site root used for broad crawling
+STUDIO1_URL = "https://docs.studio1hq.com/"
+
+# High-value pages we always want to ingest explicitly, even if the crawler
+# misses them due to depth/links. This includes services, legal, and marketing
+# "About" pages.
+STUDIO1_STATIC_URLS = [
+ "https://docs.studio1hq.com/about-us",
+ "https://docs.studio1hq.com/faq",
+ "https://docs.studio1hq.com/services/technical-content",
+ "https://docs.studio1hq.com/services/developer-advocacy",
+ "https://docs.studio1hq.com/services/tech-video-production",
+ "https://docs.studio1hq.com/services/audit-services",
+ "https://docs.studio1hq.com/services/organic-campaign",
+ "https://docs.studio1hq.com/services/product-launch",
+ "https://docs.studio1hq.com/services/influencer-management",
+ "https://docs.studio1hq.com/terms-of-use",
+ "https://docs.studio1hq.com/privacy-policy",
+ "https://www.studio1hq.com/about-us",
+]
+
+
+def _init_memori() -> tuple[Memori, OpenAI]:
+ """Initialize Memori v3 with SQLAlchemy + OpenAI, mirroring ai_consultant_agent.
+
+ Returns (Memori instance, OpenAI client) so we can use the registered
+ OpenAI client to drive Memori's automatic ingestion.
+ """
+ openai_key = os.getenv("OPENAI_API_KEY", "")
+ if not openai_key:
+ raise RuntimeError("OPENAI_API_KEY is not set.")
+
+ db_path = os.getenv("SQLITE_DB_PATH", "./memori.sqlite")
+ database_url = f"sqlite:///{db_path}"
+ engine = create_engine(
+ database_url,
+ pool_pre_ping=True,
+ connect_args={"check_same_thread": False},
+ )
+
+ # Optional connectivity check
+ with engine.connect() as conn:
+ conn.execute(text("SELECT 1"))
+
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+ client = OpenAI(api_key=openai_key)
+ mem = Memori(conn=SessionLocal).openai.register(client)
+ mem.attribution(entity_id="studio1-support-kb", process_id="studio1-ingest")
+ mem.config.storage.build()
+ return mem, client
+
+
+def _crawl_studio1() -> List[dict]:
+ """Use Firecrawl to crawl the Studio1 docs site and return extracted pages."""
+ firecrawl_key = os.getenv("FIRECRAWL_API_KEY", "")
+ if not firecrawl_key:
+ raise RuntimeError("FIRECRAWL_API_KEY is not set.")
+
+ app = FirecrawlApp(api_key=firecrawl_key)
+
+ # Basic crawl config; this can be tuned later.
+ # Modern Firecrawl Python SDK exposes a `crawl(url, *, limit, scrape_options, ...)`
+ # signature and returns a Pydantic model (e.g. CrawlStatus / CrawlJob).
+ limit = 50
+ # Firecrawl v2 expects `scrape_options.formats` to be a ScrapeFormats model
+ # or a list of *supported* format literals. "text" is not a valid format,
+ # so we just request markdown + HTML and derive plain text ourselves later.
+ scrape_options = {
+ "formats": ["markdown", "html"],
+ "onlyMainContent": True,
+ }
+
+ if hasattr(app, "crawl_url"):
+ # Some SDK variants expose `crawl_url`; keep this for backwards-compat.
+ job = app.crawl_url(
+ url=STUDIO1_URL,
+ limit=limit,
+ scrape_options=scrape_options,
+ )
+ elif hasattr(app, "crawl"):
+ # Preferred modern API, matching docs.firecrawl.dev.
+ job = app.crawl(
+ STUDIO1_URL,
+ limit=limit,
+ scrape_options=scrape_options,
+ )
+ else:
+ raise RuntimeError(
+ "Installed Firecrawl client has neither `crawl_url` nor `crawl` method. "
+ "Please check your `firecrawl-py` version and docs."
+ )
+
+ # Normalize Firecrawl response into a list of page dicts.
+ # - Newer SDKs return a Pydantic model (e.g. CrawlJob / CrawlStatus)
+ # - Older SDKs may return a plain dict with a 'data' key
+ if isinstance(job, dict):
+ pages = job.get("data") or job.get("pages") or job
+ else:
+ # Pydantic models don't support `.get`, but do support attributes and `.model_dump()`.
+ pages = (
+ getattr(job, "data", None)
+ or getattr(job, "pages", None)
+ or getattr(job, "results", None)
+ )
+ if pages is None:
+ if hasattr(job, "model_dump"):
+ # Pydantic v2
+ data = job.model_dump()
+ elif hasattr(job, "dict"):
+ # Backwards compat with Pydantic v1
+ data = job.dict()
+ else:
+ raise RuntimeError(
+ f"Unexpected Firecrawl response type (no data/pages/results): {type(job)}"
+ )
+ pages = data.get("data") or data.get("pages") or data.get("results") or data
+
+ if not isinstance(pages, list):
+ raise RuntimeError(f"Unexpected Firecrawl response format: {type(pages)}")
+ return pages
+
+
+def _scrape_static_pages() -> List[dict]:
+ """Use Firecrawl to scrape a fixed list of high-value Studio1 URLs."""
+ firecrawl_key = os.getenv("FIRECRAWL_API_KEY", "")
+ if not firecrawl_key:
+ raise RuntimeError("FIRECRAWL_API_KEY is not set.")
+
+ app = FirecrawlApp(api_key=firecrawl_key)
+ results: List[dict] = []
+
+ for url in STUDIO1_STATIC_URLS:
+ try:
+ if hasattr(app, "scrape"):
+ doc = app.scrape(
+ url=url,
+ formats=["markdown", "html"],
+ onlyMainContent=True,
+ )
+ elif hasattr(app, "scrape_url"):
+ # Backwards-compat for older SDKs
+ doc = app.scrape_url(
+ url=url,
+ scrape_options={
+ "formats": ["markdown", "html"],
+ "onlyMainContent": True,
+ },
+ )
+ else:
+ raise RuntimeError(
+ "Installed Firecrawl client has neither `scrape` nor `scrape_url`."
+ )
+
+ # Normalise into a dict similar to crawl results
+ if isinstance(doc, dict):
+ data = doc.get("data") or doc
+ else:
+ if hasattr(doc, "model_dump"):
+ data = doc.model_dump()
+ elif hasattr(doc, "dict"):
+ data = doc.dict()
+ else:
+ data = doc
+
+ # Some SDKs wrap the page document under "data"
+ if isinstance(data, dict) and "markdown" in data or "html" in data:
+ page = data
+ elif isinstance(data, dict) and isinstance(data.get("data"), dict):
+ page = data["data"]
+ else:
+ # Fallback – if it's already shaped like a page list, skip here
+ if isinstance(data, list):
+ # Append each if we somehow got multiple docs back
+ for p in data:
+ if isinstance(p, dict):
+ results.append(p)
+ continue
+ page = data
+
+ if isinstance(page, dict):
+ # Ensure URL is set for deduplication later
+ page.setdefault("url", url)
+ results.append(page)
+ except Exception as e:
+ print(f"[Firecrawl] Warning: could not scrape {url}: {e}")
+
+ return results
+
+
+def ingest():
+ mem, client = _init_memori()
+ crawled_pages = _crawl_studio1()
+ static_pages = _scrape_static_pages()
+
+ # Deduplicate by URL to avoid double-ingesting the same page from crawl+scrape
+ pages: List[dict] = []
+ seen_urls = set()
+
+ def _add_pages(src_pages: List[dict]):
+ for p in src_pages:
+ url = None
+ if isinstance(p, dict):
+ meta = p.get("metadata") or {}
+ url = p.get("url") or meta.get("sourceURL")
+ if not url:
+ # Fallback to id of object to prevent accidental merge
+ key = id(p)
+ else:
+ key = url
+ if key in seen_urls:
+ continue
+ seen_urls.add(key)
+ pages.append(p)
+
+ _add_pages(crawled_pages)
+ _add_pages(static_pages)
+
+ print(f"Fetched {len(pages)} pages from Studio1 docs + static URLs")
+
+ for idx, page in enumerate(pages, start=1):
+ # Firecrawl v2 returns Pydantic models (e.g. Document) rather than plain dicts.
+ # Normalize each item into a dict first.
+ if isinstance(page, dict):
+ page_dict = page
+ else:
+ if hasattr(page, "model_dump"):
+ page_dict = page.model_dump()
+ elif hasattr(page, "dict"):
+ page_dict = page.dict()
+ else:
+ raise RuntimeError(f"Unexpected page type from Firecrawl: {type(page)}")
+
+ metadata = page_dict.get("metadata") or {}
+ url = page_dict.get("url") or metadata.get("sourceURL") or STUDIO1_URL
+
+ markdown = (
+ page_dict.get("markdown")
+ or page_dict.get("text")
+ or page_dict.get("content")
+ or ""
+ )
+ if not markdown:
+ continue
+
+ title = page_dict.get("title") or metadata.get("title") or f"Studio1 Page {idx}"
+
+ doc_text = f"""Studio1 Documentation Page
+Title: {title}
+URL: {url}
+
+Content:
+{markdown}
+"""
+ print(f"Ingesting page {idx}: {url}")
+
+ # Use the registered OpenAI client so Memori can automatically
+ # capture this as a "conversation" / memory. We keep the prompt
+ # lightweight and ask the model to simply acknowledge storage.
+ try:
+ _ = client.chat.completions.create(
+ model="gpt-4o-mini",
+ messages=[
+ {
+ "role": "user",
+ "content": (
+ "Store the following Studio1 documentation page in "
+ "memory for future retrieval. Respond with a short "
+ "acknowledgement only.\n\n"
+ f"{doc_text}"
+ ),
+ }
+ ],
+ )
+ except Exception as record_err:
+ # Don't abort ingestion if a single record fails; just log it.
+ print(f"[Memori] Could not index page {idx} ({url}): {record_err}")
+
+ # Ensure any buffered writes are flushed to the backing store.
+ try:
+ adapter = getattr(mem.config.storage, "adapter", None)
+ if adapter is not None:
+ adapter.commit()
+ adapter.close()
+ except Exception as final_err:
+ print(
+ f"[Memori] Warning: issue committing/closing storage adapter: {final_err}"
+ )
+
+ print("Ingestion complete.")
+
+
+if __name__ == "__main__":
+ ingest()
diff --git a/demos/customer_support_voice_agent/pyproject.toml b/demos/customer_support_voice_agent/pyproject.toml
new file mode 100644
index 00000000..16c4748e
--- /dev/null
+++ b/demos/customer_support_voice_agent/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "customer_support_voice_agent"
+version = "0.1.0"
+description = "Customer Support Voice Agent for Studio1 using Memori v3, Firecrawl, and OpenAI GPT-4o + TTS."
+requires-python = ">=3.10"
+dependencies = [
+ "streamlit>=1.36.0",
+ "python-dotenv>=1.0.1",
+ "sqlalchemy>=2.0.25",
+ "memori>=0.3.0", # Adjust to the version you use elsewhere in this repo
+ "openai>=1.40.0",
+ "firecrawl-py>=0.1.0", # Firecrawl Python SDK
+ "pydantic>=2.7.0",
+]
+
+[project.optional-dependencies]
+dev = ["black", "ruff", "mypy"]
+
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+
diff --git a/demos/customer_support_voice_agent/requirements.txt b/demos/customer_support_voice_agent/requirements.txt
new file mode 100644
index 00000000..3fff1219
--- /dev/null
+++ b/demos/customer_support_voice_agent/requirements.txt
@@ -0,0 +1,9 @@
+streamlit>=1.36.0
+python-dotenv>=1.0.1
+sqlalchemy>=2.0.25
+memori>=0.3.0
+openai>=1.40.0
+firecrawl-py>=0.1.0
+pydantic>=2.7.0
+
+