wenchang/reference.md View as Markdown (for AI agents; also llms.txt)
Tool and error reference#
Version 0.1.1. Everything here is taken from the source: the tool layer (wenchang.tools, wenchang.schemas, wenchang.mcp), the core store (wenchang.core), and storage (wenchang.storage). Paths, scopes, metadata, versions, and limits are explained in Concepts. How to wire the tools in is under Integrate, and how to run storage is in Storage and operations.
Tools#
Seven tools. With tool_prefix set, each is registered as {tool_prefix}_{name}. All results are JSON objects; all failures are rendered as described under errors. In every tool, scope, area, and name are strings, and expected_version is a string (the version from a prior result).
Common file object, returned by read_file, write_file, append_line, and replace_fact:
| Field | Type | Notes |
|---|---|---|
scope, area, name |
string | Where the file lives. |
content |
string | The body. Present on a file; absent on index and list entries. |
version |
string | Opaque. Pass as expected_version. |
description |
string | |
aliases |
array of strings | |
sources |
array of strings | Sorted. |
last_updated |
string | UTC ISO 8601, Z suffix. |
get_memory_indexreads#
get_memory_index()
Load the metadata index of every memory scope available in this session. Call this first. Each entry gives a file's scope, area, name, metadata, and version, without content.
Call it first in every session.
| Parameter | Type | Required | Description |
|---|---|---|---|
| (none) |
Returns an object:
| Field | Type | Description |
|---|---|---|
entries |
array of file entries | Each is the common file object without content, in index order, up to the byte budget. |
capped |
array of objects | One per area not fully returned. Empty means the index is complete. |
Each capped object has exactly scope (string), area (string), and omitted (integer, files left out). Page a capped area with list_prefix(scope, area).
read_filereads#
read_file(scope, area, name)
Read one memory file: its content, metadata, and version. Any area may be read, including
system/.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope |
string | yes | A granted scope. |
area |
string | yes | Lowercase slug. |
name |
string | yes | File name without .md. |
Returns the common file object. If no file exists: NotFoundError with reason file_absent.
list_prefixreads#
list_prefix(scope, area?, cursor?)
List the files in a scope or area, one page at a time, without content. Each page returns entries with metadata and version, and a
next_cursor; until it is null, pass it back ascursorwith the samescopeandareafor the next page.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope |
string | yes | A granted scope. |
area |
string or null | no (default null) |
Restrict to one area; null lists the whole scope. |
cursor |
string or null | no (default null) |
The next_cursor from the previous page, with the same scope and area. |
Returns {"entries": [...], "next_cursor": string | null}. Entries are the common file object without content, in ascending storage order. A malformed cursor, or one from a different prefix, returns InvalidArgumentError for cursor. Listing is not a snapshot: a file written or deleted between two pages may or may not appear.
write_filewrites#
write_file(scope, area, name, content, description, aliases, expected_version)
Create a memory file or replace one whole. Pass
expected_version=Noneto create a file, or the version you read to replace one.description(one line) andaliasesreplace the stored values; they are not merged. Content over the per-file byte ceiling is rejected with the current size and the limit; shorten it and retry. A version conflict is routine: merge your change into the content it returns and retry with its version.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope |
string | yes | A granted scope the caller may write. |
area |
string | yes | Lowercase slug; not system. |
name |
string | yes | File name without .md. |
content |
string | yes | The whole new body, at most max_file_bytes UTF-8 bytes. |
description |
string | yes | One line; replaces the stored value. |
aliases |
array of strings | yes | Replaces the stored set (use [] for none). |
expected_version |
string or null | yes | null creates (fails if the file exists); otherwise the version being replaced. |
Returns the common file object. Errors: VersionConflictError, NotFoundError (file_absent, when replacing a file that does not exist), OversizeWriteError, FileQuotaExceededError (creating a file in an entity at its file cap), InvalidArgumentError, RestrictedScopeError.
append_linewrites#
append_line(scope, area, name, line, expected_version, aliases?, description?)
Add one fact line to the end of an existing memory file.
lineis a single fact line with a leading bracketed label ([stated],[observed],[inferred], or[system]), as in- [stated] Prefers tea. Pass the version you read asexpected_version. The per-file byte ceiling applies to the result. Optionalaliasesare added to the file's existing aliases and never removed (write_filereplaces the whole set); an optional one-linedescriptionreplaces the stored one. A version conflict is routine: merge your change into the content it returns and retry with its version.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope |
string | yes | A granted scope the caller may write. |
area |
string | yes | Lowercase slug; not system. |
name |
string | yes | Existing file name without .md. |
line |
string | yes | One fact line, - [label] text. Rejected with InvalidArgumentError otherwise. |
expected_version |
string | yes | The version read. |
aliases |
array of strings or null | no (default null) |
Added to existing aliases, never removed. |
description |
string or null | no (default null) |
Replaces the stored description when given. |
The file must already exist (NotFoundError with file_absent otherwise). A newline is added before the line if the body does not end with one, and one after. Returns the common file object.
replace_factwrites#
replace_fact(scope, area, name, old_string, new_string, expected_version, aliases?, description?)
Change one fact in a memory file by quoting the text to replace.
old_stringmust match the content exactly once; otherwise the error carries the current content and version, so quote a unique span.new_stringreplaces it. Pass the version you read asexpected_version. The per-file byte ceiling applies to the result. Optionalaliasesare added to the file's existing aliases and never removed (write_filereplaces the whole set); an optional one-linedescriptionreplaces the stored one. A version conflict is routine: merge your change into the content it returns and retry with its version.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope |
string | yes | A granted scope the caller may write. |
area |
string | yes | Lowercase slug; not system. |
name |
string | yes | Existing file name without .md. |
old_string |
string | yes | Non-empty text that appears exactly once in the file. |
new_string |
string | yes | Replacement. An empty string removes the span (to drop a whole fact line, quote the line including its line break). |
expected_version |
string | yes | The version read. |
aliases |
array of strings or null | no (default null) |
Added to existing aliases, never removed. |
description |
string or null | no (default null) |
Replaces the stored description when given. |
Zero or several matches return ReplaceFactMatchError with the current content, version, and match_count. Returns the common file object.
delete_filewrites#
delete_file(scope, area, name, expected_version)
Delete a memory file. Pass the version you read as
expected_version. A version conflict is routine: retry with the version it returns only if the file should still go.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope |
string | yes | A granted scope the caller may write. |
area |
string | yes | Lowercase slug; not system. |
name |
string | yes | File name without .md. |
expected_version |
string | yes | The version read. |
Returns {"ok": true}. Errors: VersionConflictError, NotFoundError, RestrictedScopeError.
Tool schemas#
wenchang.schemas (in the base package) describes each tool for frameworks other than MCP:
tool_schemas(product=None) -> Mapping[str, ToolSchema]: one read-only entry per tool, inwenchang.tools.TOOL_NAMESorder.ToolSchema:name,description,input,output, anderror, the last three JSON Schema draft 2020-12 dicts.error_schema() -> dict: the schema shared by everyrender_errorpayload.
wenchang.tools.tool_descriptions(product=None) -> Mapping[str, str] returns each tool's description by name, in TOOL_NAMES order, without binding a session. With product, each first line names it. It is the same text as ToolSchema.description and MemoryTools.descriptions().
Tool schemas explains each field and how to register the tools with them.
The memory prompt resource#
The server exposes the assembled memory prompt as an MCP resource:
| Property | Value |
|---|---|
| URI | wenchang://memory-prompt (wenchang.mcp.PROMPT_RESOURCE_URI) |
| Name | memory_prompt |
| MIME type | text/markdown |
| Content | The same text as the server's instructions: build_memory_prompt(slots). |
Clients that drop server instructions can read the resource instead. Over MCP, a successful get_memory_index call also returns the same prompt as a second text content block.
wenchang.mcp.mcp_tool_descriptions(slots=None, product=None) -> Mapping[str, str] returns the tool descriptions the MCP server registers: the core descriptions plus the guidance preamble (see Where the guidance goes). With slots, get_memory_index starts with slots.purpose. wenchang.mcp.MAX_TOOL_DESCRIPTION_CHARS is 2000; build_server and register_memory_tools raise ValueError at startup if a description exceeds it. register_memory_tools takes an optional slots keyword for this. The prompt is markdown with ## headings and no H1; see Customizing the prompts for its sections.
Errors#
Every failure renders through render_error. Over MCP it is the payload of an error result (isError true); without MCP you call render_error yourself. The payload has this shape:
{
"error": "VersionConflictError",
"category": "recoverable",
"message": "...",
"scope": "...",
"area": "...",
"name": "...",
"content": "...",
"version": "..."
}
error is the class name, category is recoverable, permanent, transient, or internal, and message combines the detail with fixed guidance for the category. Extra fields appear only when relevant. When the location parses, the payload carries scope, area, and name, and the message names it as scope user, area preferences, name csv. Errors never include a storage path.
| Category | Meaning | What the agent should do |
|---|---|---|
recoverable |
Normal coordination. | Correct the call from the attached details and retry in the same turn, without asking the user. |
permanent |
The call cannot succeed as given. | Do not retry. |
transient |
A temporary condition. | Retry. Version-guarded calls are safe to retry; a retried append_line may duplicate a line. |
internal |
An unexpected failure, or corrupt stored data. | The payload carries only a generic message (or, for corrupt metadata or a non-UTF-8 body, that error's message). |
| Error | Category | Raised when | Extra fields |
|---|---|---|---|
VersionConflictError |
recoverable | The file changed since it was read. | content, version (current) |
OversizeWriteError |
recoverable | The resulting content exceeds the per-file limit. | size, limit |
FileQuotaExceededError |
recoverable | A write_file create would exceed the entity's file cap. |
limit |
ReplaceFactMatchError |
recoverable | old_string matched zero spans or more than one. |
content, version, match_count |
InvalidArgumentError |
recoverable | An argument was rejected: unknown scope, bad area slug, bad name, not a fact line, empty old_string, bad cursor, and similar. |
argument |
NotFoundError |
recoverable | The location is invalid (invalid_path) or no file exists there (file_absent). |
reason |
RestrictedScopeError |
permanent | A write to system/ (system_read_only), or to a scope whose policy requires another role (role_required), or outside the caller's grant (not_granted). |
scope, reason, and required_roles for role_required |
ResolverFailureError |
permanent | The identity could not be resolved, or credentials_from_context raised. Memory is unavailable for the session. |
none |
BackendUnavailableError |
transient | The storage backend timed out or is unreachable. | reason (timeout or unavailable) |
InvalidArgumentError#
wenchang.errors.InvalidArgumentError(argument, detail) names the rejected argument. argument must be a non-empty str (TypeError or ValueError otherwise). The message is Argument {argument} is invalid: {detail} (Argument {argument} is invalid when detail is blank), ended with a period unless it already ends in ., !, or ?, followed by the recoverable guidance. It renders as error, category (recoverable), message, and argument, with no location fields:
{
"error": "InvalidArgumentError",
"category": "recoverable",
"message": "Argument scope is invalid: scope 'project' is not available in this session; available scopes: organization, user. ...",
"argument": "scope"
}
The tools raise it with argument set to one of their own parameter names: scope, area, name, content, description, aliases, line, old_string, new_string, expected_version, or cursor. You can raise it from your own dispatch code too, as the direct example does for a missing or unknown argument.
Notes:
- The tool layer builds each path from the caller's own grants, so over MCP an ungranted scope is reported as
InvalidArgumentError(naming the available scopes);not_grantedis raised by the core path check. - MCP validates arguments before wenchang sees them. A wrong-type or missing argument returns MCP's own error text, not a rendered wenchang error.
- MCP pre-parses JSON-like strings: the string
"null"arrives asNonefor a nullable parameter. For examplelist_prefix(area="null")lists the whole scope.
Configuration#
Identity types#
wenchang.identity.Identity(grants) is what a resolver returns: a mapping from scope name to ScopeGrant(entity_id, role). Scope names and entity IDs must be valid path segments (non-empty, no /, no backslash, no control characters, not . or ..). The role must be non-empty. identity.scope_map is scope name to entity ID. identity.entity_id(scope) and identity.role(scope) raise KeyError if the scope is not granted.
IdentityResolver[C] is a protocol with one method, resolve(credentials: C) -> Identity | ResolutionFailure. It must not raise, and the detail of a ResolutionFailure is shown to the agent. resolve must be a plain synchronous method: it is never awaited. If it is detectably async (an async def, an async generator function, an async staticmethod, a functools.partial of an async function, or a callable object with async def __call__), register_memory_tools, build_server, and bind_tools raise TypeError("resolver.resolve must be a plain method, not async"). The first two raise at registration, before anything is registered; bind_tools raises before the resolver runs. A sync def that returns a coroutine is not detected, and every call fails with ResolverFailureError. It runs on every tool call over MCP, and once per bind_tools call without MCP. SandboxResolver(identity) returns one fixed identity for any credentials, for sandboxes and tests.
wenchang.scope.ScopePolicy(write_roles) maps a scope to the frozenset of roles allowed to write it. A scope absent from the mapping is unrestricted. policy.permitted_roles(scope) returns the set or None.
Store options#
wenchang.core.MemoryStore(storage, *, max_file_bytes=16384, clock=..., list_page_size=100, index_max_bytes=65536, scope_priority=(), max_files_per_entity=500, index_cache=None). The three size options must be positive. max_files_per_entity caps the non-system/ files per entity and must be positive or None (no cap); see Files per entity. index_cache is an optional IndexCache; see The index cache. scope_priority entries must be distinct valid path segments, and the scopes it lists are kept first when the startup index is capped; unlisted scopes rank after them, and system/ areas always come first. clock returns timezone-aware datetimes and stamps last_updated; pass a fixed one in tests. A write over max_file_bytes raises OversizeWriteError.
from datetime import UTC, datetime
from wenchang.core import MemoryStore
from wenchang.storage.memory import InMemoryStorage
from wenchang.transport import InProcessClient
store = MemoryStore(
InMemoryStorage(),
max_file_bytes=8 * 1024, # default 16384: larger writes raise OversizeWriteError
index_max_bytes=32 * 1024, # default 65536: the startup index is capped at this size
list_page_size=50, # default 100: entries per list_prefix page
max_files_per_entity=200, # default 500 (None: no cap): at the cap, creates fail
scope_priority=("user", "project", "organization"), # which scopes the capped index keeps first
clock=lambda: datetime.now(UTC), # must return timezone-aware datetimes
)
client = InProcessClient(store)
print(store.index_max_bytes, store.scope_priority)
Storage backends#
wenchang.storage.Storage is the only way the rest of wenchang touches bytes. It is a protocol over objects with a string key, bytes, a flat string metadata map, and an opaque version token:
| Method | Contract |
|---|---|
get(key) |
The object at exactly key, or None. |
put(key, data, metadata) |
Unconditional atomic replace; returns a new version token. |
put_if_version(key, data, metadata, expected) |
Replace only if the current version equals expected; expected=None means create, and must not exist. Otherwise raises PreconditionFailedError and changes nothing. |
delete_if_version(key, expected) |
Delete only at expected; otherwise PreconditionFailedError. |
list_page(prefix, start_after, limit) |
Up to limit objects whose key starts with prefix, ascending by key, never reading bodies. |
Backends may raise BackendUnavailableError for timeouts and outages. Three ship with the library:
GcsStorage(bucket)inwenchang.storage.gcstakes agoogle.cloud.storage.Bucket. One bucket is one storage instance. Metadata is GCS custom object metadata, the version is the object's generation, and conditional writes use generation preconditions. The constructor builds the bucket client's HTTP session (including any mTLS channel setup), so mTLS configuration errors raise fromGcsStorage(bucket)rather than from the first call. It makes no network call.InMemoryStorage()inwenchang.storage.memoryis a fake for tests and trials. It holds a lock, so it is safe to share across threads.FilesystemStorage(root)inwenchang.storage.filesystem(not re-exported fromwenchang.storage) keeps objects in a local directory, for tests and local development rather than production.rootis aPath, created if missing. POSIX and a local filesystem only. Each key segment is percent-encoded, with uppercase escaped, and directories mirror the key's segments. A keya/b/c.mdis stored asa/b/c.md%obj, the exact body, plusa/b/c.md%meta, a JSON sidecar holding the version and metadata. Versions are random hex strings. It is safe to share across threads, and across instances and processes on one root, through a lock onroot/.lock. Accepted limits: a crash between replacing the body and the sidecar can pair a new body with old metadata; a hand-edited body is visible on read but keeps its old version; nothing callsfsync; and stray.tmp-files are not cleaned up. An empty key, or a key segment that encodes to more than 200 bytes or is not valid UTF-8 (such as a lone surrogate), raisesInvalidStorageKeyError.
Object keys, the stored metadata keys, timeouts, and retries are on Storage and operations.
Operator methods#
Not agent tools, and not on TransportClient or MemoryTools. See Erasing an entity.
MemoryStore.purge(scope, entity_id, *, max_passes=PURGE_MAX_PASSES) -> int: delete every object under{scope}/{entity_id}/; returns the confirmed delete count.wenchang.core.PURGE_MAX_PASSESis 5. Raiseswenchang.core.PurgeIncompleteErrorif objects remain after the last pass.MemoryStore.export(scope, entity_id) -> tuple[ExportedObject, ...]: every object under that prefix, raw, in key order.ExportedObjecthaskey,data,metadata, andversion.MemoryStore.invalidate_index(scope, entity_id) -> None: force the next index that includes the entity to list storage again. Does nothing without an index cache.
Index cache#
wenchang.index_cache.IndexCache(get, set, ttl=None, key_prefix="wenchang:index:"), where get(key) -> str | None and set(key, value, ttl) -> None. InMemoryIndexCache() provides both for one process. See The index cache.
Transport#
The tool layer calls a wenchang.transport.TransportClient, a protocol that mirrors MemoryStore's seven operations. InProcessClient(store) calls a MemoryStore directly and is the only client shipped. The MCP adapter calls one client from concurrent worker threads. What is safe to share across threads is under Threads.
Telemetry#
An on_call observer receives a wenchang.observer.ToolCallRecord (re-exported by wenchang.mcp) after each memory tool call. How to use it on each path: new server, existing server, without MCP.
| Field | MCP | Direct call |
|---|---|---|
tool |
Registered tool name, prefix included. | Bare method name, such as read_file. |
arguments |
The arguments the wrapper received. | Every parameter by name, defaults filled. |
result |
A copy of the payload sent to the client. | render_result of the return value, or render_error of the raised error. |
is_error |
Whether result is a rendered error. |
Same. |
request_id |
The MCP request's ID, for correlating records that arrive in completion order. | None. |
duration_s |
Wall time of the wrapper (credentials, binding, the tool, rendering). | Wall time of the tool method only. |
arguments and result are read-only mappings (MappingProxyType) built from deep copies, so an observer can't change what the tool or client sees.
on_call must be a plain (non-async) function. An exception it raises is logged at ERROR and swallowed.
Conformance suites#
pip install "wenchang[testing] @ git+https://github.com/msiebert/wenchang@v0.1.1" adds pytest and two mixins in wenchang.testing:
ResolverConformance[C]checks anIdentityResolver. Supply fixturesresolver,valid_credentials,invalid_credentials(callpytest.skipif your resolver accepts everything),policy,expected_scopes, andknown_roles.TransportConformancechecks aTransportClient. Supply fixturesclient(fresh and empty per test),source,scope_map(at least two writable scopes),max_file_bytes(at least 64),index_max_bytes(at least 1024),scope_priority, andlist_page_size. Within one test the client's writes must stamp strictly increasinglast_updatedvalues. An optionalmax_files_per_entityfixture (defaultNone) returns the client's storemax_files_per_entity(at least 32 and at leastlist_page_size + 2) to run the file-cap case, which checks that a create at the cap raisesFileQuotaExceededError; withNonethat case is skipped.
Name your subclasses with a Test prefix. This example passes against the shipped InProcessClient:
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
import pytest
from wenchang.core import MemoryStore
from wenchang.identity import Identity, IdentityResolver, ResolutionFailure, ScopeGrant
from wenchang.scope import ScopePolicy
from wenchang.storage.memory import InMemoryStorage
from wenchang.testing import ResolverConformance, TransportConformance
from wenchang.transport import InProcessClient, TransportClient
POLICY = ScopePolicy({"organization": frozenset({"admin", "owner"})})
class MyResolver:
def resolve(self, credentials: str) -> Identity | ResolutionFailure:
if credentials != "tok-tianlong":
return ResolutionFailure("unknown token")
return Identity(
grants={
"user": ScopeGrant("u-tianlong", "owner"),
"organization": ScopeGrant("o-hanlin-press", "admin"),
}
)
class TestMyResolver(ResolverConformance[str]):
@pytest.fixture
def resolver(self) -> IdentityResolver[str]:
return MyResolver()
@pytest.fixture
def valid_credentials(self) -> str:
return "tok-tianlong"
@pytest.fixture
def invalid_credentials(self) -> str:
return "tok-nobody"
@pytest.fixture
def policy(self) -> ScopePolicy:
return POLICY
@pytest.fixture
def expected_scopes(self) -> frozenset[str]:
return frozenset({"user", "organization"})
@pytest.fixture
def known_roles(self) -> frozenset[str]:
return frozenset({"owner", "admin", "member"})
class _Ticking:
"""A clock whose every call is strictly later than the last."""
def __init__(self) -> None:
self._n = 0
def __call__(self) -> datetime:
self._n += 1
return datetime(2024, 1, 1, tzinfo=UTC) + timedelta(microseconds=self._n)
class TestMyClient(TransportConformance):
@pytest.fixture
def client(self) -> TransportClient:
return InProcessClient(
MemoryStore(
InMemoryStorage(),
clock=_Ticking(),
max_file_bytes=256,
index_max_bytes=4096,
scope_priority=("user", "organization"),
list_page_size=2,
)
)
@pytest.fixture
def source(self) -> str:
return "conformance"
@pytest.fixture
def scope_map(self) -> Mapping[str, str]:
return {"user": "u-tianlong", "organization": "o-hanlin-press"}
@pytest.fixture
def max_file_bytes(self) -> int:
return 256
@pytest.fixture
def index_max_bytes(self) -> int:
return 4096
@pytest.fixture
def scope_priority(self) -> tuple[str, ...]:
return ("user", "organization")
@pytest.fixture
def list_page_size(self) -> int:
return 2
Known limitations are on Status and compatibility.