Metadata-Version: 2.5
Name: brickly-sdk
Version: 0.10.0
Summary: Brickly Brick Python runtime 官方 SDK。
Author: Brickly
License: MIT
Requires-Python: >=3.10
Requires-Dist: grpcio>=1.75.1
Requires-Dist: protobuf>=6.32.1
Requires-Dist: typing-extensions>=4.1; python_version < '3.11'
Description-Content-Type: text/markdown

# brickly-sdk

Brickly Brick **Python runtime** 官方 SDK。通过 loopback gRPC 接入 Host Runtime
（`invoke` / `interact`），让 Python Brick 专注写命令逻辑。缺少 Host endpoint 时拒绝 BPP fallback。

业务日志请使用 `brick.info` / `brick.warn` / `brick.error`（或兼容旧名 `brick.log`），经 Host `diagnostics.log` 进入日志中心。不要手写旧 stdin/stdout 协议帧。平台未连接时这些方法是 no-op，不会抛错。

## 安装

```bash
pip install brickly-sdk==0.10.0
```

国内环境可以使用 PyPI 镜像：

```bash
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple brickly-sdk==0.10.0
```

## 快速开始

```python
from brickly import BricklyRuntime

brick = BricklyRuntime()


@brick.on_command("hello")
def hello(ctx, input_value):
    data = input_value if isinstance(input_value, dict) else {}
    name = str(data.get("name") or "Brickly")
    brick.info("hello", {"name": name})
    return {"message": f"Hello, {name}"}


brick.run()
```

SDK 会自动处理：

- 连接 `BRICKLY_HOST_ENDPOINT` 并注册 gRPC Runtime
- `invoke` / `interact` 命令分发
- 取消信号、`ctx.is_cancelled()` 和 `ctx.on_cancel(...)`
- `invoke` 一次一结果；`interact` 用 `ctx.send` / `on_event` / `closed`
- 再跑自己的命令：`invoke` / `interact` / `call`（已有占用则不 dispose；没有占用则拒绝）
- Host 平台 / UI / Resource 客户端
- 可选 shutdown hook
- 子窗口创建、窗口方法调用、`window.*` 事件路由
- 事件总线 `events.publish(...)` / `events.on(...)`（公共事件 `命名空间:主题`；窗口寿命用 `win.on`）
- alias-first 跨 Brick 调用与会话：`dependencies.require(alias)`
- 平台能力 `platform.screenshot`、`platform.screen`、`platform.input`、`platform.clipboard`、`platform.system`

跨 Brick 的 `invoke` / `interact` 不设置 SDK 本地固定超时，由 Host
调用生命周期统一终止，避免大资源或长任务被误判超时。其他底层 Host API
仍保留默认超时，显式 `timeout` 的调用方式不变。

### 进阶：双向 `live`

`ctx.send` 只在 `interact` 里有意义，不要写进 `hello`。页面用 `interact`，不要用 `call`。

```python
@brick.on_command("live")
def live(ctx, input_value):
    n = {"value": 0}

    def on_event(event):
        data = event if isinstance(event, dict) else {}
        ctx.send({"type": "reply", "text": "收到" + str(data.get("text") or "")})

    ctx.on_event(on_event)
    ctx.closed.wait()
    return {"n": n["value"]}
```

会话内 RPC 用 `ctx.handle_requests`。作者和调用方成对写法见 [`docs/invoke-interact.md`](../../../docs/invoke-interact.md) 第 4.6 节。摘要：

```python
from brickly import BppError

def complete(request, req_ctx):
    if req_ctx.is_cancelled():
        raise BppError("CANCELLED", "request 已取消")
    body = request if isinstance(request, dict) else {}
    return {"items": suggest(str(body.get("prefix") or ""))}

@brick.on_command("assist")
def assist(ctx, _input):
    ctx.handle_requests(complete)
    ctx.closed.wait()
    return {"ok": True}
```

调用方（`require(alias)`、`ToolHandle`、`brick.interact` 同一套）：必须传 `on_event`，结果走 `end()`。

```python
session = await client.interact(
    "assist",
    {"file": "main.ts"},
    on_event=lambda event: None,
)
items = await session.request({"prefix": "con"})
result = await session.end()
```

**破坏性：** `request()` 同步返回句柄，不是结果，也不是 `async def`。`pending = session.request(...)` 拿到的是句柄。`await session.request(...)` 仍可用（句柄可 await），但要 `cancel()` 必须先拿句柄再 `await`：

```python
pending = session.request({"prefix": "con"})
pending.cancel()
await pending            # 也可 await pending.result()
```

错误看 `error.code`：`CANCELLED` / `DEADLINE_EXCEEDED` / `SESSION_CLOSED`。`DEADLINE_EXCEEDED` 同时也是 `TimeoutError`。`invoke` / `call` 本轮不改成 CallHandle。作者 `ctx.cancel_event` / `is_cancelled()` 不改。

`call` 是 interact + 立刻 end：

```python
poem = await client.call(
    "complete",
    {"prompt": "写一首诗"},
    on_event=lambda event: None,
)
```

### 进阶：两种子窗

`attached`（默认）随这次调用 / runtime 消失。`standalone` 窗在则 runtime 在：须声明 `command.window: standalone`，并在命令执行期间创建。后台定时弹窗请 `invoke` 一条 `window: standalone` 的命令，不要直接 `create_browser_window`。建窗写 `lifetime="standalone"`。不要把「附加」理解成常驻。

## 与 Node SDK 的同步关系

Python SDK 的协议语义与 `@syllm/brickly-sdk` 保持一致：

- Python 使用 `snake_case` 方法名，例如 `create_browser_window()`、`set_full_screen()`。
- Node 使用 `camelCase` 方法名，例如 `createBrowserWindow()`、`setFullScreen()`。
- 两者底层走同一套 gRPC Runtime / Host 服务，窗口方法名一致。
- 当前 SDK 包版本为 `0.10.0`（`__version__`）；生产协议是 `brickly.runtime.v1`。
- Python 不提供 Node 的 TypeScript `CommandMap` 类型生成能力；Python 侧依赖类型标注和中文 docstring 提供 IDE 补全。

## 命令上下文

命令处理函数会收到 `CommandContext`：

```python
@brick.on_command("process")
def process(ctx, input_value):
    if ctx.is_cancelled():
        return {"cancelled": True}

    ctx.send({"type": "status", "step": "start"})
    return {"ok": True}
```

长期占用使用 `ToolSdk.start()` / `ToolHandle`。Runtime 里占用依赖用 `require(alias).start()`，必须先进入自己的命令（`brick.invoke` 中转）；占用跟这次 Call，return 自动放手。异步上下文管理器是 `dispose()` 语法糖。一次性 invoke 不会 pin 进程。

常用属性和方法：

- `ctx.request_id`：当前请求 ID
- `ctx.command_id`：当前命令 ID
- `ctx.invocation`：宿主传入的可信调用来源和依赖 Profile 映射
- `ctx.config`：当前 Profile 配置快照
- `ctx.storage`：本机持久 KV / collection / secrets；与体验窗共库。看不见路径或 `_rev`
- `ctx.ui`：子窗口 API
- `ctx.events`：事件总线 API
- `ctx.platform`：平台能力 API
- `ctx.system`：`ctx.platform.system` 的快捷别名
- `ctx.send(event)`：推给调用方（仅 interact）
- `ctx.on_event(handler)`：收调用方 send（仅 interact）
- `ctx.handle_requests(handler, concurrency=8)`：注册会话内 request handler；return 就是那条 request 的结果（仅 interact）
- `ctx.closed.wait()`：等到调用方 closeInput / 断开
- `ctx.dependencies.require(alias)`：获取绑定当前 command parent、trace 与 Profile 的依赖客户端

## 子窗口

```python
from brickly import BricklyRuntime

brick = BricklyRuntime()


@brick.on_command("open")
def open_window(ctx, _input):
    win = ctx.ui.create_browser_window("ui/index.html", {"width": 640, "height": 480})
    win.expose({
        "pause": lambda _payload, _session=None: None,
    })
    win.send("tick", {"remaining": 60})
    win.on("closed", lambda payload: brick.info("窗口已关闭", {"payload": payload}))
    win.set_title("Hello from Python")
    return {"windowId": win.id, "windowKey": win.window_key}


brick.run()
```

`WindowHandle` 提供与 Node SDK 对齐的常用窗口方法，例如：

- 几何尺寸：`set_bounds()`、`get_bounds()`、`set_position()`、`set_size()`
- 内容区域：`set_content_bounds()`、`get_content_bounds()`、`set_content_size()`
- 状态切换：`minimize()`、`maximize()`、`restore()`、`show()`、`hide()`、`focus()`
- 状态查询：`is_visible()`、`is_focused()`、`is_minimized()`、`is_full_screen()`
- 外观能力：`set_title()`、`set_opacity()`、`set_background_color()`、`set_has_shadow()`
- webContents：`send()`、`execute_javascript()`、`open_dev_tools()`、`go_back()`、`set_zoom_factor()`、`copy()`、`paste()`、`undo()`

关闭是显式生命周期操作：

```python
result = win.close()
if result["status"] in ("pending", "prevented"):
    win.focus()  # 句柄仍可用

termination = win.force_close()
```

`close()` 返回 `closed/prevented/pending/not-found`。只有 `closed/not-found` 和 `window.closed` 终态事件会把句柄标记为 closed、从 Runtime Map 删除并清空 listener。终态 `eventId` 有界去重，transport 结束时也会释放全部窗口句柄。

## 跨 Brick 调用

命令处理函数内部只使用 manifest alias：

```python
result = ctx.dependencies.require("openai").invoke(
    "chat",
    {"prompt": "hello"},
    profile_id="work",
)

from brickly.internal.grpc.client import call

poem = await call(
    ctx.dependencies.require("openai"),
    "complete",
    {"prompt": "写一首诗"},
    on_event=lambda event: None,
)
```

精确来源和版本由 Host 握手绑定。热键依赖 Profile 会按绑定的精确 `BrickKey` 自动使用，显式
`profile_id` 始终优先。

没有当前命令时，同一套 `invoke` / `call` / `interact` 就是 root：

```python
result = brick.dependencies.require("openai").invoke(
    "chat",
    {"prompt": "hello"},
    profile_id="work",
)
```

### 大载荷与资源

普通 `invoke` 始终返回直接值，逻辑 JSON 输入和结果上限为 10 MiB，一次传完；
超限抛出 `PAYLOAD_TOO_LARGE`，不会静默改成资源类型。更大的字节走 Resource。

改限额时先改这张表，再对三语言 README 保持同一组数字：

| 限制 | 值 | 含义 |
| --- | --- | --- |
| 单帧（wire chunk） | 1 MiB | Create/Read 每一帧上限。SDK 自动拆，调用方不必切块 |
| gRPC 单条消息 | 4 MiB | 传输天花板，给 protobuf 信封留余量 |
| `bytes()` / `text()` / `json()` | 200 MiB | 整份进内存的上限；更大用 `stream()` / `save_to()` |
| 单对象默认配额 | 8 GiB | 真正的「能不能存 1G」 |
| 并发上传 | 8 | 每个 runtime 同时进行的 Create |

`str` 默认 `text/plain; charset=utf-8`，`bytes` 默认 `application/octet-stream`。
创建受 Host 配额与 Call/Lifetime 治理。finish 前资源不可读。命令内创建会自动带
`x-brickly-invocation-id`。不要记录 capability token，也不要长期持久化 Ref。

已经在内存里、通常不大——超过 1 MiB 时 SDK 自动改走 Writer，调用方式不变：

```python
note = brick.resources.create("hello", name="note.txt")
bin = brick.resources.create(data, name="input.bin")
```

文件、流、未知长度或 1G 级对象用 `create_from`，边读边传，不要先拼完整副本：

```python
with open("large.bin", "rb") as source:
    resource = brick.resources.create_from(source, name="large.bin")
resource.save_to("out.bin")
```

也接受 `Iterable[str | bytes]` 或带 `read()` 的文件对象。来源失败会 `abort`。

边算边写用 Writer。`write` 接受任意大小；关闭后再写返回 `RESOURCE_UPLOAD_CLOSED`。
`finish` 幂等；`finish` 之后的 `abort` 不会撤销已发布对象：

```python
writer = brick.resources.create_writer(name="out.bin")
try:
    writer.write(chunk)
    resource = writer.finish()
except Exception:
    writer.abort()
    raise
```

大结果由作者 `create` 后 `return` Handle；`invoke` 交回 `ResourceRef`，调用方再 `open`：

```python
ref = ctx.dependencies.require("report").invoke("export", input_value)
resource = brick.resources.open(ref)

if resource.ref["sizeBytes"] <= 200 * 1024 * 1024:
    report = resource.json()
else:
    resource.save_to(output_path)

ctx.dependencies.require("consumer").invoke(
    "analyse",
    {"source": resource},
)
```

`open()` 只绑定句柄，不立刻访问 Host。`ResourceHandle` 支持 `stream()`、`bytes()`、
`text()`、`json()`、`save_to()`、`close()`、`revoke()`；再次作为 input 时只传 `ResourceRef`。

```python
resource = brick.resources.open(payload["attachment"])
try:
    resource.save_to(output_path)
finally:
    resource.close()
```

`events.on()` 回调收到的就是发布时的业务对象，不会再包一层资源，也不会水合成
`ResourceHandle`。若业务对象里本身带 `ResourceRef`，需要读内容时再 `resources.open`。

过程调用用 `call`，必须传入 `on_event`：

```python
from brickly.internal.grpc.client import call

return await call(
    ctx.dependencies.require("openai"),
    "chat",
    {"prompt": "hello"},
    on_event=lambda event: ctx.send(event),
)
```

跨 Brick 调用需要在调用方 manifest 的 `dependencies` 中声明目标 Brick 和命令：

```json
"dependencies": {
  "openai": {
    "target": {
      "brickId": "com.brickly.openai",
      "origin": "installed",
      "version": "2.1.0"
    },
    "commands": ["chat"]
  }
}
```

## 跨 Brick 会话

目标 Brick 有状态时，在 command handler 里 `interact`，不要另做 `open()`。收过程只走 `on_event`，说完用 `end`：

```python
session = await ctx.dependencies.require("openai").interact(
    "chat",
    {"prompt": "继续这个话题"},
    on_event=lambda event: None,
)
return await session.end()
```

## 平台能力

系统 API：

```python
@brick.on_command("show-app-info")
def show_app_info(ctx, _input):
    return {
        "appName": ctx.system.get_app_name(),
        "appVersion": ctx.system.get_app_version(),
        "userData": ctx.system.get_path("userData"),
        "isWindows": ctx.system.is_windows(),
    }
```

剪贴板 API：

```python
@brick.on_command("replace-clipboard")
def replace_clipboard(ctx, _input):
    previous = ctx.platform.clipboard.read_content()
    updated = ctx.platform.clipboard.set_content({"kind": "text", "text": "来自 Python"})
    return {"previous": previous, "updated": updated}
```

输入和屏幕 API：

```python
@brick.on_command("screen-info")
def screen_info(ctx, _input):
    point = ctx.platform.screen.get_cursor_screen_point()
    display = ctx.platform.screen.get_primary_display()
    return {"point": point, "display": display}


@brick.on_command("click")
def click(ctx, _input):
    ctx.platform.input.mouse_click(100, 100)
    ctx.platform.input.keyboard_tap("A", "control")
    return {"ok": True}
```

宿主错误会以 `BppError` 原样抛出。`shell_open_external` 仅允许 `http` / `https` / `mailto`。

## 错误处理

抛出 `BppError` 可以保留明确错误码：

```python
from brickly import BppError

raise BppError("INVALID_INPUT", "url 不能为空")
```

普通异常会被 SDK 转换为 `INTERNAL_ERROR` 并返回给宿主。

## 日志

```python
brick.info("开始处理", {"id": 1})
```

`debug` / `info` / `warn` / `error` 经 Host `diagnostics.log` 进入日志中心。命令 handler 内会带上当前 `invocationId` 挂到该 command 节点；`on_ready` 等无当前 command 时走顶级 diagnostic。handler 返回后的异步日志仍能靠 ContextVar 挂回（与 Node 一致）。不要手写 stdin/stdout 协议帧。

## 源码结构

实现按领域拆分为 `transport` / `scope` / `command` / `session` / `window` / `events` / `platform` / `runtime`，与 Node SDK 对齐。

详情见 [`brickly/README.md`](brickly/README.md)。

## AI 对齐框架

本 SDK 是 **Follower** 实现。用 AI 跟进 Node 时请走：

- `specs/sdk/AGENT.md`
- `specs/sdk/capability-matrix.yaml`
- `specs/sdk/api-mapping.yaml`
- `specs/sdk/prompts/follower-agent.md`（`target=python`）
