memoair-vapi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ # ============================================
2
+ # External dependencies (vendored)
3
+ # ============================================
4
+ /mem0/
5
+ /graphiti/
6
+ /HippoRAG/
7
+ /memvid/
8
+ /PageIndex/
9
+ /NornicDB/
10
+ /cognee/
11
+
12
+ # ============================================
13
+ # Legacy directories (removed, ignore local copies)
14
+ # ============================================
15
+ /memory-service/
16
+ /home-page/
17
+ /extensionV2/
18
+
19
+ # ============================================
20
+ # Python virtual environments
21
+ # ============================================
22
+ memory-service-v2/.venv/
23
+ doc-parser/.venv/
24
+ .venv/
25
+ venv/
26
+ env/
27
+ ENV/
28
+
29
+ # ============================================
30
+ # Environment files (contain secrets)
31
+ # ============================================
32
+ # Ignore all .env files except .env.example
33
+ .env
34
+ .env.voice.local
35
+ .env.local
36
+ .env.*.local
37
+ !.env.example
38
+ !**/.env.example
39
+
40
+ # ============================================
41
+ # Node.js
42
+ # ============================================
43
+ node_modules/
44
+ extension/node_modules/
45
+ dashboard/node_modules/
46
+ homePage/node_modules/
47
+ home-page/node_modules/
48
+ mcp-server/node_modules/
49
+
50
+ # ============================================
51
+ # Backend (Go)
52
+ # ============================================
53
+ backend/uploads/
54
+ backend/vendor/
55
+ backend/backend
56
+ backend/server
57
+ backend/*.test
58
+ backend/notetaker.db
59
+ backend/notetaker.db-shm
60
+ backend/notetaker.db-wal
61
+ backend/tmp/
62
+ backend/build-errors.log
63
+
64
+ # ============================================
65
+ # Next.js / React build output
66
+ # ============================================
67
+ .next/
68
+ out/
69
+ dist/
70
+ build/
71
+
72
+ # TypeScript incremental build info (regenerated on every tsc run)
73
+ *.tsbuildinfo
74
+
75
+ # ============================================
76
+ # Extension build output
77
+ # ============================================
78
+ extension/dist/
79
+ extension/dist-extension/
80
+ extension/.next/
81
+
82
+ # ============================================
83
+ # MCP server build output
84
+ # ============================================
85
+ mcp-server/dist/
86
+
87
+ # ============================================
88
+ # OS files
89
+ # ============================================
90
+ .DS_Store
91
+ Thumbs.db
92
+
93
+ # ============================================
94
+ # IDE files
95
+ # ============================================
96
+ .vscode/
97
+ .idea/
98
+ *.swp
99
+ *~
100
+
101
+ # ============================================
102
+ # Python
103
+ # ============================================
104
+ __pycache__/
105
+ *.py[cod]
106
+ *$py.class
107
+ *.so
108
+ .Python
109
+ *.egg-info/
110
+ .eggs/
111
+
112
+ # ============================================
113
+ # Logs
114
+ # ============================================
115
+ *.log
116
+ npm-debug.log*
117
+ yarn-debug.log*
118
+ yarn-error.log*
119
+ pnpm-debug.log*
120
+
121
+ # ============================================
122
+ # Temp files
123
+ # ============================================
124
+ *.tmp
125
+ *.temp
126
+ .cache/
127
+
128
+ # ============================================
129
+ # Database files (local dev)
130
+ # ============================================
131
+ *.db
132
+ *.db-shm
133
+ *.db-wal
134
+ !memory-service-v2/.gitkeep
135
+
136
+ # ============================================
137
+ # Benchmark data (downloaded, large files)
138
+ # ============================================
139
+ memory-service-v2/benchmarks/data/memorybench_large.json
140
+ memory-service-v2/benchmarks/data/*.json
141
+ !memory-service-v2/benchmarks/data/notes.json
142
+ memory-service-v2/benchmarks/results/*.json
143
+ !memory-service-v2/benchmarks/results/baseline.json
144
+ memory-service-v2/.env
145
+
146
+ # ============================================
147
+ # Rust / memvid-service local config
148
+ # ============================================
149
+ memvid-service/.config/
150
+
151
+ # Generated benchmark data (large / reproducible)
152
+ memorybench/data/benchmarks/locomo/locomo10-entities.json
153
+ memorybench/data/benchmarks/longmemeval/
154
+
155
+ # Go test binaries
156
+ backend/service.test
157
+
158
+ # Debug logs (runtime instrumentation)
159
+ backend/debug_logs/
160
+
161
+ # OpenMemory - IDE/Assistant specific rules
162
+ .cursor/rules/openmemory.mdc
163
+
164
+ # MemoAir agent scratch pad (ephemeral, never commit)
165
+ .memoair/
166
+
167
+ # Spec Kit (project-local config, regenerable via `specify init`)
168
+ .specify/
169
+
170
+ # Git worktrees (isolated workspaces for parallel feature work)
171
+ .worktrees/
172
+ CLAUDE.md
173
+ AGENTS.md
174
+
175
+ # ============================================
176
+ # Generated caches (code-review-graph tool output; fully regenerable)
177
+ # ============================================
178
+ graphify-out/
179
+ .graphify-scopes/
180
+
181
+ # ============================================
182
+ # Runtime / notebook artifacts (never commit)
183
+ # ============================================
184
+ dump.rdb
185
+ v3_store.json
186
+ v3_artifacts/
187
+
188
+ # voice-runtime build artifacts
189
+ voice-runtime/target/
190
+ .claude/worktrees/
191
+ memory-service-v2/data/permanent_store/
192
+ voice-runtime/dist/
193
+
194
+ # Secrets / account recovery codes — NEVER commit these
195
+ PyPI-Recovery-Codes*.txt
196
+ *recovery-codes*.txt
197
+ *.pem
198
+ *.key
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: memoair-vapi
3
+ Version: 0.1.0
4
+ Summary: VAPI Custom Knowledge Base integration for MemoAir voice memory
5
+ Project-URL: Homepage, https://memoair.dev
6
+ Project-URL: Documentation, https://docs.memoair.dev/voice/integrations/vapi
7
+ Project-URL: Repository, https://github.com/memoair/memoair-python
8
+ Author-email: MemoAir <hello@memoair.dev>
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,memoair,memory,vapi,voice,voice-agents
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: memoair-voice>=0.3.2
27
+ Provides-Extra: dev
28
+ Requires-Dist: black>=23.0.0; extra == 'dev'
29
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
31
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # memoair-vapi
36
+
37
+ Serve [VAPI's Custom Knowledge Base webhook](https://docs.vapi.ai/knowledge-base/custom-knowledge-base)
38
+ from MemoAir voice memory. Phase 1 retrieves from the shared org index.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install memoair-vapi
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ import json
50
+
51
+ from memoair_vapi import MemoAirVapiSearch, verify_vapi_signature
52
+
53
+ search = MemoAirVapiSearch(
54
+ api_key="memoair_pk_...",
55
+ project_id="proj_xxx",
56
+ agent_id="agent_xxx",
57
+ top_k=5,
58
+ )
59
+
60
+ # In your webhook handler (raw_body = exact request bytes):
61
+ if not verify_vapi_signature(raw_body, signature_header, secret):
62
+ ... # return 401
63
+
64
+ payload = json.loads(raw_body)
65
+ documents = await search.handle_request(payload) # -> {"documents": [...]}
66
+ ```
67
+
68
+ `handle_request` dispatches on `message.type`: `knowledge-base-request` returns
69
+ documents; any other type is acked with `{}`. A malformed knowledge-base request
70
+ raises `VapiWebhookError` (map it to HTTP 400). Retrieval failures degrade to
71
+ `{"documents": []}` so a live call never breaks.
72
+
73
+ A runnable FastAPI server is in [`examples/vapi/`](../../examples/vapi/).
74
+
75
+ ## Docs
76
+
77
+ https://docs.memoair.dev/voice/integrations/vapi
@@ -0,0 +1,43 @@
1
+ # memoair-vapi
2
+
3
+ Serve [VAPI's Custom Knowledge Base webhook](https://docs.vapi.ai/knowledge-base/custom-knowledge-base)
4
+ from MemoAir voice memory. Phase 1 retrieves from the shared org index.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install memoair-vapi
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```python
15
+ import json
16
+
17
+ from memoair_vapi import MemoAirVapiSearch, verify_vapi_signature
18
+
19
+ search = MemoAirVapiSearch(
20
+ api_key="memoair_pk_...",
21
+ project_id="proj_xxx",
22
+ agent_id="agent_xxx",
23
+ top_k=5,
24
+ )
25
+
26
+ # In your webhook handler (raw_body = exact request bytes):
27
+ if not verify_vapi_signature(raw_body, signature_header, secret):
28
+ ... # return 401
29
+
30
+ payload = json.loads(raw_body)
31
+ documents = await search.handle_request(payload) # -> {"documents": [...]}
32
+ ```
33
+
34
+ `handle_request` dispatches on `message.type`: `knowledge-base-request` returns
35
+ documents; any other type is acked with `{}`. A malformed knowledge-base request
36
+ raises `VapiWebhookError` (map it to HTTP 400). Retrieval failures degrade to
37
+ `{"documents": []}` so a live call never breaks.
38
+
39
+ A runnable FastAPI server is in [`examples/vapi/`](../../examples/vapi/).
40
+
41
+ ## Docs
42
+
43
+ https://docs.memoair.dev/voice/integrations/vapi
@@ -0,0 +1,25 @@
1
+ from memoair_vapi._search import MemoAirVapiSearch
2
+ from memoair_vapi._signature import verify_vapi_signature
3
+ from memoair_vapi._types import (
4
+ VapiDocument,
5
+ VapiKnowledgeBaseResponse,
6
+ VapiWebhookError,
7
+ )
8
+ from memoair_vapi._webhook import (
9
+ KNOWLEDGE_BASE_REQUEST,
10
+ documents_from_search_result,
11
+ extract_query,
12
+ message_type,
13
+ )
14
+
15
+ __all__ = [
16
+ "MemoAirVapiSearch",
17
+ "verify_vapi_signature",
18
+ "VapiDocument",
19
+ "VapiKnowledgeBaseResponse",
20
+ "VapiWebhookError",
21
+ "KNOWLEDGE_BASE_REQUEST",
22
+ "documents_from_search_result",
23
+ "extract_query",
24
+ "message_type",
25
+ ]
@@ -0,0 +1,114 @@
1
+ """MemoAirVapiSearch — serve VAPI Custom Knowledge Base from MemoAir org memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from memoair_voice import MemoAirVoiceClient
10
+
11
+ from memoair_vapi._types import VapiKnowledgeBaseResponse, VapiWebhookError
12
+ from memoair_vapi._webhook import (
13
+ KNOWLEDGE_BASE_REQUEST,
14
+ documents_from_search_result,
15
+ extract_query,
16
+ message_type,
17
+ )
18
+
19
+ _logger = logging.getLogger("memoair_vapi")
20
+
21
+ DEFAULT_TOP_K = 5
22
+ DEFAULT_SEARCH_TIMEOUT_MS = 250
23
+ DEFAULT_CLOUD_BASE_URL = "https://backend.memoair.space"
24
+
25
+
26
+ class MemoAirVapiSearch:
27
+ """Wraps a MemoAirVoiceClient to answer VAPI knowledge-base-request webhooks.
28
+
29
+ Phase 1 is org-lane-only. ``user_id`` / ``lanes`` are accepted now as inert
30
+ seams: when ``org_only`` is True they are ignored and the org lane is forced.
31
+ Phase 2 will route a supplied ``user_id`` to per-user lanes.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ api_key: str,
38
+ project_id: str,
39
+ agent_id: str,
40
+ top_k: int = DEFAULT_TOP_K,
41
+ org_only: bool = True,
42
+ cloud_base_url: str = DEFAULT_CLOUD_BASE_URL,
43
+ search_timeout_ms: int = DEFAULT_SEARCH_TIMEOUT_MS,
44
+ max_concurrent_users: int = 4,
45
+ client: Any | None = None,
46
+ ) -> None:
47
+ if not api_key:
48
+ raise ValueError("api_key is required")
49
+ if not project_id:
50
+ raise ValueError("project_id is required")
51
+ if not agent_id:
52
+ raise ValueError("agent_id is required")
53
+
54
+ self._project_id = project_id
55
+ self._org_only = org_only
56
+ self._top_k = int(top_k)
57
+ self._search_timeout_ms = int(search_timeout_ms)
58
+ self._client = client or MemoAirVoiceClient(
59
+ api_key=api_key,
60
+ project_id=project_id,
61
+ agent_id=agent_id,
62
+ cloud_base_url=cloud_base_url,
63
+ org_only=org_only,
64
+ max_concurrent_users=max_concurrent_users,
65
+ )
66
+
67
+ async def search(
68
+ self,
69
+ query: str,
70
+ *,
71
+ user_id: str | None = None,
72
+ lanes: list[str] | None = None,
73
+ ) -> VapiKnowledgeBaseResponse:
74
+ effective_lanes = ["org"] if self._org_only else (lanes or ["org"])
75
+ result = await self._client.search_memory(
76
+ query,
77
+ lanes=effective_lanes,
78
+ timeout_ms=self._search_timeout_ms,
79
+ )
80
+ docs = documents_from_search_result(result, top_k=self._top_k)
81
+ return VapiKnowledgeBaseResponse(documents=docs)
82
+
83
+ async def handle_request(
84
+ self,
85
+ payload: Mapping[str, Any],
86
+ *,
87
+ user_id: str | None = None,
88
+ ) -> dict[str, Any]:
89
+ """Parse a VAPI webhook payload, dispatch on type, return a JSON-able dict.
90
+
91
+ Non-``knowledge-base-request`` types are acked with ``{}``. Malformed
92
+ KB payloads raise VapiWebhookError (server maps to 400). Search failures
93
+ degrade to ``{"documents": []}`` so the live call keeps flowing.
94
+
95
+ A non-object payload (valid JSON array/scalar reaching a public
96
+ endpoint) is treated as malformed rather than allowed to raise an
97
+ uncaught AttributeError — preserves the never-500 contract.
98
+ """
99
+ if not isinstance(payload, Mapping):
100
+ raise VapiWebhookError("payload is not a JSON object")
101
+ if message_type(payload) != KNOWLEDGE_BASE_REQUEST:
102
+ return {}
103
+ query = extract_query(payload)
104
+ try:
105
+ response = await self.search(query, user_id=user_id)
106
+ except Exception as exc: # noqa: BLE001 — degrade, never 500 mid-call
107
+ _logger.warning(
108
+ "vapi search failed (project=%s): %s", self._project_id, exc
109
+ )
110
+ return {"documents": []}
111
+ return response.to_dict()
112
+
113
+ async def aclose(self) -> None:
114
+ await self._client.aclose()
@@ -0,0 +1,27 @@
1
+ """HMAC-SHA256 verification for VAPI webhook requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+
8
+
9
+ def verify_vapi_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
10
+ """Return True iff ``signature_header`` is a valid HMAC-SHA256 of
11
+ ``raw_body`` under ``secret``.
12
+
13
+ VAPI's docs are inconsistent about whether the header carries a bare hex
14
+ digest or a ``sha256=<hex>`` form, so both are accepted. Comparison is
15
+ timing-safe. Any missing input (empty body, header, or secret) returns
16
+ False rather than raising.
17
+ """
18
+ if not raw_body or not signature_header or not secret:
19
+ return False
20
+ candidate = signature_header.strip()
21
+ if candidate.lower().startswith("sha256="):
22
+ candidate = candidate[len("sha256="):]
23
+ expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
24
+ try:
25
+ return hmac.compare_digest(candidate, expected)
26
+ except (TypeError, ValueError):
27
+ return False
@@ -0,0 +1,38 @@
1
+ """Public types for the memoair-vapi package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ class VapiWebhookError(Exception):
10
+ """Raised when a VAPI webhook payload is malformed (missing message,
11
+ missing/empty messages, or no user turn with content)."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class VapiDocument:
16
+ """A single document returned to VAPI's Custom Knowledge Base webhook."""
17
+
18
+ content: str
19
+ similarity: float | None = None
20
+ uuid: str | None = None
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ out: dict[str, Any] = {"content": self.content}
24
+ if self.similarity is not None:
25
+ out["similarity"] = self.similarity
26
+ if self.uuid is not None:
27
+ out["uuid"] = self.uuid
28
+ return out
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class VapiKnowledgeBaseResponse:
33
+ """The `{"documents": [...]}` response body VAPI expects."""
34
+
35
+ documents: list[VapiDocument] = field(default_factory=list)
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {"documents": [d.to_dict() for d in self.documents]}
@@ -0,0 +1,81 @@
1
+ """Parse VAPI Custom Knowledge Base requests and map MemoAir results to docs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from memoair_voice import SearchResult
9
+
10
+ from memoair_vapi._types import VapiDocument, VapiWebhookError
11
+
12
+ KNOWLEDGE_BASE_REQUEST = "knowledge-base-request"
13
+
14
+
15
+ def message_type(payload: Mapping[str, Any]) -> str | None:
16
+ """Return ``message.type`` from a VAPI webhook payload, or None."""
17
+ message = payload.get("message")
18
+ if isinstance(message, Mapping):
19
+ mtype = message.get("type")
20
+ if isinstance(mtype, str):
21
+ return mtype
22
+ return None
23
+
24
+
25
+ def extract_query(payload: Mapping[str, Any]) -> str:
26
+ """Return the latest user message content from a knowledge-base-request.
27
+
28
+ Raises VapiWebhookError if the payload is malformed or has no user turn.
29
+ """
30
+ message = payload.get("message")
31
+ if not isinstance(message, Mapping):
32
+ raise VapiWebhookError("payload missing 'message' object")
33
+ messages = message.get("messages")
34
+ if not isinstance(messages, list) or not messages:
35
+ raise VapiWebhookError("payload 'message.messages' missing or empty")
36
+ for item in reversed(messages):
37
+ if isinstance(item, Mapping) and item.get("role") == "user":
38
+ content = item.get("content")
39
+ if isinstance(content, str) and content.strip():
40
+ return content
41
+ raise VapiWebhookError("no user message with content found")
42
+
43
+
44
+ def documents_from_search_result(result: SearchResult, *, top_k: int) -> list[VapiDocument]:
45
+ """Map a SearchResult's org lane to VAPI documents.
46
+
47
+ Org items follow the runtime contract {docId, chunkId, text, score} but we
48
+ read defensively across alternative keys. Falls back to a single document
49
+ carrying contextText when the org lane is empty.
50
+ """
51
+ docs: list[VapiDocument] = []
52
+ for item in result.org or []:
53
+ if not isinstance(item, Mapping):
54
+ continue
55
+ content = item.get("text") or item.get("content") or item.get("chunk")
56
+ if not isinstance(content, str) or not content.strip():
57
+ continue
58
+ raw_score = item.get("score")
59
+ if raw_score is None:
60
+ raw_score = item.get("similarity")
61
+ similarity = float(raw_score) if isinstance(raw_score, (int, float)) else None
62
+ raw_uuid = (
63
+ item.get("chunkId")
64
+ or item.get("docId")
65
+ or item.get("id")
66
+ or item.get("uuid")
67
+ )
68
+ docs.append(
69
+ VapiDocument(
70
+ content=content,
71
+ similarity=similarity,
72
+ uuid=str(raw_uuid) if raw_uuid is not None else None,
73
+ )
74
+ )
75
+
76
+ if not docs and result.contextText and result.contextText.strip():
77
+ docs.append(VapiDocument(content=result.contextText))
78
+
79
+ if top_k and top_k > 0:
80
+ return docs[:top_k]
81
+ return docs
File without changes
@@ -0,0 +1,73 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "memoair-vapi"
7
+ version = "0.1.0"
8
+ description = "VAPI Custom Knowledge Base integration for MemoAir voice memory"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "MemoAir", email = "hello@memoair.dev" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+ keywords = ["ai", "agents", "vapi", "memory", "memoair", "voice", "voice-agents"]
32
+ dependencies = [
33
+ "memoair-voice>=0.3.2",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=7.0.0",
39
+ "pytest-asyncio>=0.21.0",
40
+ "mypy>=1.0.0",
41
+ "ruff>=0.1.0",
42
+ "black>=23.0.0",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://memoair.dev"
47
+ Documentation = "https://docs.memoair.dev/voice/integrations/vapi"
48
+ Repository = "https://github.com/memoair/memoair-python"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["memoair_vapi"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py39"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
59
+ ignore = ["E501", "B008"]
60
+
61
+ [tool.ruff.lint.isort]
62
+ known-first-party = ["memoair_vapi"]
63
+
64
+ [tool.mypy]
65
+ python_version = "3.9"
66
+ strict = true
67
+ warn_return_any = true
68
+ warn_unused_configs = true
69
+ disallow_untyped_defs = true
70
+
71
+ [tool.pytest.ini_options]
72
+ asyncio_mode = "auto"
73
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,102 @@
1
+ import logging
2
+
3
+ import pytest
4
+ from memoair_voice import MemoAirVoiceMemoryError, SearchResult
5
+
6
+ from memoair_vapi._search import MemoAirVapiSearch
7
+ from memoair_vapi._types import VapiWebhookError
8
+ from memoair_vapi._webhook import KNOWLEDGE_BASE_REQUEST
9
+
10
+
11
+ class FakeVoiceClient:
12
+ """In-memory stand-in for MemoAirVoiceClient (org_only)."""
13
+
14
+ def __init__(self, result: "SearchResult | None" = None, raises: "Exception | None" = None) -> None:
15
+ self._result = result
16
+ self._raises = raises
17
+ self.closed = False
18
+ self.calls: list = []
19
+
20
+ async def search_memory(self, query, *, lanes=None, timeout_ms=None, **kwargs):
21
+ self.calls.append({"query": query, "lanes": lanes, "timeout_ms": timeout_ms})
22
+ if self._raises is not None:
23
+ raise self._raises
24
+ return self._result
25
+
26
+ async def aclose(self) -> None:
27
+ self.closed = True
28
+
29
+
30
+ def _result(org: list, context_text: str = "") -> SearchResult:
31
+ return SearchResult(
32
+ contextText=context_text, profile=None, working=[],
33
+ permanent=[], org=org, sources=[], trace={},
34
+ )
35
+
36
+
37
+ def _kb_payload(messages: list) -> dict:
38
+ return {"message": {"type": KNOWLEDGE_BASE_REQUEST, "messages": messages}}
39
+
40
+
41
+ def _search(client: "FakeVoiceClient", **overrides) -> MemoAirVapiSearch:
42
+ kwargs = {"api_key": "memoair_pk_t", "project_id": "proj_t", "agent_id": "agent_t", "client": client}
43
+ kwargs.update(overrides)
44
+ return MemoAirVapiSearch(**kwargs)
45
+
46
+
47
+ def test_constructor_requires_api_key() -> None:
48
+ with pytest.raises(ValueError, match="api_key"):
49
+ MemoAirVapiSearch(api_key="", project_id="p", agent_id="a")
50
+
51
+
52
+ async def test_search_queries_org_lane() -> None:
53
+ client = FakeVoiceClient(_result([
54
+ {"chunkId": "c1", "text": "hello", "score": 0.9},
55
+ ]))
56
+ search = _search(client, search_timeout_ms=300)
57
+ resp = await search.search("hi there")
58
+ assert client.calls == [{"query": "hi there", "lanes": ["org"], "timeout_ms": 300}]
59
+ assert resp.to_dict() == {"documents": [{"content": "hello", "similarity": 0.9, "uuid": "c1"}]}
60
+
61
+
62
+ async def test_handle_request_happy_path() -> None:
63
+ client = FakeVoiceClient(_result([{"chunkId": "c1", "text": "answer", "score": 0.8}]))
64
+ search = _search(client)
65
+ out = await search.handle_request(_kb_payload([{"role": "user", "content": "q?"}]))
66
+ assert out == {"documents": [{"content": "answer", "similarity": 0.8, "uuid": "c1"}]}
67
+
68
+
69
+ async def test_handle_request_non_kb_type_acks() -> None:
70
+ client = FakeVoiceClient(_result([]))
71
+ search = _search(client)
72
+ out = await search.handle_request({"message": {"type": "end-of-call-report"}})
73
+ assert out == {}
74
+ assert client.calls == []
75
+
76
+
77
+ async def test_handle_request_malformed_raises() -> None:
78
+ search = _search(FakeVoiceClient(_result([])))
79
+ with pytest.raises(VapiWebhookError):
80
+ await search.handle_request(_kb_payload([{"role": "assistant", "content": "x"}]))
81
+
82
+
83
+ async def test_handle_request_non_object_payload_raises() -> None:
84
+ search = _search(FakeVoiceClient(_result([])))
85
+ with pytest.raises(VapiWebhookError):
86
+ await search.handle_request([]) # type: ignore[arg-type]
87
+
88
+
89
+ async def test_handle_request_search_failure_degrades(caplog: pytest.LogCaptureFixture) -> None:
90
+ client = FakeVoiceClient(raises=MemoAirVoiceMemoryError(code="runtime.timeout", message="boom"))
91
+ search = _search(client)
92
+ with caplog.at_level(logging.WARNING, logger="memoair_vapi"):
93
+ out = await search.handle_request(_kb_payload([{"role": "user", "content": "q?"}]))
94
+ assert out == {"documents": []}
95
+ assert "vapi search failed" in caplog.text
96
+
97
+
98
+ async def test_aclose_closes_client() -> None:
99
+ client = FakeVoiceClient(_result([]))
100
+ search = _search(client)
101
+ await search.aclose()
102
+ assert client.closed is True
@@ -0,0 +1,33 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+ from memoair_vapi._signature import verify_vapi_signature
5
+
6
+ SECRET = "shh-secret"
7
+ BODY = b'{"message":{"type":"knowledge-base-request"}}'
8
+
9
+
10
+ def _sig(body: bytes, secret: str) -> str:
11
+ return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
12
+
13
+
14
+ def test_valid_raw_hex_signature() -> None:
15
+ assert verify_vapi_signature(BODY, _sig(BODY, SECRET), SECRET) is True
16
+
17
+
18
+ def test_valid_sha256_prefixed_signature() -> None:
19
+ assert verify_vapi_signature(BODY, f"sha256={_sig(BODY, SECRET)}", SECRET) is True
20
+
21
+
22
+ def test_invalid_signature() -> None:
23
+ assert verify_vapi_signature(BODY, _sig(BODY, "wrong"), SECRET) is False
24
+
25
+
26
+ def test_tampered_body() -> None:
27
+ assert verify_vapi_signature(b"tampered", _sig(BODY, SECRET), SECRET) is False
28
+
29
+
30
+ def test_missing_secret_or_header() -> None:
31
+ assert verify_vapi_signature(BODY, _sig(BODY, SECRET), "") is False
32
+ assert verify_vapi_signature(BODY, "", SECRET) is False
33
+ assert verify_vapi_signature(b"", _sig(BODY, SECRET), SECRET) is False
@@ -0,0 +1,95 @@
1
+ import pytest
2
+ from memoair_voice import SearchResult
3
+
4
+ from memoair_vapi._types import VapiWebhookError
5
+ from memoair_vapi._webhook import (
6
+ KNOWLEDGE_BASE_REQUEST,
7
+ documents_from_search_result,
8
+ extract_query,
9
+ message_type,
10
+ )
11
+
12
+
13
+ def _kb_payload(messages: list) -> dict:
14
+ return {"message": {"type": KNOWLEDGE_BASE_REQUEST, "messages": messages}}
15
+
16
+
17
+ def test_extract_query_returns_latest_user_message() -> None:
18
+ payload = _kb_payload([
19
+ {"role": "user", "content": "first"},
20
+ {"role": "assistant", "content": "ok"},
21
+ {"role": "user", "content": "latest question"},
22
+ ])
23
+ assert extract_query(payload) == "latest question"
24
+
25
+
26
+ def test_extract_query_missing_message_raises() -> None:
27
+ with pytest.raises(VapiWebhookError):
28
+ extract_query({})
29
+
30
+
31
+ def test_extract_query_empty_messages_raises() -> None:
32
+ with pytest.raises(VapiWebhookError):
33
+ extract_query({"message": {"type": KNOWLEDGE_BASE_REQUEST, "messages": []}})
34
+
35
+
36
+ def test_extract_query_no_user_turn_raises() -> None:
37
+ with pytest.raises(VapiWebhookError):
38
+ extract_query(_kb_payload([{"role": "assistant", "content": "hi"}]))
39
+
40
+
41
+ def test_message_type() -> None:
42
+ assert message_type(_kb_payload([])) == KNOWLEDGE_BASE_REQUEST
43
+ assert message_type({"message": {"type": "end-of-call-report"}}) == "end-of-call-report"
44
+ assert message_type({}) is None
45
+
46
+
47
+ def _result(org: list, context_text: str = "") -> SearchResult:
48
+ return SearchResult(
49
+ contextText=context_text, profile=None, working=[],
50
+ permanent=[], org=org, sources=[], trace={},
51
+ )
52
+
53
+
54
+ def test_documents_from_org_items() -> None:
55
+ result = _result([
56
+ {"docId": "doc_42", "chunkId": "doc_42:7", "text": "Standup is 10:30 IST.", "score": 0.71},
57
+ {"docId": "doc_9", "chunkId": "doc_9:1", "text": "Returns within 30 days.", "score": 0.6},
58
+ ])
59
+ docs = documents_from_search_result(result, top_k=5)
60
+ assert [d.to_dict() for d in docs] == [
61
+ {"content": "Standup is 10:30 IST.", "similarity": 0.71, "uuid": "doc_42:7"},
62
+ {"content": "Returns within 30 days.", "similarity": 0.6, "uuid": "doc_9:1"},
63
+ ]
64
+
65
+
66
+ def test_documents_top_k_truncates() -> None:
67
+ result = _result([
68
+ {"chunkId": f"c{i}", "text": f"t{i}", "score": 0.5} for i in range(10)
69
+ ])
70
+ assert len(documents_from_search_result(result, top_k=3)) == 3
71
+
72
+
73
+ def test_documents_fallback_to_context_text() -> None:
74
+ result = _result([], context_text="some recalled context")
75
+ docs = documents_from_search_result(result, top_k=5)
76
+ assert [d.to_dict() for d in docs] == [{"content": "some recalled context"}]
77
+
78
+
79
+ def test_documents_empty_when_no_org_and_no_context() -> None:
80
+ assert documents_from_search_result(_result([]), top_k=5) == []
81
+
82
+
83
+ def test_documents_defensive_keys_and_zero_score() -> None:
84
+ result = _result([
85
+ {"docId": "d1", "text": "zero score kept", "score": 0.0},
86
+ {"content": "alt content key", "similarity": 0.4, "docId": "d2"},
87
+ "not-a-mapping",
88
+ {"chunk": "alt chunk key", "id": "d3"},
89
+ ])
90
+ docs = documents_from_search_result(result, top_k=10)
91
+ assert [d.to_dict() for d in docs] == [
92
+ {"content": "zero score kept", "similarity": 0.0, "uuid": "d1"},
93
+ {"content": "alt content key", "similarity": 0.4, "uuid": "d2"},
94
+ {"content": "alt chunk key", "uuid": "d3"},
95
+ ]