mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 17:08:01 +00:00
- 目录 tools/plugindev → tools/hmapdev,可执行文件名/平台产物名同步
(hmapdev_linux_amd64 等;包格式仍叫 .hmap)
- module path github.com/JianFeeeee/homeagent-sdk/tools/... → gitcode.com/...
(与仓库实际托管一致;核心仓不依赖该 path,改动无外部影响)
- SDK 存储目录 ~/.homeagent/plugindev/sdk → ~/.homeagent/hmapdev/sdk
新目录不存在而旧目录存在时沿用旧目录 → 已装 SDK 版本不会丢失
- 命令表/usage/--help/生成项目 README/示例 README/NSIS 安装器/
package/build.sh/build-examples.sh 全部同步;PLUGINDEV 环境变量保留兼容
- sdk/ 目录零改动(公开接口不变)
验证:
- go build ./... ok;go test ./tools/hmapdev/ ok(含模板接线守卫 TestProcTemplate_CoversAllCoreMethods)
- bash -n package/{build,build-examples}.sh ok
- 端到端:hmapdev init demo && hmapdev build → dist/demo_bundle.hmap(linux+darwin)
- 本机安装 /usr/local/bin/hmapdev,旧名以软链保留;sdk list/current 正常
832 lines
33 KiB
Markdown
832 lines
33 KiB
Markdown
# HomeAgent SDK
|
||
|
||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||
|
||
## Version and Compatibility
|
||
|
||
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
||
|
||
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||
|
||
| Kernel version | Matching SDK |
|
||
|---|---|
|
||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||
| 1.2.0 onward | 1.2.0 |
|
||
|
||
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||
has changed.
|
||
|
||
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||
because the handshake validates `ProtocolVersion`, not the SDK version). Rebuild only when you want
|
||
the new fields.
|
||
|
||
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
|
||
No public signature changed (the SDK adds `InjectOptions`, six `*Opts` variants and
|
||
`ChannelDef.ContextPolicy`), so not calling the new capabilities means not being affected — but the
|
||
kernel's **plugin protocol went to 2** (the fd3 layout of the unified shared-memory region changed,
|
||
and **rolling upgrades are not supported**). `plugin.bin` must therefore be rebuilt with the matching
|
||
`hmapdev` and installed **together with** the kernel; otherwise the handshake fails on protocol
|
||
version mismatch (the error says explicitly to rebuild with the matching hmapdev — it never
|
||
degrades silently).
|
||
|
||
## Injection Behaviour and Context Pruning (1.2.0)
|
||
|
||
"Should this go into memory" and "should the context be pruned based on this" used to be
|
||
something only `ToolDef` could declare. Since 1.2.0 **injections can declare them too**, sharing
|
||
the same semantics and values.
|
||
|
||
```go
|
||
type InjectOptions struct {
|
||
NoMemory bool // true = excluded from memory computation (vectorize/keywords/distill); the
|
||
// original text still stays in context
|
||
ContextPolicy string // ""/none = do not prune (default); prune = prune context based on this
|
||
CleanerName string // name of the compute-layer cleaner: run it first to get the effective
|
||
// content, then compute/prune on that
|
||
}
|
||
|
||
const (
|
||
ContextPolicyNone = "none"
|
||
ContextPolicyPrune = "prune"
|
||
)
|
||
|
||
// Six variants, one-to-one with the older three-argument methods, plus opts
|
||
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||
```
|
||
|
||
Key points:
|
||
|
||
- **A zero-valued `InjectOptions{}` is key-for-key equivalent to the older three-argument methods**
|
||
(recorded in memory, not pruned). The old methods remain as zero-value sugar (`InjectText`,
|
||
`InjectInterruptText`, `InjectTextNoMemory`, …), so existing plugins keep working without a single
|
||
line changed *or* a rebuild.
|
||
- **Pruning (`prune`) must be declared explicitly**: it archives/drops low-relevance events, which
|
||
is a side effect, so it is off by default. The kernel only accepts `""` / `none` / `prune`
|
||
(`ValidContextPolicy`); anything else is rejected.
|
||
- Pruning first goes through the plugin's registered **`Cleaner`** (named by `CleanerName`) to get
|
||
the effective content, avoiding the inconsistency of "prune on the raw text, compute on the
|
||
cleaned text".
|
||
- `ChannelDef` carries the same `context_policy` (1.2.0 also gave `ChannelDef` JSON tags — the
|
||
definition crosses the process boundary, while `Cleaner` is a function that must be ignored; with
|
||
no tags, newly added fields would be silently dropped).
|
||
|
||
## SDK API Surface
|
||
|
||
### Plugin Interface
|
||
|
||
Plugins implement the `Plugin` interface:
|
||
|
||
```go
|
||
type Plugin interface {
|
||
Name() string
|
||
Start(sdk *PluginSDK) error
|
||
Stop() error
|
||
}
|
||
```
|
||
|
||
### PluginSDK Methods
|
||
|
||
The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
||
|
||
| Category | Method | Description |
|
||
|----------|--------|-------------|
|
||
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
|
||
| Input Channel | `RegisterInputChannel(name, def)` | Register input channel with `ChannelDef` (NoMemory/Cleaner) |
|
||
| Output Channel | `RegisterOutputChannel(name, caps, desc, def, handler)` | Register output channel with `ChannelDef` and capability bitmask |
|
||
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
|
||
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
|
||
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
|
||
| Text Memory | `TextMemory()` | Access text memory API (chronological events) |
|
||
| Doc Memory | `DocMemory()` | Access document memory API (vector store) |
|
||
| Social Graph | `Social()` | Access social graph API (read-only for external plugins) |
|
||
| Knowledge | `Knowledge()` | Access knowledge base API |
|
||
| LLM | `LLM()` | Access LLM provider manager API |
|
||
| Settings | `Settings()` | Access settings API |
|
||
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||
| Media inject | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | Inject input carrying images/audio (added in 1.1.0) |
|
||
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||
|
||
### Stage Hooks
|
||
|
||
```go
|
||
// Listen to all stage events globally
|
||
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||
|
||
// Listen only to this plugin's own tool calls (before_toolcall / after_toolcall only)
|
||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||
```
|
||
|
||
### ChannelDef
|
||
|
||
```go
|
||
type ChannelDef struct {
|
||
NoMemory bool // Channel input/output skips memory computation (vector/keyword/distill), original text preserved
|
||
Cleaner func(string) string // Optional: computation layer filter (does not modify original text)
|
||
}
|
||
```
|
||
|
||
`ChannelDef` controls channel behavior in the memory computation layer, with the same semantics as `ToolDef.NoMemory`/`Cleaner`.
|
||
|
||
### Input Channels
|
||
|
||
```go
|
||
sdk.RegisterInputChannel("qq", ChannelDef{
|
||
NoMemory: true,
|
||
Cleaner: func(text string) string { return strings.TrimSpace(text) },
|
||
})
|
||
```
|
||
|
||
### Output Channels
|
||
|
||
```go
|
||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", ChannelDef{}, handler)
|
||
```
|
||
|
||
The handler receives three arguments:
|
||
- `payload` (string) — message content. For `type=text` it's plain text, for `type=file/image` it's a URL
|
||
- `meta` (string) — optional JSON routing metadata (e.g. `{"group_id":123,"user_id":456}`)
|
||
- `type` (string) — content type enum (see below)
|
||
|
||
Capability flags:
|
||
|
||
| Flag | Value | Description |
|
||
|------|-------|-------------|
|
||
| `CapText` | 1 | Plain text output |
|
||
| `CapFile` | 2 | File output |
|
||
| `CapImage` | 4 | Image output |
|
||
| `CapAudio` | 8 | Audio output |
|
||
| `CapStructured` | 16 | Structured data output |
|
||
|
||
Type enum values:
|
||
|
||
| Value | Description |
|
||
|-------|-------------|
|
||
| `text` | plain text |
|
||
| `voice` / `audio` | audio/voice |
|
||
| `image` | image |
|
||
| `file` | file |
|
||
|
||
### IOInjector Channel Routing
|
||
|
||
| Method | Description |
|
||
|--------|-------------|
|
||
| `InjectText(source, channel, text)` | Inject text, record to memory, route to specified channel |
|
||
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||
|
||
### Multimodal Injection (added in 1.1.0)
|
||
|
||
| Method | Description |
|
||
|--------|-------------|
|
||
| `InjectInputMedia(source, channel, text, blocks)` | Inject media-bearing input, asynchronous |
|
||
| `InjectInputMediaSync(source, channel, text, blocks)` | Inject media-bearing input and wait for the reply text |
|
||
| `InjectInterruptMedia(source, channel, text, blocks)` | Inject a media-bearing interrupt that can preempt current processing |
|
||
|
||
`blocks` is `[]sdk.ContentBlock`, the same type `SetToolBlocks` takes:
|
||
|
||
```go
|
||
s.InjectInputMedia("myplugin", "webui", "take a look at this", []sdk.ContentBlock{{
|
||
Type: "image_url",
|
||
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||
}})
|
||
```
|
||
|
||
How this differs from `SetToolBlocks`: that one is only callable inside a tool handler and
|
||
its media reaches the model with the *next* tool message. These three let a plugin
|
||
**initiate a turn that carries media** — the media goes out with this turn's message and is
|
||
automatically stored in the media store with a memory reference attached.
|
||
|
||
`data:` URLs in the blocks are stored and deduplicated by the kernel; `http(s)` URLs are
|
||
passed to the model only and never stored (storing them would require the kernel to make
|
||
network requests, bringing timeouts, auth and SSRF into scope).
|
||
|
||
`source` identifies the origin, `channel` specifies the target output channel.
|
||
|
||
### Triple Extended Fields
|
||
|
||
The Triple data structure includes additional fields:
|
||
|
||
- `Confidence` — confidence score (0.0–1.0)
|
||
- `SubjectType` — subject type
|
||
- `ObjectType` — object type
|
||
- `SentenceText` — the original sentence (added in 1.1.0), written to the `sentences` table; media references hang off the sentence
|
||
- `MediaDigests` — associated media digests (added in 1.1.0)
|
||
|
||
### Media in Memory (added in 1.1.0)
|
||
|
||
Inside plain-text memory, media is represented as a **marker** of the form
|
||
`[<mime> <short digest>] <description>`:
|
||
|
||
```
|
||
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||
```
|
||
|
||
The description is the durable semantic memory (retrieval uses it); the digest is the key
|
||
back to the bytes (reverse lookup uses it). Markers are generated by the kernel — a plugin
|
||
never has to assemble one, it just **supplies the digest**.
|
||
|
||
#### Graph memory
|
||
|
||
```go
|
||
s.Memory().Commit([]sdk.Triple{{
|
||
Subject: "palette", Relation: "contains", Object: "three-band",
|
||
MediaDigests: []string{"a1b2c3d4e5f6"}, // short digest is fine, the kernel resolves it
|
||
}})
|
||
```
|
||
|
||
With no `SentenceText`, the kernel uses the marker itself as the sentence — media must have
|
||
a sentence to hang off, otherwise the reference has nowhere to attach.
|
||
|
||
#### Knowledge base
|
||
|
||
```go
|
||
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||
Title: "illustrated note",
|
||
Content: "body",
|
||
}, []sdk.MediaAttachment{
|
||
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // new content, stored and deduped
|
||
{Digest: "a1b2c3d4e5f6"}, // reference existing content
|
||
})
|
||
```
|
||
|
||
`Insert` keeps its original signature; markers already present in the body are bound as
|
||
document-level references too. `Query` fills `MediaDigests` and `Attachments` (mime plus
|
||
description, **no bytes** — one query can match dozens of media items). Removing a document
|
||
releases its references.
|
||
|
||
#### Text memory
|
||
|
||
```go
|
||
s.TextMemory().Append(sdk.TextEvent{
|
||
Role: "user", Content: "look at this",
|
||
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||
})
|
||
```
|
||
|
||
`RecentEvents` decodes markers in the body back into `Attachments`.
|
||
|
||
The media store can be disabled kernel-side (`core.memory.media.enabled=false`); all of the
|
||
above then degrades to plain-text behaviour — no errors, no panics, identical to how it
|
||
behaved before this feature shipped.
|
||
|
||
### ToolDef Field Reference
|
||
|
||
The `def` parameter of `RegisterTool` is of type `sdk.ToolDef`, with the following fields:
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `Name` | `string` | Tool name, use plugin name prefix to avoid conflicts |
|
||
| `Description` | `string` | Tool description, LLM uses this for tool selection |
|
||
| `Parameters` | `map[string]interface{}` | JSON Schema parameter definition |
|
||
| `NoMemory` | `bool` | Default `false`; when `true`, output skips vector/jieba/distill computation (original text preserved) |
|
||
| `Cleaner` | `func(string) string` | Optional, filters output before computation layer (e.g., extract `.content` from JSON) |
|
||
|
||
For detailed design rationale of `NoMemory` and `Cleaner`, see `docs/en/PLUGIN_DEV.md` in the core repository.
|
||
|
||
### New Constructor
|
||
|
||
`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually:
|
||
|
||
```go
|
||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||
```
|
||
|
||
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
||
|
||
## hmapdev Toolchain
|
||
|
||
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
|
||
named after that package format). Prebuilt binaries ship as **release assets**
|
||
(linux/darwin/windows × amd64/arm64); download from
|
||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
|
||
|
||
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
|
||
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
|
||
> (the old directory is still honored, so installed versions are not lost).
|
||
|
||
```bash
|
||
# From release assets (latest SDK release / linux amd64 shown)
|
||
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
|
||
chmod +x hmapdev
|
||
|
||
# Or build from source
|
||
cd tools/hmapdev && go build -o hmapdev .
|
||
```
|
||
|
||
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
|
||
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
|
||
> reproducible from source anyway.
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
||
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
||
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
|
||
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
|
||
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
|
||
|
||
Supports both **Go** and **Lua** plugin languages.
|
||
|
||
### plg.json Manifest Format
|
||
|
||
```json
|
||
{
|
||
"name": "weather",
|
||
"name_zh": "天气查询",
|
||
"name_en": "Weather",
|
||
"version": "1.0.0",
|
||
"description": "Weather plugin",
|
||
"author": "HomeAgent",
|
||
"entry": "plugin.bin",
|
||
"tags": ["weather", "forecast"],
|
||
"targets": "linux/amd64,windows/amd64",
|
||
"outdir": "dist",
|
||
"bundle": true,
|
||
"replaces": {
|
||
"github.com/example/pkg": "../local/pkg"
|
||
},
|
||
"source_dirs": [
|
||
"../shared-lib"
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `name` | string | Plugin identifier |
|
||
| `name_zh` | string | Chinese name |
|
||
| `name_en` | string | English name |
|
||
| `version` | string | Version |
|
||
| `description` | string | Plugin description |
|
||
| `author` | string | Author |
|
||
| `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
|
||
| `tags` | string[] | Tags |
|
||
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
||
| `outdir` | string | Output directory (default `dist`) |
|
||
| `bundle` | bool | Bundle mode (build all platforms at once) |
|
||
| `replaces` | object | Go module replacements, key=module path, value=local path |
|
||
| `source_dirs` | string[] | Additional source search paths (auto-imported at build time) |
|
||
|
||
### .hmap Package Format
|
||
|
||
`.hmap` is a ZIP archive containing:
|
||
|
||
- `plugin.json` — plugin metadata
|
||
- `plugin.bin` — Go compiled artifact (single-platform build)
|
||
- `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
|
||
the one matching the current platform and renames it to `plugin.bin`
|
||
- `main.lua` — Lua plugin entry (for Lua plugins)
|
||
|
||
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
|
||
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
|
||
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
|
||
|
||
## Plugin Lifecycle
|
||
|
||
### Start & Stop
|
||
|
||
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||
- `Stop() error` — Plugin shutdown, release resources
|
||
- `sdk.RegisterStopHandler(fn func())` — Register a shutdown cleanup callback. The kernel (for built-in plugins) or z_bridge (for external plugins) runs all registered handlers **before** calling the plugin's `Stop()` (LIFO order, cleared after running — idempotent). Use it for persistence and cancelling background work: plugin memory is still fresh at that point, avoiding stale-state write-backs that resurrect deleted data.
|
||
|
||
### Remove Cleanup (onRemove)
|
||
|
||
`Stop` / `RegisterStopHandler` run whenever the plugin **stops** (including reload and disable); `RegisterOnRemoveHandler` runs **only once when the plugin is uninstalled (removed)** — never on reload or disable:
|
||
|
||
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
|
||
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
|
||
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `hmapdev` template includes an onRemove demo.
|
||
|
||
```go
|
||
sdk.RegisterOnRemoveHandler(func() {
|
||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||
})
|
||
```
|
||
|
||
### Auto-Restart
|
||
|
||
```go
|
||
sdk.SetAutoRestart(true)
|
||
// Query state
|
||
enabled := sdk.AutoRestart()
|
||
```
|
||
|
||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||
|
||
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||
> external connection has been established, and that connection setup usually happens in a
|
||
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||
> concurrent. **SDK 1.1.0 locks this flag and all API fields** (`-race` reported 11 data races;
|
||
> in production this showed up as sporadic nil-dereference crashes during plugin reload). Upgrade
|
||
> if you are on anything earlier.
|
||
|
||
## Concurrency Contract for Plugin Developers
|
||
|
||
`PluginSDK` is a **shared object used by multiple goroutines**: the polling, listening and timer
|
||
callbacks you start in `Start()` all hold the same `*PluginSDK` and push messages into it, while
|
||
the kernel writes its API fields during load/reload. So:
|
||
|
||
- **Guaranteed by the SDK**: all API accessors (`Memory()`/`DocMemory()`/…), all injection methods,
|
||
`SetAutoRestart`/`AutoRestart`, `RegisterTool`/`RegisterStage`, and
|
||
`RunStopHandlers`/`RunOnRemoveHandlers` (idempotent; concurrent calls still run it once).
|
||
- **Your responsibility**: every field of `StageContext` is exported, and concurrent read/write
|
||
must hold `ctx.Lock()`/`ctx.RLock()`. Especially `ctx.Extra` — **concurrent map writes are a
|
||
fatal in Go, and `recover` cannot catch it**.
|
||
|
||
```go
|
||
ctx.Lock()
|
||
ctx.Extra["mykey"] = value
|
||
ctx.FinalText += "supplementary note"
|
||
ctx.Unlock()
|
||
```
|
||
|
||
## Restricted SDK vs Full SDK
|
||
|
||
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||
|
||
| Restricted API | Allowed Operations |
|
||
|----------------|-------------------|
|
||
| `SocialAPI` | Read-only: `GetPerson`, `GetTrait`, `GetRelations`, `GetNetwork`, `ListPersons` |
|
||
| `EventSubscriber` | Subscribe-only: `Subscribe` (no `Publish`) |
|
||
|
||
Internal plugins (platform built-in) have full SDK access including SocialAPI write operations and EventPublisher.
|
||
|
||
## Example Plugins
|
||
|
||
| Plugin | Type | Description |
|
||
|--------|------|-------------|
|
||
| [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||
| [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||
| [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
|
||
| [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
|
||
| [ai_image](example/ai_image) | Go | AI image generation |
|
||
| [bili](example/bili) | Go | Bilibili video downloading |
|
||
| [browser](example/browser) | Go | Web search, page fetching, browser rendering |
|
||
| [calendar](example/calendar) | Go | Calendar management |
|
||
| [editdoc](example/editdoc) | Go | Document editing |
|
||
| [files](example/files) | Go | File management |
|
||
| [memo](example/memo) | Go | Memos (PreAction injection + scheduled reminders) |
|
||
| [music](example/music) | Go | Music playback |
|
||
| [ocr](example/ocr) | Go | Optical character recognition |
|
||
| [rss](example/rss) | Go | RSS subscriptions |
|
||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
||
|
||
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
|
||
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
|
||
that plugin binaries are **protocol-bound** to the kernel (`ProtocolVersion` + the shared-memory
|
||
magic), so shipping the toolchain without matching artifacts invites installing an old artifact —
|
||
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
|
||
match".
|
||
|
||
## Remote Device SDK
|
||
|
||
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
||
|
||
### Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────┐
|
||
│ ha_remotedevice (C SDK) │
|
||
│ Protocol Engine │ WS Frames │ JSON │ State │
|
||
│ Machine │ Transport Abstraction │
|
||
└──────────┬──────────────────────────────────────┘
|
||
│ Same C code, shared by device & app
|
||
┌──────┴──────────────────┐
|
||
▼ ▼
|
||
┌──────────────┐ ┌──────────────────────────┐
|
||
│ ESP32 Bare │ │ Linux App │
|
||
│ Pure C │ │ (Python ctypes / Go CGo /│
|
||
│ Simple Cmd │ │ Node addon / C# P/Invoke)│
|
||
└──────────────┘ └──────────────────────────┘
|
||
```
|
||
|
||
### Declarative API Design
|
||
|
||
The device declares **what it is** and **what it can do** in code. The SDK handles all protocol details automatically:
|
||
|
||
```c
|
||
#include "ha_remotedevice.h"
|
||
|
||
/* Declare capabilities */
|
||
const char *caps[] = {"camera", "status", NULL};
|
||
|
||
ha_config_t config = {
|
||
.transport = my_transport, // User implements 4 functions
|
||
.server = "192.168.1.100:9890",
|
||
.token = "my-token",
|
||
.device = {
|
||
.device_id = "esp32-cam-1",
|
||
.name = "Front Door Camera",
|
||
.kind = "camera",
|
||
.caps = caps,
|
||
},
|
||
.on_cmd = my_cmd_handler, // Called when receiving commands
|
||
.on_binary = my_data_handler, // Called on binary data (TTS audio, etc.)
|
||
.on_state = my_state_handler, // Connection state changes
|
||
};
|
||
|
||
ha_client_t *client = ha_client_new(&config);
|
||
ha_client_start(client);
|
||
while (1) {
|
||
ha_client_process(client); // Main loop processing
|
||
}
|
||
```
|
||
|
||
### Transport Layer Abstraction
|
||
|
||
Users only need to implement 4 functions to adapt to different platforms:
|
||
|
||
```c
|
||
ha_transport_t my_transport = {
|
||
.connect = my_tcp_connect, // Establish TCP connection
|
||
.send = my_tcp_send, // Send data
|
||
.recv = my_tcp_recv, // Receive data (blocking)
|
||
.close = my_tcp_close, // Close connection
|
||
.ctx = &my_platform_ctx,
|
||
};
|
||
```
|
||
|
||
### Protocol Support
|
||
|
||
| Feature | API |
|
||
|---------|-----|
|
||
| WS connection + handshake | Automatic via `ha_client_start` |
|
||
| Device registration (hello/bind) | Automatic on startup |
|
||
| Command receive (shell/homeagent) | `on_cmd` callback |
|
||
| Command result | `ha_client_send_result` |
|
||
| Binary chunked transfer (video) | `ha_client_send_data_chunked` |
|
||
| TTS audio receive | `on_binary` callback |
|
||
| Event reporting | `ha_client_send_event` |
|
||
| Status reporting | `ha_client_send_status` |
|
||
| Heartbeat keepalive | Automatic ping/pong |
|
||
|
||
### Usage
|
||
|
||
Initialize a project via the `hmapdev` toolchain:
|
||
|
||
```bash
|
||
hmapdev init my-adapter --type remotedevice
|
||
```
|
||
|
||
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
||
|
||
```cmake
|
||
add_subdirectory(path/to/ha_remotedevice)
|
||
target_link_libraries(my_app ha_remotedevice)
|
||
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||
```
|
||
|
||
### Quick Start Guide
|
||
|
||
A complete step-by-step guide from zero to a device successfully connected to HomeAgent.
|
||
|
||
#### Step 1: Preparation
|
||
|
||
Create an access token on the HomeAgent platform:
|
||
|
||
```bash
|
||
# Create a device access token on the HomeAgent server
|
||
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"device_id":"esp32-cam-1","name":"Front Door Camera","kind":"camera"}'
|
||
# Returns: {"token":"ha-dev-token-xxxxx"}
|
||
```
|
||
|
||
Save the returned `token` — you'll need it in the device configuration.
|
||
|
||
#### Step 2: Implement the Transport Layer (4 functions)
|
||
|
||
Implement the 4 function pointers of `ha_transport_t` for your platform. Here are common scenarios:
|
||
|
||
**Scenario A: Embedded device with TCP/IP stack (e.g., ESP32 + lwIP)**
|
||
|
||
```c
|
||
#include "ha_remotedevice.h"
|
||
#include "lwip/sockets.h"
|
||
|
||
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||
struct sockaddr_in addr;
|
||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||
if (sock < 0) return -1;
|
||
addr.sin_family = AF_INET;
|
||
addr.sin_port = htons(port);
|
||
inet_pton(AF_INET, host, &addr.sin_addr);
|
||
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||
if (ret < 0) { closesocket(sock); return -1; }
|
||
*(int *)ctx = sock;
|
||
return 0;
|
||
}
|
||
|
||
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||
int sock = *(int *)ctx;
|
||
return send(sock, (const char *)data, len, 0);
|
||
}
|
||
|
||
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||
int sock = *(int *)ctx;
|
||
return recv(sock, (char *)buf, len, 0);
|
||
}
|
||
|
||
static void esp_close(void *ctx) {
|
||
int sock = *(int *)ctx;
|
||
closesocket(sock);
|
||
}
|
||
|
||
int esp_ctx = -1;
|
||
ha_transport_t transport = {
|
||
.connect = esp_connect,
|
||
.send = esp_send,
|
||
.recv = esp_recv,
|
||
.close = esp_close,
|
||
.ctx = &esp_ctx,
|
||
};
|
||
```
|
||
|
||
**Scenario B: Serial (UART) passthrough module**
|
||
|
||
```c
|
||
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||
(void)host; (void)port;
|
||
return uart_init((uart_ctx_t *)ctx, 115200);
|
||
}
|
||
|
||
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||
return uart_write((uart_ctx_t *)ctx, data, len);
|
||
}
|
||
|
||
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||
}
|
||
|
||
static void uart_close(void *ctx) {
|
||
uart_deinit((uart_ctx_t *)ctx);
|
||
}
|
||
```
|
||
|
||
> Note: For UART passthrough, a TCP bridge program must run on the other end to forward serial data to the HomeAgent WebSocket port.
|
||
|
||
#### Step 3: Declare Device Capabilities and Command Handlers
|
||
|
||
```c
|
||
#include "ha_remotedevice.h"
|
||
|
||
/* Declare device capabilities */
|
||
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||
|
||
/* Handle camerasue command (take photo) */
|
||
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||
ha_cmd_result_t *result, void *userdata) {
|
||
(void)req_id; (void)userdata;
|
||
int duration = args[0] ? atoi(args) : 0;
|
||
|
||
// Capture image, fill the result
|
||
result->status = 0;
|
||
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 image data
|
||
return HA_OK;
|
||
}
|
||
|
||
/* Handle shell command */
|
||
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||
ha_cmd_result_t *result, void *userdata) {
|
||
(void)req_id; (void)userdata;
|
||
result->status = 0;
|
||
result->output = "command executed";
|
||
return HA_OK;
|
||
}
|
||
|
||
/* Declarative command handler table */
|
||
ha_cmd_handler_def_t handlers[] = {
|
||
{.command = "shell", .handler = handle_shell},
|
||
{.command = "camerasue", .handler = handle_camera},
|
||
{.command = "screensee", .handler = handle_camera},
|
||
{.command = "speakeruse", .handler = handle_speaker},
|
||
{.command = NULL}, /* terminator */
|
||
};
|
||
```
|
||
|
||
#### Step 4: Configure and Start the Client
|
||
|
||
```c
|
||
ha_config_t config = {
|
||
.transport = transport, // Transport layer implementation
|
||
.server = "192.168.1.100:9890", // HomeAgent server address
|
||
.token = "ha-dev-token-xxxxx", // Token from Step 1
|
||
.device = {
|
||
.device_id = "esp32-cam-1",
|
||
.name = "Front Door Camera",
|
||
.kind = "camera",
|
||
.caps = caps,
|
||
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||
},
|
||
.handlers = handlers, // Command handler table
|
||
.on_binary = on_binary_data, // Receive TTS audio etc.
|
||
.on_state = on_state_change, // Connection state callback
|
||
.ping_interval = 30,
|
||
};
|
||
|
||
ha_client_t *client = ha_client_new(&config);
|
||
ha_status_t ret = ha_client_start(client);
|
||
if (ret != HA_OK) {
|
||
printf("Device connection failed: %d\n", ret);
|
||
return;
|
||
}
|
||
|
||
/* Main loop */
|
||
while (1) {
|
||
ha_client_process(client); // Process protocol frames, heartbeats, commands
|
||
|
||
/* Optional: device-initiated event reporting */
|
||
ha_client_send_event(client, "motion_detected",
|
||
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||
|
||
/* Optional: report device status */
|
||
ha_client_send_status(client, "online");
|
||
|
||
vTaskDelay(100 / portTICK_PERIOD_MS); // RTOS-style delay
|
||
}
|
||
```
|
||
|
||
#### Step 5: Verify the Connection
|
||
|
||
Check if the device is online on the HomeAgent server:
|
||
|
||
```bash
|
||
# List registered devices
|
||
curl http://<homeagent-server>:8080/api/v1/device/list
|
||
# Expected output includes: {"device_id":"esp32-cam-1","status":"online",...}
|
||
|
||
# Send a command to the device (test camerasue)
|
||
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"cmd":"camerasue","args":"3"}'
|
||
# Expected: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||
```
|
||
|
||
#### Step 6: Debugging Tips
|
||
|
||
| Issue | Check |
|
||
|-------|-------|
|
||
| Connection failed | Verify `server` address and port are reachable; check `token` |
|
||
| WS handshake failed | Verify HomeAgent server WebSocket support is enabled |
|
||
| Command not responding | Confirm the command name is registered in `handlers` table; check `on_binary` |
|
||
| Reconnection issues | `max_reconnect` controls retry count; -1 = infinite |
|
||
| Low memory (embedded) | Define `HA_NO_ALLOC` to disable dynamic memory allocation |
|
||
|
||
### Location
|
||
|
||
- **SDK Source**: `remotedevice/`
|
||
- **hmapdev template**: `hmapdev init --type remotedevice`
|
||
|
||
## Building & Installing
|
||
|
||
### Build
|
||
|
||
```bash
|
||
hmapdev build
|
||
```
|
||
|
||
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `hmapdev build --no-bundle` for a single-target build).
|
||
|
||
### Install
|
||
|
||
Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
|
||
|
||
```bash
|
||
# Local path
|
||
curl -X POST http://127.0.0.1:9876/plugins \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||
|
||
# Upload binary directly
|
||
curl -X POST http://127.0.0.1:9876/plugins \
|
||
--data-binary @dist/my-plugin.hmap
|
||
```
|
||
|
||
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
|
||
|
||
## License
|
||
|
||
The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE).
|
||
|
||
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
|
||
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
|
||
this SDK and **must be released under the same license**. Because AGPL §13 covers network
|
||
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
|
||
If you need a closed-source plugin, the only compliant route is a separate exception/commercial
|
||
license from this project — none is offered today.
|
||
|
||
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
||
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
||
(Chinese-CLIP Apache-2.0, ONNX Runtime MIT) are not part of this SDK; their full license texts
|
||
ship with the release packages under `/usr/share/doc/homeagent/licenses/`.
|