Metadata-Version: 2.4
Name: tex-sdk
Version: 1.5.1
Summary: Tex Python SDK — SuperMemory-compatible memory API
Author: Tex
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.24.0
Provides-Extra: http2
Requires-Dist: h2>=4.1.0; extra == "http2"
Provides-Extra: test
Requires-Dist: respx>=0.20.0; extra == "test"
Requires-Dist: pytest>=7.0; extra == "test"
Requires-Dist: jsonschema>=4.18; extra == "test"

# Tex Python SDK

SuperMemory-compatible memory SDK for Python.

## Installation

```bash
pip install tex-sdk
```

Optional HTTP/2 support:

```bash
pip install "tex-sdk[http2]"
```

Requires Python 3.9+.

## Quick Start

### API key

```python
from tex import Tex

client = Tex(api_key="sk-...", base_url="http://localhost:8000")

client.add("The quick brown fox jumps over the lazy dog.")
results = client.search.documents("fox")
```

### Environment variables

Set `TEX_API_KEY` and `TEX_BASE_URL`, then:

```python
from tex import Tex

client = Tex()
```

### Per-user clients (production)

To act as one end user of your app, pass that user's id alongside your API key.
The SDK makes a single `POST /auth/token-exchange` with
`{api_key, user_id, session_id}` and receives a JWT minted for that user:

```python
import os
from tex import Tex

# base_url falls back to TEX_BASE_URL, api_key to TEX_API_KEY
alice = Tex(
    api_key=os.environ["TEX_API_KEY"],
    base_url=os.environ["TEX_BASE_URL"],
    org_id="org1",          # optional; if set, must match the key's org
    user_id="alice",
    session_id="chat-42",   # optional
)
alice.recall(q="what did I order last week?", session_id="chat-42")  # every call is scoped to alice
```

- The API key must carry an impersonation-capable scope, normally
  `impersonate_user` (also accepted: `tenant:write:any_user`, `agent`,
  `service`, `system`, `*`). Without one the backend refuses the `user_id`
  and the SDK raises `PermissionDeniedError` saying so.
- The minted token carries an `act` claim naming the key: it is locked to
  that user and cannot impersonate anyone else. The SDK also checks the
  returned token is really for the requested user.
- On expiry the SDK uses the refresh token, and otherwise re-exchanges for the
  same `user_id`.
- The per-user token is sent on **every** call, including the
  Supermemory-compatible ones (`memories.add`, `documents.add`,
  `search.memories`, `profile`, ...), so they also act as that user with that
  user's team scopes. `container_tag` on such a client must be omitted or
  equal the user id; naming another user is refused with 403
  `container_tag_not_permitted`. (Requires a backend that accepts per-user
  tokens on `/v3` and `/v4`.)
- Omit `user_id` to act as the key's own identity; compat calls then send the
  raw API key and `container_tag` selects the end user, Supermemory-style.

### Org + user login (development servers only)

```python
from tex import Tex

client = Tex(base_url="http://localhost:8000", org_id="org1", user_id="user1")
```

With no API key the SDK calls `POST /auth/login`, which exists only when the
backend runs with `DEBUG=true`. Production returns 403 and the SDK raises
`PermissionDeniedError` pointing you to the per-user API key pattern above.

## Agent memory: remember and recall

The path for an agent's conversational memory. `remember` stores turns, `recall` reads them back,
`forget` retires one.

```python
turns = [
    {"role": "user", "text": "I moved to Lisbon last month.", "timestamp": "2026-10-14T09:00:00Z"},
    {"role": "assistant", "text": "Noted - Lisbon it is.", "timestamp": "2026-10-14T09:00:05Z"},
]
saved = client.conversations.remember(turns, session_id="chat-42")
print(saved.turn_ids)                      # one durable id per stored turn

result = client.recall("Where does the user live?", session_id="chat-42")
for hit in result.hits.turns + result.hits.observations:
    print(hit.score, hit.text)

# Deeper long-term search: documents and the knowledge graph too (see below)
result = client.recall("Where does the user live?", session_id="chat-42", mode="deep")

# Retire one turn, by an id from saved.turn_ids
gone = client.conversations.forget(saved.turn_ids[0])
print(gone.forgotten, gone.residue)        # residue: pieces that outlive it once consolidated
```

- A turn is `{"role", "text", "timestamp"}`, all three required. `timestamp` is ISO 8601, epoch
  seconds/ms, or natural language (`"yesterday 3pm"`, `"2 hours ago"`), read in `timezone=` (an IANA name
  such as `"Europe/Berlin"`, or `"+05:30"`; default UTC). `saved.turn_times` says how each one was read:
  `source` is `explicit`, `relative` or `arrival` (not understood: the arrival time, `uncertain=True`).
  Recall hits carry the same as `hit.timestamp` / `hit.timestamp_ms` / `hit.time_uncertain`.
  An optional `observations` list attaches atomic facts to the turn.
- `recall` takes `min_score=`, a floor on each hit's `score` (the cosine between your query and the
  memory): without it you always get the `top_k` nearest hits, however weak.

### Probable answer

Sometimes memory can give the most probable answer: when, by sense - the time or context the question
is about - one memory is more probable than the others. `recall` (both modes) then sets
`result.probable_answer`; the list is still returned as before.

```python
result = client.recall("What did the user drink before switching?", session_id="chat-42")
pa = result.probable_answer
if pa is not None and pa.is_clear(0.7):    # 0.7 is your app's own threshold
    print(pa.text, pa.probability, pa.question_time)   # question_time: current|past|date|ever|future|timeless
    print([(alt.id, alt.probability) for alt in pa.alternatives])
else:
    print(result.probable_answer_reason)   # None, no_memory_answers, unavailable or not_enabled

# Skip it for one call
result = client.recall("Where does the user live?", session_id="chat-42", probable_answer=False)
```

- `probable_answer` is always one of the hits (same `id`, `text`, `timestamp`); `probability` is 0..1.
- When it is `None`, `probable_answer_reason` says why: `no_memory_answers` (no memory in the list
  answers the question), `unavailable` (it could not be worked out this time) or `not_enabled`.

### Which tier recall reads

`recall()` defaults to `mode="active"`: short-term **active memory** plus one search over the facts
already consolidated into long-term memory. When a session has been idle for its inactivity window
(30 minutes by default; see [Active memory inactivity window](#active-memory-inactivity-window)) it is
consolidated and leaves the active tier, and a default `recall()` still finds those facts.
`mode="deep"` searches the rest of long-term memory as well (documents, knowledge-graph statements) and
is slower.

## Documents

Add, retrieve, update, delete, list, and batch-manage documents.

```python
# Top-level shortcut
resp = client.add("Document content", container_tag="notes")

# Equivalent resource call
resp = client.documents.add("Document content", container_tag="notes")

# add() returns the document id (and the ingestion job id)
print(resp.id, resp.status, resp.job_id)

# document_date: when the content is FROM (YYYY-MM-DD or ISO 8601), not when it was uploaded.
# documents.update and documents.batch_add take it too.
client.documents.add("Minutes of the March offsite", container_tag="notes", document_date="2026-03-01")

# Get by ID. A document belongs to its container (user): pass the same
# container_tag you added it with when acting for a container with an API key.
doc = client.documents.get(resp.id, container_tag="notes")

# Update: metadata/custom_id in place; new content replaces the document
# under the same id (re-ingested). Owner-only; 409 while it is still processing.
client.documents.update(resp.id, content="Updated content", container_tag="notes")

# Delete the document and everything derived from it. Owner-only.
client.documents.delete(resp.id, container_tag="notes")

# List with filters
docs = client.documents.list(container_tags=["notes"], limit=20)

# Batch add
client.documents.batch_add(
    ["First document", "Second document"],
    container_tag="batch-import",
)

# Bulk delete: by ids, or every document of a container (documents only —
# memories and conversations are untouched). An empty filter is refused.
out = client.documents.delete_bulk(container_tags=["old-tag"])
for err in out.errors:            # each document NOT deleted, with the reason
    print(err.id, err.error)
```

A document lives in the container it was added to. `documents.get`, `documents.update` and
`documents.delete` must be given the same `container_tag` the document was added with; without it the
server looks in the caller's own container and answers 404.

## Search

### Document search

```python
results = client.search.documents("quarterly revenue", limit=10)

# Alias
results = client.search.execute("quarterly revenue", limit=10)
```

### Memory search

```python
results = client.search.memories("user preferences", container_tag="user1", threshold=0.3)
```

`threshold` is a cut-off on each hit's `similarity`: the cosine between your query and the memory, the
same number every result reports. The server's default is 0.3; raise it for fewer, closer hits. For an agent's conversational memory, `recall` (below) is the primary read path.

## Memories

```python
# Add memories
client.memories.add(
    [{"content": "User prefers dark mode"}],
    container_tag="prefs",
)

# A memory longer than one embedding (8191 tokens) is stored as a document
result = client.memories.add([{"content": "A very long design note ..."}], container_tag="prefs")
for m in result.stored_as_document:   # empty for ordinary memories
    print(m.document_id, m.job_id)    # m.id is the document id
print(result.message)                 # why, in words (None when nothing was redirected)

# Forget a memory
client.memories.forget("prefs", id="mem_abc123")

# Update a memory
client.memories.update_memory("prefs", "User prefers light mode", id="mem_abc123")
```

## Profile

```python
profile = client.profile("user-container-tag")
print(profile.profile)
```

## Tex-Unique Features

### NLQ (Natural Language Query)

```python
result = client.nlq.execute("What meetings did I have last week?")
print(result.text)
print(result.evidence)
```

### Episodes

```python
# Store a conversation
client.episodes.store(
    [
        {"role": "user", "content": "Book a table for two"},
        {"role": "assistant", "content": "Which restaurant?"},
    ],
    episode_id="ep_001",
)

# List episodes
episodes = client.episodes.list(limit=10)
```

`episodes.store` stores each message the same way `conversations.remember` stores a turn. The messages can
be recalled at once, and the episode appears in `episodes.list` after it is consolidated. Each message takes
`content` (or `text`), `role`, and an optional `timestamp` (same forms as a turn's, natural language read in
`timezone=`). If you leave out the timestamp, the arrival time is used. The result is the server's JSON as a
`dict` with typed accessors: `.episode_id` (yours, else your session, else a new id), `.turn_ids` (pass one to
`conversations.forget`), `.turn_times` and `.usage`.

### Preferences

```python
job = client.preferences.store([
    {
        "category": "beverage",
        "entity": "espresso",
        "entity_type": "Drink",
        "preference_type": "PREFERS",   # or "DISLIKES"
        "confidence": "high",           # "high" | "medium" (default) | "low"
        "context": "first thing in the morning",
    },
])
print(job["job_id"], job["status"])
```

`category`, `entity`, `entity_type` and `preference_type` are required; `context`, `confidence`, `source`
and `timestamp` are optional. Anything else (a numeric confidence, an unknown key) raises
`InvalidPreferenceError` before any request: the server would reject it or silently drop it. Storing
starts an ingestion job; `client.jobs.get(job["job_id"])` reports its progress.

### DB (developer keys only)

Raw HelixQL access to the graph engine. **It needs an API key with the `developer` (or `admin`) role, on a
client built without `user_id=`.** Any other key, and every per-user token, gets 403
`Requires one of roles: admin, developer`. Product code should use `recall`, `search` and `nlq` instead.

<!-- readme-check: roles=developer -->
```python
schema = client.db.schema()            # the node/edge labels your graph has
result = client.db.query("QUERY GetPeople() => people <- N<Person> RETURN people")  # HelixQL, not Cypher
print(result["success"], result.get("data"))
```

### Auth

```python
info = client.auth.whoami()
```

### Jobs

```python
status = client.jobs.get("job_abc123")
```

## Scope memberships

A user always sees their own `user:<id>` scope and their `org:<org>` scope — those are implicit. Every
shared `team:` or `project:` space is an explicit **grant**, and `client.scopes` lets your own org admin
manage those grants instead of asking us to.

Same key as deletion: a **non-impersonated org-admin API key** (role `admin` or `*`, on a client built
*without* `user_id=`). A per-user token inherits the key's roles and is refused, so it can never grant
itself a scope. The org is always the token's — cross-org grants are impossible here.

<!-- readme-check: roles=admin -->
```python
grant = client.scopes.grant("alice", "team:eng")          # role="member" by default
print(grant.created)            # False on an idempotent replay
print(grant.membership.role, grant.membership.created_at)
print(grant.note)               # how the change propagates, see below

listing = client.scopes.list(scope="team:eng")            # both filters optional
print(listing.total)
for m in listing.memberships:
    print(m.user_id, m.role)

client.scopes.revoke("alice", "team:eng")                 # NotFoundError if there is no such grant
```

- **A change takes effect on the user's next request.** The server resolves a user's read-set from the
  membership rows on every request; it is not cached in their tokens. A revoked scope is unreadable from
  the next call on, and a fresh grant is visible from the next call on. Revoking access does not delete
  anything written into the scope: when the data itself must go, pair the revocation with a deletion.
- **Only `team:` and `project:` scopes can be granted.** `user:` is personal and implicit for its own user
  (granting someone else's would hand over their private memory) and `org:` is already implicit for every
  member. Both raise `ScopeNotDelegableError` before any request; a malformed scope id raises
  `InvalidScopeError`.
- **Granting to a user who has not logged in yet is fine.** A user is just a string in a token, so the row
  sits inert until someone authenticates as that user in your org — onboarding before first login is a
  normal flow, not a workaround.
- **Replays are idempotent.** Re-granting returns the existing row with `created=False`; passing a different
  `role` updates it in place.
- Implicit scopes are not rows, so `list()` never shows them — an empty result does not mean the user sees
  nothing.

Platform operators have the same three calls across orgs at `client.admin.scopes.*` (explicit `org_id=`,
any scope kind); a normal customer key gets 403 there.

## Deletion

`client.deletions` erases an org's data across every tier in one request and returns an auditable
receipt. It needs a **non-impersonated org-admin API key** — role `admin` (or `*`), on a client built
*without* `user_id=`. A per-user token inherits the key's roles but is still refused (403).

<!-- readme-check: roles=admin -->
```python
receipt = client.deletions.delete_user_scope("alice", reason="GDPR erasure request")

print(receipt.status)          # "completed" | "partial"   (also .completed / .partial)
print(receipt.purge_after)     # when the retention window elapses
print(receipt.tiers.helixdb.deleted, receipt.tiers.helixdb.refused)
print(receipt.tiers.active_memory.fragments_deleted)
print(receipt.tiers.auth_db.usage_events_retained)

for gap in receipt.not_covered:
    print("not covered:", gap.tier, "—", gap.reason)

# Re-read the receipt later
receipt = client.deletions.get(receipt.request_id)
```

Targets:

<!-- readme-check: roles=admin -->
```python
client.deletions.delete_user_scope("alice")                     # one user's personal scope (grants kept)
client.deletions.delete_scope("team:eng")                       # everything stamped with a scope
client.deletions.delete_user("alice")                           # a user: personal data, active memory, grants
client.deletions.delete_memory("node-7", owner_user_id="alice") # one memory node; owner is REQUIRED
client.deletions.create(kind="memory", value="node-7", owner_user_id="alice")  # general form
```

`delete_memory` requires `owner_user_id` — the id of the user who owns the node. The server does not
default it to the caller; an empty value raises `ValueError` before any request.

`delete_user` removes the user's **personal-scope** long-term memory only: what they wrote into `team:` /
`project:` scopes stays (use `delete_scope` for shared data). Their active-memory data is removed across
all scopes and their scope memberships are removed; usage/billing and episode-registry rows are retained in
soft mode. `delete_user_scope` leaves scope grants alone (`tex.scopes.revoke` removes one).

Every method takes `reason=` and `idempotency_key=` (replaying a key returns the first receipt instead of
re-running). A malformed scope id raises `InvalidScopeError` before any request.

### What a deletion actually does

- **Soft by default, and soft is the only mode an org owner may ask for.** The target is *tombstoned*
  across the graph engine, active memory and the auth DB: it disappears from recall, search and profile
  immediately. It is not yet destroyed; `receipt.purge_after` says when it becomes eligible for the hard
  purge.
- **Purge is platform-admin only.** `mode="purge"` on `client.deletions.*` raises `PurgeNotPermittedError`
  before any request. An org owner can make their data unreachable and start the clock; irreversible
  destruction is a platform operation, so a compromised org key cannot destroy anything on the spot.
- **Ingestion records and raw S3 files are not covered yet.** The ingestion service's documents and jobs
  and the raw uploaded objects in object storage are untouched; a follow-up phase owns them. Every receipt
  lists them under `not_covered`.
- **The graph engine tombstones; nothing reclaims the space yet.** HelixDB exposes only a soft delete, so
  even a purge leaves the tombstoned nodes on disk (`helixdb_hard_delete` in `not_covered`). They are
  unreadable, but no job currently reclaims their bytes. Its delete is owner-only, so nodes it refuses are
  counted in `tiers.helixdb.refused` (the receipt goes `partial`) rather than raising, and nodes with no
  scope stamp are left alone (`skipped_unscoped`) so a shared-scope deletion cannot erase someone's
  personal data.
- **Billing survives a soft deletion.** Usage events are retained (`tiers.auth_db.usage_events_retained`)
  and removed only by a purge.

### Platform-admin surface

`client.admin.deletions.*` wraps `/admin/deletions`. **These routes are for Tex operators — a normal
customer key gets 403.** They take a required `org_id=` and may ask for `mode="purge"`.

<!-- readme-check: roles=platform_admin -->
```python
client.admin.deletions.delete_user("bob", org_id="org1", mode="purge")
client.admin.deletions.get("del_abc123")                # any org's receipt

# Hard-delete everything past its retention window (point a cron at this)
result = client.admin.deletions.run_purges(limit=50)
print(result.purged, len(result.receipts))
```

## Error Handling

The SDK provides a detailed error hierarchy:

```
TexError
  APIError
    APIConnectionError
      APITimeoutError
    APIStatusError
      BadRequestError          (400)
      AuthenticationError      (401)
      PermissionDeniedError    (403)
      NotFoundError            (404)
      ConflictError            (409)
      UnprocessableEntityError (422)
      RateLimitError           (429)
      InternalServerError      (500+)
    APIResponseValidationError
```

```python
from tex import Tex, NotFoundError, AuthenticationError, APIStatusError

client = Tex(api_key="sk-...", base_url="http://localhost:8000")

try:
    doc = client.documents.get("nonexistent")
except NotFoundError as e:
    print(f"Not found: {e}")
except AuthenticationError as e:
    print(f"Auth failed: {e}")
except APIStatusError as e:
    print(f"HTTP {e.status_code}: {e.message}")
    print(f"Code: {e.code}  Request ID: {e.request_id}  Retryable: {e.retryable}")
```

- `e.message` is the server's own reason (e.g. `Requires one of roles: admin, developer`), and `str(e)` is
  `HTTP <status>: <reason> (request_id=...)`.
- `e.code` is the server's machine-readable code when it sends one (e.g. `scope_not_visible`), else `None`.
- `e.request_id` identifies the request in the server's logs; quote it when reporting a problem.
- `e.retryable` is the server's verdict on whether repeating the call can help (`None` if it gave none).
  The SDK retries 408/429/5xx on its own (reads, and writes that provably never reached the server), but
  never when the server says `retryable: false`. A daily quota (`RateLimitError` with
  `code == "quota_exceeded"`) is `retryable: false`: it resets at 00:00 UTC.
- Client-side checks raise before any request: `InvalidScopeError`, `ScopeNotDelegableError`,
  `InvalidInactivityTimeoutError`, `InvalidPreferenceError`, `PurgeNotPermittedError` (all `ValueError`s).

## Active memory inactivity window

Conversation turns stored with `conversations.remember` land in short-term *active memory* first. An
active-memory episode (one per `session_id`) is closed and consolidated into long-term memory once it has been
idle for its **inactivity window** — 30 minutes by default. Tune it to the use case: a support chat can close
after a few minutes, a long-running agent task may need hours.

```python
from tex import Tex

turns = [{"role": "user", "text": "My order arrived damaged.", "timestamp": "2026-10-14T09:00:00Z"}]

# Per conversation (the episode keeps this window for later writes)
client.conversations.remember(turns, session_id="ticket-981", inactivity_timeout_seconds=2 * 60 * 60)

# Client-wide default for every write that does not pass its own
client = Tex(api_key="sk-...", active_inactivity_timeout_seconds=10 * 60)
```

- Resolution: per-call value → client default → org default (set by an org admin with `PATCH /me/settings`
  `{"active_memory_inactivity_seconds": 3600}`) → 30 minutes.
- Bounds: 300–86400 seconds (5 minutes to 24 hours). Invalid values raise `InvalidInactivityTimeoutError`
  before any request.
- `memories.add` and `memories.update_memory` accept the same `inactivity_timeout_seconds`.

## Configuration

Use `TexConfig` and `TexEndpoints` for advanced configuration:

```python
from tex import Tex, TexConfig, TexEndpoints

config = TexConfig(
    base_url="https://api.example.com",
    api_key="sk-...",
    timeout_s=30.0,
    max_retries=3,
    http2=True,
    active_inactivity_timeout_seconds=3600,  # optional; see "Active memory inactivity window"
    endpoints=TexEndpoints(
        db_query="/custom/db/query",
        db_schema="/custom/db/schema",
    ),
)

client = Tex(config=config)
```

Default timeout is 60 seconds. Default max retries is 2.

## Migration from SuperMemory

| SuperMemory | Tex SDK |
|---|---|
| `client.add(content)` | `client.add(content)` or `client.documents.add(content)` |
| `client.documents.get(id)` | `client.documents.get(id)` |
| `client.documents.update(id)` | `client.documents.update(id)` |
| `client.documents.delete(id)` | `client.documents.delete(id)` |
| `client.documents.list()` | `client.documents.list()` |
| `client.documents.batch_add(docs)` | `client.documents.batch_add(docs)` |
| `client.search.execute(q)` | `client.search.execute(q)` or `client.search.documents(q)` |
| `client.search.memories(q)` | `client.search.memories(q)` |
| `client.memories.add(memories)` | `client.memories.add(memories)` |
| `client.memories.forget(tag)` | `client.memories.forget(tag)` |
| `client.profile(tag)` | `client.profile(tag)` |

Tex extends SuperMemory with additional resources: `nlq`, `episodes`, `preferences`, `db`, `auth`, `jobs`,
`usage`, `scopes`, and `deletions` (plus the platform-admin-only `admin.scopes` / `admin.deletions`).

## HTTP/2

HTTP/2 is enabled by default. If the `h2` package is not installed, the SDK falls back to HTTP/1.1 automatically. To install with HTTP/2 support:

```bash
pip install "tex-sdk[http2]"
```

To explicitly disable HTTP/2:

```python
from tex import Tex

client = Tex(api_key="sk-...", base_url="http://localhost:8000", http2=False)
```
