Files
HomeAgent/assets/docs/en/ARCHITECTURE.md
JianFeeeee a9e0489e57 docs: 修正「项目概览/架构」把已删除机制写成现行机制(中英四份)
## 问题

assets/docs/{zh,en}/OVERVIEW.md 的媒体记忆段仍在描述 v1.2.0 **已整体拆除**的机制,
且两份文档的第一读者入口(README「文档」区、site/index.html 的「项目概览」链接)
都指向它。docs/git-branching.md §七.7 的反例表里恰好列着这一条,说明这是
「文档当事实后反向误导」的复发项。

同时修掉同一批里其余过期的结构性断言(均为逐条回源码核实后改)。

## 改动(四份文件,7 处)

OVERVIEW.md(zh/en 对称):
- 媒体记忆段:「引用计数 GC + 描述文本才是持久语义记忆 + [<mime> <digest>] 标记」
  → 「一等记忆块:块自带向量与指纹直接检索;无独立 GC / 无引用计数 / 无 keep-set;
    删除块即删内容」,并显式注明旧机制已在 v1.2.0 拆除
  - 依据:internal/memory/block.go("不携带 Layer、Owner 或 RefCount")、
    ARCHITECTURE.md 自身的「不再有描述式索引」段、
    graphmedia_test.go:24「描述文本、marker 反解…全部已废弃」、
    inputunify_test.go:226「不往文本里贴 marker」
- LLM Provider 段:「三种实现 OpenAIProvider/OllamaProvider/LuaAdaptedProvider」
  → 「唯一实现 LuaAdaptedProvider」
  - 依据:internal/agent/api/provider.go 只有 LuaAdaptedProvider 一个实现类型
- 适配器:「内置 8 个」→「内置 10 个」并补全名单(+kimicode/server)
  - 依据:ls internal/lua/adapters/*.lua = 10;README.md 同一处早已写「10 个」,
    两份文档此前自相矛盾
- 「支持 8 个 LLM 源自动降级」→「多源自动降级」
  - 依据:源数量由部署配置,默认 seed 仅 deepseek 一个
    (registry.go defaultSources 唯一条目)

ARCHITECTURE.md(zh/en 对称):
- 代码结构树 internal/skill/(**该目录不存在**)→ 移入 plugins/skillmgr/
  - 依据:internal/plugins/skillmgr/ 存在,internal/skill/ 不存在(README 亦写 plugins/skillmgr)
- dynamic.go 描述「.so 动态加载器」→「加载通道分派(proc/lua/skill);.so 仅剩给明确报错」
  - 依据:dynamic.go 的 tryDynamic 实际只分派 proc 与 lua,.so 命中只为给出「请用新版
    hmapdev 重编」的明确错误(legacyCABIEntries 注释)
- 配置系统「8 个 LLM 源的 seeds」→「含 1 个 LLM 源 seed,其余源由部署时添加」
- 适配器数 8 → 10
- Provider 分层图(仅 en 侧滞后):删掉不存在的 OpenAIProvider/OllamaProvider
  - 依据:zh 侧同一图早已写作「LuaAdaptedProvider (唯一实现)」,en 侧未跟上

## 验证

- 全量残留扫描:OpenAIProvider / OllamaProvider / 引用计数 GC /
  Reference-counted GC / 8 个 LLM / 8 LLM / internal/skill / .so 动态加载
  → 在 assets/docs/ 与两份 README 中均为 **0 命中**(改前分别为 3/2/2/1/2/1/2/2)
- 每条新断言逐条回源码核实(adapters=10、provider struct=1、skillmgr 存在、
  internal/skill 不存在、defaultSources=1、无 marker 写入代码)
- 纯文档改动:不涉及 .go,不改变构建产物(assets/docs 未被 //go:embed 打包,
  仅被 site/index.html 以 GitHub 链接形式引用)
- 未新增版本号字面量,符合 §七.7 的「模型或用户会当作事实的文本不得写死版本号」

## 分支归属

按 docs/git-branching.md §七.7:面向使用者的开发者文档先落 release 线、
再 cherry-pick 合入 main(不 merge)。本次提交在 release/v1.3.x。
2026-10-08 07:55:28 +08:00

38 KiB
Raw Blame History

中文 | English

HomeAgent Architecture

Architectural Principles

HomeAgent's cognitive architecture consists of three subsystems: the event loop (eventLoop), the context window (RelevanceContext), and the stage pipeline (StageHost). Together they form the orchestration framework. Within this framework, the LLM serves as a scheduled reasoning unit; cognitive continuity is maintained by the event loop, context window, and stage pipeline.

The event loop (eventLoop) is a three-way select: a.io.InputChan() receives external user input and dispatches to processTextInput / processMediaInput; a.selfInputCh receives internal system tasks (memory merges, distillation callbacks) routed through processConsolidation under the _consolidation_ output channel; a.ctx.Done() accepts shutdown signals. A concurrently running interceptLoop goroutine independently reads a.io.InputInterruptChan() — on receiving a high-priority interrupt, it cancels the in-flight LLM HTTP request (a.cancelLLM()), then writes the event to a.interceptCh. This channel is drained non-blockingly by drainInterrupts() before each LLM call in process(), injecting interrupts as [打断消息] formatted entries into message history. The three interrupt delivery paths carry distinct semantics: cancelLLM terminates the current HTTP request, interceptCh injects text before the next LLM turn, and InjectInput triggers a new processing cycle when the event loop is idle.

The stage pipeline (StageHost) manages two registration categories: tool definitions (ToolDef) and stage handlers (StageHandler). ToolDef includes two optional memory control fields: NoMemory bool — when true, the tool's output is excluded from vectorization/jieba/distillation (original text preserved); and Cleaner func(string) string — a filter applied before the output enters the computation layer (e.g., extracting a content field from JSON). Neither modifies the original output; both only affect the computation layer input. RegisterTool rejects duplicate names, infers the owning plugin name from the tool name prefix, and maintains a toolPlugins mapping. RegisterStage appends handlers to the corresponding stage list. On stage execution (RunStage), all registered handlers execute in parallel via goroutines, sharing a single *StageContext protected by sync.RWMutex. Individual handler panics are recovered independently without affecting other handlers. Short-circuit semantics are implemented by checking ctx.Response != nil — any stage handler can set this value to terminate the pipeline early. ExecuteTool includes built-in panic recovery with stack-trace recording. UnregisterPluginTools removes a plugin's tool set during hot-reload.

The context window (RelevanceContext) maintains a chronologically ordered event list. Append aggregates tool outputs through textForVector before computing the embedding vector: NoMemory skips, Cleaner filters (per-tool cleaners registered by plugins — e.g. the QQ plugin strips its own tool-call templates), and CleanText finalizes with basic whitespace normalization. A three-branch strategy selects the text source (agent events use Response, user events use Input, cold_storage uses Input+Response). Prune triggers when the event count exceeds topK: it unconditionally protects the last 10 events from eviction (recency bias), scores remaining candidates against the current input via CosineSimilarity, keeps topK - 10 highest-scoring entries (floor at 0), then re-sorts chronologically. Pruned events from sources other than agentcli and terminal are archived to the Document layer via docStore.ContextToDoc, retaining original timestamps. Persistence uses 5-second debounced writes to a JSON file.

Tool definitions are aggregated from five sources: IOManager-registered plugin tools; StageHost-registered SDK tools; Indexer-provided memory index tools; conditionally added built-in tools (depending on non-nil state of memory/knowledge/docStore/social/pluginReg/providerManager modules — including memory operations, knowledge retrieval, document queries, social networking, plugin reloading, child-agent spawning, per-output-channel send tools, and LLM source switching); and media processing tools added based on pendingMedia state. buildToolDefs() re-aggregates all sources on each process cycle.

Provider invocation follows an ordered fallback strategy: ProviderManager.OrderedProviders() returns the provider list in registration order. The process() inner loop iterates this list attempting Chat() on each. HTTP 401/403 responses mark the provider as permanently unavailable; other error types also mark unavailability but with higher tolerance. If all providers fail, an error is returned to the caller. If a call is interrupted by context cancellation while the agent is still running, it is retried (only on non-consolidation paths).

The memory system adopts a three-tier storage hierarchy (Context → Document → Graph), tiering data by access locality and persistence requirements: the Context layer is a fast-volatile working window using StaticEmbedder (pretrained word embeddings with TF-IDF fallback) for semantic relevance scoring; the Document layer shares the same StaticEmbedder vector space with Context (the embedder is injected into the Document Store at agent startup via docStore.SetVectorizer(embedder)), ensuring that relevance scores during Context pruning and semantic retrieval during Document queries operate within the same vector space — TF-IDF serves only as a fallback when the embedder is unavailable; the Graph layer uses SQLite as its persistence substrate with an entities table (nodes) and a relations table (directed edges), supporting BFS traversal recall. Data migration policies govern movement across tiers: low-scoring events sink from Context to Document (vectorized using the same embedder at archival time); cold documents, after a 72-hour no-access threshold, are distilled into triples via docToTriples and committed to Graph. The Indexer uses dual retrieval (entity vector similarity search + jieba keyword extraction) to construct Graph query seeds, and the MarkRecalled mechanism prevents entities already fetched via tool calls from being re-injected into the system prompt.

The separation of core domain and application domain constrains the kernel's responsibilities to LLM orchestration, memory management, and knowledge retrieval — no direct IO operations; all external interaction is mediated through the plugin domain. This separation limits the kernel's complexity to a verifiable scope while granting the plugin domain independent evolution: plugins can be independently developed, independently released, hot-loaded, and do not directly affect the stability of the core domain.

Message Processing Flow

Full Pipeline

External input (via plugin InjectInput)
        │
        ▼
eventLoop() → processTextInput()
        │
        ├── on_input stage          Plugins can intercept/rewrite/short-circuit
        ├── Context.Append          Record to context window
        ├── Context.Prune           Low-relevance events archived to Document
        ├── buildMemoryContext()    Indexer recall → GraphDB BFS traversal
        │
        ├── pre_action stage        Plugins can inject system messages
        │
        ├── [Tool Loop] process()
        │   ├── buildSystemPrompt   Persona + Memory + Knowledge + Context
        │   ├── buildToolDefs       Built-in tools + Plugin tools
        │   ├── provider.Chat()     LLM call
        │   ├── post_action stage   Plugins see LLM output + tool list
        │   ├── Has tools?
        │   │   ├── before_toolcall Plugins can reject/modify params
        │   │   ├── executeToolCall Route to plugin/built-in
        │   │   ├── after_toolcall  Plugins can modify results
        │   │   └── → back to post_action
        │   └── No tools → exit loop
        │
        ├── Context.Append(response)
        ├── before_output stage     Plugins can modify final text
        ├── emitResponse()          Send via output_send
        └── after_output stage      Read-only, cleanup

Code: internal/agent/core/process.go — process() is the main tool loop

7 Stage Hooks

Stage Trigger Plugin Capabilities
on_input Message arrives at Agent, zero processing Blacklist/rate-limit/short-circuit reply
pre_action Context ready, before LLM call Inject external data into context
post_action LLM returns text + tool list Sensitive word filter/forced redirect
before_toolcall Before single tool execution Audit/reject/modify params
after_toolcall After single tool execution Desensitize/sort results
before_output Final text ready, before sending Format adaptation
after_output Already sent Statistics/logging

Code: internal/agent/core/stages.go — StageHost orchestration

Loop Rules

post_action → [before_toolcall → execute → after_toolcall] → post_action forms the inner loop. Exit conditions: LLM has no tool calls / all rejected / exceeded limit.

Short-Circuit Rules

Setting ctx.Response at any stage jumps to after_output.

Three-Layer Memory

Memory Flow

① Context (Working Window)
   RelevanceContext — In-memory events[] + JSON persistence
   Append: Each input, CleanTemplateText → three-branch vector(textForVector)
           agent→Response, user→Input, cold_storage→Input+Response
           Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
   Prune:  DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
       ├── Keep → timeline → chronologically sorted → system prompt
       └── Low score → Document layer archive (original timestamp)
   Save: 5s debounce write to disk

         ↓ Prune archive                       ↑ LLM active recall

② Document (File Memory)
   DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
   Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
   Read:
       ├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
       └── LLM active:  doc_query → Consume(read and delete)
                         → context.Append{Timestamp: d.CreatedAt, Source: "cold_storage"} per doc
                         → Docs written to context timeline with original timestamps, deleted from docStore
   Cold: FindColdDocs(72h, ≤2 accesses) → docToTriples → Graph

         ↓ Cold doc distillation               ↑ Auto recall

③ Graph (Graph Database)
   SQLite — entities + relations tables
   Write: memory_commit / cold doc distillation / Pipeline rule distillation / memory_merge
   Read:
       ├── Auto recall: Indexer.BuildContext(input)
       │     → CleanTemplateText → vector entity search + jieba keywords → SQLite LIKE + BFS depth=2
       │     → [Memory Index] → system prompt
       └── LLM active: memory_recall / memory_merge / memory_purge / memory_edit / memory_delete_entity
   Social: person_query / set_trait / relate (wraps GraphDB)

④ Four Independent Heartbeat Loops (separate tickers and config intervals)
   distillLoop  (distillInterval,  default 30m):  Context pruning — Context.Prune → Document
   archiveLoop (archiveInterval, default 60m):  Cold doc archival — docToTriples → GraphDB
   mergeLoop   (mergeInterval,   default 120m): Entity merge detection — similarity → LLM decision
   reviewLoop  (reviewInterval,  default 120m): Relation review — SentenceRef recall → LLM fix

⑤ Pipeline Rule Distiller (every 10min heartbeat)
   distillOnce → regex match personal info:
     我叫X / 我住在X / 我喜欢X / 我X岁 / 我的工作是X
     → triples → GraphDB.Commit

Vectorization: Unified Multimodal Space (primary) → Word Embedding → TF-IDF (fallback)

Vectorization degrades through three layers by availability; each missing layer reports an explicit error and never pretends to succeed:

① Unified multimodal space (primary path, since v1.2.0) Text and images share one model, one dimension, one fingerprint (default chineseclip: 512d, Apache-2.0, Chinese-native; qwen3vl or an external http provider are alternatives). Providers register through the public pkg/embedding SPI — the kernel hardcodes no model. Vectors persist together with their fingerprint (dense_fp / vec_model); any mismatch with the current fingerprint triggers recomputation, and only blocks with the same fingerprint and the same dimension participate in fusion (mixing coordinate systems yields a direction resembling neither).

② Word embedding (text fallback) — StaticEmbedder (internal/memory/static_embedder.go):

Model sources (aligned 300d)

  • Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
  • Configured via core.agent.embedding_model_path (comma-separated multi-model)
  • Path containing numberbatch → auto-download ConceptNet; cc.zh. → fastText Chinese; cc.en. → fastText English
  • Falls back to ConceptNet by default if no match
  • Pre-processing: plugins register per-tool Cleaner functions; textForVector applies them before CleanText final normalization
  • Three-branch vector source: agent→Response, user→Input, cold_storage→Input+Response
  • TF-IDF fallback: auto-fallback to bag-of-words TF-IDF if model download fails or not configured
Location File Purpose Algorithm
Context Prune context.go:155 Trim low-relevance context events VectorizeClean → CosineSimilarity(queryVec, evt.Vector)
DocStore Query document.go:206 Recall from document memory StaticEmbedder.Vectorize (primary) / TF-IDF (fallback) → vec.Search
Indexer Entity Search indexer.go:96+111 Recall from Graph vector entity search + jieba keywords → SQLite LIKE + BFS
Entity Similarity Detection distill.go Detect similar entities in Graph Bigram Jaccard (>0.75 → consolidation)

Context Layer

internal/agent/core/context.go — RelevanceContext

  • Maintains recent event list, writes JSON on each Append/Prune to prevent data loss
  • Pre-vectorization pipeline: per-tool Cleaner functions strip template noise, then CleanText for basic whitespace normalization
  • Three-branch textForVector: agent events → Response, user events → Input, cold_storage → Input+Response
  • Pretrained word embedding StaticEmbedder → CosineSimilarity, auto-fallback to TF-IDF if unavailable
  • Protects last 10 events from eviction; excess candidates are sorted by relevance and archived to document memory
  • Archived events retain original timestamps; on doc_query recall they re-insert into the context timeline at their original position

Document Layer

internal/memory/document/document.go — Store

  • Consume-on-read mode: deleted after doc_query retrieval
  • Dual recall: shared StaticEmbedder semantic vector search + jieba keyword extraction (falls back to char-bigram TF-IDF when model is not loaded)

Graph Layer

internal/memory/graph.go — GraphDB

  • SQLite WAL mode, two tables (driver: mattn/go-sqlite3, CGo)
  • Commit(triples) — UPSERT entities + INSERT relations
  • Recall(keywords, depth) — Keyword LIKE search + BFS traversal

Memory Tools (LLM-callable)

Tool Purpose
memory_recall Recall from Graph
memory_commit Write triples to Graph
memory_merge Merge two entity nodes
memory_purge Delete entity node
memory_edit Edit existing entity/relation
memory_delete_entity Delete entity and all its relations
memory_introspect View memory statistics
doc_query Search from Document
doc_commit Write to Document

Since v1.1.1 memory_commit and doc_commit accept media_digests, and the kernel appends the [<mime> <short digest>] <description> marker into the sentence/body — the kernel builds the marker, the model only supplies the digest. Requiring the caller to know the format would mean a single typo silently breaks reference binding with no error anywhere in the chain. memory_commit also gained sentence_text: media references hang off a sentence, so with no sentence there is nowhere to attach them.

Media Memory (since v1.2.0: first-class memory blocks)

Media is not attached content but a first-class memory node: internal/memory/media/ is a content-addressed store (CAS), and graph block nodes carry its digest plus its own vector, while structural edges (e.g. sentence --contains--> block) express ownership.

Concern Approach Why
Addressing sha256 digest; metadata in SQLite, blobs on disk (blobs/<first2>/<rest>, two-level fanout) Identical bytes stored once; metadata must be queryable, blobs must not live in the database
Integrity Every Get re-verifies the digest Silently returning corrupt data on disk damage is far worse than an error
Write atomicity .tmp + rename A half-written file taken as complete content would permanently poison that digest
Retrieval Blocks carry their own multimodal vector and fingerprint and are searched directly No description text is needed as an intermediary
Lifecycle No separate GC, no refcounts, no keep-set; deleting the block deletes the content Media is a memory node, not a cache that needs keeping alive

Description-based indexing is gone: the old implementation embedded a [<mime> <short digest>] <description> marker in the body and treated the description as the semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse than retrieving the image's own vector. Images are now retrieved only by their own vector in the unified space, and no media marker is written into the body.

Cross-space vector migration: media rows store their vector together with vec_model (the space fingerprint). At startup reembedStaleMedia() recomputes and writes back every row whose vec_model is empty (never embedded) or differs from the current space (model/dimension switched). Modalities outside the space return ErrModalityUnsupported — the kernel never substitutes another model's vector.

The media store is optional throughout: with core.memory.media.enabled=false or no configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.

Other Memory Layers

  • Social (internal/memory/social/social.go) — Persona traits and relationship network, wraps GraphDB entity types
  • Text Memory (internal/memory/text/text.go) — Raw conversation JSONL logs, rotation strategy
  • Memory Indexer (internal/memory/indexer.go) — Entity vectorization + jieba keyword extraction, auto-inject into system prompt

Distillation Pipeline

internal/memory/pipeline/pipeline.go

  • 10-minute tick, 7-day retention
  • Rule-based triple extraction (name / location / likes / age / job patterns)
  • Writes to GraphDB

Context Pruning

Four independent heartbeat loops (each with configurable interval):
  ├── distillLoop  (distillInterval,  default 30m)
  │   └── distillContext() — Context.Prune → Document
  ├── archiveLoop (archiveInterval, default 60m)
  │   └── archiveColdDocs() — Cold docs → docToTriples → GraphDB
  ├── mergeLoop   (mergeInterval,   default 120m)
  │   └── detectEntityMerge() — Entity similarity detection → LLM decision
  └── reviewLoop  (reviewInterval,  default 120m)
      └── reviewRelations() — Relation review → SentenceRef recall → LLM fix

Entity conflict detection heuristic (bigram Jaccard > 0.75), routed through selfInputCh internal channel, LLM makes the final merge decision.

Knowledge Base

internal/knowledge/knowledge.go

  • File directory knowledge/<name>/content.md
  • Independent TF-IDF index, separate from memory system
  • knowledge_search / knowledge_create / knowledge_list

Provider & Lua Adapter Layer

Agent
  │
  ▼
Provider Interface (Name / Chat / ChatStream)
  │
  └── LuaAdaptedProvider (the only implementation)
      ├── Serialize CompletionRequest → JSON
      ├── adapter.transform_request() → API format
      ├── HTTP request + adapter.headers
      ├── adapter.transform_response() → unified format
      └── Deserialize

Code: internal/agent/api/provider.go

ProviderManager manages multiple sources, fallback in registration order. Lua adapters at internal/lua/adapters/, each .lua script defines transform_request / transform_response / transform_stream_chunk.

VM built-ins: json.encode / json.decode / log / http_get / http_post.

Plugin System

Four Loading Methods

Method Registration Mechanism Compilation Usage
Built-in init() → RegisterFactory internal/plugins/ compiled into kernel webui/cli/timer/mcp etc.
External subprocess plugin Handshake + stdio JSON-RPC reverse registration hmapdev build → plugin.bin (ordinary Go binary) qq/browser/files etc.
Lua script plugin Execute main.lua to register tools No compilation, takes effect after restart/reload luademo etc.
SKILL plugin Parse SKILL.md Markdown definition Loaded via clawhubadapter

Built-in plugin registration: internal/plugins/all.go blank imports → each plugin init() → Registry.Load() scans directory to match factory.

External plugin loading (since v1.0.0): internal/plugin/dynamic_proc.go → exec.Command(plugin.bin) → inherit shared-segment fds → handshake (protocol version check) → plugin.init → plugin.start (the plugin reverse-registers tools/stages/channels during this window). The C ABI channel (-buildmode=c-shared + bridge) was removed entirely in v1.0.0— the old plugin.Open path-cache workarounds (SHA256 temp-path copies) retired with it.

Lua script plugin loading: internal/plugin/ → the gopher-lua interpreter executes main.lua (at load time sdk.register_* only buffers handlers), then Start() swaps in the real SDK implementation and registers them in batch. The script is read only once at load time; runtime execution happens via callbacks.

Built-in vs External Plugins

Dimension Built-in Plugin External Plugin
Registration init() calls plugin.RegisterFactory(name, factory) Implements NewPluginFactory(name, config) (sdk.Plugin, error) entry function
Compilation Compiled into homed binary, no separate build Compiled via hmapdev build to plugin.bin (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess
Distribution Bundled with kernel, not independently installable .hmap package (ZIP archive), installed via WebUI or pluginmgr API
Metadata plugin.RegisterPluginMeta() for display name plugin.json manifest file (name, version, entry, platforms, capabilities, etc.)
Plugin directory No separate directory, compiled into binary plugins/<name>/ independent directory with plugin.json + plugin.bin
SDK permissions Full PluginSDK (SocialAPI read/write, Publish events) Narrowed procCore surface + manifest capabilities declaration + RPC boundary rejection
Lifecycle Starts/stops with kernel, no individual hot-reload Independent process; true hot-reload by swapping plugin.bin (ReloadOne) plus enable/disable
Crash recovery No independent recovery Process-level isolation: a crash cannot take down the kernel; the kernel detaches its registrations then restarts it with backoff (SetAutoRestart(false) opts out)

Common ground:

  • Built-in RegisterFactory and external NewPluginFactory share the same NativeFactory type signature
  • Registry.Load() handles both uniformly: checks the factory table first (built-in), otherwise dispatches by the manifest entry to the proc / lua / skill channel
  • Both use the same Plugin interface and public SDK API; tool registration, stage hooks, and output channel APIs are identical
  • Both share the same tool registry (StageHost); LLM invocations treat them identically

The Three Communication Planes of Subprocess Plugins (v1.0.0)

Plane Mechanism Why this choice
Control stdio JSON-RPC (NDJSON frames), 55 core.* methods The process boundary is the ABI boundary—no need to maintain three platform-specific dynamic-library loaders
Data Shared memory segment, one segment shared by all subprocesses One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly
Notification Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter

Subprocess lifecycle management:

  • One dedicated waitLoop per subprocess (the sole cmd.Wait() call site)—it does not rely on stdout EOF, because grandchild processes forked by a plugin (browser spawning chromium, editdoc spawning python) inherit the same stdout, so EOF never arrives after the plugin itself dies
  • Central ledger proc.Supervisor: registered on successful handshake, unregistered on exit; Host.Close() runs StopAll before tearing down the segment (reversing that order leaves plugins holding a mapping that has been unmapped—SIGBUS on their next access)
  • Crash self-healing: detach registrations (tools + stage handlers + IO channels) → remove from the registry → restart with backoff
  • Linux Pdeathsig is the last-resort guard so subprocesses do not linger as orphans when homed is SIGKILLed

PluginSDK Four Channels

Plugin ──→ Kernel

RegisterTool(name, fn)   ──→  buildToolDefs() / executeToolCall()
RegisterStage(stage, fn, scope...)  ──→  runStage() called at corresponding phase (scope: global / own-tools-only)
Subscribe(event, fn)      ──→  Publish() notify all subscribers
RegisterOutputChannel(name, caps, desc, handler) ──→ output_send__{name} tool generation

internal/sdk/ bridges external SDK interface to kernel, defines complete PluginSDK:

sdk.RegisterTool(name, def, handler)
sdk.RegisterStage(stage, handler, scope...)
sdk.Publish(event)
sdk.InjectInput(source, channel, payload)
sdk.InjectInterrupt(source, channel, payload)
sdk.Memory().Recall/Commit
sdk.Knowledge().Search/Create
sdk.Settings().Get/Set/List
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler)

// v1.1.1 media APIs (all additive, no signature changes)
sdk.DocMemory().InsertWithMedia(doc, attachments)   // attachments with Data land in CAS; Digest-only ones reference existing content
sdk.InjectInputMedia(source, channel, text, blocks) // media reaches the model in *this* turn
sdk.InjectInputMediaSync(...)                       // same, and waits for the reply
sdk.InjectInterruptMedia(...)                       // media-bearing interrupt, can preempt current processing

How media injection differs from SetToolBlocks: the latter 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 — it goes out with this turn's message and is automatically stored in CAS with a memory reference attached. Triple and Doc gained MediaDigests / Attachments correspondingly.

internal/sdk/ is the bridge implementation for this layer and is not subject to the public interface freeze (see docs/git-branching.md §6).

Plugin Interface

type Plugin interface {
    Name() string
    Start(sdk *PluginSDK) error
    Stop() error
}

Output Channel System

Each output channel generates two tools:

Tool Type Purpose
output_send__{name} function Accepts payload (content), meta (JSON routing metadata), type (enum) — routed to plugin handler
output_send__{name}_help function Returns the channel's meta format and type enum documentation

Capability flags:

Flag Value Meaning
CapText 1 Plain text
CapFile 2 File
CapImage 4 Image
CapAudio 8 Audio
CapStructured 16 Structured data

System prompt injection: output gate rules, multi-call support, long message splitting. Child agent permission: output_send__ prefix tools are allowed.

EventAgentLLMChain Event

  • Event type agent_llm_chain emitted after each LLM turn
  • Contains the full LLM response (text + tool calls + reasoning)
  • WebUI subscribes to this event via SSE for real-time display
  • Plugins can subscribe via EventSubscriber (read-only for external plugins)

Restricted External Plugin API

Layered architecture: internal plugins get full PluginSDK, external plugins get restricted SDK.

API Internal Plugin External Plugin
SocialAPI Full read/write Read-only (GetPerson / GetTrait / GetRelations / GetNetwork / ListPersons)
EventSubscriber Subscribe + Publish Subscribe-only (no Publish capability)

Extended fields:

  • Triple extensions: Confidence, SubjectType, ObjectType
  • Relation extension: Confidence

Input Scheduler & Interrupt Mechanism

Inputs do not go straight to the LLM — they first enter the input scheduler (internal/agent/core/scheduler.go). Full design: docs/zh/input-scheduler-design.md.

Two task classes

Class Level Meaning
TaskQueued none (always 0) Pending work. Any interrupt (≥ L1) preempts it
TaskInterrupt L1–L4 "How urgent is this", declared by the source via InjectOptions.Priority

Four interrupt levels

Level Meaning Typical source
L1 Background Fully deferrable QQ/WeChat messages, bulk notifications
L2 Message General notice Plugin hints that should be seen soon but aren't urgent
L3 Interactive Needs timely handling Timer expiry, terminal output, resident-agent reports
L4 Critical Kernel-exclusive panic, kernel events, kernel-level plugin stop button

When no level is declared it defaults to L1 — "explicit is a privilege", so a new plugin never gets preemption rights by accident. L4 declared by an external plugin is clamped to L3 (clampPluginLevel).

Preemption and suspension

  • Same level never preempts same level (canPreempt requires strictly greater) — this is why messages normally wait for the running task to finish.
  • A preempted task is pushed onto the interrupt stack (LIFO) with its frame saved, and resumed later; the stack is never re-sorted by priority.
  • Starvation guard: preemption count raises the effective level (effectiveLevel = Level + min(PreemptCount, 2), capped at L4).
  • Preemption cooldown: a just-preempted task cannot be preempted again for preemptCooldown (2s), so a high-priority stream cannot interrupt the same task forever.
  • The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).

Stop (user presses stop / /stop)

Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference; ② short-circuit the x messages already queued at the moment of stop during their pre-action phase (cancelBudget snapshot), instead of running them as new input. Inputs arriving after the stop are unaffected.

PendingInputs() must include the segment still sitting in io.inputCh (not yet moved into the queue by pumpInbox) — during a stop the scheduler is usually busy running a task, and counting only sched.queue yields 0.

Resident sub-agents and timely feedback

Design: docs/zh/resident-subagent-design.md.

  • A resident is an independent lightweight-kernel agent: its own scheduler, its own temp graph memory, sharing the channel registry.
  • Parent→child control plane: resident_agents (list / create / send / inspect / compress / reclaim / destroy).
  • Backlog feedback: when the main agent is busy for a long time (default > 5m, configurable), the kernel hands queued inputs to a temporary triage assistant (offload_* config): simple ones are handled directly, ones needing the main agent get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
  • The triage assistant gets no inputch (it receives no plugin user input) and all output channels (results must reach the original channel).
  • On reclaim/destroy, its residual tasks are decided explicitly by the parent: residual=keep (returned to the parent queue, default) or drop (explicitly discarded with a per-item log entry).

Legacy three-path view (still present, now a layer beneath the scheduler)

interceptLoop (goroutine)
  ├── InputInterruptChan() ← Timer/message notifications
  ├── (a) cancelLLM() → Cancel Provider HTTP request
  ├── (b) interceptCh → process() pre-loop read [interrupt message]
  └── (c) InjectInput() → Trigger new processing when idle

Code: internal/agent/core/scheduler.go (scheduler), eventloop.go (intercept loop).

Context Budget

internal/agent/core/tokenbudget.go — ComputeTokenBudget:

maxCtx        = provider.MaxContextTokens()      // declared window (per-source context_window wins)
targetUsage   = min(maxCtx × 0.8, 600000)        // working band, capped at 600K
  ├── memory recall budget = (targetUsage - fixed) / 3
  └── context events budget = remaining 2/3

★ Window ≠ working band: a source's real window may reach 1M, but near-full windows lose attention and cost/latency rise linearly, so maxTargetTokens=600000 caps the working band separately. If the model name (e.g. AUTO) yields no window, ModelContextWindow logs a warning and falls back conservatively; operators should declare core.llm.sources.<name>.context_window explicitly.

Budgets are ceilings, not fill targets: memory is recall-ranked (it stops when nothing is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K budget, actual injection was still a few hundred characters.

Configuration System

internal/config/registry.go — ConfigRegistry

  • SQLite storage, config table + config_<plugin> independent tables
  • Namespaces: core.* / plugin.<name>.*
  • RegisterDefault inserts ~80 default keys (including 1 LLM source seed; further sources are added at deploy time)
  • WebUI settings page /api/v1/settings for read/write

Code Structure

cmd/homed/main.go          — Entry: assembles all subsystems
cmd/waiter/main.go         — CLI client (Unix socket)
internal/
├── agent/
│   ├── core/              — Agent core (eventLoop/process/stages/context)
│   │   └── plugin_health.go — Plugin health monitoring and auto-restart
│   ├── api/               — Provider interface + LuaAdaptedProvider
│   ├── io/                — IOManager (queue/interrupt/output)
│   └── personal.go        — Persona loading
├── plugin/
│   ├── registry.go        — Registry + lifecycle
│   ├── dynamic.go         — Load-channel dispatch (proc / lua / skill); .so only remains to error loudly
│   └── manifest.go        — plugin.json metadata
├── plugins/               — Built-in plugin implementations
│   ├── all.go             — Blank imports
│   ├── webui/             — HTTP server + embedded SPA
│   ├── cli/               — Unix socket CLI
│   ├── timer/             — Timer
│   ├── cmd/               — Command execution
│   ├── mcp/               — MCP protocol
│   ├── files/             — File operations
│   ├── clawhubadapter/   — ClawHub adapter (OC plugin/SKILL/JS/Python sidecar)
│   ├── agentcli/          — PTY terminal
│   ├── healthcheck/       — Health check
│   ├── pluginmgr/         — Plugin manager
│   ├── skillmgr/          — Skill plugin management
│   └── cfgmgr/            — Config manager
├── sdk/                   — PluginSDK definitions
│   ├── plugin.go          — Plugin interface + PluginSDK
│   ├── memory.go          — MemoryAPI
│   ├── knowledge.go       — KnowledgeAPI
│   ├── settings.go        — SettingsAPI
│   └── llm.go             — LLMAPI
├── memory/
│   ├── graph.go           — SQLite graph database
│   ├── indexer.go         — Graph → vector index
│   ├── vector/store.go    — TF-IDF vector engine
│   ├── document/document.go — Document memory
│   ├── text/text.go       — Text logs
│   └── pipeline/          — Distiller
├── knowledge/knowledge.go — Knowledge base
├── lua/
│   ├── vm.go              — Lua VM (json/log/http)
│   └── adapters/          — 10 LLM protocol adapter scripts
├── config/registry.go     — SQLite config center
├── events/bus.go          — Event bus
├── tracker/               — OverlayFS change tracking
├── supervisor/            — Daemon management
└── meta/                  — Meta information
    └── meta.go            — Agent metadata