Metadata-Version: 2.4
Name: shiori-sdk
Version: 4.1.0
Summary: Public Shiori plugin contracts and independent testing support.
License-Expression: MIT
Project-URL: Homepage, https://github.com/YinFengWindy/Shiori-Agent/tree/main/packages/sdk
Project-URL: Repository, https://github.com/YinFengWindy/Shiori-Agent
Project-URL: Issues, https://github.com/YinFengWindy/Shiori-Agent/issues
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28.1
Requires-Dist: json-repair>=0.40.0
Provides-Extra: testing
Requires-Dist: pytest>=9.0; extra == "testing"
Requires-Dist: pytest-asyncio>=1.3; extra == "testing"
Requires-Dist: httpx>=0.28; extra == "testing"
Requires-Dist: PyYAML>=6.0; extra == "testing"
Dynamic: license-file

# Shiori SDK

[@yinfengwindy/shiori-sdk](https://www.npmjs.com/package/@yinfengwindy/shiori-sdk)
and [shiori-sdk](https://pypi.org/project/shiori-sdk/) are the TypeScript and Python distributions of the
same plugin contract. This README documents **4.1.0** and manifest **API 4**.
The installation commands below target SDK 4.1. This source update does not publish
a release; use the workspace package or verified 4.1.0 wheel/tarball artifacts
until registry publication is confirmed. MCP client plugins require
`shiori-sdk>=4.1.0,<4.2.0`; unchanged consumers may retain `>=4.0.0,<4.2.0`.

Start with the [plugin guide](https://github.com/YinFengWindy/Shiori-Agent/blob/main/docs/_handbook/plugin-guide.md)
and the [generated API reference](https://github.com/YinFengWindy/Shiori-Agent/blob/main/docs/_handbook/plugin-api.md)
for plugin layout, `inject` service declarations and packaging.

## Compatibility

Shiori SDK uses a custom **A.B.C** policy: A identifies the overall generation,
B may introduce breaking API changes, and C contains compatible fixes, features
and migrations within the A.B line. Numeric ordering and range syntax remain
unchanged; this is not standard SemVer compatibility. Use explicit bounds such
as `>=4.1.0 <4.2.0` for manifest/npm peers and `>=4.1.0,<4.2.0` for Python.
A caret range does not enforce this compatibility line. Existing external
packages keep the meaning of their published ranges; the host adds no implicit
upper bound. Authors must republish ranges after checking their callers. See the
[version policy](https://github.com/YinFengWindy/Shiori-Agent/blob/main/docs/decisions/2026-11-05-sdk-version-policy.md).

### 4.1.0 migration

Breaking compatibility-line update; the manifest format remains **API 4**. Remove calls to
`ctx.require(PROCESSES).mcp(...)`: the host now provides only generic process
creation and ownership through the existing `spawn` / `popen` methods.

MCP sessions, `McpSession`, `McpToolInfo`, `McpToolError`, result decoding and
`FakeMcpSession` move from `shiori_sdk` to the internal `shiori-mcp-client`
distribution (`shiori_mcp_client`; test doubles live with that library). Its
source is `packages/mcp-client/src/shiori_mcp_client`, with independent version
0.1.0. Declare that dependency and construct its sessions with the injected
`Processes`; update imports and test doubles instead of retaining a host factory.
For example, replace the old factory with the library's public stdio session:

```python
import sys
from shiori_mcp_client import StdioMcpSession
from shiori_sdk.core.services import PROCESSES

session = StdioMcpSession(
    "example", command, processes=ctx.require(PROCESSES),
    own_process_tree=sys.platform == "win32",
)
ctx.effect("example.mcp", session.disconnect)
```

Connect after publication or when the owning tool turn starts, not during inert
setup. HTTP consumers use the library's public `HttpMcpSession`.
The shared library owns stdio/HTTP protocol handling; user server configuration,
credentials and OAuth policy remain in the MCP plugin. Browser Use and Computer
Use retain their independent session ownership. `ToolResult` and `MCP_TOOLS`
remain SDK contracts with their existing behavior. No new key or runtime layer
is introduced.

Migrated consumers declare `sdk: ">=4.1.0 <4.2.0"` and `shiori-sdk>=4.1.0,<4.2.0`.
Consumers using only unchanged APIs can explicitly admit both compatibility lines with `>=4.0.0,<4.2.0`
after checking their callers. The internal client is built into the private
wheelhouse and frozen desktop; it does not require a new public PyPI release.
Frozen dependency updates still require rebuilding the desktop, and installing
a plugin ZIP does not install Python dependencies. Existing SDK 4.0 artifacts
and tags are immutable. The notes below describe historical releases.

### 4.0.0

Historical release notes: the version convention below was superseded by the
2026-11-05 A.B.C policy above; released artifacts remain unchanged.

Breaking major. Manifests declare `api: 4` and `sdk: ">=4.0.0"`. There is no
`runtime_api` version. Within 4.x, existing keys do not gain methods. See the
plugin guide. The unused `STORAGE` key and `PrivateStorage.migrate_data` are
removed in this major. Resolve private paths with
`shiori_sdk.storage.plugin_data_dir` and use `KV` for JSON state; the memory
port retains `resolve_config` and `open_database`. The 3.1.x notes below are
historical migration notes; they do not describe the API 4 host's admission rules.

### 3.1.2 migration

**3.1.2 includes breaking changes despite its patch version.** Relative to the
published 3.1.1:

- Remove `ChannelContext.push_tool` registration. `PushSenders`, `FakePushSenders`
  and the `push_tool=` argument to `fake_channel_context` are gone. Role sends
  to channels use `account_send` (`account.send`); role `message_push`
  only targets the role's own desktop session.
- A `message_push` tool obtained from the tool registry (`get_tool`) raises on an unknown
  channel, unsupported payload or sender failure. Handle exceptions at the
  caller's boundary; do not inspect returned error text. A normal return means
  the message was sent or queued in the current turn.
- `background_results` captures an authenticated tool turn for later reports
  to its original role conversation; `process_resources` owns versioned plugin
  resources across runtime generations. The renderer adds `host.openExternal`
  and `secondarySidebarShellClass`. These APIs and their independent testing
  fakes require SDK / Runtime API `>=3.1.2 <4.0.0`.

The 3.1.x host range check accepted older 3.x ranges, so affected authors had to
migrate and declare the 3.1.2 minimum themselves. Unchanged APIs retain their
previous minimum. The selected patch number is the project's release batch
number, not a backward-compatibility guarantee.

Desktop v0.5.5 shipped the four additions while incorrectly advertising 3.1.1;
its embedded SDK differs from the registry 3.1.1. Do not infer support from that
label or replace old artifacts/tags. This 3.1.2 batch gives the additions and
the subsequent channel migration a new, consistent version.

### Previously published 3.1.1 migration

**3.1.1 includes breaking changes despite its patch version.** Compared with the
published 3.1.0:

- `SurfaceHandle.voice` and its voice-state/gesture types are removed. Speech
  consumers must own their orchestration and use scoped native resources and
  surface-to-background messages.
- Relationship snapshots no longer carry `closeness`; consumers of closeness
  use the role's affection (`AffectionSummary`) instead. (4.0.0 removes the
  snapshot types and `RoleRecord.affection` altogether: the affection plugin
  serves its data through its own RPC.)

The 3.1.x host's range check accepted `>=3.1.0 <4.0.0`, so it did not protect a
plugin that used these removed APIs. Affected authors had to migrate, rebuild and
declare `>=3.1.1 <4.0.0`. Plugins using any new API in this batch use that same
minimum in their manifest and Python SDK dependency. Audited plugins using only
unchanged APIs may retain `>=3.1.0 <4.0.0`.

The unpublished `roleUi` contribution, `PluginRoleUiProps` /
`PluginRoleUiContribution` types and `usePrivateDraft` hook were removed before
this release batch. They are not 3.1.1 APIs. Use `roleSettings` capability cards
and `usePrivateAutosave` for plugin-owned settings.

4.0.0 drops that batch. A breaking contract change is a new SDK major. The
version source, npm package and host pins stay the same number, checked by
`node scripts/check_sdk_release.mjs --align`. A version already shipped is not
reused for different source.

## TypeScript

Install the SDK and its React peers as development dependencies:

```sh
pnpm add -D "@yinfengwindy/shiori-sdk@>=4.1.0 <4.2.0" "react@^19.2.5" "react-dom@^19.2.5"
```

Build the plugin UI as ESM, externalizing `@yinfengwindy/shiori-sdk`, `react`,
`react/jsx-runtime`, `react-dom` and `react-dom/client`. Shiori's import map supplies
the host instances at runtime; do not bundle a separate SDK or React instance.
The npm package requires React and React DOM `^19.2.5` for local development.
The host renderer ABI guarantees `19.2.0`; declare compatible host peers separately
in the plugin's `package.json`:

```json
{
  "peerDependencies": {
    "react": ">=19.2.0 <20.0.0",
    "react-dom": ">=19.2.0 <20.0.0",
    "@yinfengwindy/shiori-sdk": ">=4.1.0 <4.2.0"
  }
}
```

The plugin builder writes these ranges to the output manifest's `frontend.peers`.
An API 4 source manifest declares one `frontend.entry` with named `ui`,
`background` and/or `surface` exports. Legacy `peer_dependencies`, `renderer`
and `runtime_api` declarations are rejected.

The main entry exports plugin contracts, components and helpers. `/contract`
contains React/DOM-free types; `/host-internal` is reserved for host code; `/testing`
provides independent UI fakes and a DOM harness and is never a runtime peer.
TypeScript consumers of `/testing` use `@types/node >=26.6.3`, declared as an
optional type peer because happy-dom exposes Web Streams types from that version.
This is a declaration requirement, not a change to the runtime Node requirement.
Workspace consumers resolve source; `pnpm --filter @yinfengwindy/shiori-sdk pack` builds an ESM
tarball with declarations and external React peers. The host import map provides
the same main entry to precompiled plugins. Only the main entry belongs in a
plugin's production peer imports.

The SDK provides `usePrivateAutosave(client, identity, { load, save, debounceMs? })`
for plugin settings pages that save like the host's: `update` saves the latest draft
once edits pause (400 ms by default), saves never overlap, `commit` saves at once for
blur/Enter fields (keep their unvalidated text in component state and commit only a
valid value), and leaving the page or switching scope submits the last scheduled draft.
A failed save keeps the draft and later edits and pauses until `retry()`; a failed read
exposes `loadError` and `reload()` and never writes a default. `reload()` does nothing
while a save is in flight and otherwise drops unsaved edits. Render its `savePhase` with
`host.ui.SettingsSavedStatus` for the host's page-corner 「已保存」 mark, and lay the page
out with the host's `SettingsGroup`, `SettingsSectionCard`, `SettingsField`,
`SettingsToggleField`, `settingsInputClass` and `settingsGroupStackClass`.

The host event `chat.cancelled` (`{session_key, turn_id}`)
and `chatTerminalEventMethods` / `isChatTerminalEvent`: while the bridge connection
is open, every accepted chat turn ends with exactly one of `chat.done`, `chat.error`
and `chat.cancelled`.

A disposed scoped client rejects with `PluginBridgeError`, code
`plugin_unavailable`, and `details.reason: "context_disposed"`. Cleanup may treat
that exact reason as expected; the same error code without the reason can indicate
other unavailable services and must not be silently classified as local disposal.

## Python

Requires Python **3.12+**. Install in your plugin project:

```sh
uv add "shiori-sdk>=4.1.0,<4.2.0"
```

For independent plugin tests, add the optional testing support and run your suite:

```sh
uv add --dev "shiori-sdk[testing]>=4.1.0,<4.2.0"
uv run pytest tests
```

These commands set up your plugin's development environment. The Shiori host
supplies the SDK and declared host dependencies when it runs the installed plugin.

Frozen desktop builds collect all runtime SDK modules, including implicit namespace
directories such as `files/`, independently of installed or bundled plugins.
`shiori_sdk.testing` is excluded from the runtime. A frozen executable probe in CI
verifies dynamic imports without repository paths in its environment.

The wheel contains contracts and pure values, without a dependency on the host.
The core plugin surface includes `PluginRuntimeContext`, `EventsCapability`,
`Dispose` and `EventHandler`; turn and tool events live in
`shiori_sdk.core.conversation` and `shiori_sdk.core.tools`.
The host owns turn execution, storage and cleanup.
`setup(ctx)` receives a `PluginRuntimeContext`. Services listed in `inject` are
taken with `ctx.require`; a key the manifest does not declare raises the
kernel's `undeclared_key`. A missing provider blocks the plugin before
`setup`. `kv` without a workspace fails while the host builds the context,
with `HostServiceUnavailable`. Host service implementations remain in the host.

```python
from dataclasses import replace
from types import MappingProxyType

from shiori_sdk import PluginRuntimeContext
from shiori_sdk.core.conversation import StepFinished


async def trace_step(event: StepFinished) -> StepFinished:
    metadata = MappingProxyType({**event.extra_metadata, "example": True})
    return replace(event, extra_metadata=metadata)


async def setup(ctx: PluginRuntimeContext) -> None:
    ctx.on(StepFinished, trace_step)
```

Install `shiori-sdk[testing]` for pytest/pytest-asyncio, `sdk_context`,
`FakePluginContext`, `FakeEvents`, package staging
and shared test TLS contexts. The desktop bridge request helper lives in
`shiori_host_testing.bridge`, since only host integration tests use it. Fakes store only test values
in memory; they never open host persistence. `sdk_context` reads the nearest
`manifest.yaml` above the test file (within the pytest rootdir; override the
`sdk_plugin_dir` fixture otherwise) and declares the manifest's
`inject` / `optional_inject` / `provides`: `ctx.require` / `ctx.get` of a key
not listed there raises the kernel's `undeclared_key`, as in the host. Other
fake contexts take `package_dir=` to read the same lists from a package's
manifest. Hand a fake to a declared host-provided key with
`ctx.serve(KV, FakeKV())`; the plugin's `ctx.require(KV)` then returns it.
The preset contexts (`FakeExtensionContext`, `FakeServiceContext`,
`FakeChannelSetupContext`, `FakeMemorySetupContext`) already serve their fakes
and keep them on `fake_<name>` attributes for assertions (`ctx.fake_rpc`,
`ctx.fake_kv`, `ctx.fake_accounts`); pass the context straight to `setup`.
Reading or setting a removed capability attribute name (`ctx.kv = ...`,
`ctx.rpc`) raises, as the host context has no such attributes. The manifest is
only read: `plugin_dir`
is an isolated temporary directory, and bundled files are package assets. A
plugin test emits a turn event through the fake context
(`await sdk_context.emit(StepFinished(...))`), which runs the plugin's `on`
handlers on a real kernel without the host's core checks. A replacement event
holds no list, dict or set field: wrap mappings in `MappingProxyType` and use
tuples, or the kernel rejects the replacement and, under `fail_open`, skips the
handler.
HTTP response contracts and tolerant JSON helpers declare httpx/json-repair as
runtime dependencies. pytest-asyncio and test fixtures remain optional.
The base wheel can coexist with pytest without the testing extra: unrelated tests
collect and run normally. Requesting `sdk_context` without the extra reports the
installation requirement. Optional fixtures load only when their dependencies exist.
SDK-only tests never start `AppRuntime`. Real host integration fixtures live in
`shiori_host_testing`, including its real workspace-backed memory fake. That
private development package is never installed in plugin or SDK isolation.

## Maintenance and validation

Release tags `sdk-v<version>` publish the validated npm tarball and Python wheel/sdist.
See the [publishing guide](https://github.com/YinFengWindy/Shiori-Agent/blob/main/docs/agents/sdk-publishing.md)
for registry setup, first publication and recovery.

`python/shiori_sdk/_version.py` is the version source. Python packaging reads it
directly. After changing it, set the same version on the npm package, `setup.py`
and host-testing. Lint and the npm build run `node scripts/check_sdk_release.mjs --align`.
Socket changes also need `python -m scripts.gen_plugin_api`.

```sh
pnpm lint
pnpm typecheck
pnpm run sdk:smoke
uv run python -m scripts.verify_sdk
uv run python -m scripts.verify_plugin_tests
uv run python -m scripts.check_sdk_imports
```

The artifact probes install tarball/wheel non-editably outside the checkout. The
wheel probe first collects/runs an unrelated test with only the base SDK and pytest,
checks the missing-extra diagnostic, then installs the extra and runs all SDK tests.
The plugin probe discovers all plugin suites (currently all 20 baseline plugins),
builds ordinary wheels from external copies, and installs only each target's
declared dependency closure. Every `shiori-*` package comes from the local
wheelhouse with `--no-index --no-deps`; a missing local wheel fails instead of
falling back to an index. Third-party requirements are installed separately and
`uv pip check` verifies the result. No host or default-memory package is injected.
Each suite and its awaited failure probe owns a separate pytest temporary directory.
Provenance checks run before and after the suite, checking host absence, all
distribution origins, editable installs, repository path injection and SDK versions.
An execution probe starts before initial conftests and rejects implementation code
from every staged target/dependency tree or the original checkout (including SDK
sources), even when a temporary module alias is removed before the suite ends.
Installed entry origins and hashes cover the target's complete declared plugin closure.

The import guard has no exemptions. It checks SDK and plugin Python sources,
tests, stubs and packaged testing helpers, including TYPE_CHECKING, import aliases,
literal/string-composed dynamic imports, string patch targets, `importlib.resources`
and `pkgutil` resource access (attribute, alias or literal `getattr` forms), and host
resource paths built from `__file__` (`Path`/`pathlib.Path`, `os.path.dirname`/
`split`/`join`, literal `*[...]` arguments, `os.pardir`, `p /= ...`, folded string
concatenation) or explicitly rooted at the working directory (`Path.cwd()`,
`os.getcwd()`, `Path()`, including `os.getcwd() + "/apps/backend"`) that names the host
layout. A relative path counts as cwd-rooted only when it reaches a file-system sink
listed in `scripts/sdk_path_sinks.py` (`open`, `io.open`, `os.listdir`/`chdir`/...,
`glob`, `shutil.*`, `sys.path.insert`/`append`, file-system methods of concrete
pathlib paths). Host layout (one definition in `scripts/sdk_repository_layout.py`) means a path or
literal starting with `apps/backend`, `apps/desktop` or `tests/backend` (rejected
anywhere; URLs, prose and plugin-internal `.../tests/backend/...` are not), an
existing entry below `apps/backend/`, or a file inside an existing host package
directory. Ordinary call arguments, `PurePath` values, `str()` and `posixpath.join`
are not file-system paths; `os.path` functions are recognized through imports only.
Each violation counts once, at the value where it first appears; paths derived
from it are the same violation. Path values are followed through names bound in the
same module only; paths passed through attributes, containers, call results or loops
(for example `self.root.parents[3]`, `parents[-1]`, `__spec__.origin`,
`sys.path[0]`, repeated `parent` in a loop) are left to external execution.
Exclusions are judged relative to the scanned package: virtual environments,
caches and a plugin's root-level `build/`/`dist/` outputs; package-internal
directories such as `backend/build/` are scanned wherever the checkout lives.
Host roots follow the actual backend package/module tree.
SDK dependencies cannot point to concrete plugins. Declared public sibling plugin
dependencies remain valid. This static guard is complemented by external execution;
it does not claim to sandbox arbitrary Python.

CI separates all-plugin isolation, SDK artifact tests, host integration and installed
host-resource checks. Existing renderer and Windows process lifecycle jobs remain.



## Memory engines

`shiori_sdk.memory.engine` owns memory requests, results, scopes, tool profiles and
`MemoryEngine`. `MemoryAdminApi.rollback_turn(turn_id, message_ids)` supersedes
memories for one passive turn — `turn_id` is its anchor user message id,
`message_ids` every message of the turn — and returns `TurnRollback`. `shiori_sdk.core.memory` owns the `MEMORY_ENGINE` socket plugins
provide and consumers `get` (`None` means no retrieval; chat still runs).
`memory.access` holds `MemoryRoles`, `MemoryStorage` and `MemoryEvents`.
`memory.events` and `memory.committed` own shared ingestion, consolidation and
committed-turn values; implementations and SQLite stay outside SDK.
Pure semantic pagination/filter declarations live in `memory.requests`; each engine
owns its status vocabulary and removes private vector/hash fields from responses.
`shiori_sdk.core.models` owns `MAIN_PROVIDER` and `MAIN_MODEL`, the generation's
main chat pair. A providing plugin `require`s them.

A providing plugin constructs the engine in `setup` from its own config and
`ctx.provide(MEMORY_ENGINE, engine)`. Embedding credentials come from that plugin
config. Register each database and embedder with `ctx.effect` before the next
allocation; the plugin scope closes them. Databases are opened only through
`ctx.require(MEMORY).storage.open_database(path)` so the host's live-database
lease applies. `MemoryCapability.skill_names()` supplies procedure-tagger names.
The `MEMORY` service never carries an engine.

A memory plugin lists `memory` and `rpc` in `inject` and takes
`ctx.require(MEMORY)` (`shiori_sdk.core.services`): shared Markdown reads, role
existence/permissions and storage come from the host, while RPC registration
through `ctx.require(RPC)` remains plugin-owned. A turn's retrieval arrives as
`shiori_sdk.core.conversation.TurnStarting` and tool results as
`shiori_sdk.core.tools.ToolCalled`. Standalone tests
can use `FakeMemorySetupContext`, `FakeMemoryRoles` and `FakeMemoryStorage`;
provide `MEMORY_ENGINE` on the test kernel when the test has an engine.
Real runtime assembly remains a host integration test.


## Tool policies, commands and observation

`shiori_sdk.tools` owns `Tool`, `ToolResult` and result normalization;
`shiori_sdk.core.tools` owns the tool call events `ToolCalling`, `ToolCalled` and
`ToolVeto`, which policy plugins join with `ctx.on`. Policies remain in plugins;
the host's own minimal shell guard runs after every `ToolCalling` chain.
`ToolVeto(reason, finalize=True)` requests host summarization while ordinary
denial leaves the existing execution flow intact. A policy plugin that needs
its config or the workspace injects `config` / `workspace` and takes
`ctx.require(CONFIG)` / `ctx.require(WORKSPACE)`.

Command plugins declare `inject: [commands]` and register with
`ctx.require(COMMANDS).register(name, handler, description=...)`
(`shiori_sdk.core.conversation`), handing the returned removal to `ctx.effect`.
The host answers `/name` right after acquiring the session, before the memory
backlog guard: the handler reads an immutable `CommandInput` and returns the reply;
the host builds the ordinary turn result around it, retaining channel, timestamp
and context scope and skipping retrieval/model calls. Described registrations form
the command menu channels receive as `ChannelContext.bot_commands`; aliases pass no
description. `last_consolidated` is memory progress, not model compaction.
`shiori_sdk.core.sessions` owns the `SESSIONS` socket (`inject: [sessions]`;
session metadata and media are the separate `SESSION_STORE`, `inject:
[session_store]`). `last_passive_turn` reads the latest passive turn
as `PassiveTurn` (`turn_id` is the anchor user message id; `message_ids` are every
message of the turn) without deleting it. `rollback_last_turn` deletes it under
the session lock and returns `RollbackResult`; with `expected_turn` it deletes
only that exact turn (same anchor and message ids), otherwise it returns
`TurnChanged`, and
`rollback_source_ids` rewinds the consolidation cursor to a memory window.
`plugin_undo` calls `MemoryAdminApi.rollback_turn(turn_id, message_ids)` first,
then the session; an empty `MEMORY_ENGINE` rolls back the session only.
A command plugin that calls another plugin's export injects `dependencies` and
reads `ctx.require(DEPENDENCIES)` on each call.

Observe injects `background`, `workspace` and `crash_reporting`. It resolves
its own database under `plugin_data_dir(workspace, "observe")`. The host owns
the active error session, installed package and external plugin code roots, and the
process-global hook stack behind `CRASH_REPORTING` (`install` / `uninstall`,
`session_key`, `top_frame`). The plugin owns fingerprinting, deduplication,
flush and SQLite. Register collector cleanup before installing hooks; stop
subscriptions and watches before final flush, then cancel retention and writer
in reverse order. Source checkout depth is never a plugin contract.
`ContextBudgetObserved` and `MemoryWritten` are watched (`ctx.watch`);
`TurnCommitted` stays on the domain bus. A budget watcher sees a read-only
status mapping; a memory watcher sees `superseded_ids` as a tuple.

Status commands declare `observe` as an optional manifest dependency and resolve
its public `recent_cache_turns` export for each command. Unload yields an unavailable
reply; reload uses the new export. Consumers do not inspect Observe's database.

`testing.extensions.FakeExtensionContext` (its `emit` runs the plugin's tool event
handlers on a bare kernel, without the host's checks),
`commands.CommandRegistry` (the host's own `COMMANDS` registry; provide it on the
fake context's `kernel`), `testing.commands.FakeSessions` and
`testing.crash_reporting.FakeCrashReporting` provide independent contract doubles.
The crash-reporting fake records callbacks without modifying process-wide handlers.
Global hook restoration and real lifecycle ordering stay in host integration tests.


`shiori_sdk.storage.plugin_data_dir` and `PLUGIN_DATA_DIRNAME` own the pure canonical
private-data layout and portable plugin-ID validation. Host code, plugins and SDK
fakes use the same helper. Observe passes its canonical database path to both
writer and public telemetry reader; resolving the path does not create storage.
Memory plugins use `MEMORY.storage.resolve_config` for their local config and
`open_database` for host-managed database leases. Neither port copies legacy data.

## Role, generation and native services

Each service below is a key in `shiori_sdk.core.services`: list the `inject`
name in the manifest and take it with `ctx.require(KEY)` in `setup`. A key the
manifest does not declare raises `undeclared_key`; a declared key without a host
provider blocks the plugin before `setup`.

| `inject` name / key | Contract and ownership |
| --- | --- |
| `roles` / `ROLES` | Detached role snapshots, explicit asset resolution/adoption and opaque role-extension transactions. The host keeps its canonical RoleStore and write lock. |
| `models` / `MODELS` | `async with ctx.require(MODELS).activate(role_id, "chat" or "vision")` holds the host-selected provider/model snapshot across awaited work. No runtime registry or full Config is exported. |
| `session_store` / `SESSION_STORE` | Session metadata, original media provenance, atomic image replacement and its host-owned desktop projection. |
| `external_turns` / `EXTERNAL_TURNS` | (3.1.1) `await ctx.require(EXTERNAL_TURNS).submit(ExternalTurnMessage(...))` runs one message from a non-channel source (platform, conversation id/title, sender id/name, message id, text) as an external-context group turn of a role and returns `ExternalTurnResult` (`replied` with the reply text, `busy`, `duplicate`). It never waits for the role, never dispatches to a channel, and cancelling the call cancels the turn. `testing.external_turns.FakeExternalTurns` answers with queued results. |
| `http` / `HTTP` | `HttpClient` uses the injected external transport and its default retry/budget policy. Memory's bounded `HttpRequester` remains separate. |
| `background` / `BACKGROUND` | `spawn` owns scoped tasks; `spawn_runtime` additionally retains the calling runtime generation until task completion. Both cancel and join outstanding work on unload. |
| `processes` / `PROCESSES` | Starts owned child processes through the host's owned_spawn/WindowsJob implementations. MCP sessions belong to shiori-mcp-client; the SDK contains no MCP protocol or process implementation. |
| `resources` / `RESOURCES` | Supplies source/frozen resource roots and shared emoji paths; plugins do not guess checkout depth. |
| `tool_turn` / `TOOL_TURN` | Returns the current host turn identity and awaited ownership finalizers. Model arguments cannot forge it. |
| `runtime` / `RUNTIME` | `was_active` and `on_drain` preserve accepted work during generation replacement. |

`Roles.read_scope()` keeps a plugin's multi-read namespace reconciliation atomic
against canonical role edits. Plugins retain their schemas and policies; only
host storage operations cross this boundary. Plugin catalogs resolve their private
files with `plugin_data_dir`; the host does not migrate or copy old catalog roots.

Shared prompt-section, scene-observation, RPC-error and MCP values have one SDK
definition. `files`, `media`, `errors` and `redaction` contain standalone helpers
operating on explicit values/paths. Native discovery, HTTP transport, role/session
storage and runtime leases remain host-owned.

`testing.service_context.FakeServiceContext` composes independent role, session,
tool, HTTP, resource, process, RPC and task fixtures. Native calls fail until a
test explicitly supplies their result. Plugin policy tests execute with only the
SDK and declared sibling dependencies (Story → NovelAI).
Individual fixtures live in their owning `testing.tools`, `testing.storage`,
`testing.sessions`, `testing.resources`, `testing.models`, `testing.http`
and `testing.runtime` modules; the context only
assembles them. Role draft writers and covariant read-only projectors are defined
once in `shiori_sdk.roles` and used by both the host and independent fixtures.
Actual kernel ordering, role saves, session media adoption, runtime lease
retention, screen/desktop-pet integration and Windows Job cleanup remain in host
tests. PR CI includes a dedicated Windows process-lifecycle job.

Desktop pet also injects `workspace`, `roles`, `tools`, `rpc` and `events`
(typed `role_events.RoleDeleted`). Its private paths use `plugin_data_dir`.
Sprite packages, role selection, exclusive visibility and asset reconciliation
remain plugin policies. Host integration tests exercise canonical role locks,
atomic saves and kernel replacement with those public keys. `RPC`'s `register` supports
`admission_exempt=True` for bounded controls that must remain reachable while
ordinary admission pauses; desktop-pet bubble dismissal uses this flag.
Pure timestamp/path helpers are defined once in `shiori_sdk.values`.

## Channel and account contracts

Channel plugins inject `channels` (`CHANNELS`, a `ChannelsCapability` in `shiori_sdk.channels.context`), `accounts` (`ACCOUNTS`) and whatever else they use, and take them with `ctx.require` in `setup`; transport startup receives `shiori_sdk.channels.ChannelContext`. Account values/rules/targets live in `shiori_sdk.accounts`, message values in `shiori_sdk.messages`, stream events in `shiori_sdk.channel_events`, and provenance/quote helpers in `shiori_sdk.channels.message_source` and `reply_context`. The host injects intake, routing, attachment storage, HTTP and avatar services; it retains lifecycle and cache policy.

Inject `processes` (`PROCESSES`) for native children. `Processes.popen` is the synchronous counterpart of `spawn`, returning a process and an optional `ProcessOwner`; only the host implements OS ownership. QQ keeps NapCat installation, private profiles, QR codes and OneBot policy. Independent testing uses shared `FakeAccounts`, `FakeChannelSetupContext`, `FakeChannelIntake`, `FakeHttp`, `FakeAvatars` and `FakeProcesses`, never host services.

`ChannelsCapability.group(name)` constructs the host account-group coordinator; `ChannelSessions.identity_index(...)` constructs the host metadata-backed identity view. Platform dedupe, credential/authentication, stream formatting and reconnect policy stay in plugins. Use `FakeAccountChannelGroup` and `FakeChannelSessions` for independent tests. Scoped KV supports idempotent `delete`; `read_mapping` validates object-valued records before consumers access them. Telegram tool-call preview events and `EventBinding` are shared SDK values.


## Surface interaction

A background owner declares `surfaces.setInteraction(surfaceId, { roleId, available })`
for generic role activity projection. Surface events carry real window identity.
Surface-to-background messages belong to the owning plugin; the host never interprets
press/release as speech or selects a role's provider.

`SurfaceHandle.onRoleActivity(listener)` carries only role/session identity, phase
and notification intent. Plugins own animation, voice state and scheduling.
Runtime API 3.1.1 removes the published 3.1.0 `SurfaceHandle.voice` business interface.
Desktop pet uses its own surface messages and scoped native capture, playback and
key registration instead. The host retains native resources and revokes them when
their plugin activation or actual renderer window is gone.

## Discoverable services and speech contracts (Runtime API 3.1.1)

Provider backends inject `services` and take `ctx.require(SERVICES)`
(`shiori_sdk.services.PluginServices`). Its `register(service_id, contract=..., label=...,
methods={...}, metadata={...})` publishes explicit JSON methods for exactly the
plugin instance's lifetime. Metadata is JSON data, not implementation objects.
Consumers use `client.services.list(contract)` and `client.services.call(reference,
method, payload)`. Dynamic discovery does not grant private RPC access or require
a static dependency on every possible provider ID. Calls validate the active
communication owner, generation and exact registration; unloaded services are not
silently replaced with another provider.

`shiori_sdk.voice` contains only shared wire values: `shiori.asr.v1` exposes
`transcribe({audio_base64, format: "wav"}) -> {text}` and `shiori.tts.v1` exposes
`synthesize({text, role_id, mood}) -> {audio_base64, format}`. The provider owns its
role configuration and audio generation, including serialization until actual
inference completes. The SDK has no voice controller, vendor client, voice asset
lifecycle or default selection. Independent setup tests serve
`testing.services.FakePluginServices` on a `FakePluginContext`
(`ctx.serve(SERVICES, FakePluginServices(ctx))`).

Desktop pet requires SDK 3.1.1 or later. It owns input gestures, hotkey preferences, microphone
selection, selected services, chat/reply matching, synthesis queues and manual
stop. Its backend writes `plugin-data/desktop_pet/voice-preferences.json`; these
values never enter host `config.toml`, `runtime_config.tts` or role `plugin_data`.
The host provides generic UI slots and native device/recording/playback/key resources.
Existing role extension drafts continue their original host role transaction;
plugin-private settings use `roleSettings` cards with `storage: "plugin"` and
`usePrivateAutosave`, without claiming a transaction across host and plugin files.

Tencent/MiniMax integrations and their installation/migration code are removed.
Existing user files, credentials and remote voice assets are left untouched.
SenseVoice and GPT-SoVITS are bundled providers with their own plugin versions,
both disabled by default.

## 3.1.1 local-service utilities

`shiori_sdk.files.audio.pcm_wav_duration` validates complete PCM WAV audio, optionally rejecting digital silence. `shiori_sdk.files.staging.staged_import_file` validates a native picker's namespace, extension and byte limit. `shiori_sdk.local_http.loopback_http_url` validates a plain HTTP loopback origin. These utilities do not open a connection or supply inference policy.

A manifest may declare `distribution: external` for repository sources delivered through normal plugin ZIP installation instead of bundled discovery. Omission retains builtin distribution. External installation, trust, update and removal still use the existing workspace plugin lifecycle.

## 3.1.1 private managed environments

`shiori_sdk.managed` contains opt-in generic artifact acquisition, atomic installation,
owned-process and background-operation helpers. A provider supplies fixed `Artifact`
records, a private root, build/launch callbacks, and its connection-mode policy.
SDK helpers contain no model names, vendor recipes, concrete plugin imports or
consumer orchestration. Process creation still delegates to injected `Processes`.

`Installation` validates sizes/SHA-256, resumes bounded HTTP ranges, supports an
offline resource ZIP and publishes complete version directories through an atomic
pointer. `OwnedService` holds a cross-generation file lease, waits for a matching
child generation token, and admits calls only after readiness. Close kills and
joins its native tree before releasing the lease. Windows host crashes rely on
the existing Windows Job ownership contract; no PID is adopted or killed by ID.

`ManagedRuntime` retains background progress/errors, cancellation and explicit
start/stop. `register_runtime_rpc` wires opt-in `runtime.status`, `prepare`, `start`,
`stop`, `cancel`, `remove` and `relocate` endpoints plus plugin drain cleanup. Providers own imported
file extensions/namespaces, models, private settings and recovery policy. They
`register_runtime_rpc` takes `ctx.require(RPC)`, so the provider must list `rpc`
in `inject`; a managed provider typically also injects `workspace`, `background`
and `processes`.

The renderer exports `ManagedRuntimePanel` and `useManagedRuntime`; the panel
receives provider-selected import extensions and never stores
provider settings in the host. Leaving settings does not cancel preparation.
Native file staging remains streamed and bounded, with generic 16 GiB per-file
and 32 GiB per-selection ceilings; plugins still request their own smaller limits.

Controlled filesystem operations use Windows' extended-length namespace for
drive and UNC paths. Compact `s/<id>` and `v/<id>` directories avoid duplicating
the revision in every filename; the validated receipt retains version/resource
identity. Existing trial pointers to `versions/` remain readable.

Runtime API 3.1.1 adds environment hygiene. `ManagedRuntime.remove()` deletes
installed versions, the pointer, caches and staging in the background, also
when nothing is installed but a failed or cancelled preparation kept its
download cache; it is refused while a task runs or a service of any generation
holds the service lease, and retains state-root files (including locks, logs and
provider settings). Status reports
`reclaimable` (bytes of kept downloads, one stat per artifact) and `staging`
(unfinished preparation files left over, never walked). A published preparation
deletes the download cache and every other version directory (older ones stay
while a service may still run from them); failure or cancellation keeps the
cache so a retry resumes. Removal and post-publication cleanup run in a thread
that cancellation does not abandon: `cancel()` / `stop()` / `close()` return
only after it has finished and released its leases, and status reports the
actual outcome. `Installation(..., installed_size=)` declares the bytes of one
prepared version; before any copy, preparation requires free space on the
install root's volume of at least the missing artifact bytes (an in-place import
counts as zero download-copy bytes) +
`installed_size` + max(1 GiB, 5% of both), and otherwise fails with
「磁盘空间不足：需要约 X GiB，剩余 Y GiB（位置）」. Child output is piped and
decoded incrementally per child, UTF-8 until the first invalid byte and the
Windows ANSI code page from then on (native tools such as 7-Zip), into UTF-8
`prepare.log` / `service.log`; CR, LF and CRLF all end a line. A failed
preparation command or an early service exit names its last meaningful line.
`Processes.spawn` therefore accepts `asyncio.subprocess` constants for
`stdout` / `stderr`. `ManagedRuntimePanel` shows 「删除环境」 for a stopped,
idle runtime that is installed or keeps a cache, behind a destructive
`host.ui.ConfirmDialog`.

Runtime API 3.1.1 lets the user choose where an environment is installed and
imports packages in place. The provider's `root` is the *state root* (plugin
data); it keeps `current.json`, `location.json`, locks, logs and provider files,
so `OwnedService.root` and every service identity stay put. The *install root*
(`Installation.install_root`, by default the state root) holds `downloads/`,
`s/`, `v/`, the `tmp/` / `cache/` directories of `private_environment` and any
`Installation(..., scratch=)` names; providers pass it to their build and launch
callbacks for child temp/cache locations. `runtime.relocate {directory}` (and
`ManagedRuntime.relocate()`) selects a dedicated `<namespace>` directory inside
an existing directory on a drive letter (UNC and device paths are rejected);
without `directory` it restores the default. Both need nothing installed or
kept, no running task and a target without installation entries; the choice
persists. `current.json` records the install root only for a chosen location,
so a default installation still follows plugin data when it moves. An
unreadable `location.json` appears in status `error`; relocating (with no
pointer) or removing recovers from it. `runtime.prepare {source}` takes the
original absolute path from `host.pickFilePaths`: a regular file reached without
any link or junction, with an accepted suffix and, for the provider's
`ManagedRuntime(..., import_asset=)`, that artifact's exact size. Its SHA-256 is
verified while reading, before any build; the file is never copied, moved or
deleted. In the finalized API, builds take `build(staging, resources)` with
each verified artifact path (`resources[name]`, an in-place import at its
original path), `acquire_resources` returns that mapping, and `acquire_artifact`
does not accept `source=` (see `verify_file`). `import_asset` belongs to the
`ManagedRuntime` constructor, not `register_runtime_rpc` / `submit`;
`register_runtime_rpc` has no `max_bytes` parameter. These signatures replace
unpublished workspace experiments. A ZIP bundle's members are extracted
into staging. The free-space check uses the install root's volume and counts an
in-place import as 0 bytes. Status adds `location`, `customized`, `required`
(download), `required_import` (the provider's import), `free`, `removable` and
`relocatable`; whatever blocks relocation is `removable`. Removal deletes only
the install root's installation entries, unlinking links and junctions without
entering them, plus a chosen dedicated directory once empty.
`ManagedRuntimePanel` has no `namespace` prop, shows location
and space, and offers 「更改位置」 (`host.pickDirectory`) and 「恢复默认」.

Process arguments and working directories preserve the provider's spelling:
native Python libraries can require ordinary `sys.prefix` paths when they append
relative components. Third-party `TEMP`/`TMP`, cache and `PATH` values likewise
retain ordinary syntax at the same private location. This does not alter global
Windows settings or move data outside the plugin's private root.
