From aa51d1cd2f92775ba8448a2d225e121b1379928b Mon Sep 17 00:00:00 2001 From: Steve Paltridge Date: Fri, 24 Apr 2026 02:06:23 -0600 Subject: [PATCH 1/5] ci: tag-triggered release workflows for Python + TS SDKs - release-python-sdk.yml: tag python-sdk-v* -> build + publish to PyPI - release-ts-sdk.yml: tag ts-sdk-v* -> typecheck/test/build + publish to npm with provenance - docs/releasing-sdks.md: one-time secrets setup + release runbook Both workflows verify tag matches package version before publishing. --- .github/workflows/release-python-sdk.yml | 48 ++++++++++++++++ .github/workflows/release-ts-sdk.yml | 53 ++++++++++++++++++ docs/releasing-sdks.md | 71 ++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 .github/workflows/release-python-sdk.yml create mode 100644 .github/workflows/release-ts-sdk.yml create mode 100644 docs/releasing-sdks.md diff --git a/.github/workflows/release-python-sdk.yml b/.github/workflows/release-python-sdk.yml new file mode 100644 index 0000000..b7aa14c --- /dev/null +++ b/.github/workflows/release-python-sdk.yml @@ -0,0 +1,48 @@ +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | tag-triggered PyPI publish +# Trigger: push a tag matching `python-sdk-v*` (e.g. `python-sdk-v0.1.0`). +# Requires repo secret `PYPI_API_TOKEN` (a PyPI project-scoped token). +# Optional: switch to OIDC trusted-publishing by removing the `password` line and +# configuring the project on PyPI as a trusted publisher. +name: release-python-sdk + +on: + push: + tags: + - "python-sdk-v*" + +permissions: + contents: read + id-token: write # for future OIDC trusted publishing + +jobs: + build-and-publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/python + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build deps + run: python -m pip install --upgrade pip build + + - name: Verify version matches tag + run: | + TAG="${GITHUB_REF_NAME#python-sdk-v}" + PKG=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + echo "tag=$TAG pkg=$PKG" + test "$TAG" = "$PKG" + + - name: Build sdist + wheel + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: clients/python/dist + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release-ts-sdk.yml b/.github/workflows/release-ts-sdk.yml new file mode 100644 index 0000000..db79035 --- /dev/null +++ b/.github/workflows/release-ts-sdk.yml @@ -0,0 +1,53 @@ +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | tag-triggered npm publish +# Trigger: push a tag matching `ts-sdk-v*` (e.g. `ts-sdk-v0.1.0`). +# Requires repo secret `NPM_TOKEN` (npm automation token with publish scope on +# the `@recallworks` org). +name: release-ts-sdk + +on: + push: + tags: + - "ts-sdk-v*" + +permissions: + contents: read + id-token: write # for npm provenance + +jobs: + build-and-publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/typescript + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Install + run: npm install --no-audit --no-fund + + - name: Verify version matches tag + run: | + TAG="${GITHUB_REF_NAME#ts-sdk-v}" + PKG=$(node -p "require('./package.json').version") + echo "tag=$TAG pkg=$PKG" + test "$TAG" = "$PKG" + + - name: Typecheck + run: npx tsc --noEmit + + - name: Test + run: npx vitest run --reporter=basic + + - name: Build + run: npx tsup src/index.ts --format esm,cjs --dts --clean + + - name: Publish to npm (with provenance) + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/releasing-sdks.md b/docs/releasing-sdks.md new file mode 100644 index 0000000..f45bfd0 --- /dev/null +++ b/docs/releasing-sdks.md @@ -0,0 +1,71 @@ + +# Releasing the Recall client SDKs + +Tag-driven, GitHub Actions-driven, no local secrets required after one-time setup. + +## One-time setup + +### PyPI + +1. Create a PyPI account if you don't have one: . +2. Reserve the project name once by manually uploading the first build, OR set up + a [pending publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) + for `RecallWorks/Recall` with workflow file `release-python-sdk.yml` and + environment (none). +3. Generate a project-scoped API token at + (scope = `Project: recall-client`). +4. Add it as a GitHub repo secret named `PYPI_API_TOKEN`: + . + +### npm + +1. Sign in / create the `recallworks` org at . +2. Generate an automation token: + - Type: **Granular Access Token** + - Permissions: `Read and write` on packages of `@recallworks` scope + - Expiration: 90 days (rotate via this same procedure) +3. Add it as repo secret `NPM_TOKEN`. + +## Cutting a release + +### Python SDK + +```pwsh +cd C:\Dev\Recall\clients\python +# bump version in pyproject.toml first, then: +git add pyproject.toml +git commit -m "chore(python-sdk): bump to 0.1.1" +git tag python-sdk-v0.1.1 +git push && git push --tags +``` + +The `release-python-sdk.yml` workflow will: +1. Verify the tag suffix matches `pyproject.toml` version. +2. Build sdist + wheel. +3. Publish to PyPI using `PYPI_API_TOKEN`. + +### TypeScript SDK + +```pwsh +cd C:\Dev\Recall\clients\typescript +# bump version in package.json first, then: +git add package.json +git commit -m "chore(ts-sdk): bump to 0.1.1" +git tag ts-sdk-v0.1.1 +git push && git push --tags +``` + +The `release-ts-sdk.yml` workflow will typecheck, test, build, and `npm publish --provenance`. + +## Verification + +- PyPI: +- npm: +- Both should show the new version within ~60 seconds of the workflow finishing. + +## Rollback + +- **PyPI** does NOT allow re-uploading the same version. To "yank": + `pip` will skip yanked versions; do it from the project admin page. +- **npm**: `npm unpublish @recallworks/recall-client@0.1.1` works for ~72 hours + after publish; otherwise issue a patch release with the fix. From bbc605c97d497c566dbc82eac63e3d65001dfacd Mon Sep 17 00:00:00 2001 From: Steve Paltridge Date: Fri, 24 Apr 2026 02:21:28 -0600 Subject: [PATCH 2/5] fix(sdks)!: rewrite Python and TypeScript clients to match real server contract (0.2.0) BREAKING CHANGE: 0.1.0 was published with a fictional API. Live smoke against ghcr.io/recallworks/recall:0.1.0 caught two server-contract violations (Authorization header instead of X-API-Key; tags as list instead of comma-separated string). Root-cause review showed the entire surface was invented. This commit rewrites both SDKs to match the real server. Real contract: - Auth header: X-API-Key (not Authorization: Bearer) - Endpoint: POST /tool/{name} with JSON body - Response envelope (ALL tools): {result: str, tool: str, by: str} - Errors: 4xx/5xx with {error: str} - /health: GET, no auth Signatures now match server exactly (recall uses n+type, remember tags is comma-separated string, forget takes source label not chunk id, checkpoint/reflect/anti_pattern/session_close use snake_case body keys, etc.). Verification: - Python: 16/16 unit tests pass; live smoke against running container PASSED (sync+async remember/recall/memory_stats/checkpoint + auth-error path) - TypeScript: 16/16 vitest pass; tsc --noEmit clean; live smoke PASSED (health/remember/recall/checkpoint + auth-error path) --- clients/python/pyproject.toml | 2 +- clients/python/recall_client/__init__.py | 32 +-- clients/python/recall_client/async_client.py | 176 ++++++++----- clients/python/recall_client/client.py | 228 +++++++++++------ clients/python/recall_client/models.py | 66 ++--- clients/python/smoke_live.py | 91 +++++++ clients/python/tests/test_client.py | 141 ++++++----- clients/typescript/package.json | 3 +- clients/typescript/smoke_live.ts | 53 ++++ clients/typescript/src/client.ts | 245 ++++++++++++------- clients/typescript/src/index.ts | 6 +- clients/typescript/src/types.ts | 35 +-- clients/typescript/tests/client.test.ts | 161 ++++++------ 13 files changed, 770 insertions(+), 469 deletions(-) create mode 100644 clients/python/smoke_live.py create mode 100644 clients/typescript/smoke_live.ts diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index e1bb44a..90467fe 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "recall-client" -version = "0.1.0" +version = "0.2.0" description = "Official Python client for Recall — open-source memory for AI agents" readme = "README.md" license = "MIT" diff --git a/clients/python/recall_client/__init__.py b/clients/python/recall_client/__init__.py index db555b3..c11614d 100644 --- a/clients/python/recall_client/__init__.py +++ b/clients/python/recall_client/__init__.py @@ -1,23 +1,19 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | Python SDK package init | prev: NEW +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | rewrite to match real server contract | prev: 0.1.0 """recall_client — official Python SDK for Recall memory server. -Quick start: +All Recall tools return ``{"result": str, "tool": str, "by": str}`` over HTTP. +This SDK exposes: - from recall_client import RecallClient - - client = RecallClient("http://localhost:8787", api_key="changeme") - client.remember("first memory", tags=["hello"]) - hits = client.recall("hello", limit=5) - for h in hits: - print(h.content, h.score) +- ``RecallClient`` / ``AsyncRecallClient`` with one typed wrapper per registered tool. +- Generic ``call_tool(name, **kwargs) -> ToolResponse`` for anything new. -Async usage: +Quick start:: - from recall_client import AsyncRecallClient + from recall_client import RecallClient - async with AsyncRecallClient("http://localhost:8787", api_key="changeme") as c: - await c.remember("async memory") - hits = await c.recall("memory") + with RecallClient("http://localhost:8787", api_key="changeme") as c: + c.remember("first memory", tags="hello,greeting") + print(c.recall("hello", n=3).result) """ from .async_client import AsyncRecallClient @@ -29,18 +25,16 @@ RecallServerError, RecallToolError, ) -from .models import Hit, RememberResult, ToolResult +from .models import ToolResponse -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "AsyncRecallClient", - "Hit", "RecallAuthError", "RecallClient", "RecallConnectionError", "RecallError", "RecallServerError", "RecallToolError", - "RememberResult", - "ToolResult", + "ToolResponse", ] diff --git a/clients/python/recall_client/async_client.py b/clients/python/recall_client/async_client.py index 63f443e..5c3bad2 100644 --- a/clients/python/recall_client/async_client.py +++ b/clients/python/recall_client/async_client.py @@ -1,5 +1,5 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | async HTTP client | prev: NEW -"""Asynchronous Recall client. Mirrors RecallClient API surface.""" +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | async client matching real contract | prev: 0.1.0 +"""Asynchronous Recall client. Mirrors RecallClient surface.""" from __future__ import annotations @@ -13,11 +13,11 @@ RecallServerError, RecallToolError, ) -from .models import Hit, RememberResult, ToolResult +from .models import ToolResponse class AsyncRecallClient: - """Async HTTP client for a Recall server. Use as an async context manager.""" + """Async HTTP client for a Recall server.""" def __init__( self, @@ -32,9 +32,9 @@ def __init__( self._client = client or httpx.AsyncClient( timeout=timeout, headers={ - "Authorization": f"Bearer {api_key}", + "X-API-Key": api_key, "Content-Type": "application/json", - "User-Agent": "recall-client-python/0.1.0", + "User-Agent": "recall-client-python/0.2.0", }, ) @@ -48,10 +48,10 @@ async def close(self) -> None: if self._owns_client: await self._client.aclose() - async def call_tool(self, name: str, **payload: Any) -> dict[str, Any]: + async def call_tool(self, name: str, **kwargs: Any) -> ToolResponse: url = f"{self.base_url}/tool/{name}" try: - resp = await self._client.post(url, json=payload) + resp = await self._client.post(url, json=kwargs) except httpx.RequestError as e: raise RecallConnectionError(f"Cannot reach {url}: {e}") from e @@ -59,75 +59,128 @@ async def call_tool(self, name: str, **payload: Any) -> dict[str, Any]: raise RecallAuthError(f"Auth failed ({resp.status_code}): {resp.text}") if resp.status_code >= 500: raise RecallServerError( - f"Server error {resp.status_code}: {resp.text}", - resp.status_code, + f"Server error {resp.status_code}: {resp.text}", resp.status_code ) if resp.status_code >= 400: raise RecallToolError(name, f"HTTP {resp.status_code}: {resp.text}") data = resp.json() - if isinstance(data, dict) and data.get("error"): + if isinstance(data, dict) and "error" in data and data.get("error"): raise RecallToolError(name, str(data["error"])) - return data if isinstance(data, dict) else {"result": data} + if not isinstance(data, dict): + raise RecallToolError(name, f"unexpected response shape: {data!r}") + return ToolResponse.from_dict(data) async def remember( + self, content: str, source: str = "agent-observation", tags: str = "" + ) -> ToolResponse: + return await self.call_tool("remember", content=content, source=source, tags=tags) + + async def recall(self, query: str, n: int = 5, type: str = "all") -> ToolResponse: + return await self.call_tool("recall", query=query, n=n, type=type) + + async def reflect( + self, + domain: str, + hypothesis: str, + reasoning: str, + result: str, + revised_belief: str, + next_time: str, + confidence: float = 0.7, + session: str = "", + ) -> ToolResponse: + return await self.call_tool( + "reflect", + domain=domain, + hypothesis=hypothesis, + reasoning=reasoning, + result=result, + revised_belief=revised_belief, + next_time=next_time, + confidence=confidence, + session=session, + ) + + async def anti_pattern( self, - content: str, - tags: list[str] | None = None, - metadata: dict[str, Any] | None = None, - ) -> RememberResult: - payload: dict[str, Any] = {"content": content} - if tags: - payload["tags"] = tags - if metadata: - payload["metadata"] = metadata - return RememberResult.from_dict(await self.call_tool("remember", **payload)) - - async def recall( - self, query: str, limit: int = 5, tags: list[str] | None = None - ) -> list[Hit]: - payload: dict[str, Any] = {"query": query, "limit": limit} - if tags: - payload["tags"] = tags - result = await self.call_tool("recall", **payload) - hits = result.get("hits", result.get("results", [])) - return [Hit.from_dict(h) for h in hits] - - async def reflect(self, summary: str, tags: list[str] | None = None) -> ToolResult: - payload: dict[str, Any] = {"summary": summary} - if tags: - payload["tags"] = tags - return ToolResult.from_dict(await self.call_tool("reflect", **payload)) + domain: str, + temptation: str, + why_wrong: str, + signature: str, + instead: str, + session: str = "", + ) -> ToolResponse: + return await self.call_tool( + "anti_pattern", + domain=domain, + temptation=temptation, + why_wrong=why_wrong, + signature=signature, + instead=instead, + session=session, + ) + + async def session_close( + self, + session_id: str, + reasoning_changed: str, + do_differently: str, + still_uncertain: str, + temptations: str, + ) -> ToolResponse: + return await self.call_tool( + "session_close", + session_id=session_id, + reasoning_changed=reasoning_changed, + do_differently=do_differently, + still_uncertain=still_uncertain, + temptations=temptations, + ) async def checkpoint( self, - session: str, - established: str, intent: str, + established: str, pursuing: str, - summary: str, - open_questions: list[str] | None = None, - ) -> ToolResult: - return ToolResult.from_dict( - await self.call_tool( - "checkpoint", - session=session, - established=established, - intent=intent, - pursuing=pursuing, - summary=summary, - open_questions=open_questions or [], - ) + open_questions: str, + session: str = "", + domain: str = "", + ) -> ToolResponse: + return await self.call_tool( + "checkpoint", + intent=intent, + established=established, + pursuing=pursuing, + open_questions=open_questions, + session=session, + domain=domain, ) - async def pulse(self) -> dict[str, Any]: - return await self.call_tool("pulse") + async def pulse( + self, domain: str = "", include_reasoning: bool = True + ) -> ToolResponse: + return await self.call_tool( + "pulse", domain=domain, include_reasoning=include_reasoning + ) - async def memory_stats(self) -> dict[str, Any]: + async def memory_stats(self) -> ToolResponse: return await self.call_tool("memory_stats") - async def forget(self, id: str, source: str = "user-request") -> ToolResult: - return ToolResult.from_dict(await self.call_tool("forget", id=id, source=source)) + async def forget(self, source: str) -> ToolResponse: + return await self.call_tool("forget", source=source) + + async def reindex(self, path: str = "") -> ToolResponse: + return await self.call_tool("reindex", path=path) + + async def index_file(self, filepath: str) -> ToolResponse: + return await self.call_tool("index_file", filepath=filepath) + + async def maintenance(self, pull: bool = True) -> ToolResponse: + return await self.call_tool("maintenance", pull=pull) + + async def snapshot_index(self) -> ToolResponse: + return await self.call_tool("snapshot_index") async def health(self) -> dict[str, Any]: try: @@ -135,5 +188,8 @@ async def health(self) -> dict[str, Any]: except httpx.RequestError as e: raise RecallConnectionError(f"Cannot reach health: {e}") from e if resp.status_code != 200: - raise RecallServerError(f"Health failed: {resp.status_code}", resp.status_code) - return resp.json() + raise RecallServerError( + f"Health failed: {resp.status_code}", resp.status_code + ) + data = resp.json() + return data if isinstance(data, dict) else {"raw": data} diff --git a/clients/python/recall_client/client.py b/clients/python/recall_client/client.py index dbef241..fe061c0 100644 --- a/clients/python/recall_client/client.py +++ b/clients/python/recall_client/client.py @@ -1,8 +1,5 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | sync HTTP client | prev: NEW -"""Synchronous Recall client. - -Uses httpx.Client. Thread-safe. Use as a context manager or call ``close()``. -""" +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | sync HTTP client matching real contract | prev: 0.1.0 +"""Synchronous Recall client. Mirrors the server's real tool registry.""" from __future__ import annotations @@ -16,7 +13,7 @@ RecallServerError, RecallToolError, ) -from .models import Hit, RememberResult, ToolResult +from .models import ToolResponse class RecallClient: @@ -24,11 +21,9 @@ class RecallClient: Args: base_url: Base URL of the Recall server, e.g. ``http://localhost:8787``. - api_key: API key sent as ``Authorization: Bearer ``. + api_key: API key sent as ``X-API-Key``. timeout: Per-request timeout in seconds. Default 30. - client: Optional pre-configured ``httpx.Client``. If provided, - ``base_url`` and ``api_key`` are still used to build request URLs - and headers, but the underlying transport is yours. + client: Optional pre-configured ``httpx.Client``. """ def __init__( @@ -44,9 +39,9 @@ def __init__( self._client = client or httpx.Client( timeout=timeout, headers={ - "Authorization": f"Bearer {api_key}", + "X-API-Key": api_key, "Content-Type": "application/json", - "User-Agent": "recall-client-python/0.1.0", + "User-Agent": "recall-client-python/0.2.0", }, ) @@ -57,17 +52,16 @@ def __exit__(self, *_: Any) -> None: self.close() def close(self) -> None: - """Close the underlying HTTP client (only if we own it).""" if self._owns_client: self._client.close() - # ── core tool dispatch ──────────────────────────────────────────────── + # ── core dispatch ──────────────────────────────────────────────────── - def call_tool(self, name: str, **payload: Any) -> dict[str, Any]: - """Invoke any tool by name. Returns the raw JSON response.""" + def call_tool(self, name: str, **kwargs: Any) -> ToolResponse: + """Invoke any registered tool by name.""" url = f"{self.base_url}/tool/{name}" try: - resp = self._client.post(url, json=payload) + resp = self._client.post(url, json=kwargs) except httpx.RequestError as e: raise RecallConnectionError(f"Cannot reach {url}: {e}") from e @@ -75,95 +69,167 @@ def call_tool(self, name: str, **payload: Any) -> dict[str, Any]: raise RecallAuthError(f"Auth failed ({resp.status_code}): {resp.text}") if resp.status_code >= 500: raise RecallServerError( - f"Server error {resp.status_code}: {resp.text}", - resp.status_code, + f"Server error {resp.status_code}: {resp.text}", resp.status_code ) if resp.status_code >= 400: raise RecallToolError(name, f"HTTP {resp.status_code}: {resp.text}") data = resp.json() - if isinstance(data, dict) and data.get("error"): + if isinstance(data, dict) and "error" in data and data.get("error"): raise RecallToolError(name, str(data["error"])) - return data if isinstance(data, dict) else {"result": data} + if not isinstance(data, dict): + raise RecallToolError(name, f"unexpected response shape: {data!r}") + return ToolResponse.from_dict(data) - # ── typed convenience wrappers ──────────────────────────────────────── + # ── typed wrappers (exact server signatures) ───────────────────────── def remember( + self, content: str, source: str = "agent-observation", tags: str = "" + ) -> ToolResponse: + """Store a memory. + + Args: + content: The text to remember. + source: Origin label (default ``agent-observation``). + tags: Comma-separated tag string (e.g. ``"pref,ui"``). + """ + return self.call_tool("remember", content=content, source=source, tags=tags) + + def recall(self, query: str, n: int = 5, type: str = "all") -> ToolResponse: + """Semantic recall. + + Args: + query: What to search for. + n: Number of hits to return (default 5). + type: Filter (``all`` | ``observation`` | ``reflection`` | ``checkpoint``). + """ + return self.call_tool("recall", query=query, n=n, type=type) + + def reflect( self, - content: str, - tags: list[str] | None = None, - metadata: dict[str, Any] | None = None, - ) -> RememberResult: - """Store a memory. Returns the assigned id.""" - payload: dict[str, Any] = {"content": content} - if tags: - payload["tags"] = tags - if metadata: - payload["metadata"] = metadata - result = self.call_tool("remember", **payload) - return RememberResult.from_dict(result) - - def recall( + domain: str, + hypothesis: str, + reasoning: str, + result: str, + revised_belief: str, + next_time: str, + confidence: float = 0.7, + session: str = "", + ) -> ToolResponse: + """Persist a structured reasoning artifact.""" + return self.call_tool( + "reflect", + domain=domain, + hypothesis=hypothesis, + reasoning=reasoning, + result=result, + revised_belief=revised_belief, + next_time=next_time, + confidence=confidence, + session=session, + ) + + def anti_pattern( + self, + domain: str, + temptation: str, + why_wrong: str, + signature: str, + instead: str, + session: str = "", + ) -> ToolResponse: + """Record a 'looks right but isn't' pattern.""" + return self.call_tool( + "anti_pattern", + domain=domain, + temptation=temptation, + why_wrong=why_wrong, + signature=signature, + instead=instead, + session=session, + ) + + def session_close( self, - query: str, - limit: int = 5, - tags: list[str] | None = None, - ) -> list[Hit]: - """Search memories semantically. Returns ranked hits.""" - payload: dict[str, Any] = {"query": query, "limit": limit} - if tags: - payload["tags"] = tags - result = self.call_tool("recall", **payload) - hits = result.get("hits", result.get("results", [])) - return [Hit.from_dict(h) for h in hits] - - def reflect(self, summary: str, tags: list[str] | None = None) -> ToolResult: - """Persist a reflection / lesson learned.""" - payload: dict[str, Any] = {"summary": summary} - if tags: - payload["tags"] = tags - return ToolResult.from_dict(self.call_tool("reflect", **payload)) + session_id: str, + reasoning_changed: str, + do_differently: str, + still_uncertain: str, + temptations: str, + ) -> ToolResponse: + """End-of-session reflection.""" + return self.call_tool( + "session_close", + session_id=session_id, + reasoning_changed=reasoning_changed, + do_differently=do_differently, + still_uncertain=still_uncertain, + temptations=temptations, + ) def checkpoint( self, - session: str, - established: str, intent: str, + established: str, pursuing: str, - summary: str, - open_questions: list[str] | None = None, - ) -> ToolResult: - """Snapshot the current session state.""" - return ToolResult.from_dict( - self.call_tool( - "checkpoint", - session=session, - established=established, - intent=intent, - pursuing=pursuing, - summary=summary, - open_questions=open_questions or [], - ) + open_questions: str, + session: str = "", + domain: str = "", + ) -> ToolResponse: + """Snapshot current working state.""" + return self.call_tool( + "checkpoint", + intent=intent, + established=established, + pursuing=pursuing, + open_questions=open_questions, + session=session, + domain=domain, ) - def pulse(self) -> dict[str, Any]: - """Get current server health + active sessions.""" - return self.call_tool("pulse") + def pulse(self, domain: str = "", include_reasoning: bool = True) -> ToolResponse: + """Get current orientation (last checkpoint, recent reasoning).""" + return self.call_tool("pulse", domain=domain, include_reasoning=include_reasoning) - def memory_stats(self) -> dict[str, Any]: - """Get aggregate counts (chunks, sessions, artifacts).""" + def memory_stats(self) -> ToolResponse: + """Aggregate counts across the store.""" return self.call_tool("memory_stats") - def forget(self, id: str, source: str = "user-request") -> ToolResult: - """Soft-archive a memory by id. Source defaults to 'user-request'.""" - return ToolResult.from_dict(self.call_tool("forget", id=id, source=source)) + def forget(self, source: str) -> ToolResponse: + """Soft-archive all chunks matching ``source``. + + NOTE: ``source`` is the tag/source label, NOT a chunk id. The server + marks matching chunks ``archived=true`` rather than deleting them. + """ + return self.call_tool("forget", source=source) + + def reindex(self, path: str = "") -> ToolResponse: + """Re-index a path (or the whole corpus if empty).""" + return self.call_tool("reindex", path=path) + + def index_file(self, filepath: str) -> ToolResponse: + """Index a single file.""" + return self.call_tool("index_file", filepath=filepath) + + def maintenance(self, pull: bool = True) -> ToolResponse: + """Run maintenance (default: pull latest corpus + reindex).""" + return self.call_tool("maintenance", pull=pull) + + def snapshot_index(self) -> ToolResponse: + """Snapshot the current index to the prebuilt directory.""" + return self.call_tool("snapshot_index") + + # ── plain HTTP endpoints (no auth) ──────────────────────────────────── def health(self) -> dict[str, Any]: - """Plain GET /health — does not require a tool path.""" + """GET /health — does not require auth.""" try: resp = self._client.get(f"{self.base_url}/health") except httpx.RequestError as e: raise RecallConnectionError(f"Cannot reach health: {e}") from e if resp.status_code != 200: - raise RecallServerError(f"Health failed: {resp.status_code}", resp.status_code) - return resp.json() + raise RecallServerError( + f"Health failed: {resp.status_code}", resp.status_code + ) + data = resp.json() + return data if isinstance(data, dict) else {"raw": data} diff --git a/clients/python/recall_client/models.py b/clients/python/recall_client/models.py index a536283..ef4154b 100644 --- a/clients/python/recall_client/models.py +++ b/clients/python/recall_client/models.py @@ -1,63 +1,35 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | typed response models | prev: NEW -"""Typed response models for Recall SDK. +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | response model matching real envelope | prev: typed Hits +"""Response model for Recall tool calls. -Models are dataclasses (no Pydantic dep) for zero-friction install. +Every Recall tool returns the JSON envelope ``{"result": str, "tool": str, "by": str}``. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any @dataclass -class Hit: - """A single recall search hit.""" +class ToolResponse: + """Wraps a Recall tool response. - content: str - score: float - tags: list[str] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - id: str | None = None + ``result`` is always the tool's return value coerced to ``str`` by the + server. For ``recall``/``pulse``/``memory_stats`` it's human-readable + markdown. For ``remember``/``reflect``/etc it's a status line. + """ - @classmethod - def from_dict(cls, d: dict[str, Any]) -> Hit: - return cls( - content=d.get("content", ""), - score=float(d.get("score", 0.0)), - tags=list(d.get("tags", [])), - metadata=dict(d.get("metadata", {})), - id=d.get("id"), - ) - - -@dataclass -class RememberResult: - """Result of a remember() call.""" - - id: str - artifact_path: str | None = None + result: str + tool: str + by: str @classmethod - def from_dict(cls, d: dict[str, Any]) -> RememberResult: + def from_dict(cls, d: dict[str, Any]) -> ToolResponse: return cls( - id=d.get("id", ""), - artifact_path=d.get("artifact_path"), + result=str(d.get("result", "")), + tool=str(d.get("tool", "")), + by=str(d.get("by", "")), ) - -@dataclass -class ToolResult: - """Generic tool-invocation result for tools without a typed wrapper.""" - - ok: bool - data: dict[str, Any] = field(default_factory=dict) - error: str | None = None - - @classmethod - def from_dict(cls, d: dict[str, Any]) -> ToolResult: - return cls( - ok=bool(d.get("ok", True)), - data=dict(d) if "ok" not in d else {k: v for k, v in d.items() if k != "ok"}, - error=d.get("error"), - ) + def __str__(self) -> str: # pragma: no cover + return self.result diff --git a/clients/python/smoke_live.py b/clients/python/smoke_live.py new file mode 100644 index 0000000..9d18fb8 --- /dev/null +++ b/clients/python/smoke_live.py @@ -0,0 +1,91 @@ +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | live SDK smoke against running Recall container +"""Live smoke test against a Recall container. + +Run order: + docker run -d --name recall-smoke -p 8799:8787 -e API_KEY=smoke-key ghcr.io/recallworks/recall:0.1.0 + python smoke_live.py +""" + +from __future__ import annotations + +import asyncio +import sys + +from recall_client import ( + AsyncRecallClient, + RecallAuthError, + RecallClient, +) + +BASE = "http://localhost:8799" +KEY = "smoke-key" + + +def sync_smoke() -> None: + print("=== sync smoke ===") + with RecallClient(BASE, api_key=KEY) as c: + h = c.health() + print(f"health: {h}") + assert h["status"] == "ok", h + + r = c.remember("the user prefers dark mode", tags="pref,ui") + print(f"remember: {r.result[:80]}") + assert r.tool == "remember" + + c.remember("favorite color is orange", tags="pref") + c.remember("monitor resolution is 4K", tags="pref,ui") + + rec = c.recall("user preferences", n=3) + print(f"recall ({len(rec.result)} chars):") + print(rec.result[:300]) + assert rec.tool == "recall" + assert rec.result, "recall must return non-empty markdown" + + stats = c.memory_stats() + print(f"memory_stats: {stats.result[:160]}") + + cp = c.checkpoint( + intent="validate SDK 0.2.0 against live server", + established="X-API-Key auth + envelope shape match", + pursuing="bump to 0.2.0 + republish", + open_questions="confirm npm/pypi tokens", + session="a3f7", + ) + print(f"checkpoint: {cp.result[:120]}") + + print("--- sync auth-error path ---") + bad = RecallClient(BASE, api_key="WRONG") + try: + bad.remember("should fail") + raise AssertionError("expected auth error") + except RecallAuthError as e: + print(f" got expected RecallAuthError: {str(e)[:60]}") + finally: + bad.close() + + +async def async_smoke() -> None: + print("=== async smoke ===") + async with AsyncRecallClient(BASE, api_key=KEY) as c: + h = await c.health() + print(f"health: {h['status']}") + r = await c.remember("async memory works fine", tags="async") + print(f"async remember: {r.result[:60]}") + rec = await c.recall("async", n=2) + print(f"async recall: {rec.result[:120]}") + assert rec.result + + +def main() -> int: + try: + sync_smoke() + asyncio.run(async_smoke()) + except Exception as e: + print(f"SMOKE FAILED: {type(e).__name__}: {e}", file=sys.stderr) + return 1 + print("\nALL SMOKE TESTS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py index 72ceba0..f76f817 100644 --- a/clients/python/tests/test_client.py +++ b/clients/python/tests/test_client.py @@ -1,8 +1,5 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | Python SDK tests w/ respx | prev: NEW -"""Unit tests for the sync and async Recall clients. - -Uses respx to stub httpx calls — no real server needed. -""" +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | unit tests for real-contract SDK | prev: typed-Hit fiction +"""Unit tests for the sync and async Recall clients (0.2.0 contract).""" from __future__ import annotations @@ -23,6 +20,10 @@ API_KEY = "test-key" +def _envelope(result: str, tool: str, by: str = "test-user") -> dict: + return {"result": result, "tool": tool, "by": by} + + @pytest.fixture def client(): with RecallClient(BASE_URL, api_key=API_KEY) as c: @@ -30,52 +31,70 @@ def client(): @respx.mock -def test_remember_returns_id(client): +def test_remember_returns_tool_response(client): respx.post(f"{BASE_URL}/tool/remember").mock( - return_value=httpx.Response(200, json={"id": "abc123", "artifact_path": "/d/abc.md"}) + return_value=httpx.Response(200, json=_envelope("Stored 1 chunk", "remember")) ) - result = client.remember("hello world", tags=["greeting"]) - assert result.id == "abc123" - assert result.artifact_path == "/d/abc.md" + r = client.remember("hello", tags="greeting,ui") + assert r.tool == "remember" + assert "Stored" in r.result @respx.mock -def test_recall_returns_typed_hits(client): - respx.post(f"{BASE_URL}/tool/recall").mock( - return_value=httpx.Response( - 200, - json={ - "hits": [ - {"id": "1", "content": "first hit", "score": 0.95, "tags": ["a"]}, - {"id": "2", "content": "second hit", "score": 0.81, "tags": []}, - ] - }, - ) +def test_remember_sends_string_tags(client): + route = respx.post(f"{BASE_URL}/tool/remember").mock( + return_value=httpx.Response(200, json=_envelope("ok", "remember")) ) - hits = client.recall("query", limit=2) - assert len(hits) == 2 - assert hits[0].content == "first hit" - assert hits[0].score == 0.95 - assert hits[0].tags == ["a"] + client.remember("x", tags="a,b") + body = route.calls.last.request.content.decode() + assert '"tags":"a,b"' in body + assert '"source":"agent-observation"' in body @respx.mock -def test_recall_handles_results_key_alias(client): - """Some Recall tool variants return 'results' instead of 'hits'.""" - respx.post(f"{BASE_URL}/tool/recall").mock( +def test_recall_uses_n_param(client): + route = respx.post(f"{BASE_URL}/tool/recall").mock( + return_value=httpx.Response(200, json=_envelope("# Hits\n- foo", "recall")) + ) + client.recall("query", n=3) + body = route.calls.last.request.content.decode() + assert '"n":3' in body + assert '"type":"all"' in body + + +@respx.mock +def test_forget_takes_source_not_id(client): + route = respx.post(f"{BASE_URL}/tool/forget").mock( + return_value=httpx.Response(200, json=_envelope("Archived 5", "forget")) + ) + client.forget("agent-observation") + body = route.calls.last.request.content.decode() + assert '"source":"agent-observation"' in body + + +@respx.mock +def test_checkpoint_real_signature(client): + route = respx.post(f"{BASE_URL}/tool/checkpoint").mock( return_value=httpx.Response( - 200, json={"results": [{"content": "x", "score": 0.5}]} + 200, json=_envelope("Checkpoint stored", "checkpoint") ) ) - hits = client.recall("q") - assert len(hits) == 1 - assert hits[0].content == "x" + client.checkpoint( + intent="ship SDK", + established="contract verified", + pursuing="live smoke", + open_questions="none", + session="a3f7", + ) + body = route.calls.last.request.content.decode() + assert '"intent":"ship SDK"' in body + assert '"session":"a3f7"' in body @respx.mock def test_auth_error_raises(client): respx.post(f"{BASE_URL}/tool/remember").mock( - return_value=httpx.Response(401, text="bad key") + return_value=httpx.Response(401, json={"error": "unauthorized"}) ) with pytest.raises(RecallAuthError): client.remember("x") @@ -94,11 +113,10 @@ def test_server_error_raises(client): @respx.mock def test_tool_error_payload_raises(client): respx.post(f"{BASE_URL}/tool/remember").mock( - return_value=httpx.Response(200, json={"error": "invalid tag format"}) + return_value=httpx.Response(400, json={"error": "bad arguments: missing content"}) ) - with pytest.raises(RecallToolError) as exc: + with pytest.raises(RecallToolError): client.remember("x") - assert "invalid tag format" in str(exc.value) @respx.mock @@ -110,56 +128,67 @@ def test_connection_error_raises(client): @respx.mock def test_call_tool_generic_dispatch(client): - respx.post(f"{BASE_URL}/tool/index_file").mock( - return_value=httpx.Response(200, json={"chunks": 7}) + respx.post(f"{BASE_URL}/tool/maintenance").mock( + return_value=httpx.Response( + 200, json=_envelope("Maintenance complete", "maintenance") + ) ) - result = client.call_tool("index_file", path="/data/notes.md") - assert result == {"chunks": 7} + r = client.call_tool("maintenance", pull=True) + assert r.tool == "maintenance" @respx.mock -def test_health(client): +def test_health_no_auth_path(client): respx.get(f"{BASE_URL}/health").mock( - return_value=httpx.Response(200, json={"status": "ok", "uptime": 1234}) + return_value=httpx.Response( + 200, json={"status": "ok", "ready": True, "chunks": 0} + ) ) h = client.health() assert h["status"] == "ok" @respx.mock -def test_authorization_header_sent(client): +def test_x_api_key_header_sent(client): route = respx.post(f"{BASE_URL}/tool/pulse").mock( - return_value=httpx.Response(200, json={"sessions": 0}) + return_value=httpx.Response(200, json=_envelope("# Pulse", "pulse")) ) client.pulse() - assert route.calls.last.request.headers["Authorization"] == f"Bearer {API_KEY}" + assert route.calls.last.request.headers["X-API-Key"] == API_KEY -# ── async client tests ──────────────────────────────────────────────────── +@respx.mock +def test_unknown_tool_404_is_tool_error(client): + respx.post(f"{BASE_URL}/tool/bogus").mock( + return_value=httpx.Response(404, json={"error": "unknown tool: bogus"}) + ) + with pytest.raises(RecallToolError): + client.call_tool("bogus") + + +# ── async ───────────────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_async_remember(): respx.post(f"{BASE_URL}/tool/remember").mock( - return_value=httpx.Response(200, json={"id": "async-1"}) + return_value=httpx.Response(200, json=_envelope("ok", "remember")) ) async with AsyncRecallClient(BASE_URL, api_key=API_KEY) as c: - result = await c.remember("hi") - assert result.id == "async-1" + r = await c.remember("hi") + assert r.tool == "remember" @pytest.mark.asyncio @respx.mock -async def test_async_recall(): +async def test_async_recall_envelope(): respx.post(f"{BASE_URL}/tool/recall").mock( - return_value=httpx.Response( - 200, json={"hits": [{"content": "h", "score": 0.9}]} - ) + return_value=httpx.Response(200, json=_envelope("# Hits", "recall")) ) async with AsyncRecallClient(BASE_URL, api_key=API_KEY) as c: - hits = await c.recall("q") - assert len(hits) == 1 + r = await c.recall("q", n=2) + assert r.tool == "recall" @pytest.mark.asyncio diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 79943b4..2628114 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@recallworks/recall-client", - "version": "0.1.0", + "version": "0.2.0", "description": "Official TypeScript/JavaScript client for Recall — open-source memory for AI agents", "type": "module", "main": "./dist/index.cjs", @@ -49,7 +49,6 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "msw": "^2.4.0", "tsup": "^8.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/clients/typescript/smoke_live.ts b/clients/typescript/smoke_live.ts new file mode 100644 index 0000000..e778cc6 --- /dev/null +++ b/clients/typescript/smoke_live.ts @@ -0,0 +1,53 @@ +// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS live smoke against running Recall container +// Run order: +// docker run -d --name recall-smoke -p 8799:8787 -e API_KEY=smoke-key ghcr.io/recallworks/recall:0.1.0 +// npx tsx smoke_live.ts (or compile + node) + +import { RecallAuthError, RecallClient } from "./src/index.js"; + +const BASE = "http://localhost:8799"; +const KEY = "smoke-key"; + +async function main() { + const c = new RecallClient({ baseUrl: BASE, apiKey: KEY }); + + console.log("=== TS smoke ==="); + const h = await c.health(); + console.log("health:", h); + + const r = await c.remember("ts-sdk live smoke ran", { tags: "smoke,ts" }); + console.log("remember:", r.result.slice(0, 80)); + + const rec = await c.recall("ts-sdk smoke", { n: 3 }); + console.log("recall (", rec.result.length, "chars):"); + console.log(rec.result.slice(0, 300)); + + const cp = await c.checkpoint({ + intent: "validate TS SDK 0.2.0 against live server", + established: "envelope shape match, X-API-Key works", + pursuing: "ship 0.2.0 PR", + openQuestions: "npm token still pending", + session: "a3f7", + }); + console.log("checkpoint:", cp.result.slice(0, 120)); + + console.log("--- auth-error path ---"); + const bad = new RecallClient({ baseUrl: BASE, apiKey: "WRONG" }); + try { + await bad.remember("should fail"); + throw new Error("expected auth error"); + } catch (e) { + if (e instanceof RecallAuthError) { + console.log(" got expected RecallAuthError:", String(e).slice(0, 60)); + } else { + throw e; + } + } + + console.log("\nALL TS SMOKE PASSED"); +} + +main().catch((e) => { + console.error("SMOKE FAILED:", e); + process.exit(1); +}); diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index d88ced1..f79cbc3 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,32 +1,26 @@ -// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS Recall client | prev: NEW +// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS Recall client matching real contract | prev: 0.1.0 import { RecallAuthError, RecallConnectionError, RecallServerError, RecallToolError, } from "./errors.js"; -import type { CheckpointInput, Hit, RememberResult, ToolResult } from "./types.js"; +import type { ToolResponse } from "./types.js"; export interface RecallClientOptions { baseUrl: string; apiKey: string; /** Per-request timeout in ms. Default 30000. */ timeoutMs?: number; - /** Custom fetch (for testing or runtime polyfills). Defaults to global fetch. */ + /** Custom fetch (for testing). Defaults to global fetch. */ fetch?: typeof fetch; } -interface RawHit { - id?: string; - content?: string; - score?: number; - tags?: string[]; - metadata?: Record; -} - /** - * Isomorphic Recall client. Works in Node 18+, Bun, Deno, and modern browsers - * (provided CORS is enabled on the Recall server). + * Isomorphic Recall client. Works in Node 18+, Bun, Deno, and modern + * browsers (provided CORS is enabled on the Recall server). + * + * Every tool returns `{ result: string, tool: string, by: string }`. */ export class RecallClient { readonly baseUrl: string; @@ -48,10 +42,7 @@ export class RecallClient { // ── core dispatch ────────────────────────────────────────────────────── - async callTool>( - name: string, - payload: Record = {}, - ): Promise { + async callTool(name: string, args: Record = {}): Promise { const url = `${this.baseUrl}/tool/${name}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); @@ -61,11 +52,11 @@ export class RecallClient { response = await this.fetchImpl(url, { method: "POST", headers: { - Authorization: `Bearer ${this.apiKey}`, + "X-API-Key": this.apiKey, "Content-Type": "application/json", - "User-Agent": "recall-client-ts/0.1.0", + "User-Agent": "recall-client-ts/0.2.0", }, - body: JSON.stringify(payload), + body: JSON.stringify(args), signal: controller.signal, }); } catch (err) { @@ -98,86 +89,160 @@ export class RecallClient { throw new RecallToolError(name, `Invalid JSON response: ${text}`); } - if (data && typeof data === "object" && "error" in data && (data as { error: unknown }).error) { - throw new RecallToolError(name, String((data as { error: unknown }).error)); + if (!data || typeof data !== "object") { + throw new RecallToolError(name, `unexpected response shape: ${text}`); } - return data as T; + const obj = data as Record; + if ("error" in obj && obj.error) { + throw new RecallToolError(name, String(obj.error)); + } + return { + result: String(obj.result ?? ""), + tool: String(obj.tool ?? name), + by: String(obj.by ?? ""), + }; } - // ── typed wrappers ───────────────────────────────────────────────────── + // ── typed wrappers (exact server signatures) ─────────────────────────── - async remember( + remember( content: string, - options: { tags?: string[]; metadata?: Record } = {}, - ): Promise { - const payload: Record = { content }; - if (options.tags?.length) payload.tags = options.tags; - if (options.metadata) payload.metadata = options.metadata; - const raw = await this.callTool<{ id?: string; artifact_path?: string }>( - "remember", - payload, - ); - return { id: raw.id ?? "", artifactPath: raw.artifact_path }; - } - - async recall( + options: { source?: string; tags?: string } = {}, + ): Promise { + return this.callTool("remember", { + content, + source: options.source ?? "agent-observation", + tags: options.tags ?? "", + }); + } + + recall( query: string, - options: { limit?: number; tags?: string[] } = {}, - ): Promise { - const payload: Record = { query, limit: options.limit ?? 5 }; - if (options.tags?.length) payload.tags = options.tags; - const raw = await this.callTool<{ hits?: RawHit[]; results?: RawHit[] }>( - "recall", - payload, - ); - const items = raw.hits ?? raw.results ?? []; - return items.map((h) => ({ - id: h.id, - content: h.content ?? "", - score: h.score ?? 0, - tags: h.tags ?? [], - metadata: h.metadata ?? {}, - })); - } - - async reflect(summary: string, tags?: string[]): Promise { - const payload: Record = { summary }; - if (tags?.length) payload.tags = tags; - return this.toToolResult(await this.callTool("reflect", payload)); - } - - async checkpoint(input: CheckpointInput): Promise { - return this.toToolResult( - await this.callTool("checkpoint", { - session: input.session, - established: input.established, - intent: input.intent, - pursuing: input.pursuing, - summary: input.summary, - open_questions: input.openQuestions ?? [], - }), - ); - } - - async pulse(): Promise> { - return this.callTool("pulse"); - } - - async memoryStats(): Promise> { + options: { n?: number; type?: string } = {}, + ): Promise { + return this.callTool("recall", { + query, + n: options.n ?? 5, + type: options.type ?? "all", + }); + } + + reflect(args: { + domain: string; + hypothesis: string; + reasoning: string; + result: string; + revisedBelief: string; + nextTime: string; + confidence?: number; + session?: string; + }): Promise { + return this.callTool("reflect", { + domain: args.domain, + hypothesis: args.hypothesis, + reasoning: args.reasoning, + result: args.result, + revised_belief: args.revisedBelief, + next_time: args.nextTime, + confidence: args.confidence ?? 0.7, + session: args.session ?? "", + }); + } + + antiPattern(args: { + domain: string; + temptation: string; + whyWrong: string; + signature: string; + instead: string; + session?: string; + }): Promise { + return this.callTool("anti_pattern", { + domain: args.domain, + temptation: args.temptation, + why_wrong: args.whyWrong, + signature: args.signature, + instead: args.instead, + session: args.session ?? "", + }); + } + + sessionClose(args: { + sessionId: string; + reasoningChanged: string; + doDifferently: string; + stillUncertain: string; + temptations: string; + }): Promise { + return this.callTool("session_close", { + session_id: args.sessionId, + reasoning_changed: args.reasoningChanged, + do_differently: args.doDifferently, + still_uncertain: args.stillUncertain, + temptations: args.temptations, + }); + } + + checkpoint(args: { + intent: string; + established: string; + pursuing: string; + openQuestions: string; + session?: string; + domain?: string; + }): Promise { + return this.callTool("checkpoint", { + intent: args.intent, + established: args.established, + pursuing: args.pursuing, + open_questions: args.openQuestions, + session: args.session ?? "", + domain: args.domain ?? "", + }); + } + + pulse(options: { domain?: string; includeReasoning?: boolean } = {}): Promise { + return this.callTool("pulse", { + domain: options.domain ?? "", + include_reasoning: options.includeReasoning ?? true, + }); + } + + memoryStats(): Promise { return this.callTool("memory_stats"); } - async forget(id: string, source = "user-request"): Promise { - return this.toToolResult(await this.callTool("forget", { id, source })); + /** + * Soft-archive all chunks matching `source`. NOTE: takes a source label, + * not a chunk id. Server marks chunks `archived=true`. + */ + forget(source: string): Promise { + return this.callTool("forget", { source }); + } + + reindex(path = ""): Promise { + return this.callTool("reindex", { path }); + } + + indexFile(filepath: string): Promise { + return this.callTool("index_file", { filepath }); + } + + maintenance(pull = true): Promise { + return this.callTool("maintenance", { pull }); } + snapshotIndex(): Promise { + return this.callTool("snapshot_index"); + } + + // ── plain HTTP endpoint (no auth) ────────────────────────────────────── + async health(): Promise> { const url = `${this.baseUrl}/health`; let response: Response; try { - response = await this.fetchImpl(url, { - headers: { Authorization: `Bearer ${this.apiKey}` }, - }); + response = await this.fetchImpl(url); } catch (err) { throw new RecallConnectionError( `Cannot reach health: ${err instanceof Error ? err.message : String(err)}`, @@ -191,14 +256,4 @@ export class RecallClient { } return (await response.json()) as Record; } - - private toToolResult(raw: Record): ToolResult { - const ok = "ok" in raw ? Boolean(raw.ok) : true; - const error = typeof raw.error === "string" ? raw.error : undefined; - const data: Record = {}; - for (const [k, v] of Object.entries(raw)) { - if (k !== "ok" && k !== "error") data[k] = v; - } - return { ok, data, error }; - } } diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index f9d4ef7..69697b5 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -1,4 +1,4 @@ -// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS SDK barrel | prev: NEW +// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS SDK barrel | prev: 0.1.0 export { RecallClient } from "./client.js"; export type { RecallClientOptions } from "./client.js"; export { @@ -8,6 +8,6 @@ export { RecallServerError, RecallToolError, } from "./errors.js"; -export type { Hit, RememberResult, ToolResult, CheckpointInput } from "./types.js"; +export type { ToolResponse } from "./types.js"; -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 63bdf09..cae9c03 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -1,28 +1,9 @@ -// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | shared types | prev: NEW -export interface Hit { - id?: string; - content: string; - score: number; - tags: string[]; - metadata: Record; -} - -export interface RememberResult { - id: string; - artifactPath?: string; -} - -export interface ToolResult { - ok: boolean; - data: Record; - error?: string; -} - -export interface CheckpointInput { - session: string; - established: string; - intent: string; - pursuing: string; - summary: string; - openQuestions?: string[]; +// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | response envelope type | prev: typed Hits +export interface ToolResponse { + /** Tool's return value, always a string from the server. */ + result: string; + /** Echo of the tool name. */ + tool: string; + /** Authenticated user identifier from the server's API-key map. */ + by: string; } diff --git a/clients/typescript/tests/client.test.ts b/clients/typescript/tests/client.test.ts index 049f3e5..81c25b8 100644 --- a/clients/typescript/tests/client.test.ts +++ b/clients/typescript/tests/client.test.ts @@ -1,5 +1,5 @@ -// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS SDK tests | prev: NEW -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +// @wbx-modified copilot-a3f7·MTN | 2026-04-24 | TS SDK tests for real contract | prev: typed-Hit fiction +import { beforeEach, describe, expect, it } from "vitest"; import { RecallAuthError, RecallClient, @@ -16,9 +16,7 @@ interface MockCall { init?: RequestInit; } -function makeFetch( - responder: (call: MockCall) => Response | Promise, -): { fn: typeof fetch; calls: MockCall[] } { +function makeFetch(responder: (call: MockCall) => Response | Promise) { const calls: MockCall[] = []; const fn = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); @@ -28,6 +26,12 @@ function makeFetch( return { fn, calls }; } +const envelope = (result: string, tool: string, by = "test-user") => + new Response(JSON.stringify({ result, tool, by }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + let client: RecallClient; let fetchMock: ReturnType; @@ -40,11 +44,7 @@ beforeEach(() => { fetchMock = undefined as unknown as ReturnType; }); -afterEach(() => { - // no global state to clean -}); - -describe("RecallClient", () => { +describe("RecallClient (0.2.0)", () => { it("requires baseUrl and apiKey", () => { expect(() => new RecallClient({ baseUrl: "", apiKey: "x" })).toThrow(); expect(() => new RecallClient({ baseUrl: "http://x", apiKey: "" })).toThrow(); @@ -55,50 +55,69 @@ describe("RecallClient", () => { expect(c.baseUrl).toBe("http://x"); }); - it("remember returns typed RememberResult", async () => { - build(() => - new Response(JSON.stringify({ id: "abc123", artifact_path: "/d/abc.md" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - const result = await client.remember("hello", { tags: ["greeting"] }); - expect(result.id).toBe("abc123"); - expect(result.artifactPath).toBe("/d/abc.md"); - }); - - it("recall returns typed Hit array", async () => { - build(() => - new Response( - JSON.stringify({ - hits: [ - { id: "1", content: "first", score: 0.9, tags: ["a"] }, - { id: "2", content: "second", score: 0.7 }, - ], - }), - { status: 200 }, - ), - ); - const hits = await client.recall("query", { limit: 2 }); - expect(hits).toHaveLength(2); - expect(hits[0].content).toBe("first"); - expect(hits[0].score).toBe(0.9); - expect(hits[1].tags).toEqual([]); - }); - - it("recall handles 'results' alias", async () => { - build(() => - new Response(JSON.stringify({ results: [{ content: "x", score: 0.5 }] }), { - status: 200, - }), - ); - const hits = await client.recall("q"); - expect(hits).toHaveLength(1); - expect(hits[0].content).toBe("x"); + it("remember returns ToolResponse envelope", async () => { + build(() => envelope("Stored 1 chunk", "remember")); + const r = await client.remember("hello", { tags: "greeting,ui" }); + expect(r.tool).toBe("remember"); + expect(r.result).toContain("Stored"); + }); + + it("remember sends string tags + default source", async () => { + build(() => envelope("ok", "remember")); + await client.remember("x", { tags: "a,b" }); + const body = JSON.parse(String(fetchMock.calls[0].init?.body)); + expect(body.tags).toBe("a,b"); + expect(body.source).toBe("agent-observation"); + }); + + it("recall uses n + type params", async () => { + build(() => envelope("# Hits", "recall")); + await client.recall("query", { n: 3 }); + const body = JSON.parse(String(fetchMock.calls[0].init?.body)); + expect(body.n).toBe(3); + expect(body.type).toBe("all"); + }); + + it("forget takes source not id", async () => { + build(() => envelope("Archived 5", "forget")); + await client.forget("agent-observation"); + const body = JSON.parse(String(fetchMock.calls[0].init?.body)); + expect(body.source).toBe("agent-observation"); + }); + + it("checkpoint maps camelCase to snake_case", async () => { + build(() => envelope("Checkpoint stored", "checkpoint")); + await client.checkpoint({ + intent: "ship SDK", + established: "contract verified", + pursuing: "live smoke", + openQuestions: "none", + session: "a3f7", + }); + const body = JSON.parse(String(fetchMock.calls[0].init?.body)); + expect(body.intent).toBe("ship SDK"); + expect(body.open_questions).toBe("none"); + expect(body.session).toBe("a3f7"); + }); + + it("reflect maps camelCase to snake_case", async () => { + build(() => envelope("ok", "reflect")); + await client.reflect({ + domain: "d", + hypothesis: "h", + reasoning: "r", + result: "FAILED: x", + revisedBelief: "rb", + nextTime: "nt", + }); + const body = JSON.parse(String(fetchMock.calls[0].init?.body)); + expect(body.revised_belief).toBe("rb"); + expect(body.next_time).toBe("nt"); + expect(body.confidence).toBe(0.7); }); it("auth error raises RecallAuthError", async () => { - build(() => new Response("bad key", { status: 401 })); + build(() => new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 })); await expect(client.remember("x")).rejects.toBeInstanceOf(RecallAuthError); }); @@ -114,9 +133,7 @@ describe("RecallClient", () => { }); it("explicit error payload raises RecallToolError", async () => { - build(() => - new Response(JSON.stringify({ error: "invalid tag format" }), { status: 200 }), - ); + build(() => new Response(JSON.stringify({ error: "bad arguments" }), { status: 400 })); await expect(client.remember("x")).rejects.toBeInstanceOf(RecallToolError); }); @@ -128,42 +145,30 @@ describe("RecallClient", () => { }); it("callTool dispatches generic tools", async () => { - build(() => new Response(JSON.stringify({ chunks: 7 }), { status: 200 })); - const result = await client.callTool<{ chunks: number }>("index_file", { - path: "/data/notes.md", - }); - expect(result.chunks).toBe(7); + build(() => envelope("Maintenance complete", "maintenance")); + const r = await client.callTool("maintenance", { pull: true }); + expect(r.tool).toBe("maintenance"); }); - it("sends Authorization header", async () => { - build(() => new Response(JSON.stringify({ sessions: 0 }), { status: 200 })); + it("sends X-API-Key header", async () => { + build(() => envelope("# Pulse", "pulse")); await client.pulse(); const headers = fetchMock.calls[0].init?.headers as Record; - expect(headers.Authorization).toBe(`Bearer ${API_KEY}`); + expect(headers["X-API-Key"]).toBe(API_KEY); }); - it("health hits GET /health", async () => { - build(({ url }) => { + it("health hits GET /health without auth header", async () => { + build(({ url, init }) => { expect(url).toBe(`${BASE_URL}/health`); + expect((init?.headers as Record | undefined)?.["X-API-Key"]).toBeUndefined(); return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); }); const h = await client.health(); expect(h.status).toBe("ok"); }); - it("checkpoint converts openQuestions camelCase", async () => { - build(({ init }) => { - const body = JSON.parse(String(init?.body)); - expect(body.open_questions).toEqual(["q1", "q2"]); - return new Response(JSON.stringify({ ok: true }), { status: 200 }); - }); - await client.checkpoint({ - session: "s1", - established: "e", - intent: "i", - pursuing: "p", - summary: "sum", - openQuestions: ["q1", "q2"], - }); + it("404 unknown tool surfaces as RecallToolError", async () => { + build(() => new Response(JSON.stringify({ error: "unknown tool: bogus" }), { status: 404 })); + await expect(client.callTool("bogus")).rejects.toBeInstanceOf(RecallToolError); }); }); From 9ebf70eb1292ccfb38d2bf5fc96b20e8a0d8e3ca Mon Sep 17 00:00:00 2001 From: Steve Paltridge Date: Fri, 24 Apr 2026 02:22:54 -0600 Subject: [PATCH 3/5] docs(sdks): refresh quickstart examples for 0.2.0 envelope --- clients/python/README.md | 25 +++++++++++++++---------- clients/typescript/README.md | 22 ++++++++++++---------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/clients/python/README.md b/clients/python/README.md index 3ad9f33..74836d1 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -15,12 +15,15 @@ pip install recall-client from recall_client import RecallClient with RecallClient("http://localhost:8787", api_key="changeme") as client: - client.remember("the user prefers dark mode", tags=["pref", "ui"]) - hits = client.recall("user preferences", limit=5) - for h in hits: - print(f"{h.score:.3f} {h.content}") + print(client.health()) # {"status": "ok", "chunks": ..., "ready": True} + + client.remember("the user prefers dark mode", tags="pref,ui") + res = client.recall("user preferences", n=5) + print(res.result) # markdown listing of hits ``` +Every tool returns a `ToolResponse` envelope with three string fields: `result`, `tool`, `by`. The server formats results as markdown — parse `result` if you need structured fields. + ## Async ```python @@ -29,9 +32,9 @@ from recall_client import AsyncRecallClient async def main(): async with AsyncRecallClient("http://localhost:8787", api_key="changeme") as c: - await c.remember("async memory works", tags=["async"]) - hits = await c.recall("memory") - print(hits) + await c.remember("async memory works", tags="async") + res = await c.recall("memory") + print(res.result) asyncio.run(main()) ``` @@ -51,13 +54,15 @@ The client exposes typed wrappers for the high-traffic tools: | `forget()` | `forget` | | `health()` | `GET /health` | -For any tool without a typed wrapper (e.g. `index_file`, `reindex`, `snapshot_index`, `anti_pattern`, `session_close`, `maintenance`), use the generic dispatch: +All 13 server tools have typed wrappers (`remember`, `recall`, `reflect`, `anti_pattern`, `session_close`, `checkpoint`, `pulse`, `memory_stats`, `forget`, `reindex`, `index_file`, `maintenance`, `snapshot_index`). For any custom or future tool, use the generic dispatch: ```python -client.call_tool("index_file", path="/data/notes.md") -client.call_tool("anti_pattern", pattern="don't auto-merge without CI") +client.call_tool("index_file", filepath="/data/notes.md") +client.call_tool("forget", source="agent-observation") ``` +> Note: `forget()` takes a `source` label and **soft-archives** every chunk with that source. It does not delete a single chunk by id. + ## Errors All exceptions derive from `RecallError`: diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 366f9a7..bef096a 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -25,16 +25,16 @@ const client = new RecallClient({ apiKey: "changeme", }); -await client.remember("the user prefers dark mode", { - tags: ["pref", "ui"], -}); +console.log(await client.health()); // { status: "ok", chunks: ..., ready: true } + +await client.remember("the user prefers dark mode", { tags: "pref,ui" }); -const hits = await client.recall("user preferences", { limit: 5 }); -for (const h of hits) { - console.log(`${h.score.toFixed(3)} ${h.content}`); -} +const res = await client.recall("user preferences", { n: 5 }); +console.log(res.result); // markdown listing of hits ``` +Every tool returns a `ToolResponse` envelope with three string fields: `result`, `tool`, `by`. The server formats results as markdown — parse `result` if you need structured fields. + ## Tool surface Typed wrappers for the high-traffic tools: @@ -50,13 +50,15 @@ Typed wrappers for the high-traffic tools: | `forget()` | `forget` | | `health()` | `GET /health` | -For tools without a typed wrapper, use the generic dispatch: +All 13 server tools have typed wrappers (`remember`, `recall`, `reflect`, `antiPattern`, `sessionClose`, `checkpoint`, `pulse`, `memoryStats`, `forget`, `reindex`, `indexFile`, `maintenance`, `snapshotIndex`). For any custom or future tool, use the generic dispatch: ```ts -await client.callTool("index_file", { path: "/data/notes.md" }); -await client.callTool("anti_pattern", { pattern: "don't auto-merge without CI" }); +await client.callTool("index_file", { filepath: "/data/notes.md" }); +await client.callTool("forget", { source: "agent-observation" }); ``` +> Note: `forget()` takes a `source` label and **soft-archives** every chunk with that source. It does not delete a single chunk by id. + ## Errors All exceptions extend `RecallError`: From c74bfa6537fcb0ea908cfd84495f66fdf3472d65 Mon Sep 17 00:00:00 2001 From: Steve Paltridge Date: Fri, 24 Apr 2026 02:30:04 -0600 Subject: [PATCH 4/5] style(python-sdk): shorten header comments to satisfy ruff E501 --- clients/python/recall_client/client.py | 2 +- clients/python/recall_client/models.py | 2 +- clients/python/tests/test_client.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clients/python/recall_client/client.py b/clients/python/recall_client/client.py index fe061c0..411ab01 100644 --- a/clients/python/recall_client/client.py +++ b/clients/python/recall_client/client.py @@ -1,4 +1,4 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | sync HTTP client matching real contract | prev: 0.1.0 +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | sync HTTP client (real contract) | prev: 0.1.0 """Synchronous Recall client. Mirrors the server's real tool registry.""" from __future__ import annotations diff --git a/clients/python/recall_client/models.py b/clients/python/recall_client/models.py index ef4154b..a473f9a 100644 --- a/clients/python/recall_client/models.py +++ b/clients/python/recall_client/models.py @@ -1,4 +1,4 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | response model matching real envelope | prev: typed Hits +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | response model (real envelope) | prev: typed Hits """Response model for Recall tool calls. Every Recall tool returns the JSON envelope ``{"result": str, "tool": str, "by": str}``. diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py index f76f817..8ab161d 100644 --- a/clients/python/tests/test_client.py +++ b/clients/python/tests/test_client.py @@ -1,4 +1,4 @@ -# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | unit tests for real-contract SDK | prev: typed-Hit fiction +# @wbx-modified copilot-a3f7·MTN | 2026-04-24 | unit tests for real-contract SDK | prev: Hit """Unit tests for the sync and async Recall clients (0.2.0 contract).""" from __future__ import annotations From ea0ddff549f3a9ae88988a0fc3ffe1895d54e20d Mon Sep 17 00:00:00 2001 From: Steve Paltridge Date: Fri, 24 Apr 2026 02:30:35 -0600 Subject: [PATCH 5/5] chore: gitignore .tokens.local for publish credentials --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index e15cdd2..8cdee68 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ __pycache__/ .env *.pem *.key + +# Local secrets — NEVER commit +.tokens.local +