Metadata-Version: 2.5
Name: keb-consol
Version: 0.3.3
Summary: Python SDK for the consol memory engine: a local vault, a hosted client, and the backend API
Author: Khas
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# keb-consol

Python SDK for the consol memory engine. Distribution `keb-consol`, import name `consol`; Python
>= 3.10, no dependencies, synchronous and thread-safe, typed payloads (`TypedDict`s, `py.typed`).
Three uses, one behaviour: a vault in your process, a client of a hosted backend, or your own
backend. Install with `uv add keb-consol` (or `pip install keb-consol`).

## Prerequisites

- **The `consol` binary.** The wheels for Linux (glibc, x64 and arm64), macOS (x64 and arm64) and
  Windows x64 bundle it. The SDK uses the first of: the `binary_path` argument, the `CONSOL_BIN`
  environment variable, the bundled binary, `consol` (`consol.exe`) on `PATH`. Elsewhere (Alpine,
  for one) the installer falls back to a wheel without it; point the SDK at a binary from the
  GitHub release. An explicit path that points nowhere fails at once with `binary_not_found`.
- **A database** for local and backend use: PostgreSQL with pgvector, or CockroachDB. Pass
  `db='postgres://...'` or set `CONSOL_DB_URL` (the one `CONSOL_*` variable the child inherits).

## 1. Local: a vault in your process

```python
from consol import create_vault

with create_vault('./memory', agent='assistant') as vault:
    vault.remember(id='deploy-day', content='Deploys run on Fridays.')
    hits = vault.recall(query='deploys run')
    assert 'deploy-day' in [card['source']['id'] for card in hits['candidates']]
    print(vault.read(id='deploy-day')['text'])  # the complete Markdown file, frontmatter included
```

`create_vault` starts `consol stdio --vault ./memory` and creates the vault (`vault_exists` if there
is one); `open_vault('./memory', agent=...)` opens an existing one (`vault_not_found`). Both check
the server version against the SDK's before they return. For an LLM tool loop, `call_tool(name,
args)` returns the whole reply and never raises for a tool error: send `reply['text']` back to the
model. `consol schema` prints the JSON Schema of the wire.

## 2. Hosted: a client of a backend

```py
from consol import open_vault

with open_vault(url='https://memory.example.com/v1/consol',
                headers={'Authorization': 'Bearer <your key>'},  # whatever your backend checks
                vault='acme', agent='alice') as vault:
    vault.remember(id='deploy-day', content='Deploys run on Fridays.')
    print(vault.recall(query='deploys run')['candidates'][0]['source']['id'])
```

The same methods over HTTP: each call POSTs `{"tool", "vault", "agent", "args"}` with your headers.
There is no create: backends create vaults. `vault` and `agent` may be left out when the backend
pins them. Redirects are never followed (they would carry your credentials). Transport failures are
`request_failed` (`status`; retryable for connect errors and 502/503/504) and `forbidden` for a 401
or 403 that is not a consol reply; a consol error body is a normal tool error whatever its status.

## 3. Backend: your own endpoint

```py
import asyncio
from consol import Consol, Context, http_status
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse

consol = Consol.start(vault_root='./vaults')  # or vault='./one-vault'; db= or CONSOL_DB_URL
app = FastAPI()
USERS = {'alice-token': ('acme', 'alice')}  # YOUR auth: token -> (vault, agent)

@app.post('/v1/consol')
async def wire(request: Request, authorization: str = Header('')):
    user = USERS.get(authorization.removeprefix('Bearer '))
    if user is None:
        raise HTTPException(401, 'unauthorized')
    ctx = Context(vaults=[user[0]], agent=user[1])  # from your auth, NEVER from the request
    reply = await asyncio.to_thread(consol.handle, await request.json(), ctx)  # handle is sync
    return JSONResponse(reply, status_code=http_status(reply))
```

`ctx` decides which vaults and which agent a request may use: build it from your own authentication,
never from the body or headers a client controls (`{'vaults': None, ...}` means every vault; write it
deliberately). `handle(body, ctx)` returns the reply dict as is (`version`, `text`, `result` or
`error`); a tool error is in the reply, never raised, and `http_status(reply)` is the status to
answer with. One child serves every thread. Creating a vault is the backend's job, never the wire's:
`consol.create_vault('acme')` (`vault_exists`), `consol.open_vault('acme')` (`vault_not_found`).
`Consol.start` takes `vault_root` (a directory or `s3://bucket/prefix`; vault `x` is `<root>/x`) or
`vault` (one vault, named `default`), plus `db`, `embedding`, `jev={'url', 'api_key', 'model',
'only', 'auto'}`, `binary_path`, `env` and `timeout`.

## Methods

Keyword-only arguments with the tool's wire names, plus `timeout=` in seconds; they return the
result payload as a plain dict and raise `ConsolError` on a tool error.

| Method | Arguments | Returns |
|---|---|---|
| `recall` | `query`, `agent` | `RecallResult`: `text` is the progressive disclosure to give a model (cards by tier, the blocks the query's symbols ask for, hints); `candidates` are the same cards as data. Cards are leads: read a note before relying on it |
| `read` | `id`, `continuation`, `agent` | `ReadResult`; `text` is the exact file |
| `remember` | `id`, `content`, `expected_version`, `meta` (`{'summary', 'entities', 'facets', 'scope', 'team', 'valid_from', 'valid_to'}`), `token`, `agent` | `RememberResult` |
| `remove` | `id`, `expected_version`, `token`, `agent` | `RemoveResult`: the note is archived, its body stays readable |
| `reflect` | `focus`, `decisions`, `agent`; call it with no arguments and follow `next` | `ReflectResult`: `next`, the delivered `items` (sources carry their `text`), every unresolved `writes` entry, the `blocked` tokens |
| `call_tool` | `(name, args)` with raw wire arguments | the whole `Reply`; a tool error is a reply with `error` |
| `close` | | final and idempotent |

- A call's `agent` goes into the envelope and overrides the vault's default.
- `remember` without `expected_version` is create-only. To update, pass `source.content_version`
  from your last `read`; a stale one fails with `version_conflict`. In `meta` a key left out keeps
  its stored value, `None`, `''` or `[]` clears it (not `scope` or `team`); a key the server does not
  know is ignored and listed in the receipt's `ignored`. `token` on `remember` and `remove` is
  an item's work token from `reflect`: it binds that one write to the work (the server derives the
  write's key); a write token from `reflect()['writes']` or a write receipt, alone, resumes that write.
  `reflect` holds nothing between calls: decide an item with `{'token', 'verdict', 'reason', 'refs', 'outputs'}`,
  `verdict` one of the item's `verdicts`.
- `byte_range` values are UTF-8 byte offsets: `slice_utf8(text, byte_range)` slices a `str` by them.

## Loading many notes

Each `remember` is independent, and the vault is safe to call from several threads, so load a
corpus in parallel: with a hosted embedding provider most of a write is waiting on it, and
parallel writes overlap that wait (8 writers loaded 3.8x faster than one in a measured run).

```py
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(8) as pool:
    list(pool.map(lambda doc: vault.remember(**doc), docs))   # docs: [{"id": ..., "content": ...}]
```

## Errors

Every failure is a `ConsolError`. Branch on `code` and `retryable`, never on the message:

```python
from consol import ConsolError, open_vault

with open_vault('./memory', agent='assistant') as vault:
    try:
        vault.remember(id='deploy-day', content='Deploys run on Mondays.')  # create-only: the id exists
    except ConsolError as error:
        if error.code != 'already_exists':
            raise
        print(error.code, error.retryable, error.tool)  # already_exists False remember
```

- `field` names the input at fault, relative to `args` (`query`, `decisions[2].refs[0]`); `tool`
  the tool; `result` keeps the raw reply of a failed call, including the durable state of a pending
  write (`error.result['result']`).
- Engine codes (accept unknown future ones): `invalid_argument`, `forbidden`, `not_found`,
  `already_exists`, `version_conflict`, `vault_exists`, `vault_not_found`, `team_not_attached`,
  `secret_rejected`, `content_too_large`, `unsupported`, `work_not_found`, `basis_mismatch`,
  `already_decided`, `unresolved_writes`, `postcondition_failed`, `index_unavailable`, `storage_unavailable`, `vault_busy`,
  `internal`.
- SDK codes: `request_failed` (hosted), `incompatible_server` (server major.minor differs from the
  SDK's), `binary_not_found`, `server_exited`, `timeout`, `closed`.
- `retryable: True` (`vault_busy`, `index_unavailable`, `storage_unavailable`, connect errors) means the identical request
  may succeed later. The SDK never retries by itself.

## Lifecycle

- Use the vault (or `Consol`) as a context manager or call `close()`: final and idempotent.
  In-flight and later calls fail with `closed`; a concurrent `close()` returns once the child exited.
- Calls may overlap; replies are matched by id. Every call has a deadline: 300 s by default,
  `timeout=` per vault or per call, `0` or `None` disables it. A timeout only stops waiting: a write
  may still complete, so read the note before you retry.
- When the child crashes, in-flight calls fail with `server_exited`. The next call starts it once
  more (a local vault is re-opened first: deleted meanwhile means `vault_not_found`).
- Run one child per vault; a second process waits up to 5 s for the vault lock, then `vault_busy`.

## Configuration and logging

Arguments become the child's environment, never its argv. Inherited `CONSOL_*` and `RUST_LOG` are
dropped first (except `CONSOL_DB_URL`), so a stray host variable cannot change the agent or the vault.

| Argument | Meaning |
|---|---|
| `path` / `vault_root` / `vault` | the vault location (made absolute) or, for `Consol`, the vaults' root |
| `agent` | default agent of every call; a call's `agent` overrides it |
| `db` | database URL (`CONSOL_DB_URL`, required unless the environment has it) |
| `embedding` | `{'url', 'model', 'api_key', 'dims', 'timeout_ms'}` (`CONSOL_EMBED_*`; another key fails with `invalid_argument`); without it recall is lexical-only |
| `env` | any other child variable, applied last |
| `binary_path` | the binary, see above |
| `timeout` | default per-call deadline in seconds |

The child's stderr is captured and never written to your stderr. Each line goes to
`logging.getLogger('consol')` at DEBUG level; add `env={'RUST_LOG': 'debug'}` for more detail. A
`server_exited` error carries the last lines in its message and in `stderr_tail`.

## Types and stability

Exports: `create_vault`, `open_vault`, `Vault`, `Consol`, `http_status`, `ConsolError`,
`slice_utf8`, `Reply`, `Context` and the result `TypedDict`s (`RecallResult`, `ReadResult`,
`RememberResult`, `RemoveResult`, `ReflectResult`, `Decision`, `ToolError`, ...). Payloads may gain
fields in any release. Experimental, so they may change in a minor release: hub and promotion
work, leads and decisions, and every `jev`, `jev_triage` and `jev_advisory` field (plain dicts).

## Check

From the repository root:

```bash
uv run --no-project python bindings/test_sdk.py          # protocol tests: no binary, no database
CONSOL_BIN=/abs/consol CONSOL_DB_URL=postgres://... uv run --no-project python bindings/test_sdk.py real
```
