telys 0.1.0a1__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.
- telys/__init__.py +30 -0
- telys/_keys/README.md +36 -0
- telys/_keys/telys_license_pub.pem +9 -0
- telys/_keys/telys_release_pub.pem +9 -0
- telys/_supervisor.py +61 -0
- telys/_wire.py +38 -0
- telys/cli.py +266 -0
- telys/client.py +173 -0
- telys/embedding.py +105 -0
- telys/engine.py +316 -0
- telys/filters.py +17 -0
- telys/installer.py +272 -0
- telys/login.py +206 -0
- telys/mcp.py +195 -0
- telys/paths.py +132 -0
- telys/runtime.py +137 -0
- telys/server.py +427 -0
- telys/tuning.py +98 -0
- telys/verify.py +226 -0
- telys-0.1.0a1.dist-info/METADATA +62 -0
- telys-0.1.0a1.dist-info/RECORD +23 -0
- telys-0.1.0a1.dist-info/WHEEL +4 -0
- telys-0.1.0a1.dist-info/entry_points.txt +2 -0
telys/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Telys — embedded, on-device memory & retrieval SDK (public, thin).
|
|
2
|
+
|
|
3
|
+
from telys import Telys, Eq, scope_key
|
|
4
|
+
eng = Telys(path, embedding_providers={...})
|
|
5
|
+
eng.create_collection(name, dim, partition_by, embedder=, dtype=, filter_columns=)
|
|
6
|
+
eng.open_collection(name, embedder=) · eng.collections()
|
|
7
|
+
col.add_texts / add / upsert_texts / upsert
|
|
8
|
+
col.search_text / search (where=, top_k=, explain=, target_recall=, with_metadata=)
|
|
9
|
+
col.ids(where=) # live external ids in a scope (or off-key column)
|
|
10
|
+
col.delete / update_texts / compact / build_ivf / save / stats / snapshot
|
|
11
|
+
|
|
12
|
+
This is the public SDK: facades, types, and provider/tuner interfaces only — no engine implementation. The
|
|
13
|
+
engine is a separate, closed, on-device runtime loaded on first use (`telys runtime install`; see D-30).
|
|
14
|
+
`telys turns filtered vector search into a contiguous memory operation.`
|
|
15
|
+
"""
|
|
16
|
+
from telys.engine import AME, Telys, FORMAT_VERSION, __version__, scope_key # noqa: F401
|
|
17
|
+
from telys.filters import Eq # noqa: F401
|
|
18
|
+
from telys.tuning import Tuner, TuningPlan # noqa: F401 (HeuristicTuner is lazy — see __getattr__)
|
|
19
|
+
|
|
20
|
+
__all__ = ["Telys", "AME", "Eq", "scope_key", "Tuner", "HeuristicTuner", "TuningPlan",
|
|
21
|
+
"__version__", "FORMAT_VERSION"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name):
|
|
25
|
+
# HeuristicTuner is a closed-runtime implementation; expose it lazily so `import telys` never loads the
|
|
26
|
+
# engine, while `from telys import HeuristicTuner` keeps working when the runtime is installed.
|
|
27
|
+
if name == "HeuristicTuner":
|
|
28
|
+
from telys.tuning import HeuristicTuner
|
|
29
|
+
return HeuristicTuner
|
|
30
|
+
raise AttributeError(f"module 'telys' has no attribute {name!r}")
|
telys/_keys/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Embedded Telys verification keys (PUBLIC only)
|
|
2
|
+
|
|
3
|
+
These are the **public** halves of the Telys signing keys, embedded as the default offline trust anchors
|
|
4
|
+
(D-31 #3/#5). `telys.verify` loads them when no `TELYS_RELEASE_PUBKEY[_FILE]` / `TELYS_LICENSE_PUBKEY[_FILE]`
|
|
5
|
+
override is set, so `telys runtime install --file …` verifies with **zero configuration**.
|
|
6
|
+
|
|
7
|
+
| File | Verifies | Signed by (private key, NOT in repo) |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `telys_release_pub.pem` | the runtime **manifest** (artifact SHA-256s) | **Telys release key** (Telys-owned, local) |
|
|
10
|
+
| `telys_license_pub.pem` | the offline **RS256 license** token (ver:2 entitlement) | the **SHARED platform license key** (decision-engine `license-signer` Worker — the SOLE license key for Telys/Algenta/Codna) |
|
|
11
|
+
|
|
12
|
+
**Two different authorities (post platform-convergence):**
|
|
13
|
+
- **RELEASE** key is Telys-owned and signs runtime manifests (`scripts/telys_issuer.py bundle`). Production-real here.
|
|
14
|
+
- **LICENSE** key is NOT Telys-owned — Telys *rides the shared signer*. Real licenses are issued by the
|
|
15
|
+
decision-engine control plane (`api.codna.ai` → the Cloudflare `license-signer` Worker), which alone holds
|
|
16
|
+
the license private key across all products. `telys_license_pub.pem` must therefore be the **decision-engine
|
|
17
|
+
license public key** (export from its JWKS `GET /.well-known/jwks.json` or the operator). `telys.verify`
|
|
18
|
+
checks the engine's ver:2 token (iss `https://license.algenta.ai`, aud `telys-runtime`) against it, offline.
|
|
19
|
+
|
|
20
|
+
**Override:** `TELYS_LICENSE_PUBKEY[_FILE]`, `TELYS_RELEASE_PUBKEY[_FILE]`, and `TELYS_LICENSE_ISSUER` /
|
|
21
|
+
`TELYS_LICENSE_AUDIENCE` env vars override the embedded defaults (rotation / self-hosted / air-gapped).
|
|
22
|
+
|
|
23
|
+
> ⚠️ **`telys_license_pub.pem` is a BOOTSTRAP platform keypair** (generated locally for dev/staging continuity
|
|
24
|
+
> so the offline-license path is exercisable end-to-end before the control plane is live). Its **private half is
|
|
25
|
+
> at `dist-keys/platform_license_priv.pem`** (gitignored, issuer custody — NEVER committed). To make real
|
|
26
|
+
> licenses verify, do ONE of:
|
|
27
|
+
> 1. **Adopt this bootstrap pair (dev/staging):** load `dist-keys/platform_license_priv.pem` into the
|
|
28
|
+
> decision-engine `license-signer` (its `license_signing_private_key` / Cloudflare Worker secret) so the
|
|
29
|
+
> signer mints with the private half of the key embedded here.
|
|
30
|
+
> 2. **Production:** generate a **KMS-born** RS256 key in the control plane, then replace `telys_license_pub.pem`
|
|
31
|
+
> with that signer's public half (export from its JWKS `GET /.well-known/jwks.json`) and delete the bootstrap
|
|
32
|
+
> private from `dist-keys/`.
|
|
33
|
+
>
|
|
34
|
+
> Either way, **one signer authority** holds the license private key across Telys/Algenta/Codna — never mint
|
|
35
|
+
> Telys licenses with a separate Telys-local key (the parallel-signer mistake the convergence removed). The
|
|
36
|
+
> RELEASE key remains Telys-owned and production-real.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0J1XV/U6NyCYqdwUCES1
|
|
3
|
+
TRXHH+ZimEwWPi7CeDXsOT1bNo57i+MAvM1ho2hUmIjVJsF8f8snEPHWycg29e72
|
|
4
|
+
BYmxtHRgcT9ErswIqzuEv7g8MzL1e7WqJMtevxGAuD2GtzrncVh51XOhc9VbIlcA
|
|
5
|
+
AifNamfk3uLcSm+HRDJxlRw+PCcDCQDEjyHC9/akbNDQSK82csR8DO+zleYMs5pB
|
|
6
|
+
pLrJc++Lamqq0IknT6dHeF2/VwGmMkZ+lRQxSXMvWdTcLw4w9eopeRq8/Yj3mwqA
|
|
7
|
+
BhG+bAlqjUzl6rq87WRszOiZjtDlYnMln8V4oDvJw/iy58VNlW6xvlj42lVXpay7
|
|
8
|
+
tQIDAQAB
|
|
9
|
+
-----END PUBLIC KEY-----
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzqOtDOYcDh0F3ImMt0kS
|
|
3
|
+
sgefxYH11ULzOyAM8M5jlGidlpSreh/449fhyECJa998Qm7ouwPJWn8iF5cYvChY
|
|
4
|
+
P5/aqlay8C3r7Q1jNkOrL3qnIYVVsORpNDHrQZCMwSBONUD8mkf+Qc219Jk8qjIj
|
|
5
|
+
nCbo8SWiiG/Wqj2nuCS6Hq6xGKTw4GpSQ19PXEDBhFFk9rMVbw5TrdhbaAZczG2w
|
|
6
|
+
pfvVnDaBu0iIBB6w+uzEkJQ5BN4Q+6gJb7JcBASENi1aXoklXm4nJMSF9FdHyAK/
|
|
7
|
+
GZad3y1wO7htt1fHf+kgbfXpFwj/65ViTJ5qehqR+eTMAoSOYPyL1dQja56wdiOY
|
|
8
|
+
iwIDAQAB
|
|
9
|
+
-----END PUBLIC KEY-----
|
telys/_supervisor.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Supervisor for the Telys self-host server — run it as a child process and relaunch on abnormal exit.
|
|
2
|
+
|
|
3
|
+
The engine runs in-process, so an (already-rare, after the FFI guards in P0/P1) uncatchable native crash takes
|
|
4
|
+
the daemon down. The supervisor restarts it from the last on-disk snapshot (collections persist and are lazily
|
|
5
|
+
reopened), bounding downtime; periodic save (`telys serve --save-interval`) bounds the data-loss window. A
|
|
6
|
+
clean exit (code 0, e.g. a SIGTERM-drained shutdown) is NOT restarted. A crash loop is capped so the
|
|
7
|
+
supervisor gives up instead of spinning forever.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger("telys.supervisor")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def supervise(child_argv: list[str], *, env: dict | None = None,
|
|
22
|
+
max_restarts: int = 20, window_s: float = 60.0, backoff_s: float = 1.0) -> int:
|
|
23
|
+
"""Run `python -m telys.cli <child_argv>` as a child, relaunching on abnormal exit.
|
|
24
|
+
|
|
25
|
+
Returns the child's last exit code (0 on a clean drain). Restarts are capped at ``max_restarts`` within
|
|
26
|
+
``window_s`` to avoid a tight crash loop. SIGTERM/SIGINT are forwarded to the child for a graceful drain.
|
|
27
|
+
"""
|
|
28
|
+
cmd = [sys.executable, "-m", "telys.cli", *child_argv]
|
|
29
|
+
child_env = dict(os.environ if env is None else env)
|
|
30
|
+
proc: subprocess.Popen | None = None
|
|
31
|
+
restarts: list[float] = []
|
|
32
|
+
|
|
33
|
+
def _forward(signum, _frame):
|
|
34
|
+
if proc is not None and proc.poll() is None:
|
|
35
|
+
proc.send_signal(signum)
|
|
36
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
37
|
+
try:
|
|
38
|
+
signal.signal(sig, _forward)
|
|
39
|
+
except (ValueError, OSError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
while True:
|
|
43
|
+
_log.info("supervisor: starting child: telys %s", " ".join(child_argv))
|
|
44
|
+
proc = subprocess.Popen(cmd, env=child_env)
|
|
45
|
+
try:
|
|
46
|
+
rc = proc.wait()
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
proc.send_signal(signal.SIGINT)
|
|
49
|
+
return proc.wait()
|
|
50
|
+
if rc == 0:
|
|
51
|
+
_log.info("supervisor: child exited cleanly (0) — done")
|
|
52
|
+
return 0
|
|
53
|
+
now = time.monotonic()
|
|
54
|
+
restarts = [t for t in restarts if now - t < window_s]
|
|
55
|
+
restarts.append(now)
|
|
56
|
+
if len(restarts) > max_restarts:
|
|
57
|
+
_log.error("supervisor: child crash-looping (%d restarts in %.0fs) — giving up (rc=%d)",
|
|
58
|
+
len(restarts), window_s, rc)
|
|
59
|
+
return rc
|
|
60
|
+
_log.warning("supervisor: child died (rc=%d) — restarting from last snapshot in %.1fs", rc, backoff_s)
|
|
61
|
+
time.sleep(backoff_s)
|
telys/_wire.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tiny length-prefixed JSON wire protocol shared by the Telys self-host server + client.
|
|
2
|
+
|
|
3
|
+
Frame = 4-byte big-endian unsigned length, then that many bytes of UTF-8 JSON. A request is
|
|
4
|
+
``{"op": str, "collection": str|null, "args": {...}}``; a reply is ``{"ok": true, "result": ...}`` or
|
|
5
|
+
``{"ok": false, "error": str}``. Vectors travel as plain JSON number lists (MVP; a binary frame is a later
|
|
6
|
+
optimization). The 4-byte length is bounded to fail closed on a hostile/oversized frame (anti-OOM).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import socket
|
|
12
|
+
import struct
|
|
13
|
+
|
|
14
|
+
MAX_FRAME = 256 * 1024 * 1024 # 256 MiB hard ceiling per message (anti-OOM on a bad/hostile length prefix)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def send_msg(sock: socket.socket, obj) -> None:
|
|
18
|
+
data = json.dumps(obj).encode("utf-8")
|
|
19
|
+
if len(data) > MAX_FRAME:
|
|
20
|
+
raise ValueError(f"frame too large: {len(data)} > {MAX_FRAME}")
|
|
21
|
+
sock.sendall(struct.pack(">I", len(data)) + data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
25
|
+
buf = bytearray()
|
|
26
|
+
while len(buf) < n:
|
|
27
|
+
chunk = sock.recv(n - len(buf))
|
|
28
|
+
if not chunk:
|
|
29
|
+
raise ConnectionError("peer closed the connection mid-frame")
|
|
30
|
+
buf += chunk
|
|
31
|
+
return bytes(buf)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def recv_msg(sock: socket.socket):
|
|
35
|
+
(n,) = struct.unpack(">I", _recv_exact(sock, 4))
|
|
36
|
+
if n > MAX_FRAME:
|
|
37
|
+
raise ValueError(f"declared frame too large: {n} > {MAX_FRAME}")
|
|
38
|
+
return json.loads(_recv_exact(sock, n).decode("utf-8"))
|
telys/cli.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Telys CLI — `telys ...` (scaffold).
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
telys login authenticate for gated runtime downloads (Phase 1: real auth)
|
|
5
|
+
telys runtime status show whether the on-device runtime is installed + where it resolves from
|
|
6
|
+
telys runtime install --file <bundle> --license <license.jwt>
|
|
7
|
+
verify a signed runtime bundle + offline RS256 license LOCALLY and install it
|
|
8
|
+
(offline-file install; hosted packages.telys.ai download lands later — D-31 #4)
|
|
9
|
+
telys runtime verify re-verify the installed runtime offline (manifest signature + artifact SHA-256 + license)
|
|
10
|
+
telys runtime update update to the latest runtime on the configured channel (Phase 1)
|
|
11
|
+
telys version print SDK version + format version
|
|
12
|
+
|
|
13
|
+
The SDK is thin: `runtime install` is the ONLY step that may touch the network (and offline-file install does
|
|
14
|
+
not even do that). Verification + query execution are always local/offline (D-30/D-31): the runtime + license
|
|
15
|
+
verify themselves against Telys public keys with no network, no OS-native signing.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _runtime_status() -> int:
|
|
25
|
+
from telys.runtime import runtime_available, load_runtime
|
|
26
|
+
from telys import __version__, FORMAT_VERSION
|
|
27
|
+
if not runtime_available():
|
|
28
|
+
print("runtime: NOT INSTALLED")
|
|
29
|
+
print(" install it: telys runtime install (or, for local dev: pip install telys-runtime)")
|
|
30
|
+
return 1
|
|
31
|
+
# Ask the runtime where its kernel resolves from — the SDK never imports engine internals (D-30).
|
|
32
|
+
info = load_runtime().kernel_info()
|
|
33
|
+
ok = bool(info.get("found"))
|
|
34
|
+
if ok:
|
|
35
|
+
print(f"runtime: READY (telys {__version__}, format v{FORMAT_VERSION}, impl={info.get('runtime')})")
|
|
36
|
+
print(f" kernel: FOUND [{info.get('source')}] {info.get('path')}")
|
|
37
|
+
return 0
|
|
38
|
+
print(f"runtime: PACKAGE PRESENT, KERNEL NOT INSTALLED (telys {__version__}, impl={info.get('runtime')})")
|
|
39
|
+
print(f" kernel: MISSING [{info.get('source')}] {info.get('path')}")
|
|
40
|
+
print(" install it: telys runtime install --file <bundle> --license <license.jwt>")
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _not_implemented(what: str) -> int:
|
|
45
|
+
print(f"`telys {what}` is not implemented yet (Phase 1: gated download + signature verification).")
|
|
46
|
+
print("For local development, install the runtime package directly: pip install telys-runtime")
|
|
47
|
+
return 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _runtime_install(args) -> int:
|
|
51
|
+
# D-31 #4: --file = offline-file install (no network). Otherwise download the signed bundle from the host
|
|
52
|
+
# (the ONLY networked step) and verify it LOCALLY — same RS256/SHA-256 gate either way.
|
|
53
|
+
from telys.installer import install_from_file, install_from_host, InstallError
|
|
54
|
+
from telys.verify import VerificationError
|
|
55
|
+
try:
|
|
56
|
+
if getattr(args, "file", None):
|
|
57
|
+
rec = install_from_file(args.file, args.license)
|
|
58
|
+
else:
|
|
59
|
+
from telys.paths import packages_url
|
|
60
|
+
print(f"downloading runtime '{args.version}' from {packages_url()} …")
|
|
61
|
+
rec = install_from_host(version=args.version, license_path=getattr(args, "license", None))
|
|
62
|
+
except (InstallError, VerificationError) as e:
|
|
63
|
+
print(f"install FAILED: {e}")
|
|
64
|
+
return 1
|
|
65
|
+
lic = rec.get("license", {})
|
|
66
|
+
print(f"runtime INSTALLED + VERIFIED [{rec['platform']}] ({len(rec['artifacts'])} artifacts)")
|
|
67
|
+
print(f" license: tier={lic.get('tier')} features={lic.get('features')} exp={lic.get('exp')}")
|
|
68
|
+
if rec.get("source"):
|
|
69
|
+
print(f" source: {rec['source']}")
|
|
70
|
+
print(" query execution is now fully local/offline — no network.")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _login(args) -> int:
|
|
75
|
+
# One command: OAuth device-code -> create API key -> register device -> platform-signed license ->
|
|
76
|
+
# install + verify the signed runtime -> cache. After this, Telys runs fully offline. --token skips the
|
|
77
|
+
# browser step (headless/CI); --no-install provisions without downloading the runtime.
|
|
78
|
+
from telys.login import login, LoginError
|
|
79
|
+
try:
|
|
80
|
+
info = login(
|
|
81
|
+
plan=getattr(args, "plan", "telys_developer"),
|
|
82
|
+
access_token=getattr(args, "token", None) or os.environ.get("TELYS_TOKEN"),
|
|
83
|
+
install=not getattr(args, "no_install", False),
|
|
84
|
+
open_browser=not getattr(args, "no_browser", False),
|
|
85
|
+
)
|
|
86
|
+
except LoginError as e:
|
|
87
|
+
print(f"login FAILED: {e}")
|
|
88
|
+
return 1
|
|
89
|
+
runtime = "installed" if info["installed"] else "not installed (run `telys runtime install`)"
|
|
90
|
+
print(f" api key: {info['api_key_prefix']} device: {info['device_id'][:12]}… "
|
|
91
|
+
f"tier: {info['tier']} runtime: {runtime}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mem(args) -> int:
|
|
96
|
+
import json as _json
|
|
97
|
+
|
|
98
|
+
from telys.mcp import TelysMemory
|
|
99
|
+
|
|
100
|
+
m = TelysMemory(getattr(args, "path", None))
|
|
101
|
+
try:
|
|
102
|
+
if args.memcmd == "list":
|
|
103
|
+
out = m.list_collections()
|
|
104
|
+
elif args.memcmd == "create":
|
|
105
|
+
out = m.create_collection(args.name, partition_by=args.partition_by)
|
|
106
|
+
elif args.memcmd == "add":
|
|
107
|
+
out = m.add(args.collection, texts=args.text, ids=args.id or None)
|
|
108
|
+
elif args.memcmd == "search":
|
|
109
|
+
out = m.search(args.collection, args.query, top_k=args.top_k)
|
|
110
|
+
elif args.memcmd == "stats":
|
|
111
|
+
out = m.stats(args.collection)
|
|
112
|
+
else:
|
|
113
|
+
print("usage: telys mem {add,search,create,list,stats} …")
|
|
114
|
+
return 2
|
|
115
|
+
except Exception as e: # noqa: BLE001
|
|
116
|
+
print(f"error: {e}")
|
|
117
|
+
return 1
|
|
118
|
+
print(_json.dumps(out, indent=2))
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _runtime_verify() -> int:
|
|
123
|
+
from telys.installer import verify_installed, InstallError
|
|
124
|
+
from telys.verify import VerificationError
|
|
125
|
+
try:
|
|
126
|
+
rep = verify_installed()
|
|
127
|
+
except (InstallError, VerificationError) as e:
|
|
128
|
+
print(f"runtime verify FAILED: {e}")
|
|
129
|
+
return 1
|
|
130
|
+
print(f"runtime VERIFIED [{rep['platform']}] {rep['dir']}")
|
|
131
|
+
print(f" artifacts: {', '.join(rep['artifacts_verified'])}")
|
|
132
|
+
print(f" license: tier={rep.get('tier')} exp={rep.get('exp')}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _serve(args) -> int:
|
|
137
|
+
import os
|
|
138
|
+
license_token = args.license or os.environ.get("TELYS_LICENSE")
|
|
139
|
+
access_token = args.access_token or os.environ.get("TELYS_ACCESS_TOKEN")
|
|
140
|
+
if not args.socket and not (args.host and args.port):
|
|
141
|
+
print("error: pass --socket <path> (Unix) or --host <h> --port <p> (TCP)")
|
|
142
|
+
return 2
|
|
143
|
+
|
|
144
|
+
if args.supervise:
|
|
145
|
+
# Re-run ourselves as a supervised child WITHOUT --supervise. Secrets go via env (inherited by the
|
|
146
|
+
# child), never on the child's argv, so they don't leak into the process list.
|
|
147
|
+
from telys._supervisor import supervise
|
|
148
|
+
if license_token:
|
|
149
|
+
os.environ["TELYS_LICENSE"] = license_token
|
|
150
|
+
if access_token:
|
|
151
|
+
os.environ["TELYS_ACCESS_TOKEN"] = access_token
|
|
152
|
+
child = ["serve", "--path", args.path]
|
|
153
|
+
if args.socket:
|
|
154
|
+
child += ["--socket", args.socket]
|
|
155
|
+
if args.host:
|
|
156
|
+
child += ["--host", args.host]
|
|
157
|
+
if args.port:
|
|
158
|
+
child += ["--port", str(args.port)]
|
|
159
|
+
if args.require_license:
|
|
160
|
+
child += ["--require-license"]
|
|
161
|
+
if args.save_interval:
|
|
162
|
+
child += ["--save-interval", str(args.save_interval)]
|
|
163
|
+
print(f"telys serve (supervised): {args.path}")
|
|
164
|
+
return supervise(child)
|
|
165
|
+
|
|
166
|
+
from telys.server import serve
|
|
167
|
+
where = args.socket if args.socket else f"{args.host}:{args.port}"
|
|
168
|
+
print(f"telys serve: shared memory at {args.path} on {where}"
|
|
169
|
+
+ (" [license-gated]" if args.require_license else "")
|
|
170
|
+
+ (" [token-auth]" if access_token else "")
|
|
171
|
+
+ (f" [save every {args.save_interval}s]" if args.save_interval else ""))
|
|
172
|
+
try:
|
|
173
|
+
serve(args.path, args.socket, host=args.host, port=args.port,
|
|
174
|
+
license_token=license_token, require_license=args.require_license,
|
|
175
|
+
access_token=access_token, save_interval_s=args.save_interval)
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
print("\nshutting down")
|
|
178
|
+
except OSError as e:
|
|
179
|
+
print(f"error: cannot bind {where} ({e}); is another telys server already running there?")
|
|
180
|
+
return 2
|
|
181
|
+
except ValueError as e:
|
|
182
|
+
print(f"error: {e}")
|
|
183
|
+
return 2
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv=None) -> int:
|
|
188
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
189
|
+
p = argparse.ArgumentParser(prog="telys", description="Telys — on-device memory & retrieval SDK")
|
|
190
|
+
sub = p.add_subparsers(dest="cmd")
|
|
191
|
+
login = sub.add_parser("login", help="one command: OAuth -> API key -> device -> license -> runtime install")
|
|
192
|
+
login.add_argument("--token", help="use an existing OAuth token (headless/CI); else browser device-code login")
|
|
193
|
+
login.add_argument("--plan", default="telys_developer", help="tier to request (default: telys_developer, free)")
|
|
194
|
+
login.add_argument("--no-install", action="store_true", help="provision only; skip downloading the runtime")
|
|
195
|
+
login.add_argument("--no-browser", action="store_true", help="don't auto-open the browser (print the code)")
|
|
196
|
+
sub.add_parser("version", help="print SDK + format version")
|
|
197
|
+
rt = sub.add_parser("runtime", help="manage the on-device runtime").add_subparsers(dest="rtcmd")
|
|
198
|
+
rt.add_parser("status", help="is the runtime installed?")
|
|
199
|
+
rt_install = rt.add_parser("install", help="install + verify the signed runtime (online host, or --file offline)")
|
|
200
|
+
rt_install.add_argument("--file", help="path to a signed runtime bundle (.bundle/.tar.gz) for offline install")
|
|
201
|
+
rt_install.add_argument("--license", help="path to the signed offline license (license.jwt)")
|
|
202
|
+
rt_install.add_argument("--version", default="latest", help="runtime version to fetch from the host (default: latest)")
|
|
203
|
+
rt.add_parser("verify", help="re-verify the installed runtime's signature/integrity + license (offline)")
|
|
204
|
+
rt.add_parser("update", help="update to the latest runtime on the channel")
|
|
205
|
+
srv = sub.add_parser("serve", help="self-host a shared Telys memory (Team tier) over a Unix socket or TCP")
|
|
206
|
+
srv.add_argument("--path", required=True, help="engine data directory (the shared memory store)")
|
|
207
|
+
srv.add_argument("--socket", help="Unix-domain socket to listen on (trusted host; no token needed)")
|
|
208
|
+
srv.add_argument("--host", help="TCP bind host (e.g. 0.0.0.0 for a container); requires --access-token")
|
|
209
|
+
srv.add_argument("--port", type=int, help="TCP bind port (with --host)")
|
|
210
|
+
srv.add_argument("--access-token", help="shared bearer token for TCP clients; or set TELYS_ACCESS_TOKEN")
|
|
211
|
+
srv.add_argument("--license", help="Telys license token (Team tier); or set TELYS_LICENSE")
|
|
212
|
+
srv.add_argument("--require-license", action="store_true",
|
|
213
|
+
help="refuse to serve without a valid Telys (products.telys) license")
|
|
214
|
+
srv.add_argument("--save-interval", type=float, default=0.0, metavar="SECONDS",
|
|
215
|
+
help="background periodic save every N seconds (bounds data loss on a crash; 0=off)")
|
|
216
|
+
srv.add_argument("--supervise", action="store_true",
|
|
217
|
+
help="run under a supervisor that auto-restarts the server from the last snapshot on crash")
|
|
218
|
+
|
|
219
|
+
mcp = sub.add_parser("mcp", help="run the Telys MCP stdio server (plugin for Claude/Cursor/OpenAI; no port)")
|
|
220
|
+
mcp.add_argument("--path", help="memory directory (default: ~/.telys/memory or $TELYS_MEMORY_PATH)")
|
|
221
|
+
|
|
222
|
+
mem = sub.add_parser("mem", help="use Telys memory from the terminal (advanced)").add_subparsers(dest="memcmd")
|
|
223
|
+
for _name in ("add", "search", "create", "list", "stats"):
|
|
224
|
+
mp = mem.add_parser(_name)
|
|
225
|
+
mp.add_argument("--path", help="memory directory (default: ~/.telys/memory)")
|
|
226
|
+
if _name in ("add", "search", "stats"):
|
|
227
|
+
mp.add_argument("-c", "--collection", required=True)
|
|
228
|
+
if _name == "create":
|
|
229
|
+
mp.add_argument("--name", required=True)
|
|
230
|
+
mp.add_argument("--partition-by", default="scope")
|
|
231
|
+
if _name == "add":
|
|
232
|
+
mp.add_argument("-t", "--text", action="append", required=True, help="document text (repeatable)")
|
|
233
|
+
mp.add_argument("--id", action="append", help="document id (repeatable; matches --text order)")
|
|
234
|
+
if _name == "search":
|
|
235
|
+
mp.add_argument("-q", "--query", required=True)
|
|
236
|
+
mp.add_argument("--top-k", type=int, default=5)
|
|
237
|
+
|
|
238
|
+
args = p.parse_args(argv)
|
|
239
|
+
if args.cmd == "serve":
|
|
240
|
+
return _serve(args)
|
|
241
|
+
if args.cmd == "version":
|
|
242
|
+
from telys import __version__, FORMAT_VERSION
|
|
243
|
+
print(f"telys {__version__} (format v{FORMAT_VERSION})")
|
|
244
|
+
return 0
|
|
245
|
+
if args.cmd == "login":
|
|
246
|
+
return _login(args)
|
|
247
|
+
if args.cmd == "mcp":
|
|
248
|
+
from telys.mcp import MCPServer, TelysMemory
|
|
249
|
+
return MCPServer(TelysMemory(getattr(args, "path", None))).serve()
|
|
250
|
+
if args.cmd == "mem":
|
|
251
|
+
return _mem(args)
|
|
252
|
+
if args.cmd == "runtime":
|
|
253
|
+
if args.rtcmd == "status":
|
|
254
|
+
return _runtime_status()
|
|
255
|
+
if args.rtcmd == "install":
|
|
256
|
+
return _runtime_install(args)
|
|
257
|
+
if args.rtcmd == "verify":
|
|
258
|
+
return _runtime_verify()
|
|
259
|
+
if args.rtcmd == "update":
|
|
260
|
+
return _not_implemented("runtime update")
|
|
261
|
+
p.print_help()
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
raise SystemExit(main())
|
telys/client.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Telys self-host client — the thin remote half of the Team deployment.
|
|
2
|
+
|
|
3
|
+
``connect()`` returns a ``RemoteTelys`` whose facade mirrors the in-process ``Telys``/``Collection`` API, so
|
|
4
|
+
application code is unchanged: it just talks to a shared self-hosted engine over a Unix socket instead of an
|
|
5
|
+
in-process dylib. Every client of the same server shares one memory.
|
|
6
|
+
|
|
7
|
+
from telys.client import connect
|
|
8
|
+
eng = connect("/tmp/telys.sock")
|
|
9
|
+
col = eng.create_collection("mem", dim=768, partition_by="tenant_id", filter_columns=["tenant_id"])
|
|
10
|
+
col.add(vectors, ids=ids, metadata=metadata)
|
|
11
|
+
hits = col.search(qvec, where={"tenant_id": "acme"}, top_k=10)
|
|
12
|
+
|
|
13
|
+
The client holds no engine code and needs no numpy — vectors may be numpy arrays or plain lists.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
from telys._wire import recv_msg, send_msg
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteError(RuntimeError):
|
|
24
|
+
"""An error raised by the server while handling a request (carries the server-side type + message)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _listify(v):
|
|
28
|
+
"""Deep-coerce to plain JSON-able values, without importing numpy: numpy arrays AND scalars (np.float32,
|
|
29
|
+
np.int64, …) both expose .tolist(); dict values (e.g. metadata pulled from a dataframe) are coerced too,
|
|
30
|
+
since json.dumps rejects numpy scalars."""
|
|
31
|
+
if hasattr(v, "tolist"): # numpy ndarray OR scalar -> python list/number
|
|
32
|
+
return v.tolist()
|
|
33
|
+
if isinstance(v, dict):
|
|
34
|
+
return {k: _listify(x) for k, x in v.items()}
|
|
35
|
+
if isinstance(v, (list, tuple)):
|
|
36
|
+
return [_listify(x) for x in v]
|
|
37
|
+
return v
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_addr(address: str):
|
|
41
|
+
"""Resolve an address to ('unix', path) or ('tcp', (host, port)). Accepts:
|
|
42
|
+
'unix:///run/telys.sock', '/abs/path.sock', './rel.sock', 'tcp://host:port', 'host:port'."""
|
|
43
|
+
if address.startswith("unix://"):
|
|
44
|
+
return "unix", address[len("unix://"):]
|
|
45
|
+
if address.startswith("tcp://"):
|
|
46
|
+
address = address[len("tcp://"):]
|
|
47
|
+
elif "/" in address or address.startswith("."): # looks like a filesystem path
|
|
48
|
+
return "unix", address
|
|
49
|
+
if ":" in address:
|
|
50
|
+
host, _, port = address.rpartition(":")
|
|
51
|
+
return "tcp", (host or "127.0.0.1", int(port))
|
|
52
|
+
return "unix", address
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RemoteTelys:
|
|
56
|
+
"""Client handle to a self-hosted Telys server (one shared engine). ``address`` is a Unix socket path or
|
|
57
|
+
a 'host:port' / 'tcp://host:port'; ``token`` is the shared access token for a TCP server."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, address: str, token: str | None = None, *, connect_timeout: float = 30.0) -> None:
|
|
60
|
+
self._addr = address
|
|
61
|
+
kind, target = _parse_addr(address)
|
|
62
|
+
fam = socket.AF_UNIX if kind == "unix" else socket.AF_INET
|
|
63
|
+
self._sock = socket.socket(fam, socket.SOCK_STREAM)
|
|
64
|
+
# Bound connect + handshake so a dead/hostile server can't hang the client forever; then go blocking
|
|
65
|
+
# for data ops (which may legitimately take a while).
|
|
66
|
+
self._sock.settimeout(connect_timeout)
|
|
67
|
+
self._sock.connect(target)
|
|
68
|
+
self._lock = threading.Lock() # one in-flight request/response per connection
|
|
69
|
+
if token is not None: # auth handshake (TCP); must precede any other op
|
|
70
|
+
try:
|
|
71
|
+
with self._lock:
|
|
72
|
+
send_msg(self._sock, {"op": "auth", "collection": None, "args": {"token": token}})
|
|
73
|
+
reply = recv_msg(self._sock)
|
|
74
|
+
except (OSError, socket.timeout) as exc:
|
|
75
|
+
self._sock.close()
|
|
76
|
+
raise RemoteError(f"auth handshake failed: {exc}") from exc
|
|
77
|
+
if not reply.get("ok"):
|
|
78
|
+
self._sock.close()
|
|
79
|
+
raise RemoteError(reply.get("error", "authentication failed"))
|
|
80
|
+
self._sock.settimeout(None)
|
|
81
|
+
|
|
82
|
+
def _call(self, op: str, collection: str | None = None, **args):
|
|
83
|
+
with self._lock:
|
|
84
|
+
send_msg(self._sock, {"op": op, "collection": collection, "args": args})
|
|
85
|
+
reply = recv_msg(self._sock)
|
|
86
|
+
if not reply.get("ok"):
|
|
87
|
+
raise RemoteError(reply.get("error", "unknown server error"))
|
|
88
|
+
return reply.get("result")
|
|
89
|
+
|
|
90
|
+
# ── engine surface ───────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
def ping(self) -> bool:
|
|
92
|
+
return bool(self._call("ping").get("pong"))
|
|
93
|
+
|
|
94
|
+
def create_collection(self, name: str, dim: int, partition_by, *, filter_columns=(),
|
|
95
|
+
dtype: str = "f32", embedder: str | None = None) -> "RemoteCollection":
|
|
96
|
+
self._call("create_collection", name, dim=dim, partition_by=partition_by,
|
|
97
|
+
filter_columns=list(filter_columns), dtype=dtype, embedder=embedder)
|
|
98
|
+
return RemoteCollection(self, name)
|
|
99
|
+
|
|
100
|
+
def open_collection(self, name: str) -> "RemoteCollection":
|
|
101
|
+
self._call("open_collection", name)
|
|
102
|
+
return RemoteCollection(self, name)
|
|
103
|
+
|
|
104
|
+
def collections(self) -> list:
|
|
105
|
+
return self._call("collections")
|
|
106
|
+
|
|
107
|
+
def __getitem__(self, name: str) -> "RemoteCollection":
|
|
108
|
+
return RemoteCollection(self, name)
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
try:
|
|
112
|
+
self._sock.close()
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
def __enter__(self):
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *exc):
|
|
120
|
+
self.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RemoteCollection:
|
|
124
|
+
"""Mirrors telys.engine.Collection — forwards each op to the shared server."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, engine: RemoteTelys, name: str) -> None:
|
|
127
|
+
self._engine = engine
|
|
128
|
+
self.name = name
|
|
129
|
+
|
|
130
|
+
def add(self, vectors, ids, metadata):
|
|
131
|
+
return self._engine._call("add", self.name, vectors=_listify(vectors),
|
|
132
|
+
ids=_listify(ids), metadata=_listify(metadata))
|
|
133
|
+
|
|
134
|
+
def upsert(self, vectors, ids, metadata):
|
|
135
|
+
return self._engine._call("upsert", self.name, vectors=_listify(vectors),
|
|
136
|
+
ids=_listify(ids), metadata=_listify(metadata))
|
|
137
|
+
|
|
138
|
+
def add_texts(self, texts, ids, metadata):
|
|
139
|
+
return self._engine._call("add_texts", self.name, texts=list(texts),
|
|
140
|
+
ids=_listify(ids), metadata=_listify(metadata))
|
|
141
|
+
|
|
142
|
+
def upsert_texts(self, texts, ids, metadata):
|
|
143
|
+
return self._engine._call("upsert_texts", self.name, texts=list(texts),
|
|
144
|
+
ids=_listify(ids), metadata=_listify(metadata))
|
|
145
|
+
|
|
146
|
+
def search(self, vector, top_k: int = 10, where=None, explain: bool = False, with_metadata: bool = False):
|
|
147
|
+
return self._engine._call("search", self.name, vector=_listify(vector), top_k=top_k,
|
|
148
|
+
where=_listify(where), explain=explain, with_metadata=with_metadata)
|
|
149
|
+
|
|
150
|
+
def search_text(self, text, top_k: int = 10, where=None, explain: bool = False, with_metadata: bool = False):
|
|
151
|
+
return self._engine._call("search_text", self.name, text=text, top_k=top_k,
|
|
152
|
+
where=_listify(where), explain=explain, with_metadata=with_metadata)
|
|
153
|
+
|
|
154
|
+
def ids(self, where=None) -> list:
|
|
155
|
+
return self._engine._call("ids", self.name, where=_listify(where))
|
|
156
|
+
|
|
157
|
+
def delete(self, ids):
|
|
158
|
+
return self._engine._call("delete", self.name, ids=_listify(ids))
|
|
159
|
+
|
|
160
|
+
def compact(self):
|
|
161
|
+
return self._engine._call("compact", self.name)
|
|
162
|
+
|
|
163
|
+
def save(self):
|
|
164
|
+
return self._engine._call("save", self.name)
|
|
165
|
+
|
|
166
|
+
def stats(self) -> dict:
|
|
167
|
+
return self._engine._call("stats", self.name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def connect(address: str, token: str | None = None) -> RemoteTelys:
|
|
171
|
+
"""Connect to a self-hosted Telys server. ``address`` is a Unix-socket path or 'host:port'/'tcp://host:port';
|
|
172
|
+
pass ``token`` for a TCP server that requires a shared access token."""
|
|
173
|
+
return RemoteTelys(address, token)
|