memcat 1.0.0__py3-none-any.whl
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.
- memcat/__init__.py +46 -0
- memcat/cli.py +608 -0
- memcat/config.py +120 -0
- memcat/core/__init__.py +0 -0
- memcat/core/audio.py +247 -0
- memcat/core/breaker.py +113 -0
- memcat/core/chunker.py +505 -0
- memcat/core/codec.py +310 -0
- memcat/core/index.py +361 -0
- memcat/core/keys.py +406 -0
- memcat/core/lookup.py +215 -0
- memcat/core/metrics.py +89 -0
- memcat/core/pcm.py +24 -0
- memcat/core/runtime.py +252 -0
- memcat/core/store/__init__.py +0 -0
- memcat/core/store/base.py +94 -0
- memcat/core/store/memory.py +233 -0
- memcat/core/store/redis.py +456 -0
- memcat/core/store/s3.py +527 -0
- memcat/core/worker.py +577 -0
- memcat/pipecat/__init__.py +0 -0
- memcat/pipecat/adapters/__init__.py +0 -0
- memcat/pipecat/adapters/base.py +172 -0
- memcat/pipecat/adapters/providers.py +190 -0
- memcat/pipecat/compat.py +161 -0
- memcat/pipecat/contexts.py +94 -0
- memcat/pipecat/mixin.py +1614 -0
- memcat/pipecat/render.py +331 -0
- memcat/pipecat/wrap.py +36 -0
- memcat/py.typed +0 -0
- memcat/serve.py +562 -0
- memcat-1.0.0.dist-info/METADATA +198 -0
- memcat-1.0.0.dist-info/RECORD +36 -0
- memcat-1.0.0.dist-info/WHEEL +4 -0
- memcat-1.0.0.dist-info/entry_points.txt +3 -0
- memcat-1.0.0.dist-info/licenses/LICENSE +24 -0
memcat/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Memcat: a clause-level text-to-speech cache for Pipecat.
|
|
2
|
+
|
|
3
|
+
from memcat import cached, MemcatConfig, RedisStore
|
|
4
|
+
|
|
5
|
+
CachedCartesia = cached(CartesiaTTSService)
|
|
6
|
+
tts = CachedCartesia(api_key=..., voice_id=..., memcat=MemcatConfig(store=RedisStore(url)))
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib import metadata as _metadata
|
|
10
|
+
|
|
11
|
+
from memcat.config import MemcatConfig
|
|
12
|
+
from memcat.core.runtime import aclose_all as _aclose_runtimes
|
|
13
|
+
from memcat.core.store.base import BoundStore, PurgeNotifyError, Store, StoreError
|
|
14
|
+
from memcat.core.store.memory import MemoryStore
|
|
15
|
+
from memcat.core.store.redis import RedisStore
|
|
16
|
+
from memcat.core.store.s3 import S3Store
|
|
17
|
+
from memcat.pipecat.mixin import MemcatTTSMixin
|
|
18
|
+
from memcat.pipecat.wrap import cached
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = _metadata.version("memcat")
|
|
22
|
+
except _metadata.PackageNotFoundError: # running from a source tree that is not installed
|
|
23
|
+
__version__ = "0.0.0+unknown"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BoundStore",
|
|
27
|
+
"MemcatConfig",
|
|
28
|
+
"MemcatTTSMixin",
|
|
29
|
+
"MemoryStore",
|
|
30
|
+
"PurgeNotifyError",
|
|
31
|
+
"RedisStore",
|
|
32
|
+
"S3Store",
|
|
33
|
+
"Store",
|
|
34
|
+
"StoreError",
|
|
35
|
+
"__version__",
|
|
36
|
+
"aclose",
|
|
37
|
+
"cached",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def aclose() -> None:
|
|
42
|
+
"""Stop Memcat's background work on the running event loop and close its clients."""
|
|
43
|
+
from memcat.pipecat.render import aclose_render_pools
|
|
44
|
+
|
|
45
|
+
await _aclose_runtimes() # workers first, so no render starts a new twin
|
|
46
|
+
await aclose_render_pools()
|
memcat/cli.py
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""``memcat`` command line: warm, stats, purge, export, import, serve (see docs/operations.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import tomllib
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
16
|
+
from functools import cached_property
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from memcat.core.store.base import StoreError
|
|
21
|
+
|
|
22
|
+
__all__ = ["CLIError", "load_config", "main"]
|
|
23
|
+
|
|
24
|
+
_ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
|
25
|
+
_SENT = re.compile(r"(?<=[.!?])\s+(?=\S)")
|
|
26
|
+
_ENTRY = ".mcat" # export file suffix
|
|
27
|
+
_READ_BATCH = 32 # entries per bulk read: a few MB at most
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CLIError(Exception):
|
|
31
|
+
"""A configuration or input problem, reported as ``memcat: <message>`` and exit 1."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _subst(value: Any) -> Any:
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
|
|
37
|
+
def rep(m: re.Match[str]) -> str:
|
|
38
|
+
v = os.environ.get(m.group(1))
|
|
39
|
+
if v is not None:
|
|
40
|
+
return v
|
|
41
|
+
default: str | None = m.group(2)
|
|
42
|
+
if default is None:
|
|
43
|
+
raise CLIError(f"memcat: environment variable {m.group(1)} is not set")
|
|
44
|
+
return default
|
|
45
|
+
|
|
46
|
+
return _ENV.sub(rep, value)
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
return {k: _subst(v) for k, v in value.items()}
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return [_subst(v) for v in value]
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_config(path: Path) -> dict[str, Any]:
|
|
55
|
+
"""memcat.toml as written, before ${ENV} substitution."""
|
|
56
|
+
try:
|
|
57
|
+
with path.open("rb") as f:
|
|
58
|
+
return tomllib.load(f)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
raise CLIError(f"memcat: {path} not found (pass --config PATH)") from None
|
|
61
|
+
except tomllib.TOMLDecodeError as e:
|
|
62
|
+
raise CLIError(f"memcat: {path} is not valid TOML: {e}") from None
|
|
63
|
+
except OSError as e:
|
|
64
|
+
raise CLIError(f"memcat: cannot read {path}: {e.strerror or e}") from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config(path: Path) -> dict[str, Any]:
|
|
68
|
+
"""memcat.toml with ${ENV} substituted everywhere."""
|
|
69
|
+
return _subst(_read_config(path))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _section(conf: dict[str, Any], name: str) -> dict[str, Any]:
|
|
73
|
+
value = conf.get(name, {})
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise CLIError(f"memcat: [{name}] must be a table")
|
|
76
|
+
return _subst(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _import(path: str) -> Any:
|
|
80
|
+
mod, _, name = path.rpartition(".")
|
|
81
|
+
if not mod:
|
|
82
|
+
raise CLIError(f"memcat: '{path}' is not a dotted class path")
|
|
83
|
+
try:
|
|
84
|
+
return getattr(importlib.import_module(mod), name)
|
|
85
|
+
except ImportError as e:
|
|
86
|
+
raise CLIError(f"memcat: cannot import {mod}: {e}") from None
|
|
87
|
+
except AttributeError:
|
|
88
|
+
raise CLIError(f"memcat: {mod} has no attribute {name}") from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _store(conf: dict[str, Any]) -> Any:
|
|
92
|
+
from memcat import MemoryStore, RedisStore, S3Store
|
|
93
|
+
|
|
94
|
+
s: dict[str, Any] = _section(conf, "store") if "store" in conf else {"type": "memory"}
|
|
95
|
+
kind = s.pop("type", "memory")
|
|
96
|
+
if kind == "memory":
|
|
97
|
+
return MemoryStore()
|
|
98
|
+
if kind == "redis":
|
|
99
|
+
return RedisStore(s.pop("url", "redis://localhost:6379/0"), **s)
|
|
100
|
+
if kind == "s3":
|
|
101
|
+
return S3Store(**s)
|
|
102
|
+
raise CLIError(f"memcat: unknown store type '{kind}'")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _memcat_config(conf: dict[str, Any]) -> Any:
|
|
106
|
+
from memcat import MemcatConfig
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
return MemcatConfig(store=_store(conf), **_section(conf, "memcat"))
|
|
110
|
+
except (TypeError, ValueError) as e:
|
|
111
|
+
raise CLIError(f"memcat: bad [memcat] or [store] option: {e}") from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _Service:
|
|
115
|
+
"""The ``[service]`` table: which Pipecat service to wrap and how to build it."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, conf: dict[str, Any]):
|
|
118
|
+
svc = _section(conf, "service")
|
|
119
|
+
if "class" not in svc:
|
|
120
|
+
raise CLIError("memcat: [service] class is required")
|
|
121
|
+
self.base: Any = _import(str(svc["class"]))
|
|
122
|
+
try:
|
|
123
|
+
self.sample_rate = int(svc.get("sample_rate", 0))
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
raise CLIError("memcat: [service] sample_rate must be a number") from None
|
|
126
|
+
args = svc.get("args", {})
|
|
127
|
+
if not isinstance(args, dict):
|
|
128
|
+
raise CLIError("memcat: [service.args] must be a table")
|
|
129
|
+
self.args: dict[str, Any] = dict(args)
|
|
130
|
+
|
|
131
|
+
def factory(self, cfg: Any) -> Callable[[int], Any]:
|
|
132
|
+
"""A function building the Memcat-wrapped service at a given output rate."""
|
|
133
|
+
from memcat import cached
|
|
134
|
+
|
|
135
|
+
wrapped = cached(self.base)
|
|
136
|
+
|
|
137
|
+
def make(rate: int) -> Any:
|
|
138
|
+
return wrapped(memcat=cfg, **{**self.args, "sample_rate": rate})
|
|
139
|
+
|
|
140
|
+
return make
|
|
141
|
+
|
|
142
|
+
def check(self, make: Callable[[int], Any], rate: int) -> Any:
|
|
143
|
+
"""Build one service now, so bad constructor arguments fail with a clear message."""
|
|
144
|
+
try:
|
|
145
|
+
return make(rate)
|
|
146
|
+
except Exception as e: # the service's own constructor: any error is a config error
|
|
147
|
+
raise CLIError(
|
|
148
|
+
f"memcat: cannot build {self.base.__name__}: {type(e).__name__}: {e}"
|
|
149
|
+
) from None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Ctx:
|
|
153
|
+
"""Everything a command needs, built from memcat.toml on first use."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, conf: dict[str, Any], cmd: str = ""):
|
|
156
|
+
self._conf = conf
|
|
157
|
+
self._cmd = cmd
|
|
158
|
+
|
|
159
|
+
@cached_property
|
|
160
|
+
def cfg(self) -> Any:
|
|
161
|
+
return _memcat_config(self._conf)
|
|
162
|
+
|
|
163
|
+
@cached_property
|
|
164
|
+
def spec(self) -> _Service:
|
|
165
|
+
spec = _Service(self._conf)
|
|
166
|
+
if not spec.sample_rate:
|
|
167
|
+
raise CLIError("memcat: [service] sample_rate is required (it is part of every key)")
|
|
168
|
+
return spec
|
|
169
|
+
|
|
170
|
+
@cached_property
|
|
171
|
+
def service(self) -> Any:
|
|
172
|
+
svc = self.spec.check(self.spec.factory(self.cfg), self.spec.sample_rate)
|
|
173
|
+
# Keys include the output rate, which Pipecat only sets when a pipeline starts;
|
|
174
|
+
# TTSService has no public setter, so set it from [service] sample_rate here.
|
|
175
|
+
svc._sample_rate = self.spec.sample_rate
|
|
176
|
+
return svc
|
|
177
|
+
|
|
178
|
+
def runtime(self) -> Any:
|
|
179
|
+
"""The shared store's runtime. A memory store would be empty and gone when the
|
|
180
|
+
command exits, so commands that read or write the cache refuse it."""
|
|
181
|
+
from memcat import MemoryStore
|
|
182
|
+
from memcat.core.runtime import get_runtime
|
|
183
|
+
|
|
184
|
+
if isinstance(self.cfg.store, MemoryStore):
|
|
185
|
+
raise CLIError(
|
|
186
|
+
f"memcat {self._cmd}: needs a shared store, but memcat.toml has no [store] "
|
|
187
|
+
'section (or type = "memory"); add [store] with type = "redis" or "s3"'
|
|
188
|
+
)
|
|
189
|
+
return get_runtime(self.cfg)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _read_all(store: Any) -> AsyncIterator[tuple[bytes, bytes]]:
|
|
193
|
+
"""Every entry in the store, read a few at a time with the store's bulk time budget
|
|
194
|
+
where it has one (the hot-path budget is far too short for megabytes)."""
|
|
195
|
+
read = getattr(store, "get_bulk", store.get_many)
|
|
196
|
+
digests = [d async for d in store.scan()]
|
|
197
|
+
for i in range(0, len(digests), _READ_BATCH):
|
|
198
|
+
chunk = digests[i : i + _READ_BATCH]
|
|
199
|
+
for d, blob in zip(chunk, await read(chunk), strict=True):
|
|
200
|
+
if blob is not None: # None: listed but expired or deleted since
|
|
201
|
+
yield d, blob
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _cmd_stats(ctx: _Ctx, args: argparse.Namespace) -> dict[str, Any]:
|
|
205
|
+
from memcat.core import codec
|
|
206
|
+
|
|
207
|
+
codecs: Counter[str] = Counter()
|
|
208
|
+
origin: Counter[str] = Counter()
|
|
209
|
+
total_bytes = 0
|
|
210
|
+
total_ms = 0
|
|
211
|
+
corrupt = 0
|
|
212
|
+
found = 0
|
|
213
|
+
names = {codec.CODEC_PCM16: "pcm16", codec.CODEC_MULAW: "mulaw", codec.CODEC_OPUS: "opus"}
|
|
214
|
+
async for _, blob in _read_all(ctx.runtime().store):
|
|
215
|
+
found += 1
|
|
216
|
+
try:
|
|
217
|
+
e = codec.decode(blob)
|
|
218
|
+
except codec.CorruptEntryError:
|
|
219
|
+
corrupt += 1
|
|
220
|
+
continue
|
|
221
|
+
total_bytes += len(blob)
|
|
222
|
+
total_ms += e.duration_ms
|
|
223
|
+
codecs[names.get(e.codec, str(e.codec))] += 1
|
|
224
|
+
if e.flags & codec.FLAG_WARMED:
|
|
225
|
+
origin["warmed"] += 1
|
|
226
|
+
elif e.flags & codec.FLAG_CAPTURED:
|
|
227
|
+
origin["captured"] += 1
|
|
228
|
+
else:
|
|
229
|
+
origin["rendered"] += 1
|
|
230
|
+
return {
|
|
231
|
+
"namespace": ctx.cfg.namespace,
|
|
232
|
+
"entries": found,
|
|
233
|
+
"bytes": total_bytes,
|
|
234
|
+
"audio_minutes": round(total_ms / 60000, 2),
|
|
235
|
+
"kb_per_minute": round(total_bytes / 1024 / (total_ms / 60000), 1) if total_ms else 0,
|
|
236
|
+
"codecs": dict(codecs),
|
|
237
|
+
"origin": dict(origin),
|
|
238
|
+
"corrupt": corrupt,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _turn_clauses(ctx: _Ctx, turn: str) -> list[str]:
|
|
243
|
+
out: list[str] = []
|
|
244
|
+
for sentence in _SENT.split(turn.strip()):
|
|
245
|
+
out.extend(
|
|
246
|
+
c.text for c in ctx.service._memcat_split(sentence) if c.cacheable and c.text.strip()
|
|
247
|
+
)
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _read_calls(path: Path) -> list[list[str]]:
|
|
252
|
+
"""Each JSONL line's ``agent_turns``."""
|
|
253
|
+
try:
|
|
254
|
+
text = path.read_text(encoding="utf-8")
|
|
255
|
+
except FileNotFoundError:
|
|
256
|
+
raise CLIError(f"memcat: {path} not found") from None
|
|
257
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
258
|
+
raise CLIError(f"memcat: cannot read {path}: {e}") from None
|
|
259
|
+
calls: list[list[str]] = []
|
|
260
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
261
|
+
line = raw.strip()
|
|
262
|
+
if not line:
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
call = json.loads(line)
|
|
266
|
+
except json.JSONDecodeError as e:
|
|
267
|
+
raise CLIError(f"memcat: {path}:{n}: not valid JSON ({e.msg})") from None
|
|
268
|
+
turns = call.get("agent_turns", []) if isinstance(call, dict) else None
|
|
269
|
+
if not isinstance(turns, list):
|
|
270
|
+
raise CLIError(f'memcat: {path}:{n}: expected {{"agent_turns": [...]}}')
|
|
271
|
+
calls.append([str(t) for t in turns])
|
|
272
|
+
return calls
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _cmd_warm(ctx: _Ctx, args: argparse.Namespace) -> dict[str, Any]:
|
|
276
|
+
from memcat.core.keys import normalize_text
|
|
277
|
+
from memcat.pipecat.render import RenderPool
|
|
278
|
+
|
|
279
|
+
phrases: list[str] = list(args.phrase or [])
|
|
280
|
+
ranked: list[tuple[str, int]] = []
|
|
281
|
+
if args.from_file:
|
|
282
|
+
counts: Counter[str] = Counter()
|
|
283
|
+
first: dict[str, str] = {}
|
|
284
|
+
for turns in await asyncio.to_thread(_read_calls, Path(args.from_file)):
|
|
285
|
+
seen: set[str] = set()
|
|
286
|
+
for turn in turns:
|
|
287
|
+
for clause in _turn_clauses(ctx, str(turn)):
|
|
288
|
+
k = normalize_text(clause)
|
|
289
|
+
first.setdefault(k, clause)
|
|
290
|
+
seen.add(k)
|
|
291
|
+
counts.update(seen)
|
|
292
|
+
ranked = [(first[k], n) for k, n in counts.most_common(args.top) if n >= args.min_calls]
|
|
293
|
+
phrases.extend(t for t, _ in ranked)
|
|
294
|
+
if args.dry_run:
|
|
295
|
+
return {"would_render": len(phrases), "top": ranked[:20]}
|
|
296
|
+
svc = ctx.service # first, so a bad [service] is reported before a missing [store]
|
|
297
|
+
rt = ctx.runtime()
|
|
298
|
+
rt.acquire()
|
|
299
|
+
spec = ctx.spec
|
|
300
|
+
pool = RenderPool.shared(spec.base, (), {**spec.args, "sample_rate": spec.sample_rate})
|
|
301
|
+
# What MemcatTTSMixin.start() wires up, without starting a Pipecat pipeline. These are
|
|
302
|
+
# Memcat's own attributes; the mixin has no public way to warm outside a pipeline.
|
|
303
|
+
svc._memcat_rt, svc._memcat_pool = rt, pool
|
|
304
|
+
before = rt.metrics.snapshot()
|
|
305
|
+
t0 = time.monotonic()
|
|
306
|
+
svc._memcat_warm(phrases)
|
|
307
|
+
await rt.worker.drain_all(timeout=args.timeout)
|
|
308
|
+
m = rt.metrics.snapshot()
|
|
309
|
+
await pool.close()
|
|
310
|
+
stored = m["entries_stored"] - before["entries_stored"]
|
|
311
|
+
store_errors = m["store_errors"] - before["store_errors"]
|
|
312
|
+
if phrases and not stored and store_errors:
|
|
313
|
+
raise CLIError(f"memcat: warm stored nothing: {store_errors} store errors")
|
|
314
|
+
return {
|
|
315
|
+
"phrases": len(phrases),
|
|
316
|
+
"stored": stored,
|
|
317
|
+
"render_failures": m["render_failures"] - before["render_failures"],
|
|
318
|
+
"store_errors": store_errors,
|
|
319
|
+
"chars_rendered": svc._memcat_metrics.counters["chars_rendered"],
|
|
320
|
+
"seconds": round(time.monotonic() - t0, 1),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_age(s: str) -> float:
|
|
325
|
+
m = re.fullmatch(r"(\d+)([smhd])", s.strip())
|
|
326
|
+
if not m or not int(m.group(1)):
|
|
327
|
+
raise CLIError("memcat: --older-than takes a positive age, e.g. 30d, 12h, 90m")
|
|
328
|
+
return int(m.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[m.group(2)]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def _confirm_purge_all(namespace: str, yes: bool) -> None:
|
|
332
|
+
if yes:
|
|
333
|
+
return
|
|
334
|
+
if not sys.stdin.isatty():
|
|
335
|
+
raise CLIError(
|
|
336
|
+
f"memcat: purge --all deletes every entry in namespace '{namespace}'; "
|
|
337
|
+
"pass --yes to confirm"
|
|
338
|
+
)
|
|
339
|
+
# The prompt goes to stderr: stdout carries only the JSON result.
|
|
340
|
+
print(f"Delete every entry in namespace '{namespace}'? [y/N] ", end="", file=sys.stderr)
|
|
341
|
+
sys.stderr.flush()
|
|
342
|
+
answer = await asyncio.to_thread(sys.stdin.readline)
|
|
343
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
344
|
+
raise CLIError("memcat: purge --all cancelled")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def _cmd_purge(ctx: _Ctx, args: argparse.Namespace) -> dict[str, Any]:
|
|
348
|
+
if not (args.all or args.text or args.older_than):
|
|
349
|
+
raise CLIError("memcat purge: pass --text, --all or --older-than")
|
|
350
|
+
age = _parse_age(args.older_than) if args.older_than else 0.0
|
|
351
|
+
rt = ctx.runtime()
|
|
352
|
+
if args.all:
|
|
353
|
+
await _confirm_purge_all(ctx.cfg.namespace, args.yes)
|
|
354
|
+
return {"deleted": await rt.purge(None)}
|
|
355
|
+
if args.text:
|
|
356
|
+
return {"deleted": await rt.purge_text(args.text, ctx.cfg)}
|
|
357
|
+
idle = getattr(rt.store, "idle_seconds", None)
|
|
358
|
+
if idle is None:
|
|
359
|
+
raise CLIError("memcat: --older-than needs a Redis or S3 store")
|
|
360
|
+
old: list[bytes] = []
|
|
361
|
+
|
|
362
|
+
async def check(batch: list[bytes]) -> None:
|
|
363
|
+
ages = await idle(batch)
|
|
364
|
+
old.extend(x for x, a in zip(batch, ages, strict=True) if a is not None and a >= age)
|
|
365
|
+
|
|
366
|
+
batch: list[bytes] = []
|
|
367
|
+
async for d in rt.store.scan():
|
|
368
|
+
batch.append(d)
|
|
369
|
+
if len(batch) >= 500:
|
|
370
|
+
await check(batch)
|
|
371
|
+
batch = []
|
|
372
|
+
if batch:
|
|
373
|
+
await check(batch)
|
|
374
|
+
return {"deleted": await rt.purge(old) if old else 0}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _prepare_export(out: Path) -> None:
|
|
378
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
if next(out.glob(f"*{_ENTRY}"), None) is not None:
|
|
380
|
+
# Leftover entries would come back on import, purged ones included.
|
|
381
|
+
raise CLIError(f"memcat: {out} already holds an export; export to a new directory")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def _cmd_export(ctx: _Ctx, args: argparse.Namespace) -> dict[str, Any]:
|
|
385
|
+
store = ctx.runtime().store
|
|
386
|
+
out = Path(args.to)
|
|
387
|
+
try:
|
|
388
|
+
await asyncio.to_thread(_prepare_export, out)
|
|
389
|
+
digests: list[str] = []
|
|
390
|
+
async for d, blob in _read_all(store):
|
|
391
|
+
await asyncio.to_thread((out / f"{d.hex()}{_ENTRY}").write_bytes, blob)
|
|
392
|
+
digests.append(d.hex())
|
|
393
|
+
manifest = {"namespace": ctx.cfg.namespace, "format": 1, "digests": digests}
|
|
394
|
+
text = json.dumps(manifest, indent=2) + "\n"
|
|
395
|
+
await asyncio.to_thread((out / "manifest.json").write_text, text)
|
|
396
|
+
except OSError as e:
|
|
397
|
+
raise CLIError(f"memcat: cannot export to {out}: {e.strerror or e}") from None
|
|
398
|
+
return {"exported": len(digests), "to": str(out)}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _read_manifest(src: Path) -> tuple[str | None, list[str] | None]:
|
|
402
|
+
"""The namespace and digests ``memcat export`` recorded in ``manifest.json``
|
|
403
|
+
(None for whatever it does not say)."""
|
|
404
|
+
try:
|
|
405
|
+
manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
|
|
406
|
+
except FileNotFoundError:
|
|
407
|
+
return None, None
|
|
408
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
409
|
+
raise CLIError(f"memcat: cannot read {src / 'manifest.json'}: {e}") from None
|
|
410
|
+
if not isinstance(manifest, dict):
|
|
411
|
+
return None, None
|
|
412
|
+
ns, digests = manifest.get("namespace"), manifest.get("digests")
|
|
413
|
+
return (
|
|
414
|
+
ns if isinstance(ns, str) else None,
|
|
415
|
+
[str(h) for h in digests] if isinstance(digests, list) else None,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def _cmd_import(ctx: _Ctx, args: argparse.Namespace) -> dict[str, Any]:
|
|
420
|
+
from memcat.core import codec
|
|
421
|
+
|
|
422
|
+
src = Path(args.from_dir)
|
|
423
|
+
rt = ctx.runtime()
|
|
424
|
+
if not await asyncio.to_thread(src.is_dir):
|
|
425
|
+
raise CLIError(f"memcat: {src} not found")
|
|
426
|
+
ns, listed = await asyncio.to_thread(_read_manifest, src)
|
|
427
|
+
if ns != ctx.cfg.namespace and not args.force:
|
|
428
|
+
where = f"namespace '{ns}'" if ns is not None else "an unknown namespace (no manifest)"
|
|
429
|
+
raise CLIError(
|
|
430
|
+
f"memcat: {src} holds {where}, not '{ctx.cfg.namespace}'; "
|
|
431
|
+
"pass --force to import it into this namespace anyway"
|
|
432
|
+
)
|
|
433
|
+
if listed is None:
|
|
434
|
+
listed = sorted(p.stem for p in await asyncio.to_thread(lambda: [*src.glob(f"*{_ENTRY}")]))
|
|
435
|
+
n = skipped = 0
|
|
436
|
+
for h in listed: # only what the export wrote, never other files beside it
|
|
437
|
+
try:
|
|
438
|
+
d = bytes.fromhex(h) # also keeps names from the manifest inside src
|
|
439
|
+
if len(d) != 16:
|
|
440
|
+
raise ValueError(h)
|
|
441
|
+
blob = await asyncio.to_thread((src / f"{h}{_ENTRY}").read_bytes)
|
|
442
|
+
codec.decode(blob) # never import something that would not replay
|
|
443
|
+
except (ValueError, OSError, codec.CorruptEntryError):
|
|
444
|
+
skipped += 1
|
|
445
|
+
continue
|
|
446
|
+
await rt.store.put(d, blob, ctx.cfg.ttl_s)
|
|
447
|
+
await rt.store.publish_new(d)
|
|
448
|
+
n += 1
|
|
449
|
+
return {"imported": n, "skipped": skipped}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _secret(serve_conf: dict[str, Any]) -> str | None:
|
|
453
|
+
secret = serve_conf.get("secret")
|
|
454
|
+
if secret is not None and not isinstance(secret, str):
|
|
455
|
+
raise CLIError('memcat: [serve] secret must be a string (quote it: secret = "...")')
|
|
456
|
+
return secret or os.environ.get("MEMCAT_SERVE_SECRET") or None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _number(serve_conf: dict[str, Any], key: str, default: Any, kind: type) -> Any:
|
|
460
|
+
try:
|
|
461
|
+
return kind(serve_conf.get(key, default))
|
|
462
|
+
except (TypeError, ValueError):
|
|
463
|
+
raise CLIError(f"memcat: [serve] {key} must be a number") from None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _make_server(conf: dict[str, Any], args: argparse.Namespace) -> Any:
|
|
467
|
+
from memcat.serve import MAX_TEXT_CHARS, QUEUE_TIMEOUT_S, Server
|
|
468
|
+
|
|
469
|
+
spec = _Service(conf)
|
|
470
|
+
make = spec.factory(_memcat_config(conf))
|
|
471
|
+
spec.check(make, spec.sample_rate or 16000)
|
|
472
|
+
serve_conf = _section(conf, "serve")
|
|
473
|
+
pool_size = getattr(args, "pool_size", None) or _number(serve_conf, "pool_size", 4, int)
|
|
474
|
+
max_waiting = serve_conf.get("max_waiting")
|
|
475
|
+
try:
|
|
476
|
+
return Server(
|
|
477
|
+
make,
|
|
478
|
+
pool_size=pool_size,
|
|
479
|
+
secret=_secret(serve_conf),
|
|
480
|
+
max_text_chars=_number(serve_conf, "max_text_chars", MAX_TEXT_CHARS, int),
|
|
481
|
+
max_waiting=None if max_waiting is None else _number(serve_conf, "max_waiting", 0, int),
|
|
482
|
+
queue_timeout_s=_number(serve_conf, "queue_timeout_s", QUEUE_TIMEOUT_S, float),
|
|
483
|
+
insecure_no_auth=bool(getattr(args, "insecure_no_auth", False)),
|
|
484
|
+
)
|
|
485
|
+
except ValueError as e:
|
|
486
|
+
raise CLIError(f"memcat: bad [serve] option: {e}") from None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
490
|
+
from aiohttp import web
|
|
491
|
+
from loguru import logger
|
|
492
|
+
|
|
493
|
+
from memcat.serve import is_loopback
|
|
494
|
+
|
|
495
|
+
conf = _read_config(Path(args.config))
|
|
496
|
+
serve_conf = _section(conf, "serve")
|
|
497
|
+
host = str(args.host or serve_conf.get("host", "127.0.0.1"))
|
|
498
|
+
if not _secret(serve_conf) and not is_loopback(host):
|
|
499
|
+
if not args.insecure_no_auth:
|
|
500
|
+
raise CLIError(
|
|
501
|
+
f"memcat: refusing to serve on {host} without a secret: anyone who can reach "
|
|
502
|
+
"it could spend your TTS credits. Set [serve] secret or MEMCAT_SERVE_SECRET "
|
|
503
|
+
"(clients send it as X-VAPI-SECRET), or pass --insecure-no-auth if something "
|
|
504
|
+
"in front of memcat already authenticates requests"
|
|
505
|
+
)
|
|
506
|
+
logger.warning(f"memcat serve: listening on {host} with no secret (--insecure-no-auth)")
|
|
507
|
+
server = _make_server(conf, args)
|
|
508
|
+
web.run_app(
|
|
509
|
+
server.app(),
|
|
510
|
+
host=host,
|
|
511
|
+
port=args.port or _number(serve_conf, "port", 8080, int),
|
|
512
|
+
print=None,
|
|
513
|
+
)
|
|
514
|
+
return 0
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _parser() -> argparse.ArgumentParser:
|
|
518
|
+
# --config is accepted before or after the command. On the subcommands it defaults
|
|
519
|
+
# to SUPPRESS so that leaving it out there keeps the value given before the command.
|
|
520
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
521
|
+
common.add_argument("--config", default=argparse.SUPPRESS, help="path to memcat.toml")
|
|
522
|
+
p = argparse.ArgumentParser(prog="memcat", description="Memcat TTS cache tools")
|
|
523
|
+
p.add_argument("--config", default="memcat.toml", help="path to memcat.toml")
|
|
524
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
525
|
+
|
|
526
|
+
def cmd(name: str, help: str) -> argparse.ArgumentParser:
|
|
527
|
+
return sub.add_parser(name, help=help, parents=[common])
|
|
528
|
+
|
|
529
|
+
w = cmd("warm", "render the most repeated clauses ahead of time")
|
|
530
|
+
w.add_argument("--from", dest="from_file", help='calls.jsonl: {"call_id", "agent_turns"}')
|
|
531
|
+
w.add_argument("--top", type=int, default=500)
|
|
532
|
+
w.add_argument("--min-calls", type=int, default=3, help="only clauses heard in this many calls")
|
|
533
|
+
w.add_argument("--phrase", action="append", help="a phrase to render (repeatable)")
|
|
534
|
+
w.add_argument("--dry-run", action="store_true")
|
|
535
|
+
w.add_argument("--timeout", type=float, default=600.0)
|
|
536
|
+
cmd("stats", "what the cache holds")
|
|
537
|
+
pu = cmd("purge", "delete entries")
|
|
538
|
+
which = pu.add_mutually_exclusive_group()
|
|
539
|
+
which.add_argument("--text")
|
|
540
|
+
which.add_argument("--all", action="store_true", help="every entry in the namespace")
|
|
541
|
+
which.add_argument(
|
|
542
|
+
"--older-than",
|
|
543
|
+
help="entries idle this long, e.g. 30d (Redis: since last written or served; "
|
|
544
|
+
"S3: since written — S3 keeps no access time)",
|
|
545
|
+
)
|
|
546
|
+
pu.add_argument("--yes", action="store_true", help="confirm --all without asking")
|
|
547
|
+
e = cmd("export", "copy every entry to a directory")
|
|
548
|
+
e.add_argument("--to", required=True)
|
|
549
|
+
i = cmd("import", "load entries from an export")
|
|
550
|
+
i.add_argument("--from", dest="from_dir", required=True)
|
|
551
|
+
i.add_argument(
|
|
552
|
+
"--force",
|
|
553
|
+
action="store_true",
|
|
554
|
+
help="import even if the export came from another namespace (or has no manifest)",
|
|
555
|
+
)
|
|
556
|
+
sv = cmd("serve", "TTS endpoint for hosted platforms (Vapi custom voice)")
|
|
557
|
+
sv.add_argument("--host", help="address to listen on (default 127.0.0.1)")
|
|
558
|
+
sv.add_argument("--port", type=int)
|
|
559
|
+
sv.add_argument("--pool-size", type=int, help="speakers per sample rate (default 4)")
|
|
560
|
+
sv.add_argument(
|
|
561
|
+
"--insecure-no-auth",
|
|
562
|
+
action="store_true",
|
|
563
|
+
help="allow a non-loopback --host without a secret (only behind your own auth)",
|
|
564
|
+
)
|
|
565
|
+
return p
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
async def _run(args: argparse.Namespace) -> dict[str, Any]:
|
|
569
|
+
import memcat
|
|
570
|
+
|
|
571
|
+
ctx = _Ctx(_read_config(Path(args.config)), args.cmd)
|
|
572
|
+
try:
|
|
573
|
+
cmd = {
|
|
574
|
+
"warm": _cmd_warm,
|
|
575
|
+
"stats": _cmd_stats,
|
|
576
|
+
"purge": _cmd_purge,
|
|
577
|
+
"export": _cmd_export,
|
|
578
|
+
"import": _cmd_import,
|
|
579
|
+
}[args.cmd]
|
|
580
|
+
return await cmd(ctx, args)
|
|
581
|
+
finally:
|
|
582
|
+
await memcat.aclose()
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
586
|
+
"""Run the CLI. Returns 0 on success; configuration and input errors print
|
|
587
|
+
``memcat: <message>`` to stderr and return 1 (usage errors exit 2, as argparse does)."""
|
|
588
|
+
args = _parser().parse_args(argv)
|
|
589
|
+
from loguru import logger
|
|
590
|
+
|
|
591
|
+
logger.remove()
|
|
592
|
+
logger.add(sys.stderr, level=os.environ.get("MEMCAT_LOG", "WARNING"))
|
|
593
|
+
try:
|
|
594
|
+
if args.cmd == "serve":
|
|
595
|
+
return _serve(args)
|
|
596
|
+
result = asyncio.run(_run(args))
|
|
597
|
+
except CLIError as e:
|
|
598
|
+
print(e, file=sys.stderr)
|
|
599
|
+
return 1
|
|
600
|
+
except StoreError as e: # includes PurgeNotifyError: entries deleted, others not told
|
|
601
|
+
print(f"memcat {args.cmd}: store error: {e}", file=sys.stderr)
|
|
602
|
+
return 1
|
|
603
|
+
print(json.dumps(result, indent=2, default=str))
|
|
604
|
+
return 0
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
if __name__ == "__main__": # pragma: no cover
|
|
608
|
+
raise SystemExit(main())
|