JianFeeeee 61f307be1a feat(qq): msg_id→get_history 7天兜底 + list_chats/mark_read 会话列表 v1.2.0
## 问题

1. NapCat get_msg 的 message_id 是 QQ 服务端临时短号,约 3 天后失效。
   实测 1306 条真实 webhook 消息,141 条(10.8%)现在查回报「消息不存在」,
   全是 3 天前的旧消息;1 小时内的消息可正常查回。NapCat 侧无保留时长配置项
   (napcat.json / onebot11_*.json / webui.json 均无),是 QQ 协议硬限制。
   模型收到 not_found 后曾编造正文(虚构 message_id + 虚构需求),
   已在核心 prompt 加事实性约束,此处从插件层根治取不到正文的问题。

2. 插件与真人客户端差距大:没有会话列表、消息不按到达先后排序、无未读提醒,
   模型只能靠单条中断消息被动响应,导致消息处理不及时。

## 改动

### msg_id → get_history 兜底(不缓存正文)
- 新增 msgRef{peerID,isGroup,time}:只记 msg_id → (peer, 时间) 映射,7 天 TTL
  (qqMsgTTL),超 2000 条时惰性清理过期项。不缓存消息正文。
- handleGetMessage 改为包装层:命中映射且 <7 天 → getMsgFromHistoryByTime()
  按 peer 拉 get_history(count=50),取时间最接近的一条,
  经 msgToGetMsgResult() 包装为与 get_msg 同构的结果(附 resolved_via:history);
  未命中或超 7 天 → 回退原 NapCat get_msg(重命名为 getMsgFromNapcat)。
- 超 7 天的消息由 get_history 自行处理,插件不做长期缓存。
- not_found 文案改为明确引导改用 qq_get_history / qq_list_chats。

### 会话列表(对齐真人客户端)
- 新增 chatMeta:会话名、未读数、最新一条 ≤60 字摘要(qqLastSumLen)、最新时间。
  只维护最新一条摘要,不存历史。
- 新增 qq_list_chats:按最新消息时间降序返回会话列表,每项含
  peer_id/type/name/unread/last_text/last_nick/last_time。
- 新增 qq_mark_read:按 group_id/user_id 清零未读;get_history 拉取某会话后
  自动标已读(看过=已读,与真人客户端一致)。
- webhook 记录时机前移:策略允许的消息(群/私聊、是否 @bot 均记)都进入映射与
  会话状态,@bot 只决定是否发中断——与真人客户端一致能看到全部会话。

### 中断模板
- 补 fallback 路径与私聊 user_id(原模板只给 message_id,取不到正文时
  模型没有 peer 信息可用于 get_history):
  「先用 get_message 取正文;若取不到(已过期),改用 get_history(...) 按会话拉取,
   或用 list_chats 查看未读会话。用 output_send__qq 回复」

## 验证

用重建的 plugindev build --target linux/amd64 产出 dist/qq_linux_amd64.hmap,
经内核 plugin_install(overwrite=true) 安装(action=reinstalled, config_kept=true),
重启 homed 后内核注册 20 个 qq_* 工具(原 18 + list_chats + mark_read)。
实测 qq_list_chats(count=8) 返回按时间排序的会话,unread=6 正确累积。

注意:cgo c-shared 插件带完整 Go runtime,dlclose 后引用计数不归零,
同路径 dlopen 复用旧映像,plgreload 无法热替换 .so,换 .so 必须重启 homed。
2026-08-30 17:11:57 +08:00

HomeAgent SDK

Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.

SDK API Surface

Plugin Interface

Plugins implement the Plugin interface:

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
Auto-Restart SetAutoRestart(enabled) / AutoRestart() Control automatic restart on crash

Stage Hooks

// 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

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

sdk.RegisterInputChannel("qq", ChannelDef{
    NoMemory: true,
    Cleaner:  func(text string) string { return strings.TrimSpace(text) },
})

Output Channels

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

source identifies the origin, channel specifies the target output channel.

Triple Extended Fields

The Triple data structure includes additional fields:

  • Confidence — confidence score (0.01.0)
  • SubjectType — subject type
  • ObjectType — object type

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:

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.

plugindev Toolchain

plugindev provides full development workflow support:

Command Description
plugindev init Initialize plugin project (generates plg.json, entry template)
plugindev build Build plugin, output .hmap package
plugindev clean Clean build artifacts
plugindev debug Run plugin in local debug mode

Supports both Go and Lua plugin languages.

plg.json Manifest Format

{
  "name": "weather",
  "name_zh": "天气查询",
  "name_en": "Weather",
  "version": "1.0.0",
  "description": "Weather plugin",
  "author": "HomeAgent",
  "entry": "plugin.so",
  "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.so / main.lua)
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.so — Go compiled artifact (Linux)
  • plugin.dll — Go compiled artifact (Windows)
  • main.lua — Lua plugin entry (for Lua plugins)

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 plugindev template includes an onRemove demo.
sdk.RegisterOnRemoveHandler(func() {
    os.Remove(filepath.Join(dataDir, "events.json"))
})

Auto-Restart

sdk.SetAutoRestart(true)
// Query state
enabled := sdk.AutoRestart()

The platform automatically restarts the plugin on crash, ensuring service availability.

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 Go Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory
luademo Lua Full-featured Lua example covering the whole v0.8.0 Lua SDK surface
qq Go QQ messaging integration (NapCat), 17 tools, full input/output channel wiring
a2a Go Agent-to-Agent protocol communication
ai_image Go AI image generation
bili Go Bilibili video downloading
browser Go Web search, page fetching, browser rendering
calendar Go Calendar management
editdoc Go Document editing
files Go File management
memo Go Memos (PreAction injection + scheduled reminders)
music Go Music playback
ocr Go Optical character recognition
rss Go RSS subscriptions
sanitizer Go Content sanitization / safety filtering

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:

#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:

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 plugindev toolchain:

plugindev init my-adapter --type remotedevice

Generates main.c + CMakeLists.txt, can be built directly or used as a third-party library:

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:

# 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)

#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

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

#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

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:

# 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/
  • plugindev template: plugindev init --type remotedevice

Building & Installing

Build

plugindev build

Outputs a .hmap package to the dist/ directory (default is the multi-platform bundle; use plugindev 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):

# 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.

Description
No description provided
Readme AGPL-3.0 154 MiB
Languages
Go 64.9%
Python 19.5%
C 7.8%
Lua 3.8%
TypeScript 2%
Other 2%