# Context Engine 插件

Context engine 插件用来完全自定义 agent 的上下文管理：从记忆召回、context window 组装，到 context 压缩和子 agent 生命周期。插件走一个极简的 JSON-over-stdin/stdout 协议，几乎任何能读 stdin、写 stdout 的语言都能写。

本页讲协议、manifest 格式、所有支持的 runtime，以及从零到跑通的完整例子。

## 目录

- [概览](#overview)
- [Hook 生命周期](#hook-lifecycle)
- [协议规范](#protocol-specification)
- [`plugin.toml` manifest](#plugin-toml-manifest)
- [插件环境变量（`[env]`）](#plugin-environment-variables-env)
- [Hook 超时覆盖（`hook_timeout_secs`）](#hook-timeout-override-hook_timeout_secs)
- [支持的 Runtime](#supported-runtimes)
- [写你的第一个插件](#writing-your-first-plugin)
- [插件叠加（`plugin_stack`）](#plugin-stacking-plugin_stack)
- [通过 Dashboard 或 API 生成脚手架](#scaffolding-via-dashboard-or-api)
- [热重载端点](#hot-reload-endpoint)
- [Hook 调用指标](#hook-invocation-metrics)
- [本地测试](#testing-locally)
- [用 Doctor 端点调试](#debugging-with-the-doctor-endpoint)
- [错误处理、超时与日志](#error-handling-timeouts-and-logs)
- [Plugin vs. Skill](#plugins-vs-skills)

---

## 概览

一个插件就是 `~/.librefang/plugins/<name>/` 下的目录：

```
my-recall-plugin/
├── plugin.toml               # manifest
└── hooks/
    ├── ingest.py             # （或 .js / .go / .rb / ...）默认激活
    ├── after_turn.py         # 默认激活
    ├── assemble.py           # 已生成模板 —— 在 plugin.toml 中解注释后激活
    ├── compact.py            # 已生成模板 —— 在 plugin.toml 中解注释后激活
    ├── bootstrap.py          # 已生成模板 —— 启动时运行一次（2× 超时）
    ├── prepare_subagent.py   # 已生成模板 —— 子 agent 启动前调用
    └── merge_subagent.py     # 已生成模板 —— 子 agent 完成后调用
```

LibreFang 加载 manifest，把声明的 hook 脚本挂到 context engine 上，在预定义的生命周期点作为子进程执行。每次调用都是一个全新的子进程：stdin 进一个 JSON 对象，stdout 出一个 JSON 对象。

插件默认沙箱化 —— 环境变量被洗成最小集合，工作目录受控，超时时间可配置（默认 30 秒）。`bootstrap` hook 获得 **2 倍**超时，因为它只运行一次，可能需要较长时间连接外部服务（如向量库）。

---

## Hook 生命周期

支持全部 7 个 hook，覆盖 context engine 完整生命周期：

| Hook | 触发时机 | 用途 | 阻塞当前轮？ | 失败时 |
|------|----------|------|-------------|--------|
| `bootstrap` | 引擎初始化，仅一次 | 连接向量库、预热缓存 | 是（启动时） | 警告，继续 |
| `ingest` | 新用户消息进入，LLM 调用前 | 召回记忆 / 注入自定义上下文 | 是 | fallback 到默认召回 |
| `assemble` | **每次 LLM 调用前** | **完全控制 context window 内容** | 是 | fallback 到默认裁剪 |
| `compact` | context 压力时 | 自定义压缩策略 | 是 | fallback 到 LLM 压缩 |
| `after_turn` | LLM 完成一轮（响应发出后） | 索引、持久化、触发后台任务 | 否（fire-and-forget） | 警告，忽略 |
| `prepare_subagent` | 子 agent 启动前 | 隔离 memory scope | 是 | 警告，继续 |
| `merge_subagent` | 子 agent 完成后 | 合并 context | 是 | 警告，继续 |

**关键特性：**

- `assemble` 是最强的 hook —— 它完全替代默认的 context window 组装，你的脚本决定 LLM 看到的每一条消息。
- `ingest` 是**叠加**在内置召回之上的 —— 返回的 memories 会和默认召回结果合并，不是替代。
- `after_turn` 是尽力而为：失败只记日志，不会反馈给用户。
- 如果 context engine 配置开了 `stable_prefix_mode`，`ingest` hook 会被跳过。
- 每个 hook 跑在全新子进程里 —— 调用之间**没有任何内存状态**。需要持久状态请用外部存储（SQLite、向量库、HTTP 服务）。

---

## 协议规范

### `ingest` hook

**请求**（一行 JSON 写到 hook 的 stdin，然后关闭 stdin）：

```json
{
  "type": "ingest",
  "agent_id": "0f3b…-uuid",
  "message": "我上次问的 Kafka 那件事是什么？",
  "peer_id": "user_12345"
}
```

| 字段 | 类型 | 总是存在 | 说明 |
|------|------|---------|------|
| `type` | `"ingest"` | 是 | 固定值，让一个脚本可以按 hook 类型分派。 |
| `agent_id` | string (UUID) | 是 | 用它限定 agent 维度的查询。 |
| `message` | string | 是 | 原始用户消息文本。 |
| `peer_id` | string 或 `null` | 是 | 消息来自 channel（Telegram、Discord、WhatsApp…）时的平台用户 ID。**`peer_id` 存在时务必用它隔离召回**，防止跨用户上下文串漏。 |

**响应**（stdout 输出 JSON，只有**最后**一行能 parse 成 JSON 的会被采纳）：

```json
{
  "type": "ingest_result",
  "memories": [
    { "content": "用户在 2026-04-01 问过 Kafka consumer groups 相关问题。" },
    { "content": "之前决定标准化使用 Kafka 而不是 RabbitMQ。" }
  ]
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | `"ingest_result"` | 固定值。 |
| `memories` | 对象数组 | 可以为空。每项必须有 `content`（字符串）。其他字段被忽略。 |

### `after_turn` hook

**请求：**

```json
{
  "type": "after_turn",
  "agent_id": "0f3b…-uuid",
  "messages": [
    { "role": "user", "content": "我上次问的 Kafka 那件事是什么？", "pinned": false },
    { "role": "assistant", "content": "你问过 consumer groups…", "pinned": false }
  ]
}
```

每条消息的 `content` 被截断到前 500 字符以保证 hook 跑得快。

**响应：**

```json
{ "type": "ok" }
```

返回值被忽略 —— 只是表示完成。吐任何合法 JSON、退出码 0 即可。

---

### `bootstrap` hook

引擎初始化时调用一次，适合做连接检查、预热缓存等初始化工作。

**请求：**

```json
{
  "type": "bootstrap",
  "context_window_tokens": 200000,
  "stable_prefix_mode": false,
  "max_recall_results": 5
}
```

**响应：**

```json
{ "type": "ok" }
```

失败只记 `warn` 日志，不阻止引擎启动。

---

### `assemble` hook ⭐ 最强 hook

每次 LLM 调用前触发，**完全控制 LLM 看到的消息列表**。

**请求：**

```json
{
  "type": "assemble",
  "system_prompt": "你是一个有帮助的助手。",
  "messages": [
    { "role": "user", "content": "帮我查一下 Kafka 的配置", "pinned": false },
    {
      "role": "assistant",
      "content": [
        { "type": "tool_use", "id": "tu_01", "name": "file_read", "input": { "path": "/etc/kafka.conf" } }
      ],
      "pinned": false
    },
    {
      "role": "user",
      "content": [
        { "type": "tool_result", "tool_use_id": "tu_01", "content": "broker=localhost:9092\n...", "is_error": false }
      ],
      "pinned": false
    }
  ],
  "context_window_tokens": 200000
}
```

消息包含**完整结构**，包括 tool_use / tool_result / image / thinking 块。`pinned: true` 的消息不应被裁剪。

**响应：**

```json
{
  "type": "assemble_result",
  "messages": [...]
}
```

返回裁剪 / 重排后的消息列表。返回空列表或失败时，自动 fallback 到默认裁剪策略。

---

### `compact` hook

context 窗口压力过大时触发，用自定义策略压缩历史消息。

**请求：**

```json
{
  "type": "compact",
  "agent_id": "0f3b…-uuid",
  "messages": [...],
  "model": "llama-3.3-70b-versatile",
  "context_window_tokens": 200000
}
```

消息格式同 `assemble`。

**响应：**

```json
{
  "type": "compact_result",
  "messages": [...]
}
```

返回压缩后的消息列表。返回空列表或失败时，fallback 到内置 LLM 压缩。

---

### `prepare_subagent` / `merge_subagent` hook

子 agent 生命周期钩子，适合需要隔离或合并 memory scope 的场景。

**请求：**

```json
{ "type": "prepare_subagent", "parent_id": "...", "child_id": "..." }
{ "type": "merge_subagent",   "parent_id": "...", "child_id": "..." }
```

**响应：**

```json
{ "type": "ok" }
```

---

### `transform_tool_result` hook

工具运行后、LLM 看到结果前重写工具输出。让插件不用改工具本身就能截断、脱敏、屏蔽路径或完全替换工具结果。

**在 agent loop 里的位置:**

```
工具执行
  ↓
after_tool_call hook(只观察)
  ↓
transform_tool_result hook  ← 重写发生在这里
  ↓
sanitize + 应用上下文预算
  ↓
结果落到对话历史
```

**Trait 签名**(Rust 插件)—— 也作为 stdin/stdout JSON 请求暴露给子进程插件:

```rust
fn transform(&self, ctx: &HookContext) -> Result<Option<String>, String>
```

`HookContext` 携带:

| 字段 | 类型 | 用途 |
|---|---|---|
| `agent_name` | `&str` | Agent 显示名 |
| `agent_id` | `&str` | Agent ID |
| `event` | `HookEvent::TransformToolResult` | 此 hook 永远是这个 variant |
| `data` | `serde_json::Value` | `{ tool_name, args, result, is_error }` |

**请求**(子进程版):

```json
{
  "type": "transform_tool_result",
  "tool_name": "shell_exec",
  "args": { "command": "cat /etc/passwd" },
  "result": "root:x:0:0:root:/root:/bin/bash\n...",
  "is_error": false
}
```

**响应形状:**

| 回复 | 含义 |
|---|---|
| `{"type": "transformed", "result": "<new>"}` | **首匹配胜出。** 替换工具结果并停止。后续插件不会再被调到。 |
| `{"type": "skip"}`(Rust 里 `Ok(None)`) | 透传。栈中下一个插件得到机会。 |
| 非 0 退出 + stderr(Rust 里 `Err(reason)`) | `warn` 级别记日志。跳过此插件。链继续 —— fail-open。 |

**语义:**

- **首匹配胜出,顺序执行。** 插件按注册顺序跑;第一个返回 transformed 结果的插件胜出,链终止。
- **Fail-open。** 报错的插件被跳过;如果所有插件都 skip 或 error,原始工具结果保持不变。
- **本身不限大小**。但 transformed 结果之后仍要过全局上下文预算 sanitiser,所以爆大的输出仍可能在下游被截断。
- **Rust 插件的 panic 不会被捕获** —— `transform()` 里的 panic 会终止 agent 这一轮。源头不可信时用 `std::panic::catch_unwind` 包一层。

**典型用例:**

- **截断** —— 保留 `shell_exec` 输出的前 200 行,后面替换为 `"... (N more lines truncated)"`。
- **脱敏** —— 用正则一遍剥掉所有工具结果里的 API key / 密码 / token。
- **路径屏蔽** —— 把绝对的 home 目录路径改写成 `~/...`,让模型不要记住本地布局。
- **格式转换** —— 把 JSON 工具结果转成对模型更友好的 markdown 表格。
- **内容过滤** —— 从 web-fetch 结果里去掉模板/广告横幅。
- **审计透传** —— 返回 `Ok(None)` 但把调用回声到外部系统做合规日志。

此 hook 在同一个 `Plugin` trait 里,跟 `ingest` / `assemble` / `compact` / `after_turn` 一起。一个插件可以实现任意子集。

---

### 错误

以非 0 退出码结束，并把错误写到 stderr。LibreFang 会把 stderr 以 `warn` 级别记日志，然后回退到默认 context engine 的结果，当前这一轮继续。

---

## `plugin.toml` manifest

每个插件根目录必须有 `plugin.toml`：

```toml
name = "qdrant-recall"
version = "0.1.0"
description = "从 Qdrant 向量库召回"
author = "Evan"

# hook_timeout_secs = 30  # 每次调用的超时秒数；bootstrap 获得 2 倍此值

[hooks]
# --- 默认激活的 hook ---
ingest    = "hooks/ingest.py"
after_turn = "hooks/after_turn.py"
runtime = "python"

# --- 可选 hook（模板文件已生成，解注释即可激活） ---
# bootstrap        = "hooks/bootstrap.py"          # 启动时运行一次（2× 超时）
# assemble         = "hooks/assemble.py"           # 控制 LLM 看到的内容（最强）
# compact          = "hooks/compact.py"            # 自定义 context 压缩
# prepare_subagent = "hooks/prepare_subagent.py"   # 子 agent 启动前
# merge_subagent   = "hooks/merge_subagent.py"     # 子 agent 完成后

requirements = "requirements.txt"
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | 是 | 必须和 `plugins/` 下的目录名一致。 |
| `version` | string | 是 | Semver。 |
| `description` | string | 否 | Dashboard 插件列表显示用。 |
| `author` | string | 否 | 自由文本。 |
| `hook_timeout_secs` | integer | 否 | 每次 hook 调用的超时秒数（默认 `30`）。`bootstrap` hook 获得 **2 倍**此值，因其只运行一次且可能需要连接外部服务。 |
| `hooks.ingest` | string | 否 | 新消息进来时召回记忆。 |
| `hooks.after_turn` | string | 否 | turn 完成后持久化 / 更新索引。 |
| `hooks.bootstrap` | string | 否 | 引擎初始化时运行一次（2× 超时）。 |
| `hooks.assemble` | string | 否 | **完全控制 LLM 看到的消息**，最强 hook。 |
| `hooks.compact` | string | 否 | context 压力时自定义压缩策略。 |
| `hooks.prepare_subagent` | string | 否 | 子 agent 启动前隔离 memory scope。 |
| `hooks.merge_subagent` | string | 否 | 子 agent 完成后合并 context。 |
| `hooks.runtime` | string | 否 | 取值：`python`、`native`、`v`、`node`、`deno`、`go`、`ruby`、`bash`、`bun`、`php`、`lua`。默认 `python`。 |
| `requirements` | string | 否 | 只有 `python` runtime 会读；指向 `requirements.txt`。 |

---

## 插件环境变量（`[env]`）

`plugin.toml` 里的 `[env]` 段让插件自行声明需要注入到每个 hook 子进程的环境变量。这样插件专用的配置就不必写进全局 agent 配置，插件可以做到自包含。

```toml
name = "qdrant-recall"
version = "0.1.0"

[env]
QDRANT_URL     = "http://localhost:6333"
COLLECTION     = "agent-memories"
QDRANT_API_KEY = "${QDRANT_API_KEY}"

[hooks]
ingest    = "hooks/ingest.py"
after_turn = "hooks/after_turn.py"
runtime   = "python"
```

**环境变量展开：** 任何以 `${VAR_NAME}` 开头的值，都会在调用时从守护进程自身的环境里展开。若 `VAR_NAME` 在守护进程环境中未设置，则传入空字符串并记一条 `warn` 日志。展开只对以 `${` 开头的值生效，`"http://localhost:6333"` 这样的静态值原样透传。

Hook 脚本可以直接从环境变量读取这些值：

```python
import os
qdrant_url = os.environ["QDRANT_URL"]
api_key    = os.environ.get("QDRANT_API_KEY", "")
```

**优先级顺序**（从低到高）：LibreFang 的基础集（`LIBREFANG_AGENT_ID`、`PATH`、`HOME`、runtime 透传变量）→ `[env]` 中的声明 → agent `allowed_env_vars` 里列出的变量（最终生效）。

---

## Hook 超时覆盖（`hook_timeout_secs`）

默认每次 hook 调用有 30 秒超时，超时后子进程会被强制终止。可以在 `plugin.toml` 的顶层字段（与 `name`、`version` 同级，**不在** `[hooks]` 里面）设置 `hook_timeout_secs` 来覆盖：

```toml
name = "qdrant-recall"
version = "0.2.0"

hook_timeout_secs = 60   # 所有 hook 60 s；bootstrap 获得 120 s

[context_engine_hooks]
runtime = "python"
ingest  = "hooks/ingest.py"
```

| 配置值 | Hook 超时 | `bootstrap` 超时 |
|-------|----------|-----------------|
| *（未设置）* | 30 s（默认） | 60 s |
| `60` | 60 s | 120 s |
| `10` | 10 s | 20 s |

`bootstrap` hook 始终获得 **2 倍**于配置值的超时，因为它只在启动时运行一次，可能需要额外时间连接外部服务、预热缓存或下载 embedding。

调用远端服务（向量库、embedding API）延迟不稳定时可适当调高；轻量 hook 想要更快检测失败可以调低。

---

## 支持的 Runtime

| Runtime | 调用命令 | 官方 Docker 镜像自带？ |
|---------|---------|-------|
| `python` | `python3 script.py`（回退 `python`、`py`） | 是 |
| `native` | 直接 exec 文件（需要可执行位 + 有效二进制或 shebang） | 是 |
| `node` | `node script.js` | 是 |
| `bash` | `bash script.sh` | 是 |
| `deno` | `deno run --allow-read --allow-env script.ts` | 否 |
| `bun` | `bun run script.ts` | 否 |
| `go` | `go run script.go` | 否 |
| `v` | `v -no-retry-compilation run script.v` | 否 |
| `ruby` | `ruby script.rb` | 否 |
| `php` | `php script.php` | 否 |
| `lua` | `lua script.lua` | 否 |

标"否"的没打进官方 Docker 镜像 —— 请参考 [Configuration → Plugins](./configuration#context_engine) 里的 snippets 自己 extend 镜像，或者裸机部署用 `apt install` 自己装。

**环境变量透传：**PATH/HOME 基础集 + 各 runtime 专属变量（Python 的 `PYTHONPATH`/`VIRTUAL_ENV`、Ruby 的 `GEM_HOME`/`GEM_PATH`、Lua 的 `LUA_PATH` 等）会自动透传。其他业务环境变量必须在 agent 的 `allowed_env_vars` 里显式声明。

---

## 写你的第一个插件

做一个按 `peer_id` 从 SQLite 文件召回用户偏好的插件。为了简洁用 Python。

### 1. 生成脚手架

Dashboard Plugins 页面 → Create Plugin → 选 `python`，名字填 `prefs`。或者调 API：

```bash
curl -X POST http://127.0.0.1:4545/api/plugins/scaffold \
  -H 'Content-Type: application/json' \
  -d '{"name":"prefs","description":"User preference recall","runtime":"python"}'
```

这会创建 `~/.librefang/plugins/prefs/`，里面带好 `plugin.toml`、`hooks/ingest.py`、`hooks/after_turn.py` 和一个空的 `requirements.txt`。

### 2. 修改 `hooks/ingest.py`

```python
#!/usr/bin/env python3
"""按 peer_id 从本地 SQLite 召回用户偏好。"""
import json
import sqlite3
import sys
from pathlib import Path

DB_PATH = Path.home() / ".librefang" / "plugins" / "prefs" / "prefs.db"

def main():
    request = json.loads(sys.stdin.read())
    peer_id = request.get("peer_id")          # 直接调 API 时可能是 None
    if not peer_id:
        print(json.dumps({"type": "ingest_result", "memories": []}))
        return

    # Open the per-plugin DB and read anything keyed to this peer.
    conn = sqlite3.connect(DB_PATH)
    conn.execute("CREATE TABLE IF NOT EXISTS prefs (peer_id TEXT, fact TEXT)")
    rows = conn.execute(
        "SELECT fact FROM prefs WHERE peer_id = ?", (peer_id,)
    ).fetchall()
    conn.close()

    memories = [{"content": f"User preference: {row[0]}"} for row in rows]
    print(json.dumps({"type": "ingest_result", "memories": memories}))

if __name__ == "__main__":
    main()
```

### 3. 修改 `hooks/after_turn.py`

这个插件不需要 turn 后处理 —— 直接 ack 就行：

```python
#!/usr/bin/env python3
import json
import sys

_ = json.loads(sys.stdin.read())
print(json.dumps({"type": "ok"}))
```

或者直接把 `plugin.toml` 里的 `after_turn` 字段删掉，就根本不会调这个 hook。

### 4. 挂给 agent

编辑 `~/.librefang/config.toml`：

```toml
[context_engine]
plugin = "prefs"
```

或者手动配 hook（效果一样，不用装插件目录）：

```toml
[context_engine.hooks]
ingest = "~/.librefang/plugins/prefs/hooks/ingest.py"
after_turn = "~/.librefang/plugins/prefs/hooks/after_turn.py"
runtime = "python"
```

### 5. 重启 LibreFang

```bash
librefang start
```

下次带 `peer_id` 的消息进来时，你的 `ingest` hook 就会跑，返回的 memories 会被合并进 agent 的 context window。

---

## 插件叠加（`plugin_stack`）

agent 配置里的 `plugin` 字段只能挂载一个插件。`plugin_stack` 允许你把**两个或更多插件串联**起来，让每个 hook 阶段同时从多个插件获取结果。

```toml
[context_engine]
plugin_stack = ["qdrant-recall", "my-indexer"]
```

数组至少包含两个插件名，每个名字必须对应 `~/.librefang/plugins/` 下的一个目录。插件按数组顺序处理。

### 链式语义

| Hook | 执行方式 |
|------|---------|
| `ingest` | 所有插件全部执行；`memories` 数组按顺序**合并**（拼接）。 |
| `assemble` | 按顺序执行；**第一个返回非空消息列表**的插件胜出，后续跳过。 |
| `compact` | 按顺序执行；**第一个返回非 fallback 结果**的插件胜出（即第一个返回非空消息列表的）。 |
| `after_turn` | 所有插件**顺序执行**；各自的失败单独记日志，不会中断后续插件。 |
| `bootstrap`、`prepare_subagent`、`merge_subagent` | 所有插件顺序执行；失败记日志，不中断链。 |

### 适用场景

- **召回与索引分离**：一个插件做向量库召回（`ingest`），另一个做 turn 后索引（`after_turn`），各司其职互不干扰。
- **降级 assembly**：把首选 `assemble` 插件排在前面，简单的 fallback 插件排在后面——链式调用自动使用第一个有非空结果的插件。
- **分层记忆**：先用快速本地缓存召回，再用较慢的远端召回——既保证低延迟，又覆盖完整历史。

`plugin_stack` 和 `plugin` 不能在同一个 `[context_engine]` 块里共存。同时存在时，`plugin_stack` 优先生效。

---

## 通过 Dashboard 或 API 生成脚手架

Dashboard 的 Plugins 页面有 **Create Plugin** 表单，runtime 下拉框选语言，会生成对应语言的模板（Ruby 就是 `ingest.rb`、Go 就是 `ingest.go`），已经按协议写好了。

HTTP 等价调用：

```bash
curl -X POST http://127.0.0.1:4545/api/plugins/scaffold \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "my-plugin",
    "description": "用途",
    "runtime": "go"
  }'
```

---

## 热重载端点

编辑了插件脚本或 `plugin.toml` 之后，不用重启守护进程就能让改动生效：

```bash
curl -X POST http://127.0.0.1:4545/api/plugins/qdrant-recall/reload
```

该端点从磁盘重新读取 `plugin.toml`，并替换内存中的插件配置。哪些变更立即生效、哪些需要重启 agent：

| 变更类型 | `/reload` 后生效 | 需要重启 agent |
|---------|----------------|--------------|
| 脚本逻辑修改（`.py`、`.js` 等内部改动） | 是——下次 hook 调用时使用新文件 | 否 |
| `hook_timeout_secs` 变更 | 是 | 否 |
| `[env]` 新增或修改 | 是 | 否 |
| 在 `plugin.toml` 中添加或删除 hook 条目 | 否 | 是 |
| `runtime` 变更 | 否 | 是 |
| 重命名插件目录 | 否 | 是 |

重载成功时返回：

```json
{ "status": "ok", "plugin": "qdrant-recall" }
```

若更新后的 `plugin.toml` 解析失败，守护进程保留旧配置并返回 400 错误信息——插件继续正常运行，不受影响。

---

## Hook 调用指标

Context engine 运行时会为每个已安装的插件累积每个 hook 的调用统计。通过以下接口查询：

```bash
curl http://127.0.0.1:4545/api/context-engine/metrics
```

响应示例：

```json
{
  "plugins": {
    "qdrant-recall": {
      "ingest": {
        "calls":       142,
        "successes":   140,
        "failures":      2,
        "latency_ms_total": 8421
      },
      "after_turn": {
        "calls":       138,
        "successes":   138,
        "failures":      0,
        "latency_ms_total": 2760
      }
    }
  }
}
```

| 字段 | 说明 |
|------|------|
| `calls` | 守护进程启动后该 hook 被调用的总次数。 |
| `successes` | 以退出码 0 结束且返回合法 JSON 的调用次数。 |
| `failures` | 超时、非 0 退出或输出无法解析的调用次数。 |
| `latency_ms_total` | hook 子进程内花费的累计挂钟时间（毫秒）。除以 `calls` 得到平均延迟。 |

计数器在守护进程重启后清零。可以利用这个端点发现：延迟高的 hook（`latency_ms_total / calls` 偏大）、频繁失败的 hook（`failures / calls` 偏高）、从未被调用的 hook（可能是 `plugin_stack` 配置有误或 hook 路径不正确）。

---

## 本地测试

每个 hook 本质就是一个读 stdin 写 stdout 的脚本 —— 不用启动 LibreFang 就能测：

```bash
echo '{"type":"ingest","agent_id":"test","message":"hello","peer_id":"u1"}' \
  | python3 ~/.librefang/plugins/prefs/hooks/ingest.py
```

期望输出：

```json
{"type": "ingest_result", "memories": [...]}
```

如果脚本 hang 住，那是你没关 stdin 或者在读 stdin 以外的东西。如果打印了非 JSON 的行，只有**最后**一个能 parse 成 JSON 的行会被当作响应 —— 前面打的 log 行不影响。

---

## 用 Doctor 端点调试

`GET /api/plugins/doctor` 扫描所有支持的 runtime，然后和已安装的插件交叉比对：

```bash
curl http://127.0.0.1:4545/api/plugins/doctor
```

```json
{
  "runtimes": [
    { "runtime": "python", "launcher": "python3", "available": true,
      "version": "Python 3.12.3", "install_hint": "..." },
    { "runtime": "go", "launcher": null, "available": false,
      "version": null, "install_hint": "Install Go from https://go.dev/dl/ ..." }
  ],
  "plugins": [
    { "name": "prefs", "runtime": "python",
      "runtime_available": true, "hooks_valid": true,
      "install_hint": "..." }
  ]
}
```

插件莫名其妙不生效时先调这个 —— `runtime_available: false` 意味着 launcher 不在 PATH 上，`hooks_valid: false` 意味着声明的 hook 脚本在磁盘上不存在。

---

## 错误处理、超时与日志

- **超时**：每次 hook 调用 30 秒。超了会被 kill，记 `Timeout` 错误。
- **退出码**：非 0 退出会把脚本的 stderr 以 `warn` 级别记日志，然后用默认 context engine 的结果。
- **空输出**：脚本 0 退出但没输出任何东西会返回 `EmptyOutput`，用默认结果。
- **JSON 格式错**：runtime 从 stdout 最后一行往前扫，取第一个能 parse 的。都 parse 不了的话，把最后一行包成 `{ "text": "..." }` 兜底。
- **路径穿越**：脚本路径里不能带 `..` 组件 —— 每次调用都会检查，不只是加载时。
- **环境变量洗白**：子进程启动前所有继承的环境变量会被 `env_clear` 清空。LibreFang 然后设置 `LIBREFANG_AGENT_ID`、`LIBREFANG_MESSAGE`、`LIBREFANG_RUNTIME`，从父进程透传 `PATH` 和 `HOME`，加上 runtime 自己的透传集合（Python 的 `PYTHONPATH`/`VIRTUAL_ENV`、Ruby 的 `GEM_HOME`/`GEM_PATH` 等 —— 见 `runtime_passthrough_vars`），最后透传 agent 的 `allowed_env_vars` 里列出的变量。你的 hook 可以从环境变量读 `LIBREFANG_AGENT_ID` / `LIBREFANG_MESSAGE`，这是解析 stdin JSON 之外的另一种方式。

Hook 的 stderr 会被捕获后打印到 LibreFang 自己的日志里 —— 想打 debug log 直接往 stderr 写就行。每行 stderr 都会**实时**通过 tracing 转发出来（target 为 `plugin_stderr`，Python 工具是 `python_stderr`），所以长时间运行的 hook 可以把进度流到 `journalctl` / `docker logs`，不用等到退出才一口气吐出。用 `RUST_LOG=plugin_stderr=info`(或 `python_stderr=info`) 过滤。

hook 退出后，完整的 stderr **还会再以一条 `debug!` 级别的汇总日志发出来**，原本依赖 `"hook stderr:"` 这个串做抓取的工具不会失效。两个通道相互独立 —— 同时开启的话，每行先实时出现一次、退出后又在汇总里出现一次。

> **缓冲注意（尤其 Python）** —— 大多数语言 runtime 默认按块缓冲 stderr，进度行要等缓冲填满或进程退出才会到 daemon。要实时流式输出，每行后 flush：Python 用 `print(..., file=sys.stderr, flush=True)`，Ruby 加 `STDERR.sync = true`。Node 在用户态没有自己的 stdio buffer，`process.stderr.write(...)` 一旦内核 pipe 排空就到 daemon。或者直接让解释器跑行缓冲模式 (`python -u`)。

> **不要把 secret 打到 stderr。** 一旦运维开启 `RUST_LOG=plugin_stderr=info`(或 `python_stderr=info`)，所有 hook 的每行 stderr 都会进 daemon 日志 —— 然后被 `journalctl`、`docker logs` 或任何把日志运出主机的 sink 持久化。Token、API key、PII，以及任何你不想被平台日志保留策略沉淀下来的内容，**都不要** `print` 到 stderr。用 `debug` 通道（同样受 `RUST_LOG` 控制）发的内容也得先确认无敏感字段。

---

## Plugin vs. Skill

| | Plugin | Skill |
|---|--------|-------|
| 自定义的对象 | 记忆召回 / 上下文组装 | 工具目录（agent 能**做**什么） |
| 生命周期 hook | `ingest`、`after_turn` | LLM 主动调工具时 |
| 作用域 | 每 agent，通过 `context_engine` 配 | 全局或每 agent |
| 语言支持 | 11 个 runtime，JSON stdin/stdout | Python、WASM、Node、prompt-only、built-in |
| 沙箱 | 环境变量洗白 + 超时 + 路径校验 | WASM 完整沙箱；Python/Node 是子进程 |

要改**agent 记住什么**或做 turn 后记账，用 **plugin**。要给 agent 加**新工具**让它在对话中调用，用 **skill**。
