# Python SDK v0.1.0 to PyPI (Episode C of 3)

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

> **Note:** this plan supersedes an earlier orphan plan at the same path that was written during the aborted bundled episode `01KSAJBMHV7F9VSJCR3YNHKBFD`. The orphan tried to ship the HTTP routes (Episode B) and Python SDK (Episode C) bundled. The split into Episodes A/B/C happened mid-session 2026-05-23; this is the cleaned-up Episode C plan.

**Goal:** Ship `pip install hippo-memory` — an async Python SDK wrapping the 13 HTTP endpoints now live on master (v1.11.4). Lives in `python/` subdir of the hippo-memory monorepo. uv-based modern tooling. Pydantic v2 typed request/response. Real-server tests (subprocess-spawned local hippo). 3 example scripts. README. GitHub Actions trusted-publisher workflow that ships to PyPI on tag push.

**Architecture:**
- **One package, one client class.** `from hippo_memory import Hippo` -> `client = Hippo(base_url="http://127.0.0.1:3737", api_key="...")` -> `await client.remember(content="...")`. Async-first (matches httpx and modern Python). Sync wrappers can land in v0.2 if there's demand.
- **Pydantic v2 models** mirror the HTTP shapes (snake_case in Python, camelCase preserved on wire via `model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)`).
- **Tests spawn hippo serve as subprocess** (real DB per project rule). One module-scoped fixture starts `node bin/hippo.js serve --port 0`, reads the bound port from stdout, yields the URL; teardown sends SIGTERM. Per-test tmp HIPPO_HOME for isolation.
- **PyPI publish via GitHub Actions trusted-publisher OIDC**. No tokens in repo, no `.pypirc` needed. Keith activates the trusted publisher on PyPI once post-merge (project name `hippo-memory`, publisher `kitfunso/hippo-memory`, workflow `pypi-publish.yml`, environment `pypi`). Workflow triggers on `python-v*.*.*` tag push.

**Tech Stack:**
- Python 3.10+ (3.12 verified locally; 3.10 = httpx async baseline)
- uv-build backend (modern, fast, replaces hatchling for this use case — uv is already on the box)
- httpx (async HTTP client) >=0.27
- pydantic v2 >=2.0
- pytest + pytest-asyncio for tests

**Why this episode exists:** v1.11.4's `/v1/outcome`, `/v1/context`, `/v1/sleep` routes complete the HTTP surface. Python users currently must shell out to `node bin/hippo.js` or use raw httpx. A typed async SDK closes the gap and gives the project a first-class non-CLI surface (one of the items on Keith's "what would make hippo #1" list).

---

## Research notes (completed in discover)

- No existing `python/` dir in hippo-memory monorepo (clean slate).
- Python 3.12.10 + uv (`~/.local/bin/uv`) both available locally.
- twine, build NOT installed — uv-build pipeline replaces them (`uv build`, `uv publish` — but publish via trusted-publisher GHA, not local).
- PyPI name `hippo-memory` confirmed AVAILABLE earlier this session (404 from `pypi.org/pypi/hippo-memory/json`).
- 13 HTTP endpoints to wrap. Mapping below.
- Project rule: "always use real DB for tests" — subprocess-spawn hippo serve, no httpx mocking for the integration layer.
- Project rule: "No em dashes in frontend strings, commit messages, PR titles, release notes." Python docstrings + README count as docs; use hyphens / colons / commas.
- Trusted-publisher OIDC is the PyPI-recommended modern pattern (https://docs.pypi.org/trusted-publishers/). No API token stored anywhere.

### HTTP endpoint map

| HTTP | Path | SDK method | Notes |
|---|---|---|---|
| GET | `/health` | `health()` | Returns `HealthInfo` (version, started_at, pid) |
| POST | `/v1/memories` | `remember(content, ...)` | Returns `MemoryEnvelope` |
| GET | `/v1/memories?q=...` | `recall(q, ...)` | Returns `RecallResult` (results, total, tokens) |
| GET | `/v1/recall/drill/:id` | `drill(id, ...)` | Returns `DrillResult` |
| DELETE | `/v1/memories/:id` | `forget(id)` | Returns `ForgetResult` |
| POST | `/v1/memories/:id/archive` | `archive(id, reason?)` | Returns `ArchiveResult` |
| POST | `/v1/memories/:id/supersede` | `supersede(id, by)` | Returns `SupersedeResult` |
| POST | `/v1/memories/:id/promote` | `promote(id)` | Returns `PromoteResult` |
| POST | `/v1/outcome` | `outcome(good, ids?)` | Returns `OutcomeResult` (applied, ids?) |
| GET | `/v1/context` | `get_context(...)` | Returns `ContextResult` |
| POST | `/v1/sleep` | `sleep(dry_run?, no_share?)` | Returns `SleepResult`; loopback-only on server side |
| GET | `/v1/sessions/:id/assemble` | `assemble(session_id, ...)` | Returns `AssembleResult` (Phase 2 context-engine API) |
| POST | `/v1/auth/keys` | `auth_create(label?)` | Returns `AuthCreated` (plaintext key) |
| GET | `/v1/auth/keys?active=true` | `auth_list(active?)` | Returns `list[AuthKey]` |
| DELETE | `/v1/auth/keys/:keyId` | `auth_revoke(key_id)` | Returns `AuthRevoked` |
| GET | `/v1/audit?op=&since=&limit=` | `audit(op?, since?, limit?)` | Returns `list[AuditEvent]` |

Out of v0.1 (deferred to v0.2): `/v1/connectors/{slack,github}/events` (webhook receivers — server-side facing, not SDK-shaped). 14 SDK methods total (was originally 13 in the discover stage; plan-eng-critic caught the missing `assemble` endpoint).

---

## Task 1: Branch + cleanup

- `git checkout -b feat/python-sdk-v0.1` from master tip `9961447`.
- Delete the orphan `docs/plans/2026-05-23-python-sdk-v01.md` (it was an untracked file from the aborted bundled episode; this NEW plan supersedes it).

Wait — actually the orphan plan IS what we're editing (this file). The orphan was untracked; this plan IS the new tracked file. T1 just confirms clean branch state.

---

## Task 2: Python package scaffold

**Files:** Create `python/pyproject.toml`, `python/README.md` (stub for T7), `python/src/hippo_memory/__init__.py`, `python/src/hippo_memory/py.typed`.

**pyproject.toml** (uv-build):

```toml
[build-system]
requires = ["uv_build>=0.5.0"]
build-backend = "uv_build"

[project]
name = "hippo-memory"
version = "0.1.0"
description = "Async Python SDK for hippo-memory (biologically-inspired memory for AI agents). Wraps the HTTP API of the hippo-memory npm package."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{name = "Kit", email = "skfsk27@gmail.com"}]
keywords = ["memory", "ai-agents", "rag", "hippo-memory"]
classifiers = [
  "Development Status :: 3 - Alpha",
  "Intended Audience :: Developers",
  "License :: OSI Approved :: MIT License",
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3.10",
  "Programming Language :: Python :: 3.11",
  "Programming Language :: Python :: 3.12",
  "Programming Language :: Python :: 3.13",
  "Typing :: Typed",
]
dependencies = [
  "httpx>=0.27",
  "pydantic>=2.0",
]

[project.optional-dependencies]
test = ["pytest>=8", "pytest-asyncio>=0.23"]

[project.urls]
Repository = "https://github.com/kitfunso/hippo-memory"
Issues = "https://github.com/kitfunso/hippo-memory/issues"
Changelog = "https://github.com/kitfunso/hippo-memory/blob/master/CHANGELOG.md"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
```

`__init__.py` exports the public surface (Hippo client + all model classes + HippoError).

**Step 1:** `cd python && uv sync --extra test` to install + lock dependencies (uv.lock committed).

**Step 2:** Verify package importable: `python -c "import hippo_memory; print(hippo_memory.__version__)"` -> `0.1.0`.

**Step 3: Commit** `feat(python): scaffold uv-built hippo_memory package (v0.1.0)`.

---

## Task 2.5: Response fixture capture (research step) — DEFERRED at execute

> **Deferred (2026-05-23 execute decision):** `python/scripts/capture_fixtures.py` ships as a helper script but was NOT successfully run end-to-end against a live serve during execute. Windows subprocess `/health` polling timed out 2 iterations in a row (subprocess didn't bind the port within the 20s window — likely an interaction between `node bin/hippo.js serve` and `cwd=tmp_home` where the auto-init path was slower than expected). Per "after 2 failed iterations stop and reconsider", T2.5 is deferred. Pydantic models in T3 are built from the implementer's direct knowledge of the server response shapes (Episode A + Episode B were written this session by the same author); T5 integration tests catch any model-shape drift against live responses. TODOS.md captures the regenerate-fixtures follow-up.

The capture script below is retained as scaffolding for a future fixture refresh once the Windows subprocess issue is debugged.

**Files:** Create `python/tests/fixtures/<endpoint>.json` for each of the 14 endpoints.

Strategy: boot `node bin/hippo.js serve --port 3737` once against an isolated tmp HIPPO_HOME, seed 3-5 memories via /v1/memories POST, then fire one happy-path request per endpoint and save the response JSON verbatim. These fixtures drive Task 3 model design.

Quick shell loop (executes once, results committed):

```bash
# Tmp setup
HIPPO_HOME=/tmp/hippo-fixtures HIPPO_TENANT=default node bin/hippo.js serve --port 3737 &
SERVER_PID=$!
sleep 1
mkdir -p python/tests/fixtures

# Seed
curl -s -X POST http://localhost:3737/v1/memories -H 'content-type: application/json' \
  -d '{"content": "fixture seed 1"}' > /dev/null
# ... seed a few more

# Capture each endpoint
for endpoint in health recall remember-response context outcome-applied outcome-last-recall sleep-dry assemble drill forget archive supersede promote auth-keys-list audit-list; do
  # appropriate curl per endpoint shape ...
  curl -s ... > python/tests/fixtures/$endpoint.json
done

kill $SERVER_PID
```

Resulting fixture files become the contract: Task 3 Pydantic models validate against them; Task 5 integration tests can cross-check that live responses match the captured fixtures.

**Step 1: Commit** `test(python): capture HTTP response fixtures for all 14 endpoints`.

---

## Task 3: Pydantic models

**Files:** Create `python/src/hippo_memory/models.py`.

Mirror the HTTP shapes. Use `populate_by_name=True` + camelCase alias generator so the JSON wire format is preserved while Python users see snake_case attribute names.

Models: `MemoryEnvelope`, `RecallEntry`, `RecallResult`, `ContextEntry`, `ContextResult`, `OutcomeResult`, `SleepResult`, `ArchiveResult`, `SupersedeResult`, `PromoteResult`, `ForgetResult`, `DrillResult`, `HealthInfo`, `AuthCreated`, `AuthKey`, `AuthRevoked`, `AuditEvent`. Plus `HippoError` exception class.

Pattern: all models inherit from a private `_Base(BaseModel)` with `ConfigDict(populate_by_name=True, alias_generator=to_camel, extra="allow")`. `extra="allow"` keeps unknown fields so server adding new fields in a future release doesn't break the SDK. **Important caveat (in-code comment):** `extra="allow"` silently swallows typos in test JSON payloads (e.g. a misspelled field never raises ValidationError). The forward-compat benefit outweighs this for an SDK consumed against an evolving server, but a future maintainer should think before tightening.

**Step 1:** Each model field validated against the actual server response captured in T2.5 fixtures. Models built FROM the fixtures (not reactively); per-fixture round-trip test ensures `Model.model_validate(fixture_json).model_dump(by_alias=True) == fixture_json` (modulo `extra="allow"` swallowing unknown fields, which the round-trip catches if intentional).

**Step 2:** `python/tests/test_models.py` asserts basic round-trip: `MemoryEnvelope.model_validate(some_dict).model_dump(by_alias=True) == some_dict`.

**Step 3: Commit** `feat(python): Pydantic v2 models for HTTP request/response shapes`.

---

## Task 4: Hippo async client

**Files:** Create `python/src/hippo_memory/client.py`.

```python
class Hippo:
    def __init__(self, base_url="http://127.0.0.1:3737", api_key=None, timeout=30.0):
        headers = {"content-type": "application/json"}
        if api_key:
            headers["authorization"] = f"Bearer {api_key}"
        self._client = httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout)

    async def __aenter__(self): return self
    async def __aexit__(self, *args): await self.close()
    async def close(self): await self._client.aclose()

    async def _request(self, method, path, **kwargs):
        response = await self._client.request(method, path, **kwargs)
        if response.status_code >= 400:
            body = None
            try: body = response.json()
            except Exception: pass
            raise HippoError(response.status_code, response.text, body)
        return response.json()

    async def health(self) -> HealthInfo: ...
    async def remember(self, content, *, kind=None, scope=None, owner=None, artifact_ref=None, tags=None) -> MemoryEnvelope: ...
    async def recall(self, q, *, limit=None, mode=None, scope=None) -> RecallResult: ...
    async def outcome(self, good, ids=None) -> OutcomeResult: ...
    async def get_context(self, *, q=None, budget=None, limit=None, pinned_only=False, scope=None, include_recent=None) -> ContextResult: ...
    async def sleep(self, *, dry_run=False, no_share=False) -> SleepResult: ...
    async def drill(self, id, *, limit=None, budget=None) -> DrillResult: ...
    async def forget(self, id) -> ForgetResult: ...
    async def archive(self, id, *, reason=None) -> ArchiveResult: ...
    async def supersede(self, id, *, by) -> SupersedeResult: ...
    async def promote(self, id) -> PromoteResult: ...
    async def auth_create(self, *, label=None) -> AuthCreated: ...
    async def auth_list(self, *, active=None) -> list[AuthKey]: ...
    async def auth_revoke(self, key_id) -> AuthRevoked: ...
    async def audit(self, *, op=None, since=None, limit=None) -> list[AuditEvent]: ...
```

**Step 1:** Implement all 13 endpoint methods. snake_case Python params -> match server's expected query/body conventions. (Server query routes accept snake_case for pinned_only / include_recent / dry_run / no_share; verify during execute.)

**Step 2:** Build: `cd python && uv build` -> `dist/hippo_memory-0.1.0-py3-none-any.whl` + sdist.

**Step 3:** Verify import: `uv run python -c "from hippo_memory import Hippo; print('ok')"`.

**Step 4: Commit** `feat(python): async httpx Hippo client wrapping 13 HTTP endpoints`.

---

## Task 5: Real-server integration tests

**Files:** Create `python/tests/conftest.py`, `python/tests/test_client.py`.

**conftest.py** spawns `node bin/hippo.js serve --port 0` as subprocess, reads bound port from stdout, yields URL. **Function-scoped** (not module-scoped — module-scope sharing was caught as a flaky-test risk by plan-eng-critic: empty-store assertions in later tests would fail because earlier tests wrote memories into the shared HIPPO_HOME). Per-function fresh tmp HIPPO_HOME. Startup cost ~200ms per test, ~2s total for the suite — acceptable for the isolation gain.

**test_client.py — 10 cases:**
1. `health()` returns version 1.11.4
2. `remember + recall` round-trip
3. `remember + get_context` returns the memory
4. `remember + outcome(good=True, ids=[id])` -> applied:1
5. `remember + recall + outcome(good=False)` (last-recall path) -> applied + ids (tenant-safe per v1.11.4 fix)
6. `get_context(pinned_only=True)` empty store -> entries:[]
7. `get_context(budget=0)` -> entries:[], tokens:0
8. `sleep(dry_run=True)` -> dry_run:True
9. `forget(id)` removes the memory
10. `HippoError` raised on 400 (e.g. `outcome` with missing good field)

**Step 1:** `cd python && uv run pytest -v`. All green.

**Step 2: Commit** `test(python): real-server integration tests (subprocess-spawned hippo serve)`.

---

## Task 6: Example scripts

**Files:** Create `python/examples/01_basic_remember_recall.py`, `python/examples/02_context_injection.py`, `python/examples/03_outcome_feedback.py`.

Each example: short, runnable against a local `hippo serve` (assume `http://127.0.0.1:3737`), demonstrates one user journey.

- **01_basic**: remember 3 memories, recall them by query.
- **02_context**: get_context against a populated store, render entries to stdout markdown.
- **03_outcome**: recall -> outcome(good=True) on last-recall path, show the audit_log row delta via the `audit()` method.

**Step 1: Commit** `docs(python): 3 example scripts (basic / context / outcome)`.

---

## Task 7: README

**Files:** Create `python/README.md` (replaces the T2 stub).

Sections:
- Install: `pip install hippo-memory`
- Quickstart: 10-line async example
- Compatibility: `hippo-memory@>=1.11.4` server required (since /v1/outcome, /v1/context, /v1/sleep land then)
- API: list of `Hippo` methods + link to per-method docstring
- Server setup: `npm install -g hippo-memory && hippo serve` -> sets up the loopback server the SDK calls
- Authentication: `Hippo(api_key="...")` for non-default-tenant
- Limitations: loopback-only server today; /v1/sleep is host-wide cross-tenant

**Step 1: Commit** `docs(python): README with quickstart + API + auth + limitations`.

---

## Task 8: PyPI trusted-publisher workflow

> **CRITICAL — DO NOT PUSH `python-v0.1.0` TAG UNTIL THE PYPI TRUSTED PUBLISHER IS CONFIGURED.** The workflow below runs on tag push. If PyPI's `hippo-memory` project doesn't yet have the trusted publisher configured for `kitfunso/hippo-memory` + workflow `pypi-publish.yml` + environment `pypi`, the first tag-push workflow run will fail noisily with an OIDC auth error. Configure FIRST (see end-of-task note), THEN tag.

**Files:** Create `.github/workflows/pypi-publish.yml`.

```yaml
name: Publish Python SDK to PyPI

on:
  push:
    tags:
      - 'python-v*.*.*'

permissions:
  id-token: write  # required for PyPI trusted publisher OIDC

jobs:
  publish:
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/project/hippo-memory
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install uv
        uses: astral-sh/setup-uv@v3
        with:
          version: "0.10.9"  # pin to known-good; uv-build backend requires >=0.5.0
      - name: Build
        working-directory: python
        run: uv build
      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          packages-dir: python/dist/
```

Tag format `python-v*.*.*` (distinct from `v*.*.*` which is npm releases — keeps the two publish surfaces independent).

**Step 1: Commit** `ci(python): trusted-publisher PyPI workflow on python-v* tags`.

**Note for Keith (post-merge action):** configure the trusted publisher on PyPI:
1. Go to https://pypi.org/manage/account/publishing/
2. Add a new publisher: owner=`kitfunso`, repo=`hippo-memory`, workflow=`pypi-publish.yml`, environment=`pypi`.
3. Once configured, push a `python-v0.1.0` tag to trigger the first publish.
4. Alternatively, for v0.1.0 the SDK ships to GitHub-only; PyPI publish happens once the trusted publisher is wired.

---

## Task 9: Top-level CHANGELOG entry

**Files:** Modify `CHANGELOG.md` (prepend a "Python SDK v0.1.0" section above the 1.11.4 entry; treat it as a sibling release line, not a hippo-memory npm version bump).

Top-level package.json npm version STAYS at 1.11.4 (this is a Python-only release, not a npm release).

**Step 1: Commit** `docs+chore: CHANGELOG entry for Python SDK v0.1.0`.

---

## Verify

- `cd python && uv run pytest -v` — all integration tests pass.
- `cd python && uv build` — wheel + sdist produced in `python/dist/`.
- `cd python && uv run python -c "from hippo_memory import Hippo; print(Hippo.__doc__)"` — sanity.
- Top-level: `npm test` for the npm package still passes (no JS/TS surface changed).

---

## Review

- `/self-review` on diff.
- `independent-review-critic`: brief on (a) endpoint coverage (all 13?), (b) Pydantic model accuracy against the server's actual response shape, (c) error handling (`HippoError` for all non-2xx), (d) auth header format (`Bearer <key>`), (e) test isolation (subprocess teardown clean? tmp HIPPO_HOME per module isolates?), (f) PyPI workflow security (trusted-publisher OIDC, no token), (g) README accuracy.

---

## Ship + Deploy

- `/ship-check`, `ship-readiness-critic`.
- PR `feat/python-sdk-v0.1`, title `feat(python): hippo-memory v0.1.0 Python SDK (PyPI release)`.
- Human-final-gate.
- Merge.
- Push `python-v0.1.0` tag.
- Trusted-publisher workflow auto-publishes to PyPI (if Keith configured it post-merge per Task 8 note).
- GitHub Release auto-created via the workflow OR manually via `gh release create`.

---

## Success criteria

- [ ] `python/` subdir exists with pyproject.toml + `hippo_memory` package.
- [ ] 13 endpoints all wrapped as `Hippo` async methods.
- [ ] Pydantic v2 models cover all request + response shapes.
- [ ] `HippoError` raised on 4xx/5xx.
- [ ] Tests spawn real hippo serve subprocess; all green.
- [ ] 3 example scripts present and runnable.
- [ ] README covers install + quickstart + API + auth + limitations.
- [ ] PyPI workflow committed to `.github/workflows/pypi-publish.yml`.
- [ ] CHANGELOG entry present.
- [ ] PR merged.
- [ ] Tag `python-v0.1.0` pushed.
- [ ] PyPI publish happens (once trusted publisher configured) OR explicit "PyPI publish deferred until Keith configures trusted publisher" note in the GitHub release.

---

## Out of scope (v0.2 candidates)

- Sync wrappers (`HippoSync`) for non-async users.
- Server-side connector webhook routes (`/v1/connectors/{slack,github}/events`).
- `/v1/recall/assemble` (no HTTP route today — check during execute; if no route, defer).
- Auto-spawn `hippo serve` from the SDK constructor (subprocess management complexity; better to leave as separate setup).
- SQLite-direct mode (skip HTTP entirely, talk to the db file directly).
- Streaming responses (recall + context don't stream today).
- Multi-tenant scoping helpers (`with_tenant(tenant_id)` chainable).
- ContextResult.entries projection helpers (the full MemoryEntry surface is exposed today; v0.2 could add a `.projected()` shortcut that mirrors `hippo context --format json`).
