Metadata-Version: 2.4
Name: wontopos
Version: 2.2.36
Summary: Wontopos — long-term memory for AI agents. Identical recall in every language, ~100× lower LLM bill.
Author: Wontopos
License: MIT
Project-URL: Homepage, https://wontopos.com
Project-URL: Documentation, https://wontopos.com/en/why
Project-URL: Issues, https://wontopos.com/contact?topic=bug
Project-URL: Report a security issue, https://wontopos.com/contact?topic=security
Keywords: memory,ai,agent,llm,multilingual,rag,context
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.32
Provides-Extra: async
Requires-Dist: httpx>=0.27; extra == "async"
Dynamic: license-file

# Wontopos — long-term memory for AI agents

```bash
pip install wontopos
```

Get an API key in the [console](https://wontopos.com). Keys look like `wos-live-...`;
the client also reads `WONTOPOS_API_KEY` from the environment.

```python
from wontopos import Client

mem = Client(api_key="wos-live-...")

# Each end-user / agent / topic gets its own store — create it once.
# (A "default" store already exists, so you can skip this and omit the id.)
mem.create_store("alice")
mem.add("she prefers tea over coffee", user_id="alice")

# one call → short-term + long-term + context, ready for your LLM prompt
ctx = mem.recall("what does alice drink?", user_id="alice")
```

## Why

- **The same in every language** — identical recall whichever language a memory was written in (Korean · Japanese · Chinese · English).
- **No LLM in the loop** — `store` / `search` / `recall` never call a language model. You pay retrieval, not generation.
- **Bounded retrieval** — `recall()` returns a small, fixed-size slice regardless of how much you've stored (~1,000 tokens on `tablet-2`, the default engine). Your LLM bill stops growing with history.

## Methods

| Method | Purpose |
|---|---|
| `add(content, user_id, **metadata)` | Store one memory |
| `add_turn(user_msg, assistant_msg, user_id?)` | Store a conversation exchange |
| `add_bulk(content, user_id, category=, timestamp=)` | Backfill a long history |
| `update(old_memory_id, new_content, user_id?)` | Supersede an old fact |
| `search(query, user_id, limit=10, **opts)` | Search stored memories |
| `search_full(query, user_id, limit=10, **opts)` | The same search with every field kept — `images` and `verify_used` included |
| `recall(query, user_id)` | One-call context (short + long + surrounding) |
| `history(user_id)` | Recent turns (short-term) |
| `stats(user_id)` | Counts |
| `get(user_id, memory_id)` | Fetch one memory by id (the text you stored, and its metadata) |
| `list_memories(user_id, limit=100, cursor=)` | Browse/export a store's raw memories, paged |
| `delete(user_id, memory_id)` | Delete one memory |
| `delete_all(user_id)` | GDPR erase (delete every memory for the user) |
| `add_speaker(speaker, user_id?)` | Register a person (explicit, up to 50 to start) |
| `list_speakers(user_id?)` | Registered people + per-person memory counts |
| `remove_speaker(speaker, user_id?)` | Unregister; memories stay, the tag goes |

All methods take a `user_id` — it names the **store**: one isolated memory space per end-user, agent, or topic, then per account (your API key). WHO said each memory inside a store is the `speaker` tag below — storing the assistant's own words never needs a separate id.

## Who said it (speakers)

Every memory can carry a speaker: `"me"` for the assistant's own words, or a
person's name. Speakers are explicit, like stores: register a person once,
then store under their name — a typo can never silently become a new person.
Search accepts a speaker too, so you can recall one person's words only.

```python
mem.add_speaker("Bob", user_id="alice")      # once per person
mem.add("I promised to send the report on Friday", user_id="alice", speaker="me")
mem.add("Bob said the deadline moved to Tuesday", user_id="alice", speaker="Bob")
mem.search("what did Bob say about deadlines?", user_id="alice", speaker="Bob")
```

Results arrive best-first — take the list in the order given. ``similarity`` on each
memory is a raw closeness score, not the ranking key: what produces the order is
internal and is not returned, so sorting by it makes results worse. There is no
``score`` field.

A store registers up to 50 people to start (a limit we plan to raise);
`"me"` never needs registration and never counts against it.

## Async

Same surface, awaitable — needs the extra:

```bash
pip install "wontopos[async]"
```

```python
from wontopos import AsyncClient

async with AsyncClient(api_key="wos-live-...", user_id="alice") as mem:
    await mem.add("she prefers tea over coffee")
    hits = await mem.search("what does alice drink?")
```

Every `Client` method exists on `AsyncClient` with identical arguments and
semantics (retries, redirect refusal, guards). Close with `async with` or
`await mem.aclose()`.

## Recall caching

Opt in per search and repeated or extended queries reuse the previous result
at 10% of the normal rate (Tablet and Scroll models).

It is not free to turn on: the FIRST call writes the cache and bills the query
tokens at 2x for a `5m` TTL, 3x for `1h`. Only hits inside the TTL bill at 0.1x.
So it pays for a query you repeat or extend, and costs more for one you issue
once — do not switch it on globally. Any write to the store invalidates its cache
at once, so a hit can never predate a new memory.

```python
hits = mem.search("...the conversation so far...", user_id="alice",
                  cache_control={"ttl": "5m"})   # or "1h"
```

## Reliability

Built in, no configuration needed:

- **Automatic retries** — 429 always, and 502 / 503 or a connection error only when a
  retry cannot double-process a write. The writes and the searches are POSTs, and a
  502 on one of those may have been returned *after* the service already stored and
  billed it, so those get 429 and connect-level failures only. The reads and the
  deletes that address a whole store — `list_stores`, `list_speakers`, `delete_store`,
  `remove_speaker`, `forget_image` — are GET or DELETE and do retry a 502. Twice, with
  exponential backoff + jitter, honoring the server's `Retry-After`. Tune with
  `Client(retries=...)`; `retries=0` disables.
- **Redirects refused** — the API key never follows a 3xx to another host.
- **Timeouts** — 30s per attempt by default (`Client(timeout=...)`), and a total
  budget for the whole call across every retry with `Client(deadline=...)` /
  `with_deadline(secs)`. At the defaults one call can hold for 30s + backoff + 30s
  + backoff + 30s, which a request handler with five seconds cannot use.
- **Key never in logs** — `repr(client)` masks the API key.
- **Wipe guard** — `delete()` without a `memory_id` raises instead of silently
  meaning "delete everything"; wiping a store is only ever the explicit
  `delete_all(user_id)` / `delete_store(user_id)`.

## Security

Built in, none of it configurable off:

- **TLS 1.2 floor** and certificate verification that cannot be disabled.
- **Redirects refused** — a 3xx is an error, so the key never follows one to
  another host.
- **Response size cap** — anything over 64MB is refused instead of buffered.
- **Key hygiene** — keys are trimmed (a stray newline from a file otherwise
  becomes a mystery 401) and inner whitespace is rejected; model names are
  validated before they reach a header.
- **`Client.from_env()`** reads `WONTOPOS_API_KEY` (or `WOS_API_KEY`) — keep
  keys out of source code.
- Plain-HTTP base URLs on non-local hosts warn. One dependency (`requests`,
  floor `>=2.32` for its certificate-verification fix).

## Errors

Any non-2xx response raises `WosError(status, message)`. When the server sent a
request id it's on `e.request_id` — include it when contacting support.

```python
from wontopos import Client, WosError

try:
    mem.search("...", user_id="alice")
except WosError as e:
    if e.status == 401:
        print("API key invalid or revoked")
    elif e.status == 429:
        print("Rate limited — back off")   # already retried twice by then
    else:
        print(e.status, e.message, e.request_id)
```

## A different API host

Point the client somewhere other than the default endpoint - a dedicated region,
a proxy of your own, or a local test server:

```python
mem = Client(api_key="...", base_url="https://api.example.com")
```

## Links

- Homepage: <https://wontopos.com>
- API reference: <https://wontopos.com/en/why> (Developers tab)

## Reporting a bug

Found something wrong, or something that looks unsafe? Tell us — every report gets read.

- Bugs: <https://wontopos.com/contact?topic=bug>
- Security: <https://wontopos.com/contact?topic=security> (also published at
  [`/.well-known/security.txt`](https://wontopos.com/.well-known/security.txt))

Include the SDK version (`wontopos.__version__`) and the language. If it involves a store id or a
memory, describe the shape rather than pasting the contents — we do not need your
data to fix it.

## Changelog

The three clients release in lockstep — same version, same surface, same day.
Patch releases are additive: nothing is removed or reordered within a minor line.
Two have bent that, deliberately and named at the top of their entry — 2.2.34 moved the
default engine, and 2.2.35 gives `search` the count range `recall` has always had. A
rule you can bend without saying so is not a rule, so both are stated rather than left
for a reader to hit.

- **2.2.36** — an outside review, read by a different model with no knowledge of why
  any of this was written. The one that mattered: `recall()` promised in its own
  documentation that a count outside 5–20 was refused rather than clamped, and
  nothing checked — so `limit=500` travelled to the service and failed there, for a
  mistake visible before opening a socket. `context_limit` (0–20) was unchecked the same way.
  Both are refused here now, and `0` still passes for `context_limit` because "attach none" is
  an answer, not a missing value.
  An argument mistake now raises `ValueError`, not `WosError`. A `WosError` with
  status 0 is `APIConnectionError` — "the request never got a response" — so code
  branching on it would have retried a typo forever. Every other argument check in
  this client already raised `ValueError`.
  New `Client(deadline=…)` / `with_deadline(secs)`: a TOTAL budget for one call across
  every retry. `timeout` bounds one ATTEMPT, so at the defaults a call could hold for
  30s + backoff + 30s + backoff + 30s and a request handler with five seconds had no
  way to say so. Unset means the previous behaviour.
  `replayed` is set only when the service did not send that field itself — these
  responses are widening, and a client overwriting a server's value is one release
  away from replacing a real answer with a guess.
- **2.2.35** — fixes, and **two behaviour changes**, both listed first because a patch
  release is not the place to find one by surprise.

  ⚠️ **`search`'s count is now 5–20, and out of range is refused rather than
  adjusted.** `recall` has carried that range from the start; search had no contract
  anywhere, so the clients sent whatever they were given and the service capped at 50
  with no floor. `search(q, limit=50)` and `search(q, limit=3)` both worked on 2.2.34
  and now raise. The default is still 10, so a call that passes no count is unaffected.
  Asking for 20 and silently getting 10 reads as "that is all there is", which is why
  this refuses instead of clamping.

  ⚠️ **A Rust `limit` of 0 is no longer rewritten to 10.** It was, and the caller whose
  prompt budget computed zero was handed ten memories and the bill for them. It is now
  refused under the range above rather than quietly changed to something nobody asked
  for.

  The fixes: a `null` idempotency key no longer becomes the literal key `"null"`, which
  had made every write on that path share one key so the second onward stored nothing
  (TypeScript). The async client no longer reads an explicit `retries=2` as "the caller
  said nothing" (Python). The image read is actually streamed, so the 64MB cap bounds a
  compressed body instead of measuring bytes already in memory (Python async), and that
  route's ERROR body is capped too (Rust). `iter_images` stops on a repeated cursor the
  way `iter_memories` always did (Python). `iterImages` no longer throws on the ordinary
  end of a walk (TypeScript). `get_image` retries a 429 and a connect-level failure like
  every other call — it had none at all — in the **sync Python client and TypeScript**;
  the async Python client and the Rust crate still make a single attempt there, and say
  so at the method. An empty or control-character API key is refused at the call site
  rather than at the network in **Python and Rust**; TypeScript still refuses only empty
  and whitespace. And a page walk that reaches its ceiling now raises instead of
  returning a truncated list that looks complete.
- **2.2.34** — the default engine is `tablet-2`: same token price as `tablet-1`, and
  the one that serves images and re-ask. Pin the old one with
  `model="tablet-1"` for the previous behaviour exactly. (The engine's own default
  count differs between the two, but these SDKs always send one, so a call through
  them is unaffected.) New `search_full` /
  `searchFull` keeps every field the answer came with — `search` was merging away
  the photos and `verify_used`, both of which you were billed for. Also: results arrive
  best-first — take the order as given. `similarity` is a raw closeness score, and
  sorting by it makes results worse.
- **2.2.33** — `get_image` can return a different FORMAT than you uploaded: the
  service re-encodes on downscale, so a large PNG comes back as WebP. Take the file
  extension from the response Content-Type, not from what you sent.
- **2.2.32** — `get_image` returns the picture the SERVICE holds, not your original.
  An image whose long edge is over 1568px is downscaled on the way in, and that
  smaller picture is what is stored and handed back. Keep your own copy if you need
  the full file.
- **2.2.31** — images, by-speaker, lineage and revisions are SDK methods in all three
  clients; `search` and `recall` take `verify`, `max_images` and recall's `limit` /
  `context_limit`.
- **2.2.30** — documentation only: this changelog, which had not been updated
  in the shipped package since 2.2.10.
- **2.2.29** — a store id that was PASSED but unusable no longer becomes the
  default store. 2.2.28 caught a blank string; the value a failed tenant lookup
  actually produces in Python is an integer primary key. `user_id=0` is falsy and
  fell through to the client default, and any other integer died inside the warn
  helper as `'int' object has no attribute 'lower'`. Anything but a non-blank
  string now raises, on the destructive calls too. Omitting the argument still
  means "use the default". Also: `delete_store` now warns when a store id folds
  (`delete_all` already did, and it is the call that removes a whole store), and
  an `idempotency_key` ending in a newline is refused here instead of failing
  inside `http.client` as `Invalid header value`.
- **2.2.28** — audit: warn caches made thread-safe, a whitespace `memory_id` can
  no longer read as a whole-store delete, a blank store id raises instead of
  silently using the default, and a gzip bomb no longer bypasses the response cap
  on the async client.
- **2.2.11–2.2.27** — additive fixes and hardening across all three clients.
- **2.2.10** — `Memory` fix: the relevance field is `similarity` (not `score`);
  added typed `importance`, `category`, `is_superseded`, `superseded_by`,
  `created_at`, `event_date`.
- **2.2.4–2.2.9** — one version across Python, TypeScript and Rust, released in
  lockstep; retries, redirect refusal, response cap, key masking, speakers.

License: MIT.
