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 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())