Compare commits

...
Author SHA1 Message Date
Ben cfc162d4e3 fix(vapi): format with repo ruff config (line-length 120) 2026-05-28 15:33:58 -04:00
Ben 43cd5d189a fix(vapi): apply ruff format and fix line-length lint errors 2026-05-28 15:25:27 -04:00
Ben 5f24bfbe78 docs(vapi): prioritize Hindsight Cloud in Quick Start; fix pre-existing ty-embed error 2026-05-28 15:15:12 -04:00
Ben 672ce5aaa6 chore(vapi): add vapi icon for integrations listing 2026-05-28 15:15:12 -04:00
Ben bee0778b48 feat(vapi): add README, LICENSE, CHANGELOG, docs page, example, and metadata
- README.md: full usage docs with FastAPI/outbound/config examples
- LICENSE: MIT
- CHANGELOG.md: initial 0.1.0 release notes
- examples/interactive_webhook.py: manual REPL for testing retain/recall cycle without a real Vapi account
- hindsight-docs/docs-integrations/vapi.md: docs page for the integrations site
- hindsight-docs/src/data/integrations.json: entry so Vapi appears on the integrations listing
- pyproject.toml: added license, authors, keywords, classifiers, URLs for PyPI
- tests/test_webhook.py: use AsyncMock to silence spurious 'coroutine never awaited' warning

Verified: 20/20 unit tests pass; interactive webhook simulator validates full retain -> recall cycle against live Hindsight.
2026-05-28 15:15:12 -04:00
Ben 5317323fdd feat(vapi): add Vapi voice AI webhook memory integration 2026-05-28 15:15:12 -04:00
17 changed files with 2331 additions and 1 deletions
+41
View File
@@ -50,6 +50,7 @@ jobs:
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
integrations-dify: ${{ steps.filter.outputs.integrations-dify }}
integrations-gemini-spark: ${{ steps.filter.outputs.integrations-gemini-spark }}
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
@@ -146,6 +147,8 @@ jobs:
- 'hindsight-integrations/dify/**'
integrations-gemini-spark:
- 'hindsight-integrations/gemini-spark/**'
integrations-vapi:
- 'hindsight-integrations/vapi/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
dev:
@@ -2768,6 +2771,43 @@ jobs:
working-directory: ./hindsight-integrations/crewai
run: uv run pytest tests -v
test-vapi-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-vapi == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build vapi integration
working-directory: ./hindsight-integrations/vapi
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/vapi
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/vapi
run: uv run pytest tests -v
test-litellm-integration:
needs: [detect-changes]
if: >-
@@ -3774,6 +3814,7 @@ jobs:
- test-paperclip-integration
- test-pipecat-integration
- test-gemini-spark-integration
- test-vapi-integration
- build-control-plane
- build-docs
- test-rust-cli
+135
View File
@@ -0,0 +1,135 @@
---
sidebar_position: 21
title: "Vapi Persistent Memory with Hindsight | Integration"
description: "Add persistent long-term memory to Vapi voice AI calls via Hindsight webhooks. Auto-recalls caller context at call start and retains the transcript when the call ends."
---
# Vapi
Persistent long-term memory for [Vapi](https://vapi.ai) voice AI calls via [Hindsight](https://vectorize.io/hindsight). A single webhook handler recalls relevant memories at call start (injected as `assistantOverrides`) and retains the full transcript when the call ends.
## Quick Start
:::tip Hindsight Cloud (recommended)
[Sign up free](https://ui.hindsight.vectorize.io/signup) — get an API key instantly, no infrastructure to run.
:::
```bash
pip install hindsight-vapi
```
Wire it into any HTTP server. FastAPI example with Hindsight Cloud:
```python
from fastapi import FastAPI, Request
from hindsight_vapi import HindsightVapiWebhook
app = FastAPI()
memory = HindsightVapiWebhook(
bank_id="user-123",
hindsight_api_url="https://api.hindsight.vectorize.io",
api_key="hsk_your_token_here",
)
@app.post("/webhook")
async def vapi_webhook(request: Request):
event = await request.json()
response = await memory.handle(event)
return response or {}
```
Point Vapi's **Server URL** at your webhook endpoint and memory is active.
**Self-hosting alternative:** [install Hindsight locally](/developer/installation) and use `hindsight_api_url="http://localhost:8888"` (omit `api_key`).
## How It Works
Unlike the Pipecat integration (per-turn `FrameProcessor`), Vapi doesn't expose a per-turn hook, so memory is injected **once per call** at call start:
```
Incoming call
└─ Vapi fires "assistant-request" webhook
└─ Recall memories (query = caller's phone number)
└─ Return as assistantOverrides with <hindsight_memories> system message
└─ Vapi merges into assistant config before the call begins
Call ends
└─ Vapi fires "end-of-call-report" webhook
└─ Retain full transcript (fire-and-forget — webhook responds immediately)
```
Memory accumulates across calls. By the second or third call with the same caller, Hindsight surfaces relevant history automatically — previous decisions, account details, stated preferences.
## Vapi Server URL Setup
In the Vapi dashboard:
1. Go to **Settings → Server URL**
2. Point it at your webhook endpoint (e.g., `https://your-domain.com/webhook`)
3. Enable the `assistant-request` and `end-of-call-report` event types
See [Vapi's server events docs](https://docs.vapi.ai/server-url) for details.
## Outbound Calls
There is no `assistant-request` webhook for outbound calls. Use `build_assistant_overrides()` at call-creation time:
```python
overrides = await memory.build_assistant_overrides("Ben from Vectorize")
vapi.calls.create(
assistant_id="...",
assistant_overrides=overrides,
customer={"number": "+15555550100"},
)
```
## Configuration
```python
HindsightVapiWebhook(
bank_id="user-123", # Required: memory bank to use
hindsight_api_url="...", # Hindsight API URL
api_key="hsk_...", # API key (Hindsight Cloud)
recall_budget="mid", # "low", "mid", or "high"
recall_max_tokens=4096, # Max tokens for recall results
enable_recall=True, # Inject memories at call start
enable_retain=True, # Store transcript at call end
memory_prefix="Relevant memories from past conversations:\n",
)
```
### Global Configuration
```python
from hindsight_vapi import configure
configure(
hindsight_api_url="http://localhost:8888",
api_key="hsk_...",
recall_budget="mid",
)
# Now create webhooks without repeating connection details
memory = HindsightVapiWebhook(bank_id="user-123")
```
## Bank Scoping
Typical patterns for the `bank_id`:
- **One bank per user** — scope by phone number (`user-+15551234567`) or your own account ID
- **Shared bank** — one bank for all callers (useful for small teams or shared memory)
- **Per-assistant** — if you have multiple Vapi assistants with different personalities or scopes
## Prerequisites
A running Hindsight instance:
**Self-hosted:**
```bash
pip install hindsight-all
export HINDSIGHT_API_LLM_API_KEY=your-api-key
hindsight-api # starts on http://localhost:8888
```
**Hindsight Cloud:** [Sign up](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
+10
View File
@@ -290,6 +290,16 @@
"link": "/sdks/integrations/dify",
"icon": "/img/icons/dify.png"
},
{
"id": "vapi",
"name": "Vapi",
"description": "Persistent memory for Vapi voice AI calls via Hindsight webhooks. Recalls caller context at call start and retains the transcript when the call ends.",
"type": "official",
"by": "hindsight",
"category": "tool",
"link": "/sdks/integrations/vapi",
"icon": "/img/icons/vapi.png"
},
{
"id": "hindclaw",
"name": "HindClaw",
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+11
View File
@@ -0,0 +1,11 @@
# Changelog
## 0.1.0 (2026-04-24)
- Initial release: `HindsightVapiWebhook` for adding persistent memory to Vapi voice calls
- `assistant-request` handler recalls memories by caller phone number and returns `assistantOverrides` with a system message
- `end-of-call-report` handler retains the full transcript to Hindsight (fire-and-forget)
- `build_assistant_overrides()` helper for outbound calls where there is no `assistant-request` webhook
- Configurable recall budget (`low`, `mid`, `high`) and token limit
- Global `configure()` helper for shared connection settings
- Framework-agnostic: wires into any HTTP server (FastAPI, Flask, aiohttp, etc.) in two lines
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Vectorize AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+132
View File
@@ -0,0 +1,132 @@
# Hindsight Vapi Integration
Persistent long-term memory for [Vapi](https://vapi.ai) voice AI calls via [Hindsight](https://vectorize.io/hindsight). A single webhook handler recalls relevant memories at call start (injected as `assistantOverrides`) and retains the full transcript when the call ends.
## Quick Start
> ✨ **Recommended:** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) — sign up free, get an API key, and skip the self-hosting setup entirely.
```bash
pip install hindsight-vapi
```
```python
from fastapi import FastAPI, Request
from hindsight_vapi import HindsightVapiWebhook
app = FastAPI()
memory = HindsightVapiWebhook(
bank_id="user-123",
hindsight_api_url="https://api.hindsight.vectorize.io",
api_key="hsk_your_token_here",
)
@app.post("/webhook")
async def vapi_webhook(request: Request):
event = await request.json()
response = await memory.handle(event)
return response or {}
```
Point Vapi's Server URL at this endpoint and memory is active.
**Self-hosting alternative:** replace the URL with `http://localhost:8888` and omit the `api_key`.
## How It Works
Unlike Pipecat (per-turn FrameProcessor), Vapi doesn't expose a per-turn hook, so memory is injected **once per call** at call start:
```
Incoming call
└─ Vapi fires "assistant-request" webhook
└─ Recall memories (query = caller's phone number)
└─ Return as assistantOverrides with <hindsight_memories> system message
└─ Vapi merges into assistant config before the call begins
Call ends
└─ Vapi fires "end-of-call-report" webhook
└─ Retain full transcript (fire-and-forget — webhook responds immediately)
```
Memory accumulates across calls. By the second or third call with the same caller, Hindsight surfaces relevant history automatically.
## Outbound Calls
There is no `assistant-request` webhook for outbound calls. Use `build_assistant_overrides()` at call-creation time:
```python
overrides = await memory.build_assistant_overrides("Ben from Vectorize")
vapi.calls.create(
assistant_id="...",
assistant_overrides=overrides,
customer={"number": "+15555550100"},
)
```
## Prerequisites
A running Hindsight instance:
**Self-hosted:**
```bash
pip install hindsight-all
export HINDSIGHT_API_LLM_API_KEY=your-api-key
hindsight-api # starts on http://localhost:8888
```
**Hindsight Cloud:** [Sign up](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
## Configuration
```python
HindsightVapiWebhook(
bank_id="user-123", # Required: memory bank to use
hindsight_api_url="...", # Hindsight API URL
api_key="hsk_...", # API key (Hindsight Cloud)
recall_budget="mid", # "low", "mid", or "high"
recall_max_tokens=4096, # Max tokens for recall results
enable_recall=True, # Inject memories at call start
enable_retain=True, # Store transcript at call end
memory_prefix="Relevant memories from past conversations:\n",
)
```
### Global Configuration
```python
from hindsight_vapi import configure
configure(
hindsight_api_url="http://localhost:8888",
api_key="hsk_...",
recall_budget="mid",
)
# Now create webhooks without repeating connection details
memory = HindsightVapiWebhook(bank_id="user-123")
```
## Vapi Setup
1. In the Vapi dashboard, set your **Server URL** to your webhook endpoint
2. Enable the `assistant-request` and `end-of-call-report` event types
3. For inbound calls, memory is recalled automatically when Vapi fires `assistant-request`
See [Vapi's server events docs](https://docs.vapi.ai/server-url) for details.
## Manual Testing
The `examples/` directory includes an interactive webhook simulator for testing without a real Vapi account:
```bash
python examples/interactive_webhook.py --bank demo-user
```
Commands: `:script` (guided demo), `:end <transcript>`, `:call <number>`, `:memories`, `:quit`.
## Running Tests
```bash
uv sync
uv run pytest tests/ -v
```
@@ -0,0 +1,212 @@
"""Interactive Vapi webhook simulator — test HindsightVapiWebhook by hand.
Simulates Vapi's webhook events (assistant-request + end-of-call-report) so you
can watch memory retain/recall happen in real time without a real Vapi account
or phone number.
Usage:
python examples/interactive_webhook.py --bank vapi-demo
python examples/interactive_webhook.py --bank vapi-demo --hindsight-url http://localhost:8888
Commands:
:call <caller-number> Simulate assistant-request (incoming call)
:end <transcript> Simulate end-of-call-report (call ends with transcript)
:script Run a scripted demo: 1 call ends → wait → next call recalls
:memories Dump all memories in the bank
:bank Show current bank id
:quit / :q Exit
Example:
vapi> :end User: My name is Alex. Assistant: Hi Alex! User: I prefer email. Assistant: Got it.
vapi> :memories
vapi> :call +15551234567
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from hindsight_vapi import HindsightVapiWebhook
BLUE = "\033[94m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
DIM = "\033[2m"
RESET = "\033[0m"
def banner(label: str, color: str = CYAN) -> None:
print(f"{color}[{label}]{RESET}", end=" ")
def dump_memories(url: str, bank: str, api_key: str | None) -> None:
req = urllib.request.Request(f"{url}/v1/default/banks/{bank}/memories/list")
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
items = data.get("items", [])
print(f"\n{YELLOW}=== Bank '{bank}'{len(items)} memories ==={RESET}")
for i, m in enumerate(items, 1):
print(f"{i}. {m.get('text', '')[:200]}")
if not items:
print("(empty)")
print()
except Exception as e:
print(f"{YELLOW}Could not list memories: {e}{RESET}")
def make_assistant_request(caller_number: str | None) -> dict:
"""Build a fake Vapi assistant-request webhook payload."""
return {
"message": {
"type": "assistant-request",
"call": {
"customer": {"number": caller_number} if caller_number else {},
},
}
}
def make_end_of_call(transcript: str) -> dict:
"""Build a fake Vapi end-of-call-report webhook payload."""
return {
"message": {
"type": "end-of-call-report",
"artifact": {"transcript": transcript},
}
}
async def cmd_end_call(webhook: HindsightVapiWebhook, transcript: str, wait_seconds: int = 8) -> None:
banner("WEBHOOK: end-of-call-report", CYAN)
print(f"transcript length: {len(transcript)} chars")
event = make_end_of_call(transcript)
response = await webhook.handle(event)
banner("RESPONSE", GREEN)
print(response if response else "None (HTTP 200, no body)")
banner("RETAIN", GREEN)
print(f"{DIM}fire-and-forget task scheduled{RESET}")
# Give the async retain task a chance to actually fire and let Hindsight
# extract facts. Without this wait the input() prompt blocks the event
# loop before the task can make progress.
print(f"{DIM}waiting {wait_seconds}s for retain + fact extraction...{RESET}")
for _ in range(wait_seconds):
await asyncio.sleep(1)
print(f"{DIM}done — try :memories to see what was extracted{RESET}")
async def cmd_assistant_request(webhook: HindsightVapiWebhook, caller: str | None) -> None:
banner("WEBHOOK: assistant-request", CYAN)
print(f"caller: {caller or '(none)'}")
event = make_assistant_request(caller)
response = await webhook.handle(event)
banner("RESPONSE", GREEN)
if not response:
print(f"{DIM}empty {{}} — no memories matched the recall query{RESET}")
return
# Pretty-print the assistantOverrides structure
print(json.dumps(response, indent=2)[:800])
overrides = response.get("assistantOverrides", {})
msgs = overrides.get("model", {}).get("messages", [])
if msgs:
banner("INJECTED SYSTEM PROMPT", CYAN)
content = msgs[0].get("content", "")
print(content[:500] + ("..." if len(content) > 500 else ""))
async def cmd_script(webhook: HindsightVapiWebhook, url: str, bank: str, api_key: str | None) -> None:
"""Run a scripted demo that proves the full retain → recall cycle."""
print(f"\n{GREEN}=== Scripted demo: Alex's first + second call ==={RESET}\n")
# Call 1 ends with a transcript
print(f"{DIM}>>> Step 1: Call 1 ends (end-of-call-report){RESET}")
transcript = (
"User: Hi, my name is Alex and I'm calling from New York. "
"Assistant: Nice to meet you Alex! How can I help? "
"User: I prefer email updates over phone calls, and my account number is A-12345. "
"Assistant: Got it — email updates, account A-12345. Anything else? "
"User: No that's all, thanks. "
"Assistant: Have a great day!"
)
await cmd_end_call(webhook, transcript)
# Wait for async extraction
print(f"\n{DIM}>>> Step 2: Waiting 8s for async retain + fact extraction...{RESET}")
await asyncio.sleep(8)
dump_memories(url, bank, api_key)
# Call 2 starts — should recall Alex's prefs
print(f"{DIM}>>> Step 3: Call 2 starts (assistant-request) — should recall Alex{RESET}")
await cmd_assistant_request(webhook, "+15551234567")
print(f"\n{GREEN}=== Demo complete ==={RESET}\n")
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--bank", default=f"vapi-demo-{os.environ.get('USER', 'anon')}")
parser.add_argument("--hindsight-url", default=os.environ.get("HINDSIGHT_API_URL", "http://localhost:8888"))
parser.add_argument("--hindsight-api-key", default=os.environ.get("HINDSIGHT_API_KEY"))
args = parser.parse_args()
webhook = HindsightVapiWebhook(
bank_id=args.bank,
hindsight_api_url=args.hindsight_url,
api_key=args.hindsight_api_key,
)
print(f"\n{GREEN}=== Vapi + Hindsight Interactive Webhook Simulator ==={RESET}")
print(f"Bank: {args.bank}")
print(f"Hindsight: {args.hindsight_url}")
cmds = ":call <number> :end <transcript> :script :memories :bank :quit"
print(f"\nCommands: {DIM}{cmds}{RESET}\n")
while True:
try:
line = input(f"{BLUE}vapi> {RESET}").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line in (":quit", ":q", ":exit"):
break
if line == ":memories":
dump_memories(args.hindsight_url, args.bank, args.hindsight_api_key)
continue
if line == ":bank":
print(f"{YELLOW}bank: {args.bank}{RESET}")
continue
if line == ":script":
await cmd_script(webhook, args.hindsight_url, args.bank, args.hindsight_api_key)
continue
if line.startswith(":call"):
parts = line.split(maxsplit=1)
caller = parts[1].strip() if len(parts) > 1 else None
await cmd_assistant_request(webhook, caller)
continue
if line.startswith(":end"):
parts = line.split(maxsplit=1)
if len(parts) < 2:
print(f"{YELLOW}Usage: :end <transcript text>{RESET}")
continue
await cmd_end_call(webhook, parts[1])
continue
print(f"{YELLOW}Unknown command. Try :script for a guided demo, or :call/:end.{RESET}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,43 @@
"""Hindsight-Vapi: Persistent memory for Vapi voice AI calls.
Provides a webhook handler that adds Hindsight long-term memory to Vapi
voice calls — recalling relevant context at call start and retaining the
transcript when the call ends.
Basic usage::
from fastapi import FastAPI, Request
from hindsight_vapi import HindsightVapiWebhook
app = FastAPI()
memory = HindsightVapiWebhook(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)
@app.post("/webhook")
async def vapi_webhook(request: Request):
event = await request.json()
response = await memory.handle(event)
return response or {}
"""
from .config import (
HindsightVapiConfig,
configure,
get_config,
reset_config,
)
from .errors import HindsightVapiError
from .webhook import HindsightVapiWebhook
__version__ = "0.1.0"
__all__ = [
"configure",
"get_config",
"reset_config",
"HindsightVapiConfig",
"HindsightVapiError",
"HindsightVapiWebhook",
]
@@ -0,0 +1,72 @@
"""Global configuration for the Hindsight-Vapi integration."""
from __future__ import annotations
import os
from dataclasses import dataclass
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
@dataclass
class HindsightVapiConfig:
"""Connection and default settings for the Vapi integration.
Attributes:
hindsight_api_url: URL of the Hindsight API server.
api_key: API key for Hindsight authentication.
recall_budget: Default recall budget level (low/mid/high).
recall_max_tokens: Default maximum tokens for recall results.
"""
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
api_key: str | None = None
recall_budget: str = "mid"
recall_max_tokens: int = 4096
_global_config: HindsightVapiConfig | None = None
def configure(
hindsight_api_url: str | None = None,
api_key: str | None = None,
recall_budget: str = "mid",
recall_max_tokens: int = 4096,
) -> HindsightVapiConfig:
"""Configure Hindsight connection and default settings.
Args:
hindsight_api_url: Hindsight API URL (default: production).
api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
recall_budget: Default recall budget (low/mid/high).
recall_max_tokens: Default max tokens for recall.
Returns:
The configured HindsightVapiConfig.
"""
global _global_config
resolved_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
_global_config = HindsightVapiConfig(
hindsight_api_url=resolved_url,
api_key=resolved_key,
recall_budget=recall_budget,
recall_max_tokens=recall_max_tokens,
)
return _global_config
def get_config() -> HindsightVapiConfig | None:
"""Get the current global configuration."""
return _global_config
def reset_config() -> None:
"""Reset global configuration to None."""
global _global_config
_global_config = None
@@ -0,0 +1,7 @@
"""Hindsight-Vapi error types."""
class HindsightVapiError(Exception):
"""Exception raised when a Hindsight memory operation fails."""
pass
@@ -0,0 +1,232 @@
"""Hindsight memory webhook handler for Vapi voice AI.
Processes Vapi server events to add persistent memory to voice calls:
- ``assistant-request``: recalled memories are injected into the assistant's
system prompt via ``assistantOverrides`` returned in the webhook response.
- ``end-of-call-report``: the full call transcript is retained to Hindsight
asynchronously (fire-and-forget) so it never blocks the webhook response.
Unlike Pipecat (per-turn injection), Vapi memory is injected **once per call**
at call start — there is no per-turn hook in Vapi's architecture.
Basic usage with FastAPI::
from fastapi import FastAPI, Request
from hindsight_vapi import HindsightVapiWebhook
app = FastAPI()
memory = HindsightVapiWebhook(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)
@app.post("/webhook")
async def vapi_webhook(request: Request):
event = await request.json()
response = await memory.handle(event)
return response or {}
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from hindsight_client import Hindsight
from .config import get_config
from .errors import HindsightVapiError
logger = logging.getLogger(__name__)
_MEMORY_MARKER = "<hindsight_memories>"
def _resolve_client(
client: Hindsight | None,
hindsight_api_url: str | None,
api_key: str | None,
) -> Hindsight:
"""Resolve a Hindsight client from explicit args or global config."""
if client is not None:
return client
config = get_config()
url = hindsight_api_url or (config.hindsight_api_url if config else None)
key = api_key or (config.api_key if config else None)
if url is None:
raise HindsightVapiError(
"No Hindsight API URL configured. Pass client= or hindsight_api_url=, or call configure() first."
)
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0}
if key:
kwargs["api_key"] = key
return Hindsight(**kwargs)
class HindsightVapiWebhook:
"""Webhook handler that adds Hindsight persistent memory to Vapi voice calls.
Handles two Vapi server events:
- ``assistant-request``: Recalls relevant memories for the caller and
returns ``assistantOverrides`` containing a system message with those
memories. Vapi merges the overrides into the active assistant config.
- ``end-of-call-report``: Retains the full call transcript to Hindsight
asynchronously (fire-and-forget). The webhook response is not delayed.
All other event types are ignored (returns ``None``, Vapi expects HTTP 200).
Args:
bank_id: Hindsight memory bank to read from and write to.
client: Pre-configured Hindsight client (preferred).
hindsight_api_url: API URL (used if no client provided).
api_key: API key for Hindsight Cloud.
recall_budget: Recall budget level — ``"low"``, ``"mid"``, ``"high"``.
recall_max_tokens: Maximum tokens for recall results.
enable_recall: Inject recalled memories into the assistant system prompt.
enable_retain: Store call transcripts after each call ends.
memory_prefix: Text prepended inside the recalled memory block.
"""
def __init__(
self,
bank_id: str,
*,
client: Hindsight | None = None,
hindsight_api_url: str | None = None,
api_key: str | None = None,
recall_budget: str = "mid",
recall_max_tokens: int = 4096,
enable_recall: bool = True,
enable_retain: bool = True,
memory_prefix: str = "Relevant memories from past conversations:\n",
) -> None:
self._bank_id = bank_id
self._client = _resolve_client(client, hindsight_api_url, api_key)
config = get_config()
self._recall_budget = recall_budget or (config.recall_budget if config else "mid")
self._recall_max_tokens = recall_max_tokens or (config.recall_max_tokens if config else 4096)
self._enable_recall = enable_recall
self._enable_retain = enable_retain
self._memory_prefix = memory_prefix
async def handle(self, event: dict[str, Any]) -> dict[str, Any] | None:
"""Process a Vapi server event.
Args:
event: The parsed JSON body of the Vapi webhook POST request.
Returns:
A response dict for Vapi (must be returned as the HTTP response body),
or ``None`` for events that require no response body (HTTP 200 OK).
"""
msg = event.get("message", {})
event_type = msg.get("type")
if event_type == "assistant-request":
return await self._handle_assistant_request(msg)
elif event_type == "end-of-call-report":
await self._handle_end_of_call(msg)
return None
async def build_assistant_overrides(self, query: str) -> dict[str, Any]:
"""Build ``assistantOverrides`` for an outbound call.
Use this when creating outbound calls via the Vapi API — there is no
``assistant-request`` webhook for outbound calls, so memories must be
injected at call-creation time::
overrides = await memory.build_assistant_overrides("user preferences")
vapi.calls.create(
assistant_id="...",
assistant_overrides=overrides,
...
)
Args:
query: Query string for memory recall (e.g. the caller's name or
a description of the call topic).
Returns:
A dict suitable for passing as ``assistantOverrides``, or ``{}``
if recall is disabled or returns no results.
"""
if not self._enable_recall:
return {}
memories = await self._recall(query)
if not memories:
return {}
return self._build_overrides(memories)
async def _handle_assistant_request(self, msg: dict[str, Any]) -> dict[str, Any]:
"""Handle ``assistant-request``: recall and return assistantOverrides."""
if not self._enable_recall:
return {}
# Use the caller's phone number as the recall query when available.
caller_number: str | None = msg.get("call", {}).get("customer", {}).get("number")
query = caller_number or "returning caller"
memories = await self._recall(query)
if not memories:
return {}
return self._build_overrides(memories)
async def _handle_end_of_call(self, msg: dict[str, Any]) -> None:
"""Handle ``end-of-call-report``: retain transcript (fire-and-forget)."""
if not self._enable_retain:
return
transcript: str = msg.get("artifact", {}).get("transcript", "")
if transcript:
asyncio.create_task(self._retain(transcript))
def _build_overrides(self, memories: str) -> dict[str, Any]:
"""Build the assistantOverrides dict with a memory system message."""
memory_block = f"{_MEMORY_MARKER}\n{memories}\n</hindsight_memories>"
return {
"assistantOverrides": {
"model": {
"messages": [{"role": "system", "content": memory_block}],
}
}
}
async def _recall(self, query: str) -> str | None:
"""Call Hindsight recall and return a formatted string, or None."""
try:
response = await self._client.arecall(
bank_id=self._bank_id,
query=query,
budget=self._recall_budget,
max_tokens=self._recall_max_tokens,
)
if not response.results:
return None
lines = [self._memory_prefix]
for i, result in enumerate(response.results, 1):
lines.append(f"{i}. {result.text}")
return "\n".join(lines)
except Exception as e:
logger.warning(f"Hindsight recall failed (continuing without memories): {e}")
return None
async def _retain(self, content: str) -> None:
"""Call Hindsight retain (fire-and-forget — errors are logged and swallowed)."""
try:
await self._client.aretain(
bank_id=self._bank_id,
content=content,
)
except Exception as e:
logger.warning(f"Hindsight retain failed: {e}")
@@ -0,0 +1,60 @@
[project]
name = "hindsight-vapi"
version = "0.1.0"
description = "Hindsight persistent memory integration for Vapi voice AI"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "Vectorize", email = "[email protected]" }
]
keywords = [
"ai",
"memory",
"vapi",
"voice",
"agents",
"hindsight",
"webhook",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"hindsight-client>=0.4.0",
]
[project.urls]
Homepage = "https://vectorize.io/hindsight"
Repository = "https://github.com/vectorize-io/hindsight"
Documentation = "https://hindsight.vectorize.io/sdks/integrations/vapi"
[tool.pytest.ini_options]
asyncio_mode = "auto"
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=0.24",
"ruff>=0.8.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_vapi"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
@@ -0,0 +1,245 @@
"""Unit tests for HindsightVapiWebhook."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_vapi import HindsightVapiWebhook, configure, reset_config
from hindsight_vapi.errors import HindsightVapiError
from hindsight_vapi.webhook import _resolve_client
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_client(results: list[str] | None = None) -> MagicMock:
"""Return a mock Hindsight client with preset recall results."""
client = MagicMock()
if results:
mock_results = [MagicMock(text=r) for r in results]
client.arecall = AsyncMock(return_value=MagicMock(results=mock_results))
else:
client.arecall = AsyncMock(return_value=MagicMock(results=[]))
client.aretain = AsyncMock(return_value=None)
return client
def _make_assistant_request(caller_number: str | None = "+15555550100") -> dict[str, Any]:
call: dict[str, Any] = {}
if caller_number is not None:
call["customer"] = {"number": caller_number}
return {"message": {"type": "assistant-request", "call": call}}
def _make_end_of_call(transcript: str = "User: hi\nAssistant: hello") -> dict[str, Any]:
return {
"message": {
"type": "end-of-call-report",
"artifact": {"transcript": transcript},
}
}
# ---------------------------------------------------------------------------
# TestResolveClient
# ---------------------------------------------------------------------------
class TestResolveClient:
def setup_method(self) -> None:
reset_config()
def test_returns_explicit_client(self) -> None:
client = MagicMock()
result = _resolve_client(client, None, None)
assert result is client
def test_creates_client_from_url(self) -> None:
with patch("hindsight_vapi.webhook.Hindsight") as mock_cls:
_resolve_client(None, "http://localhost:8888", None)
mock_cls.assert_called_once()
def test_raises_when_no_url(self) -> None:
with pytest.raises(HindsightVapiError, match="No Hindsight API URL"):
_resolve_client(None, None, None)
def test_uses_global_config_url(self) -> None:
configure(hindsight_api_url="http://configured:8888")
with patch("hindsight_vapi.webhook.Hindsight") as mock_cls:
_resolve_client(None, None, None)
mock_cls.assert_called_once()
reset_config()
# ---------------------------------------------------------------------------
# TestAssistantRequest
# ---------------------------------------------------------------------------
class TestAssistantRequest:
def _svc(self, results: list[str] | None = None, **kwargs: Any) -> HindsightVapiWebhook:
client = _make_client(results)
return HindsightVapiWebhook(bank_id="test-bank", client=client, **kwargs)
async def test_memories_injected_into_assistant_overrides(self) -> None:
svc = self._svc(["Caller is Jordan", "Prefers metric units"])
event = _make_assistant_request()
result = await svc.handle(event)
assert result is not None
assert "assistantOverrides" in result
messages = result["assistantOverrides"]["model"]["messages"]
assert len(messages) == 1
assert messages[0]["role"] == "system"
assert "<hindsight_memories>" in messages[0]["content"]
assert "Jordan" in messages[0]["content"]
async def test_empty_recall_returns_empty_dict(self) -> None:
svc = self._svc(results=None)
event = _make_assistant_request()
result = await svc.handle(event)
assert result == {}
async def test_recall_error_swallowed_empty_dict_returned(self) -> None:
client = _make_client()
client.arecall = AsyncMock(side_effect=RuntimeError("network error"))
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
event = _make_assistant_request()
result = await svc.handle(event)
assert result == {}
async def test_caller_number_used_as_recall_query(self) -> None:
client = _make_client(["fact"])
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
event = _make_assistant_request(caller_number="+15555550199")
await svc.handle(event)
client.arecall.assert_called_once()
call_kwargs = client.arecall.call_args.kwargs
assert call_kwargs["query"] == "+15555550199"
async def test_no_caller_number_uses_fallback_query(self) -> None:
client = _make_client()
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
event = _make_assistant_request(caller_number=None)
await svc.handle(event)
call_kwargs = client.arecall.call_args.kwargs
assert call_kwargs["query"] == "returning caller"
async def test_enable_recall_false_skips_recall(self) -> None:
client = _make_client(["fact"])
svc = HindsightVapiWebhook(bank_id="test-bank", client=client, enable_recall=False)
event = _make_assistant_request()
result = await svc.handle(event)
client.arecall.assert_not_called()
assert result == {}
# ---------------------------------------------------------------------------
# TestEndOfCall
# ---------------------------------------------------------------------------
class TestEndOfCall:
def _svc(self, **kwargs: Any) -> HindsightVapiWebhook:
client = _make_client()
return HindsightVapiWebhook(bank_id="test-bank", client=client, **kwargs)
async def test_transcript_retained_fire_and_forget(self) -> None:
svc = self._svc()
event = _make_end_of_call("User: hello\nAssistant: hi there")
with patch("hindsight_vapi.webhook.asyncio.create_task") as mock_task:
result = await svc.handle(event)
assert result is None
mock_task.assert_called_once()
async def test_retain_called_with_transcript(self) -> None:
client = _make_client()
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
transcript = "User: hello\nAssistant: hi there"
retained: list[str] = []
async def _fake_retain(content: str) -> None:
retained.append(content)
with patch.object(svc, "_retain", new_callable=AsyncMock, side_effect=_fake_retain):
await svc.handle(_make_end_of_call(transcript))
# Let the scheduled task run
await asyncio.sleep(0)
assert retained == [transcript]
async def test_retain_error_swallowed(self) -> None:
client = _make_client()
client.aretain = AsyncMock(side_effect=RuntimeError("retain failed"))
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
# Should not raise
await svc._retain("some transcript")
async def test_empty_transcript_is_noop(self) -> None:
svc = self._svc()
event = _make_end_of_call(transcript="")
with patch("hindsight_vapi.webhook.asyncio.create_task") as mock_task:
await svc.handle(event)
mock_task.assert_not_called()
async def test_enable_retain_false_skips_retain(self) -> None:
svc = self._svc(enable_retain=False)
with patch("hindsight_vapi.webhook.asyncio.create_task") as mock_task:
await svc.handle(_make_end_of_call())
mock_task.assert_not_called()
# ---------------------------------------------------------------------------
# TestBuildOverrides
# ---------------------------------------------------------------------------
class TestBuildOverrides:
async def test_returns_overrides_with_memories(self) -> None:
client = _make_client(["User is Jordan", "Prefers metric"])
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
result = await svc.build_assistant_overrides("what do I know about this caller?")
assert "assistantOverrides" in result
messages = result["assistantOverrides"]["model"]["messages"]
assert messages[0]["role"] == "system"
assert "Jordan" in messages[0]["content"]
async def test_empty_recall_returns_empty_dict(self) -> None:
client = _make_client(results=None)
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
result = await svc.build_assistant_overrides("query")
assert result == {}
async def test_enable_recall_false_returns_empty_dict(self) -> None:
client = _make_client(["fact"])
svc = HindsightVapiWebhook(bank_id="test-bank", client=client, enable_recall=False)
result = await svc.build_assistant_overrides("query")
client.arecall.assert_not_called()
assert result == {}
# ---------------------------------------------------------------------------
# TestUnknownEvent
# ---------------------------------------------------------------------------
class TestUnknownEvent:
async def test_unknown_event_type_returns_none(self) -> None:
client = _make_client()
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
for event_type in ["call-started", "call-ended", "speech-update", "transcript"]:
result = await svc.handle({"message": {"type": event_type}})
assert result is None, f"Expected None for event type {event_type!r}"
client.arecall.assert_not_called()
client.aretain.assert_not_called()
async def test_empty_event_returns_none(self) -> None:
client = _make_client()
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
result = await svc.handle({})
assert result is None
+1109
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "paperclip" "opencode" "cloudflare-oauth-proxy" "openai-agents" "pipecat" "agentcore" "smolagents" "n8n" "dify" "gemini-spark")
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "paperclip" "opencode" "cloudflare-oauth-proxy" "openai-agents" "pipecat" "agentcore" "smolagents" "n8n" "dify" "gemini-spark" "vapi")
usage() {
print_error "Usage: $0 <integration> <version>"