feat(core): 注入行为的记忆/裁剪标志位落地 + jieba 词库内嵌 + Windows 改走 WSL

配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。

## 1. 注入标志位(内核侧)

- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
  no_memory / context_policy / cleaner_name;策略在入口**校验**,
  非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
  (零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。

## 2. 修掉「输入无条件裁剪」这个真缺陷

eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:

  优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
          > 默认**不裁剪**

查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。

## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)

原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。

现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。

顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。

## 4. homed 放弃 Windows 原生,改走 WSL2

插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。

- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
  **不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
  procEnvForShm 返回 nil。顺手修掉两处长期编译错误
  (cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
  注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
  **linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
  「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
  确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
  linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
  (/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
  (否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
  stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
  目标下明确拒绝。

## 5. 插件调用点统一写明意图

- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
  **带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。

## 6. 版本

meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。

## 测试

- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
  nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
  payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
  **跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
  到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
  落盘幂等、内容哈希稳定。

验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
      go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
      ./internal/agent/{core,io}/... ./pkg/...
This commit is contained in:
JianFeeeee
2026-09-11 20:31:50 +08:00
parent a37bc7333e
commit d1959cbe80
39 changed files with 611001 additions and 204 deletions

View File

@ -52,6 +52,11 @@ import (
)
func main() {
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
// platform_windows.go。
requireSupportedPlatform()
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")

View File

@ -0,0 +1,9 @@
//go:build !windows
package main
// requireSupportedPlatform 在受支持的平台上不做任何事。
//
// 平台策略见 platform_windows.go只有 homed 放弃 Windows 原生支持
// (插件体系依赖 fd 继承与共享内存段内偏移Windows 用户走 WSL2。
func requireSupportedPlatform() {}

View File

@ -0,0 +1,44 @@
//go:build windows
package main
import (
"fmt"
"os"
)
// requireSupportedPlatform 在原生 Windows 上直接拒绝启动 homed。
//
// 为什么不做原生支持(不是「还没来得及做」,是设计上不做):
//
// homed 的插件体系建立在两个原语上——**继承的 fd**Single memfd: 统一共享
// 内存区 + eventfd 通知)与**同段内相对偏移解引用**(各进程 mmap 到不同虚拟
// 基址,段内一律用偏移互相读写,这样插件回调才能就地改写内核看到的那份数据)。
//
// Windows 的等价物是命名内核对象CreateFileMappingW / OpenEventW句柄表
// 没有 fd 继承语义os/exec 的 ExtraFiles 在 Windows 上直接不被支持),
// 生命周期与权限模型也按句柄而非进程继承来组织。要在其上重建这套语义,
// 等于再维护一套平台专属 ABI 与安全边界——而 C ABI 时代正是「三套 ABI 并存
// 导致改写型插件在某个平台上静默失效」的教训§9.2)。
//
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
// WSL2 里就是普通 linux/amd64走与我们测试矩阵完全相同的那条路径。
//
// 注意范围:只有 homed 如此。plugindev 工具链仍可在 Windows 上运行
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
func requireSupportedPlatform() {
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "原因:子进程插件依赖 fd 继承 + 统一共享内存区的段内偏移解引用,")
fmt.Fprintln(os.Stderr, "而 Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属")
fmt.Fprintln(os.Stderr, "ABI——C ABI 时代三套 ABI 并存曾导致改写型插件在某个平台上静默失效。")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "请改用 WSL2")
fmt.Fprintln(os.Stderr, " 1. wsl --install -d Ubuntu # 安装 WSL2")
fmt.Fprintln(os.Stderr, " 2. 在 WSL 内下载 linux/amd64 的 homed 与插件(.hmap")
fmt.Fprintln(os.Stderr, " 3. 在 WSL 内运行 homed与 Linux 主机完全相同,无需额外配置")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "数据目录可放在 /mnt/c/... 下以便与 Windows 侧共享,")
fmt.Fprintln(os.Stderr, "但不建议(跨文件系统 IO 慢、inotify 语义受限);推荐放在 WSL 内部路径。")
os.Exit(2)
}

View File

@ -55,7 +55,7 @@ case "$TARGET" in
;;
*)
echo "Unknown target: $TARGET"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|homed|waiter|initconfig|gui|payload]"
echo " [all|homed|waiter|initconfig|gui]"
exit 1
esac
@ -152,6 +152,39 @@ build_initconfig() {
echo " OK ($(du -h "$out" | cut -f1))"
}
# ---- linux-payload给 Windows 安装器用的 Linux 包)----
#
# Windows 不再安装 homed.exehomed 依赖 fd 继承 + 统一共享内存区的段内偏移
# 解引用Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go
# Windows 安装器改为引导到 WSL2并把 **Linux 包**送进发行版里安装。
# 因此 Windows 安装包必须带上 Linux 产物——这一段就是把它暂存到
# build/linux-payload/installer.nsi 从这里 File /r 打进安装包)。
#
# 复用 package-linux.sh 的产物而不是在这里另行编译WSL 里跑的就是普通
# linux/amd64安装内容必须与 Linux 原生安装**完全一致**,否则又变成两个平台。
stage_linux_payload() {
local src="$PROJECT_ROOT/dist/linux"
local out="$BUILD_DIR/linux-payload"
rm -rf "$out"
mkdir -p "$out"
local found=0
for f in "$src"/*.deb "$src"/*.tar.gz; do
[ -f "$f" ] || continue
cp "$f" "$out/"
found=$((found + 1))
done
if [ "$found" -eq 0 ]; then
echo "[FAIL] build/linux-payload 为空:先运行 package-linux.sh 产出 dist/linux/*.deb|*.tar.gz" >&2
echo " Windows 安装器会把这里的包送进 WSL 安装;空包等于装不上)" >&2
return 1
fi
echo "[BUILD] linux-payload ← $found 个包"
ls -1 "$out" | sed 's/^/ /'
}
# ---- gui (Electron) ----
build_gui() {
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
@ -179,13 +212,34 @@ build_gui() {
}
# ---- dispatch ----
case "$COMPONENT" in
all) build_homed; build_waiter; build_initconfig; build_gui ;;
homed) build_homed ;;
waiter) build_waiter ;;
initconfig) build_initconfig ;;
gui) build_gui ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
esac
if [ "${GOOS:-}" = "windows" ]; then
# Windows 目标:构建的**不是** homed——它已放弃 Windows 原生支持。
# 需要的是Linux 包(送进 WSL 安装)+ Windows 侧客户端waiter CLI / GUI
case "$COMPONENT" in
all) build_waiter; stage_linux_payload; build_gui ;;
waiter) build_waiter ;;
payload) stage_linux_payload ;;
gui) build_gui ;;
homed|initconfig)
echo "homed/initconfig 不再提供 Windows 原生构建:请用 WSL2或用 linux/amd64 目标)。" >&2
echo "原因见 cmd/homed/platform_windows.go。" >&2
exit 1
;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac
else
case "$COMPONENT" in
all) build_homed; build_waiter; build_initconfig; build_gui ;;
homed) build_homed ;;
waiter) build_waiter ;;
initconfig) build_initconfig ;;
gui) build_gui ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac
fi

View File

@ -216,17 +216,26 @@ FunctionEnd
Section "Install" SEC_INSTALL
SetOutPath "$INSTDIR"
; WSL 引导脚本随安装包分发(它负责检测/引导 WSL 并把 Linux 包装进发行版)
File "..\..\deploy\packaging\windows\install-via-wsl.ps1"
CreateDirectory "$INSTDIR\data"
CreateDirectory "$INSTDIR\data\log"
CreateDirectory "$INSTDIR\data\plugins"
CreateDirectory "$INSTDIR\data\adapters"
; homed **不再装到 Windows**:插件体系依赖 fd 继承与统一共享内存区的段内偏移
; 解引用Windows 的句柄模型无法表达(见 cmd/homed/platform_windows.go
; Windows 侧改为引导到 WSL2把 **Linux 包**送进发行版里按 Linux 的方式安装。
; 所以这里带的是 linux/amd64 的 payload不是 homed.exe。
!if "${HAS_CORE}" == "1"
File "..\..\build\initconfig.exe"
File "..\..\build\homed.exe"
SetOutPath "$PLUGINSDIR\linux-payload"
File /r "..\..\build\linux-payload\*.*"
SetOutPath "$INSTDIR"
!endif
!if "${HAS_WAITER}" == "1"
; waiter 是 CLI 客户端WSL 侧会装上 Linux 版Windows 侧仍可保留原生版
; (它只是个客户端,不走插件体系)。
File "..\..\build\waiter.exe"
!endif
@ -237,11 +246,24 @@ Section "Install" SEC_INSTALL
!endif
!if "${HAS_CORE}" == "1"
DetailPrint "初始化配置数据库..."
nsExec::Exec '"$INSTDIR\initconfig.exe" -data "$INSTDIR\data" -username "$webuiUsername" -password "$webuiPassword" -apikey "$apiKey"'
; 在 WSL2 里安装 homed。凭据页面上收的那三个透传进去避免
; 「界面显示一份、config.db 里另一份」导致登录不上。
DetailPrint "检测 WSL 并在其中安装 HomeAgent..."
nsExec::ExecToStack 'powershell -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install-via-wsl.ps1" -PayloadDir "$PLUGINSDIR\linux-payload" -ApiKey "$apiKey" -WebUIUser "$webuiUsername" -WebUIPass "$webuiPassword"'
Pop $0
Pop $1
${If} $0 != 0
DetailPrint "警告: 数据库初始化可能未成功完成"
; 退出码含义见 install-via-wsl.ps120/21 是「WSL 或发行版缺失,需要先装」,
; 属于可指引的用户动作,不当成安装失败来恐吓人。
${If} $0 == 20
MessageBox MB_ICONINFORMATION|MB_OK "未检测到 WSL。$\r$\n$\r$\n请在管理员 PowerShell 中执行:$\r$\n wsl --install$\r$\n$\r$\n然后重启 Windows再重新运行本安装程序。"
${ElseIf} $0 == 21
MessageBox MB_ICONINFORMATION|MB_OK "WSL 已安装,但还没有发行版。$\r$\n$\r$\n请先执行$\r$\n wsl --install -d Ubuntu$\r$\n$\r$\n完成首次初始化后再重新运行本安装程序。"
${Else}
MessageBox MB_ICONEXCLAMATION|MB_OK "WSL 内安装失败(退出码 $0。$\r$\n$\r$\n可进入 WSL 手动排查wsl -d Ubuntu$\r$\n安装脚本输出见上方日志。"
${EndIf}
${Else}
DetailPrint "HomeAgent 已在 WSL2 内安装完成"
${EndIf}
!endif

View File

@ -16,8 +16,12 @@ fi
mkdir -p "$DATA_DIR"
# 生成随机凭据
API_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")
# 生成随机凭据
#
# 允许环境变量覆盖:安装器(包括 Windows 上的 WSL 引导安装)已经在界面上
# 向用户收过这些值,若不接受传入就只能两个地方各生成一份,用户看到的那份
# 与实际写入 config.db 的那份不一致——那种错会直接表现为「登录不上」。
API_KEY="${HOMEAGENT_API_KEY:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")}"
WEBUI_USER="${WEBUI_USER:-admin}"
WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}"

View File

@ -0,0 +1,259 @@
<#
.SYNOPSIS
在 WSL2 中安装 HomeAgenthomed + 插件 + WebUI
.DESCRIPTION
Windows 不再提供 homed 的原生安装。原因见 cmd/homed/platform_windows.go
homed 的插件体系依赖「继承的 fd」与「统一共享内存区的段内偏移解引用」
Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属 ABI
而 C ABI 时代三套 ABI 并存正是「改写型插件在某个平台上静默失效」的根因。
本脚本因此把 Windows 安装流程变成一条引导链:
检测 WSL → 必要时引导安装 → 配置(默认版本 2 / systemd
→ 把 **Linux 包** 送进发行版 → 在 WSL 内按 Linux 的方式安装。
它复用 Linux 侧的安装包与初始化脚本,不另写一套安装逻辑——
「WSL 里就是普通 linux/amd64」这一点必须保持成立否则等于又开了第三个平台。
.PARAMETER PayloadDir
内含 Linux 安装包的目录(安装器把它解到临时目录后传进来)。
优先取 *.deb没有 deb 时回退 *.tar.gz。
.PARAMETER Distro
目标发行版名。省略则用默认发行版;没有发行版时引导安装 Ubuntu。
.PARAMETER DataDir
WSL 内的数据目录。默认 /var/lib/homeagent与 Linux 原生安装一致)。
不建议放 /mnt/c/...:跨文件系统 IO 慢,且 inotify 语义受限。
.NOTES
⚠️ 本脚本在开发环境Linux中只能做语法/逻辑审查,**未在真实 Windows + WSL
上执行过**。首次使用请逐段核对输出;下面每个阶段都打印了实际执行的命令,
便于定位到具体哪一步与预期不符。
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$PayloadDir,
[string]$Distro = "",
[string]$DataDir = "/var/lib/homeagent",
[string]$ApiKey = "",
[string]$WebUIUser = "",
[string]$WebUIPass = "",
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$script:StageNo = 0
$script:DistroName = $Distro
function Write-Stage([string]$Text) {
$script:StageNo++
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor DarkGray
Write-Host ("[$script:StageNo] $Text") -ForegroundColor Cyan
Write-Host ("=" * 64) -ForegroundColor DarkGray
}
function Write-Ok([string]$Text) { Write-Host "$Text" -ForegroundColor Green }
function Write-Warn2([string]$Text) { Write-Host " ! $Text" -ForegroundColor Yellow }
function Fail([string]$Text, [string]$Hint = "") {
Write-Host ""
Write-Host " 安装中止:$Text" -ForegroundColor Red
if ($Hint) { Write-Host " $Hint" -ForegroundColor Yellow }
exit 1
}
# ── 0. 前置检查 ────────────────────────────────────────────────────────────
Write-Stage "前置检查"
$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
# 装 WSL 与写 \\wsl$ 都需要管理员。不静默提权:用户应当看到发生了什么。
Fail "需要管理员权限" "请以管理员身份重新运行安装程序。"
}
Write-Ok "管理员权限"
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Write-Warn2 "未找到 wsl.exe"
Write-Host " homed 不再提供 Windows 原生版本,必须通过 WSL2 运行。"
Write-Host ""
Write-Host " 在管理员 PowerShell 中执行:" -ForegroundColor Yellow
Write-Host " wsl --install" -ForegroundColor White
Write-Host " 然后重启 Windows再重新运行本安装程序。"
Write-Host ""
Write-Host " Windows 10 需 2004+ 且启用虚拟机平台Windows 11 开箱可用)"
exit 20
}
Write-Ok "wsl.exe 可用"
# ── 1. 检测 WSL 状态与发行版 ───────────────────────────────────────────────
Write-Stage "检测 WSL 与发行版"
# wsl -l -v 在「没有发行版」时返回非零,且输出是 UTF-16LE——直接解析会踩编码坑。
# 用 --status 取默认发行版,再单独枚举列表。
$distros = @()
try {
$raw = (& wsl.exe -l -q 2>$null | Out-String)
$distros = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
} catch {
$distros = @()
}
if ($distros.Count -eq 0) {
Write-Warn2 "WSL 已安装,但没有任何发行版"
Write-Host ""
Write-Host " 请先安装发行版(推荐 Ubuntu" -ForegroundColor Yellow
Write-Host " wsl --install -d Ubuntu" -ForegroundColor White
Write-Host ""
Write-Host " 首次启动 Ubuntu 会要求创建 Linux 用户名与密码,完成后重新运行本安装程序。"
exit 21
}
if ($script:DistroName -eq "") {
try {
$script:DistroName = (& wsl.exe --status 2>$null | Select-String -Pattern "Default Distribution" |
ForEach-Object { ($_ -split ":")[1].Trim() })
} catch { }
if (-not $script:DistroName) { $script:DistroName = $distros[0] }
}
Write-Ok "发行版:$($script:DistroName)(共 $($distros.Count) 个:$($distros -join ', ')"
# ── 2. 确保是 WSL2 ─────────────────────────────────────────────────────────
Write-Stage "确保使用 WSL2"
# WSL1 没有真正的 Linux 内核、没有 systemd且在共享内存/事件语义上与 WSL2 不同。
# homed 依赖 eventfd + mmap 语义WSL1 会以难以诊断的方式失败,因此显式要求 WSL2。
try {
$verLine = (& wsl.exe -l -v 2>$null | Out-String) -split "`r?`n" |
Where-Object { $_ -match [regex]::Escape($script:DistroName) } | Select-Object -First 1
if ($verLine -match "\b1\b") {
Write-Warn2 "该发行版当前是 WSL1正在升级为 WSL2 ..."
& wsl.exe --set-version $script:DistroName 2
if ($LASTEXITCODE -ne 0) { Fail "WSL2 升级失败" "可手动执行wsl --set-version $($script:DistroName) 2" }
}
} catch { }
& wsl.exe --set-default-version 2 | Out-Null
Write-Ok "已使用 WSL2"
# ── 3. 准备 Linux 包 ───────────────────────────────────────────────────────
Write-Stage "准备 Linux 安装包"
$deb = Get-ChildItem -Path $PayloadDir -Filter "*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
$tar = Get-ChildItem -Path $PayloadDir -Filter "*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($deb) {
$pkg = $deb.FullName
$pkgKind = "deb"
} elseif ($tar) {
$pkg = $tar.FullName
$pkgKind = "tar"
} else {
Fail "$PayloadDir 下既没找到 .deb 也没找到 .tar.gz" "安装器应把 Linux 包解到该目录。"
}
Write-Ok "使用 $(Split-Path $pkg -Leaf)$pkgKind"
# ── 4. 把包送进 WSL ────────────────────────────────────────────────────────
Write-Stage "把安装包送入 WSL"
# 走 /mnt/c 而不是 \\wsl$:前者是 WSL 稳定的对外通道,且不需要额外的 UNC 权限;
# 后者在某些 Windows 版本上对 Program Files 路径有重定向限制。
$winPath = (Resolve-Path $pkg).Path
$mntPath = "/mnt/" + $winPath.Substring(0, 1).ToLower() + ($winPath.Substring(2) -replace '\\', '/')
Write-Host " 源:$mntPath"
& wsl.exe -d $script:DistroName -u root -- bash -lc "mkdir -p /tmp/homeagent-install"
if ($LASTEXITCODE -ne 0) { Fail "无法在 WSL 内创建临时目录" "确认发行版可正常启动wsl -d $($script:DistroName)" }
& wsl.exe -d $script:DistroName -u root -- bash -lc "cp '$mntPath' /tmp/homeagent-install/"
if ($LASTEXITCODE -ne 0) { Fail "复制安装包失败" }
Write-Ok "已送到 /tmp/homeagent-install/"
# ── 5. 在 WSL 内安装 ───────────────────────────────────────────────────────
Write-Stage "在 WSL 内安装 homed"
# 凭据经环境变量传给 setup.sh它已支持 HOMEAGENT_API_KEY / WEBUI_USER / WEBUI_PASS
# 不传的话就会「界面显示一份、config.db 里另一份」,用户直接登录不上。
$credEnv = ""
if ($ApiKey) { $credEnv += "export HOMEAGENT_API_KEY='$ApiKey'; " }
if ($WebUIUser) { $credEnv += "export WEBUI_USER='$WebUIUser'; " }
if ($WebUIPass) { $credEnv += "export WEBUI_PASS='$WebUIPass'; " }
# 安装逻辑复用 Linux 侧deb 走 aptpostinst 会调用 setup.sh 生成凭据与 config.db
# tar 则解包到你同一套布局再执行同一份 setup.sh。刻意不在这里重写安装步骤——
# 「WSL 里就是普通 linux/amd64」必须保持成立否则等于又开了第三个平台。
if ($pkgKind -eq "deb") {
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
set -e
$credEnv
export HOMEAGENT_DATA='$DataDir'
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '$inWslPkg'
"@
} else {
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
set -e
$credEnv
mkdir -p /opt/homeagent /tmp/homeagent-extract
tar -xzf '$inWslPkg' -C /tmp/homeagent-extract
cd /tmp/homeagent-extract
# deb /usr/bin/homed + /usr/lib/homeagent/setup.sh
# /
install -m 0755 homed /usr/bin/homed
install -m 0755 waiter /usr/bin/waiter
[ -f initconfig ] && install -m 0755 initconfig /usr/bin/initconfig
if [ -f homeagent.service ]; then
install -m 0644 homeagent.service /etc/systemd/system/homeagent.service
fi
mkdir -p /usr/lib/homeagent
if [ -f setup.sh ]; then install -m 0755 setup.sh /usr/lib/homeagent/setup.sh; fi
export HOMEAGENT_DATA='$DataDir'
if [ -x /usr/lib/homeagent/setup.sh ]; then bash /usr/lib/homeagent/setup.sh; fi
"@
}
if ($LASTEXITCODE -ne 0) {
Fail "WSL 内安装失败(退出码 $LASTEXITCODE" "可进入 WSL 手动排查wsl -d $($script:DistroName)"
}
Write-Ok "安装完成"
# ── 6. 启动与自启 ──────────────────────────────────────────────────────────
Write-Stage "启动 homed 与自启配置"
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q homeagent; then
systemctl enable homeagent 2>/dev/null || true
systemctl restart homeagent
echo ' systemd homeagent '
else
# systemdWSL2 nohup Windows
pkill -f '/usr/bin/homed' 2>/dev/null || true
nohup /usr/bin/homed -data '$DataDir' > /var/log/homeagent-boot.log 2>&1 &
echo ' nohup systemd'
fi
"@
$creds = & wsl.exe -d $script:DistroName -u root -- bash -lc "cat '$DataDir/credentials.txt' 2>/dev/null || true"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " HomeAgent 已在 WSL2$($script:DistroName))内安装完成" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " WebUIhttp://localhost:8080" -ForegroundColor White
Write-Host " WSL2 会把 WSL 内的端口映射到 Windows 的 localhost无需额外配置"
Write-Host ""
if ($creds) {
Write-Host " 初始凭据(也保存在 WSL 内 $DataDir/credentials.txt" -ForegroundColor Yellow
Write-Host $creds
} else {
Write-Host " 未读到凭据文件,请进入 WSL 检查cat $DataDir/credentials.txt" -ForegroundColor Yellow
}
Write-Host ""
Write-Host " 常用操作(在 PowerShell 中):"
Write-Host " 进入 WSL : wsl -d $($script:DistroName)"
Write-Host " 查看日志 : wsl -d $($script:DistroName) -u root -- journalctl -u homeagent -f"
Write-Host " 重启服务 : wsl -d $($script:DistroName) -u root -- systemctl restart homeagent"
Write-Host ""
Write-Host " 注意WSL 实例不会随 Windows 启动而自动拉起。若需要开机自启,"
Write-Host " 可创建一个登录时触发的计划任务执行:"
Write-Host " wsl -d $($script:DistroName) -u root -- systemctl start homeagent"
exit 0

View File

@ -400,7 +400,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
}
a.publishEvent(events.EventRawInput, rawPayload)
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
archived := a.pruneOnInput(evt, cleanInput)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
@ -522,3 +522,66 @@ func (a *Agent) drainInterrupts() []string {
}
}
}
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
//
// 默认**不裁剪**ContextPolicy 必须在注入点payload 的 context_policy
// 或通道定义ChannelDef.ContextPolicy上显式声明为 prune 才会裁剪。
//
// 为什么把无条件裁剪改成需声明:裁剪会把低相关事件归档到文档记忆并从上下文里
// 移走,是破坏性的。此前每条输入都裁一次,于是「谁把上下文裁了」在排查时无从
// 得知;而插件注入的内容也会被不相关的内容挤掉。按来源/注入点声明后,触发条件
// 是可枚举、可审计的。
//
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
if a.context == nil || !a.pruneDeclared(evt) {
return 0
}
topK := a.maxContextSize - 1
if topK < 1 {
topK = 1
}
return a.context.Prune(cleanInput, topK, a.docStore)
}
// pruneDeclared 判定这次输入是否显式声明了裁剪。
//
// 优先级注入点声明的payload> 通道声明的ChannelDef> 默认不裁剪。
// 注入点是更窄的声明面,同一通道下的不同注入可以有不同意图。
func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
if p, ok := evt.Payload["context_policy"].(string); ok && p != "" {
return p == pubsdk.ContextPolicyPrune
}
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok {
return chDef.ContextPolicy == pubsdk.ContextPolicyPrune
}
}
return false
}
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
//
// 优先级:注入点声明的 cleanerpayload.cleaner_name引用某个已注册的通道
// cleaner> 按 source 查到的通道 cleaner > 原文。
//
// 声明的 cleaner 名字查不到时**记日志并回退**,而不是静默当没声明:
// 注入是 fire-and-forget 的,插件那边看不到错误;至少要在内核日志里留下
// 「你声明的清洗没生效」的痕迹,否则排查时只能看到「记忆里的内容很脏」。
func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
if a.io == nil {
return input
}
if name, ok := evt.Payload["cleaner_name"].(string); ok && name != "" {
if chDef, ok := a.io.GetInputChannelDef(name); ok && chDef.Cleaner != nil {
return chDef.Cleaner(input)
}
log.Printf("[agent] 注入声明了 cleaner_name=%q 但没有注册过该通道的 Cleaner已回退", name)
}
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
return chDef.Cleaner(input)
}
return input
}

View File

@ -0,0 +1,119 @@
package core
import (
"testing"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 这一组测试锁死「默认不裁剪」这条语义。
//
// 改动前:每条非中断输入都无条件 Prune 一次,没有任何声明能关掉它。
// 这是破坏性行为(低相关事件被归档并从上下文移走),却无法从调用点看出
// 「谁触发的裁剪」。改成需声明后,必须逐条验证默认值确实是不裁剪。
func TestPruneDeclared_DefaultsToNoPrune(t *testing.T) {
m := agentIO.NewIOManager()
a := &Agent{io: m}
evt := &agentIO.InputEvent{Source: "unknown_source", Payload: map[string]interface{}{}}
if a.pruneDeclared(evt) {
t.Fatal("既没有通道声明也没有注入声明的输入,默认必须不裁剪")
}
// 通道注册了、但策略是 none / 空:仍然不裁剪。
m.RegisterInputChannel("quiet", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyNone})
if a.pruneDeclared(&agentIO.InputEvent{Source: "quiet", Payload: map[string]interface{}{}}) {
t.Fatal("ChannelDef.ContextPolicy=none 不应裁剪")
}
m.RegisterInputChannel("empty", pubsdk.ChannelDef{})
if a.pruneDeclared(&agentIO.InputEvent{Source: "empty", Payload: map[string]interface{}{}}) {
t.Fatal("ChannelDef 未设 ContextPolicy 不应裁剪")
}
}
// 通道显式声明 prune 才裁剪。
func TestPruneDeclared_ChannelOptIn(t *testing.T) {
m := agentIO.NewIOManager()
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
a := &Agent{io: m}
if !a.pruneDeclared(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}) {
t.Fatal("通道声明 prune 后应裁剪")
}
}
// 注入点声明的优先级高于通道定义:同一通道下的不同注入可以有不同意图。
func TestPruneDeclared_InjectionOverridesChannel(t *testing.T) {
m := agentIO.NewIOManager()
a := &Agent{io: m}
m.RegisterInputChannel("chan", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
// 注入点说 none → 即使通道说 prune 也不裁。
evt := &agentIO.InputEvent{Source: "chan", Payload: map[string]interface{}{
"context_policy": pubsdk.ContextPolicyNone,
}}
if a.pruneDeclared(evt) {
t.Fatal("注入点声明 none 应覆盖通道的 prune")
}
// 通道没说,注入点说 prune → 裁。
m.RegisterInputChannel("plain", pubsdk.ChannelDef{})
evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{
"context_policy": pubsdk.ContextPolicyPrune,
}}
if !a.pruneDeclared(evt) {
t.Fatal("注入点声明 prune 应生效")
}
}
// 没有 context 时不能 panic也不该裁剪。
func TestPruneOnInput_NilContextIsSafe(t *testing.T) {
m := agentIO.NewIOManager()
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
a := &Agent{io: m}
if got := a.pruneOnInput(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}, "x"); got != 0 {
t.Fatalf("nil context 应返回 0实际 %d", got)
}
}
// cleanInputFor 的优先级:注入点声明的 cleaner > 按 source 查的 cleaner > 原文。
func TestCleanInputFor_Priority(t *testing.T) {
m := agentIO.NewIOManager()
m.RegisterInputChannel("src", pubsdk.ChannelDef{
Cleaner: func(s string) string { return "by-source:" + s },
})
m.RegisterInputChannel("explicit", pubsdk.ChannelDef{
Cleaner: func(s string) string { return "by-name:" + s },
})
a := &Agent{io: m}
// 无声明 → 用 source 的 cleaner
evt := &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{}}
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
t.Fatalf("应回退到 source 的 cleaner实际 %q", got)
}
// 注入点指定 cleaner_name → 覆盖 source 的
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "explicit"}}
if got := a.cleanInputFor(evt, "raw"); got != "by-name:raw" {
t.Fatalf("注入点声明的 cleaner 应优先,实际 %q", got)
}
// 完全没有 cleaner → 原文
evt = &agentIO.InputEvent{Source: "nobody", Payload: map[string]interface{}{}}
if got := a.cleanInputFor(evt, "raw"); got != "raw" {
t.Fatalf("没有 cleaner 时应返回原文,实际 %q", got)
}
// 声明的名字查不到 → 回退到 source 的 cleaner并记日志不能 panic、不能丢内容
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "missing"}}
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
t.Fatalf("未知 cleaner_name 应回退,实际 %q", got)
}
// nil IOManager 不能 panic
if got := (&Agent{}).cleanInputFor(evt, "raw"); got != "raw" {
t.Fatalf("nil io 应返回原文,实际 %q", got)
}
}

View File

@ -25,11 +25,11 @@ const (
type OutputCapability int
const (
CapText OutputCapability = 1 << iota // 文本
CapFile // 文件
CapImage // 图片
CapAudio // 音频
CapStructured // 结构化数据JSON/卡片)
CapText OutputCapability = 1 << iota // 文本
CapFile // 文件
CapImage // 图片
CapAudio // 音频
CapStructured // 结构化数据JSON/卡片)
)
func (c OutputCapability) Supports(cap OutputCapability) bool {
@ -242,6 +242,52 @@ func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, p
return <-ch
}
// InjectOptions 声明一次注入在记忆层与上下文层的表现。
//
// 零值 = 记入记忆 + 不裁剪上下文,与历史的三参数注入方法完全一致。
// 别名到公共 SDK 而非另建一套:内置插件与外部插件必须用同一套结构,
// 否则内核要认两种类型,而漏认会静默丢失标志位。
type InjectOptions = pubsdk.InjectOptions
// applyInjectOpts 把注入标志位写进事件 payload。
//
// 只在非零时写:零值与旧 payload 逐字节一致,事件订阅方与旧内核
// (不认识这两个键)都不会受影响。
//
// 为什么不把标志位当独立参数传到底eventloop 与各注入路径都按 payload 取字段
// no_memory 本来就是这么走的payload 是这里唯一已有的携带面。
func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
if opts.NoMemory {
payload["no_memory"] = true
}
if opts.ContextPolicy != "" {
payload["context_policy"] = opts.ContextPolicy
}
if opts.CleanerName != "" {
payload["cleaner_name"] = opts.CleanerName
}
}
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
applyInjectOpts(payload, opts)
m.InjectInput(source, eventType, payload)
}
func (m *IOManager) InjectInputToOpts(source, outputChannel, eventType string, payload map[string]interface{}, opts InjectOptions) {
applyInjectOpts(payload, opts)
m.InjectInputTo(source, outputChannel, eventType, payload)
}
func (m *IOManager) InjectInputSyncToOpts(source, outputChannel, eventType string, payload map[string]interface{}, opts InjectOptions) *OutputEvent {
applyInjectOpts(payload, opts)
return m.InjectInputSyncTo(source, outputChannel, eventType, payload)
}
func (m *IOManager) InjectInterruptOpts(source, channel string, payload map[string]interface{}, opts InjectOptions) {
applyInjectOpts(payload, opts)
m.InjectInterrupt(source, channel, payload)
}
func (m *IOManager) InjectText(source string, text string) {
m.InjectInput(source, "text", map[string]interface{}{
"content": text,
@ -293,10 +339,31 @@ func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]i
}
func (m *IOManager) InjectInterruptText(source, channel, text string) {
m.InjectInterrupt(source, channel, map[string]interface{}{
m.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
}
// InjectInterruptTextOpts 注入中断文本,并声明本次注入的记忆/裁剪行为。
//
// 中断也允许声明 ContextPolicyPrune中断同样携带内容进入上下文。
func (m *IOManager) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
m.InjectInterruptOpts(source, channel, map[string]interface{}{
"type": "text",
"content": text,
})
}, opts)
}
// InjectTextOpts 注入排队文本,并声明本次注入的记忆/裁剪行为。
func (m *IOManager) InjectTextOpts(source, channel, text string, opts InjectOptions) {
m.InjectInputToOpts(source, channel, "text", map[string]interface{}{
"content": text,
}, opts)
}
// InjectTextSyncOpts 同步注入文本并声明记忆/裁剪行为。
func (m *IOManager) InjectTextSyncOpts(source, outputChannel, text string, opts InjectOptions) *OutputEvent {
return m.InjectInputSyncToOpts(source, outputChannel, "text", map[string]interface{}{
"content": text,
}, opts)
}
func (m *IOManager) InputInterruptChan() <-chan *InputEvent { return m.interruptCh }
@ -308,6 +375,33 @@ func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *Output
})
}
// ---- 带标志位的注入(记忆/裁剪行为由调用点声明)----
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
func (m *IOManager) InjectInputMediaOpts(source, outputChannel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) {
m.InjectInputToOpts(source, outputChannel, "text", map[string]interface{}{
"content": text,
"media_blocks": blocks,
}, opts)
}
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
func (m *IOManager) InjectInputMediaSyncOpts(source, outputChannel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) *OutputEvent {
return m.InjectInputSyncToOpts(source, outputChannel, "text", map[string]interface{}{
"content": text,
"media_blocks": blocks,
}, opts)
}
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
func (m *IOManager) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) {
m.InjectInterruptOpts(source, channel, map[string]interface{}{
"type": "text",
"content": text,
"media_blocks": blocks,
}, opts)
}
func (m *IOManager) EmitOutput(target string, outputType string, payload map[string]interface{}) {
m.outputCh <- &OutputEvent{
RequestID: "",
@ -343,7 +437,7 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
})
}
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
// RegisterInputChannel 注册输入通道的记忆行为
@ -463,12 +557,14 @@ func NewMicrophone(name string, sampleRate int, io *IOManager) *Microphone {
return &Microphone{name: name, sampleRate: sampleRate, io: io}
}
func (d *Microphone) Name() string { return d.name }
func (d *Microphone) Type() DeviceType { return DeviceInput }
func (d *Microphone) Name() string { return d.name }
func (d *Microphone) Type() DeviceType { return DeviceInput }
func (d *Microphone) OutputCapabilities() OutputCapability { return 0 } // 纯输入
func (d *Microphone) Description() string { return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate) }
func (d *Microphone) Start() error { return nil }
func (d *Microphone) Stop() error { return nil }
func (d *Microphone) Description() string {
return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate)
}
func (d *Microphone) Start() error { return nil }
func (d *Microphone) Stop() error { return nil }
func (d *Microphone) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *Microphone) Tools() []ToolDef {
@ -498,13 +594,13 @@ func NewSpeaker(name string, io *IOManager) *Speaker {
return &Speaker{name: name, io: io}
}
func (d *Speaker) Name() string { return d.name }
func (d *Speaker) Type() DeviceType { return DeviceOutput }
func (d *Speaker) Name() string { return d.name }
func (d *Speaker) Type() DeviceType { return DeviceOutput }
func (d *Speaker) OutputCapabilities() OutputCapability { return CapText | CapAudio }
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
func (d *Speaker) Start() error { return nil }
func (d *Speaker) Stop() error { return nil }
func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
func (d *Speaker) Start() error { return nil }
func (d *Speaker) Stop() error { return nil }
func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *Speaker) Tools() []ToolDef {
return []ToolDef{{
@ -535,13 +631,13 @@ func NewCamera(name string, io *IOManager) *Camera {
return &Camera{name: name, io: io}
}
func (d *Camera) Name() string { return d.name }
func (d *Camera) Type() DeviceType { return DeviceInput }
func (d *Camera) Name() string { return d.name }
func (d *Camera) Type() DeviceType { return DeviceInput }
func (d *Camera) OutputCapabilities() OutputCapability { return CapImage } // 可返回图片
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
func (d *Camera) Start() error { return nil }
func (d *Camera) Stop() error { return nil }
func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
func (d *Camera) Start() error { return nil }
func (d *Camera) Stop() error { return nil }
func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *Camera) Tools() []ToolDef {
return []ToolDef{
@ -583,13 +679,13 @@ func NewRobotArm(name string, io *IOManager) *RobotArm {
return &RobotArm{name: name, io: io}
}
func (d *RobotArm) Name() string { return d.name }
func (d *RobotArm) Type() DeviceType { return DeviceIO }
func (d *RobotArm) Name() string { return d.name }
func (d *RobotArm) Type() DeviceType { return DeviceIO }
func (d *RobotArm) OutputCapabilities() OutputCapability { return CapStructured }
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
func (d *RobotArm) Start() error { return nil }
func (d *RobotArm) Stop() error { return nil }
func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
func (d *RobotArm) Start() error { return nil }
func (d *RobotArm) Stop() error { return nil }
func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *RobotArm) Tools() []ToolDef {
return []ToolDef{
@ -635,13 +731,13 @@ func NewGPIODevice(name string, pins []int, io *IOManager) *GPIODevice {
return &GPIODevice{name: name, pins: pins, io: io}
}
func (d *GPIODevice) Name() string { return d.name }
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
func (d *GPIODevice) Name() string { return d.name }
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
func (d *GPIODevice) OutputCapabilities() OutputCapability { return CapStructured }
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
func (d *GPIODevice) Start() error { return nil }
func (d *GPIODevice) Stop() error { return nil }
func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
func (d *GPIODevice) Start() error { return nil }
func (d *GPIODevice) Stop() error { return nil }
func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} }
func (d *GPIODevice) Tools() []ToolDef {
return []ToolDef{

View File

@ -0,0 +1,111 @@
package io
import (
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// drainOne 取出一条注入事件;没有则 Fatal。
func drainOne(t *testing.T, ch <-chan *InputEvent) *InputEvent {
t.Helper()
select {
case evt := <-ch:
return evt
default:
t.Fatal("没有拿到注入事件")
return nil
}
}
// 零值 InjectOptions 必须与历史的三参数注入产出**完全一致**的 payload。
//
// 这是兼容性底线:任何按 payload 取字段的下游(事件订阅方、旧内核、
// 工具链测试)都不能因为这次改造而看到新键。
func TestInjectTextOpts_ZeroValueMatchesLegacyPayload(t *testing.T) {
m := NewIOManager()
m.InjectText("src", "hello")
legacy := drainOne(t, m.InputChan())
m2 := NewIOManager()
m2.InjectTextOpts("src", "chan", "hello", InjectOptions{})
withOpts := drainOne(t, m2.InputChan())
if len(withOpts.Payload) != len(legacy.Payload) {
t.Fatalf("零值注入多出了键legacy=%v opts=%v", legacy.Payload, withOpts.Payload)
}
for k, v := range legacy.Payload {
if withOpts.Payload[k] != v {
t.Fatalf("键 %q 不一致legacy=%v opts=%v", k, v, withOpts.Payload[k])
}
}
}
// 标志位必须出现在事件 payload 上——eventloop 就是从那里读的。
func TestInjectTextOpts_CarriesFlags(t *testing.T) {
m := NewIOManager()
m.InjectTextOpts("src", "chan", "hello", InjectOptions{
NoMemory: true,
ContextPolicy: "prune",
CleanerName: "clean_me",
})
evt := drainOne(t, m.InputChan())
if evt.Payload["no_memory"] != true {
t.Errorf("no_memory 未传递: %v", evt.Payload["no_memory"])
}
if evt.Payload["context_policy"] != "prune" {
t.Errorf("context_policy 未传递: %v", evt.Payload["context_policy"])
}
if evt.Payload["cleaner_name"] != "clean_me" {
t.Errorf("cleaner_name 未传递: %v", evt.Payload["cleaner_name"])
}
if evt.Payload["content"] != "hello" {
t.Errorf("content 丢失: %v", evt.Payload["content"])
}
if evt.OutputChannel != "chan" {
t.Errorf("输出通道 = %q期望 chan", evt.OutputChannel)
}
}
// 中断注入走另一条队列,标志位同样要带上(用户已确认中断允许声明 prune
func TestInjectInterruptTextOpts_CarriesFlags(t *testing.T) {
m := NewIOManager()
m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune"})
evt := drainOne(t, m.InputInterruptChan())
if evt.Payload["context_policy"] != "prune" {
t.Errorf("中断注入的 context_policy 未传递: %v", evt.Payload)
}
if evt.Payload["type"] != "text" || evt.Payload["content"] != "alert" {
t.Errorf("中断注入的基本字段不对: %v", evt.Payload)
}
if _, has := evt.Payload["no_memory"]; has {
t.Errorf("未声明的 no_memory 不应出现: %v", evt.Payload)
}
}
// 带媒体的注入同样要带标志位。
func TestInjectInputMediaOpts_CarriesFlags(t *testing.T) {
m := NewIOManager()
blocks := []pubsdk.ContentBlock{{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AA"}}}
m.InjectInputMediaOpts("src", "chan", "看图", blocks, InjectOptions{NoMemory: true})
evt := drainOne(t, m.InputChan())
if evt.Payload["no_memory"] != true {
t.Errorf("媒体的 no_memory 未传递: %v", evt.Payload)
}
if _, ok := evt.Payload["media_blocks"]; !ok {
t.Errorf("媒体块丢失: %v", evt.Payload)
}
}
// 旧方法必须继续等价工作(它们是 Opts 变体的零值糖)。
func TestLegacyNoMemoryMethodStillSetsFlag(t *testing.T) {
m := NewIOManager()
m.InjectTextNoMemoryTo("src", "chan", "quiet")
evt := drainOne(t, m.InputChan())
if evt.Payload["no_memory"] != true {
t.Fatalf("旧 NoMemory 方法应置位: %v", evt.Payload)
}
}

View File

@ -53,7 +53,7 @@ func GetJieba() *gojieba.Jieba {
}()
d := jiebaDictDir()
if d == "" {
log.Printf("[jieba] no dictionary directory found, jieba disabled")
log.Printf("[jieba] 未找到词库目录(内嵌落盘失败且模块缓存也不存在),jieba disabled")
return
}
jiebaInst = gojieba.NewJieba(
@ -68,9 +68,24 @@ func GetJieba() *gojieba.Jieba {
}
func jiebaDictDir() string {
// 首选内嵌词库:它是产物的一部分,与二进制同版本、不依赖宿主环境。
//
// 以前这里只猜 GOMODCACHE/GOPATH/~/go/pkg/mod部署机上通常没有 Go 模块缓存,
// 于是分词与关键词提取会**静默退回空列表**(详见 jieba_embed.go 的说明)。
if dir, err := materializeJiebaDict(); err == nil && dir != "" {
return dir
}
log.Printf("[jieba] 内嵌词库落盘失败,回退到模块缓存查找(内嵌失败通常意味着缓存目录不可写)")
// 回退:开发机上存在的模块缓存(仅作为兵底,不应依赖它)。
//
// GOMODCACHE is typically $GOPATH/pkg/mod. When set, Go writes modules
// under <GOMODCACHE>/github.com/... . Look first at GOMODCACHE, then
// derive from GOPATH, then try common locations.
return jiebaDictDirFromModuleCache()
}
func jiebaDictDirFromModuleCache() string {
candidates := []string{
os.Getenv("GOMODCACHE"),
}

View File

@ -0,0 +1,123 @@
// Package memory 的 jieba 词库内嵌。
//
// 为什么要把词库嵌进二进制,而不是像以前那样去猜 Go 模块缓存路径:
//
// 原实现是 `jiebaDictDir()` 依次试 GOMODCACHE / GOPATH / ~/go/pkg/mod去找
// `github.com/yanyiwu/gojieba@v1.4.7/deps/cppjieba/dict`。部署机上通常**没有**
// Go 模块缓存,于是返回 ""`GetJieba()` 返回 nil四个分词/关键词函数
// **一律静默返回空列表**只在首次打一行「jieba disabled」
//
// 后果不是「少了个优化」而是**能力整体消失**:图记忆的关键词提取、文档
// TF-IDF 分词、NLP 依存解析(进而 doc→graph 三元组抽取)全部退化为空。
// 而本机之所以看起来正常,只是因为开发机与生产机重合、恰好有那份模块缓存。
//
// 内嵌后词库成为产物的一部分:与二进制同版本、随二进制分发、不依赖宿主环境。
// 代价是包体大 ~11.6MBjieba.dict.utf8 5.1M + idf.utf8 6.0M + hmm_model 0.5M + …),
// 这是可接受的——它换来的是「装到哪都能用」。
//
// 关于 POSgojieba 的 Tag() 不读 `pos_dict/` 目录,而是从主词典每行的
// 词性列取 tagcppjieba 的 PosTagger::LookupTag 走 dict->Find(...)->tag
// 取不到时用 SpecialRule 按字符类型兜底。所以这 5 个文件已足够同时支撑
// Cut 与 Tag无需再嵌 pos_dict/。
package memory
import (
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
)
//go:embed jiebadict/*
var jiebaDictFS embed.FS
// jiebaDictFiles 是 gojieba.NewJieba 需要的 5 个文件,顺序与它的参数一致:
// dict, hmm, user, idf, stop_words。
var jiebaDictFiles = []string{
"jieba.dict.utf8",
"hmm_model.utf8",
"user.dict.utf8",
"idf.utf8",
"stop_words.utf8",
}
// materializeJiebaDict 把内嵌词库落盘,返回目录路径。
//
// gojieba 的 C++ API 只接受**文件路径**NewJieba 会对每个路径 os.Stat
// 缺失就 panic所以必须先落盘再传路径。
//
// 落盘位置与幂等性:
// - 用内容哈希命名目录:词库升级后不会复用旧文件(否则会出现「新旧词库混用」
// 这种最难查的一类问题——分词结果与版本对不上)。
// - 已存在且大小一致就跳过写入:正常启动只做几次 stat。
func materializeJiebaDict() (string, error) {
sum, err := jiebaDictDigest()
if err != nil {
return "", err
}
base, err := os.UserCacheDir()
if err != nil || base == "" {
base = os.TempDir()
}
dir := filepath.Join(base, "homeagent", "jieba-"+sum)
if jiebaDictComplete(dir) {
return dir, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("创建词库目录: %w", err)
}
for _, name := range jiebaDictFiles {
data, err := jiebaDictFS.ReadFile("jiebadict/" + name)
if err != nil {
return "", fmt.Errorf("读取内嵌词库 %s: %w", name, err)
}
path := filepath.Join(dir, name)
// 先写临时文件再 rename避免并发启动时读到写了一半的词库。
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return "", fmt.Errorf("写出词库 %s: %w", name, err)
}
if err := os.Rename(tmp, path); err != nil {
return "", fmt.Errorf("落位词库 %s: %w", name, err)
}
}
return dir, nil
}
// jiebaDictDigest 对全部内嵌词库内容求哈希,作为落盘目录名的一部分。
func jiebaDictDigest() (string, error) {
h := sha256.New()
// 按固定顺序喂入embed.FS 的遍历顺序不保证稳定,顺序变了哈希就变,
// 会导致每次启动都重建一份词库。
names := append([]string(nil), jiebaDictFiles...)
sort.Strings(names)
for _, name := range names {
data, err := jiebaDictFS.ReadFile("jiebadict/" + name)
if err != nil {
return "", fmt.Errorf("读取内嵌词库 %s: %w", name, err)
}
fmt.Fprintf(h, "%s:%d:", name, len(data))
h.Write(data)
}
return hex.EncodeToString(h.Sum(nil))[:16], nil
}
// jiebaDictComplete 判断目录下 5 个词库是否齐全且大小与内嵌版本一致。
func jiebaDictComplete(dir string) bool {
for _, name := range jiebaDictFiles {
want, err := fs.Stat(jiebaDictFS, "jiebadict/"+name)
if err != nil {
return false
}
got, err := os.Stat(filepath.Join(dir, name))
if err != nil || got.Size() != want.Size() {
return false
}
}
return true
}

View File

@ -0,0 +1,98 @@
package memory
import (
"os"
"path/filepath"
"sync"
"testing"
)
// 内嵌词库必须自足:把 GOMODCACHE/GOPATH/HOME 全部指向不存在的路径,
// 分词与关键词提取仍然要工作。
//
// 这正是修复前的故障场景:原实现只去猜 Go 模块缓存,部署机上没有那份缓存时
// GetJieba() 返回 nil四个函数静默返回空列表——关键词提取、文档 TF-IDF 分词、
// NLP 依存解析(进而 doc→graph 三元组抽取)一起失效,且只有一行日志。
func TestEmbeddedJiebaDictIsSelfContained(t *testing.T) {
t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "nonexistent"))
t.Setenv("GOPATH", filepath.Join(t.TempDir(), "nonexistent"))
t.Setenv("HOME", filepath.Join(t.TempDir(), "nonexistent"))
// 清掉可能已被其它测试初始化过的单例。
jiebaOnce = sync.Once{}
jiebaInst = nil
t.Cleanup(func() {
jiebaOnce = sync.Once{}
jiebaInst = nil
})
if x := GetJieba(); x == nil {
t.Fatal("模块缓存不可见时 jieba 必须仍能初始化(词库应来自内嵌副本)")
}
words := TokenizeWords("今天天气很好,我们去公园散步")
if len(words) == 0 {
t.Fatal("分词结果为空:内嵌词库没有真正生效")
}
t.Logf("分词结果: %v", words)
// 内容词(名词/动词/形容词)——依赖词典里的词性列,顺便验证 Tag 路径可用。
content := TokenizeContentWords("北京是中国的首都,这里有很多历史建筑")
if len(content) == 0 {
t.Fatal("内容词为空Tag词性标注路径失效")
}
t.Logf("内容词: %v", content)
if kw := ExtractKeywords("机器学习模型训练需要大量数据和算力"); len(kw) == 0 {
t.Fatal("关键词为空")
}
}
// 落盘目录必须幂等:第二次调用不应重写文件(正常启动只做几次 stat
func TestMaterializeJiebaDictIsIdempotent(t *testing.T) {
dir, err := materializeJiebaDict()
if err != nil {
t.Fatalf("materializeJiebaDict: %v", err)
}
for _, name := range jiebaDictFiles {
p := filepath.Join(dir, name)
fi, err := os.Stat(p)
if err != nil {
t.Fatalf("缺少词库文件 %s: %v", name, err)
}
if fi.Size() == 0 {
t.Fatalf("词库文件 %s 为空", name)
}
}
// 记下 mtime再调一次必须完全没动过。
before, _ := os.Stat(filepath.Join(dir, "jieba.dict.utf8"))
dir2, err := materializeJiebaDict()
if err != nil {
t.Fatal(err)
}
if dir2 != dir {
t.Fatalf("目录名不稳定:%s vs %s会导致每次启动重建词库", dir, dir2)
}
after, _ := os.Stat(filepath.Join(dir, "jieba.dict.utf8"))
if !before.ModTime().Equal(after.ModTime()) {
t.Fatal("已存在完整词库时不应重写文件")
}
}
// 词库内容哈希必须稳定:否则目录名每次都变,等于每次启动都重建。
func TestJiebaDictDigestStable(t *testing.T) {
a, err := jiebaDictDigest()
if err != nil {
t.Fatal(err)
}
b, err := jiebaDictDigest()
if err != nil {
t.Fatal(err)
}
if a != b {
t.Fatalf("哈希不稳定: %s vs %s", a, b)
}
if len(a) != 16 {
t.Fatalf("哈希长度异常: %q", a)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
云计算
韩玉鉴赏
蓝翔 nz
区块链 10 nz

View File

@ -15,6 +15,12 @@ var (
// doc.insertWithMedia、io.injectMedia / injectMediaSync /
// injectInterruptMedia并把 text/image/audio 三条输入路径归一成
// 一条 processInput 主干。
// 1.2.0:模型中立的多模态 provider SPIpkg/embedding——内核不再适配任何
// 具体模型Qwen 实现移到 providers/qwen3vl插件运行协议升到 2
// 统一共享内存区fd3 布局改变,不支持滚动升级);并实现 SDK 1.2.0
// 新增的注入行为标志位InjectOptionsno_memory / context_policy
// 与 ChannelDef.ContextPolicy使输入/排队注入/中断注入/同步注入都能
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
//
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
@ -32,9 +38,11 @@ var (
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本semver
//
// 1.1.0:本内核实现了 SDK 1.1.0 的全部新增方法。
// SDK 1.0.0 编的存量插件照旧可用——新增方法由**插件调用、内核实现**
// 不调就不受影响,无需重编。
SDKCompatibleVersion = "1.1.0"
// 1.2.0:本内核实现了 SDK 1.2.0 的全部新增方法IOInjector 的六个 *Opts
// 注入变体、InjectOptions、ChannelDef.ContextPolicy因此声明为
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
// **插件调用、内核实现**,不调就不受影响,无需重编。
SDKCompatibleVersion = "1.2.0"
)
// FullVersion 返回完整的版本字符串。

View File

@ -0,0 +1,33 @@
package proc
import (
"strings"
"testing"
)
// 非法 context_policy 必须报错,而不是静默当成 none。
//
// 为什么这条值得单独测:把拼写错误降级成「不裁剪」不会有任何报错、日志或
// 行为异常——调用方会一直以为自己声明的裁剪在生效,直到某天上下文被撑爆。
// 这类静默降级是本次改造要消掉的东西,所以要钉住。
func TestValidateContextPolicy(t *testing.T) {
ok := []string{"", "none", "prune"}
for _, policy := range ok {
if err := validateContextPolicy("tool.register", policy); err != nil {
t.Errorf("合法取值 %q 被拒绝: %v", policy, err)
}
}
bad := []string{"prune ", "PRUNE", "True", "None", "always", "裁剪"}
for _, policy := range bad {
err := validateContextPolicy("io.injectText", policy)
if err == nil {
t.Errorf("非法取值 %q 应被拒绝", policy)
continue
}
// 报错要指出位置与实际值,否则排查时不知道是谁传错了。
if !strings.Contains(err.Error(), "io.injectText") || !strings.Contains(err.Error(), policy) {
t.Errorf("错误信息应包含位置与实际值,实际: %v", err)
}
}
}

View File

@ -101,6 +101,15 @@ type CoreSDK interface {
InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock)
// 带标志位的注入:声明这一次注入是否记入记忆、是否据此裁剪上下文。
// 上面的三参数方法是它们的零值糖。
InjectTextOpts(source, channel, text string, opts pubsdk.InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts pubsdk.InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts pubsdk.InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions)
// SetToolBlocks 注入媒体块,内核在下一条 tool message 携带§3.8)。
SetToolBlocks(blocks []pubsdk.ContentBlock)
@ -150,50 +159,75 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err != nil {
return nil, fmt.Errorf("input.register: %w", err)
}
return nil, h.sdk.RegisterInputChannel(p.Name, pubsdk.ChannelDef{
NoMemory: p.Def.NoMemory,
Cleaner: cleaner,
})
if err := validateContextPolicy("input.register", p.Def.ContextPolicy); err != nil {
return nil, err
}
// 整体传 p.Def只是把函数型的 Cleaner 换成代理),不要手写字段白名单:
// 白名单会让新增字段静默丢失。
def := p.Def
def.Cleaner = cleaner
return nil, h.sdk.RegisterInputChannel(p.Name, def)
// ---- IO 注入(原 case 5/6/7/47----
//
// 注入标志位no_memory / context_policy由插件在调用点声明默认
// 记入记忆 + 不裁剪。策略值在入口校验:静默降级成 none 会让调用方
// 以为自己声明的裁剪在生效。
case MethodIOInjectText:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectText(p.Source, p.Channel, h.resolveText(p))
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
return nil, err
}
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectInterrupt:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectInterruptText(p.Source, p.Channel, h.resolveText(p))
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
return nil, err
}
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectTextNoMem:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectTextNoMemory(p.Source, p.Channel, h.resolveText(p))
if err := validateContextPolicy("io.injectTextNoMem", p.ContextPolicy); err != nil {
return nil, err
}
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectSync:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return map[string]interface{}{"reply": h.sdk.InjectInputSync(p.Source, p.Channel, h.resolveText(p))}, nil
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
return nil, err
}
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return map[string]interface{}{"reply": reply}, nil
case MethodIOInjectMedia:
var p injectMediaParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if err := validateContextPolicy("io.injectMedia", p.ContextPolicy); err != nil {
return nil, err
}
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
h.sdk.InjectInputMedia(p.Source, p.Channel, p.Text, blocks)
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectMediaSync:
@ -201,11 +235,14 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if err := validateContextPolicy("io.injectMediaSync", p.ContextPolicy); err != nil {
return nil, err
}
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
reply := h.sdk.InjectInputMediaSync(p.Source, p.Channel, p.Text, blocks)
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return map[string]interface{}{"reply": reply}, nil
case MethodIOInjectInterruptMedia:
@ -213,11 +250,14 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if err := validateContextPolicy("io.injectInterruptMedia", p.ContextPolicy); err != nil {
return nil, err
}
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
h.sdk.InjectInterruptMedia(p.Source, p.Channel, p.Text, blocks)
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
// ---- 生命周期(原 case 8----
@ -648,10 +688,13 @@ func (h *coreHandler) resolveText(p injectParams) string {
}
type injectParams struct {
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text,omitempty"`
TextRef SharedRef `json:"text_ref,omitempty"`
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text,omitempty"`
TextRef SharedRef `json:"text_ref,omitempty"`
NoMemory bool `json:"no_memory,omitempty"`
ContextPolicy string `json:"context_policy,omitempty"`
CleanerName string `json:"cleaner_name,omitempty"`
}
// injectMediaParams 是带媒体注入/工具块注入的参数。
@ -664,11 +707,33 @@ type injectParams struct {
//
// 没有 BlocksRef 时(直连 RPC 测试、arena 不可用)回退内联 Blocks。
type injectMediaParams struct {
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text,omitempty"`
Blocks []pubsdk.ContentBlock `json:"blocks,omitempty"`
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text,omitempty"`
Blocks []pubsdk.ContentBlock `json:"blocks,omitempty"`
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
NoMemory bool `json:"no_memory,omitempty"`
ContextPolicy string `json:"context_policy,omitempty"`
CleanerName string `json:"cleaner_name,omitempty"`
}
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
//
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
// 与旧三参数注入等价。
func pubSdkInjectOpts(noMemory bool, policy, cleanerName string) pubsdk.InjectOptions {
return pubsdk.InjectOptions{NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName}
}
// validateContextPolicy 校验上下文策略取值,与 tool.register 同一套规则。
//
// 空串等价于 none不裁剪。非法值必须报错而不是当成 none把拼写错误
// 静默降级成「不裁剪」会让调用方以为自己声明的裁剪在生效。
func validateContextPolicy(where, policy string) error {
if !pubsdk.ValidContextPolicy(policy) {
return fmt.Errorf("%s: context_policy 只允许 none/prune实际 %q", where, policy)
}
return nil
}
// resolveJSONRef 若 ref 非零则从共享内存读取并 JSON 反序列化到 out
@ -812,10 +877,8 @@ func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error)
if p.Name == "" {
return nil, fmt.Errorf("tool.register: 缺少 name")
}
switch p.Def.ContextPolicy {
case "", "none", "prune":
default:
return nil, fmt.Errorf("tool.register: context_policy 只允许 none/prune实际 %q", p.Def.ContextPolicy)
if err := validateContextPolicy("tool.register", p.Def.ContextPolicy); err != nil {
return nil, err
}
p.Def.Plugin = h.name
// 函数本身不进 JSONhas_cleaner 只声明其存在,实际执行回到插件进程。

View File

@ -29,6 +29,8 @@ type fakeCoreSDK struct {
// injected 记录经 InjectText 注入的文本(验证跨进程共享槽路径)。
injected []string
// lastInjectOpts 记录最近一次带标志位注入的 opts跨进程转发断言用
lastInjectOpts pubsdk.InjectOptions
// toolBlocks 累积 SetToolBlocks 收到的块(多模态注入通道)。
toolBlocks []pubsdk.ContentBlock
// 文档/知识:验证大正文经 doc_ref / content_ref 走共享内存。
@ -64,6 +66,13 @@ func (f *fakeCoreSDK) InjectText(s, c, t string) {
f.mu.Unlock()
}
// lastInjectOpts 记录最近一次带标志位注入的 opts跨进程转发断言用
func (f *fakeCoreSDK) lastOpts() pubsdk.InjectOptions {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastInjectOpts
}
func (f *fakeCoreSDK) injectedTexts() []string {
f.mu.Lock()
defer f.mu.Unlock()
@ -78,6 +87,33 @@ func (f *fakeCoreSDK) InjectInputMediaSync(s, c, t string, b []pubsdk.ContentBlo
}
func (f *fakeCoreSDK) InjectInterruptMedia(s, c, t string, b []pubsdk.ContentBlock) {}
// ---- 带 InjectOptions 的注入1.2.0----
//
// 转发到旧方法即可:本测试关心的是「注入了什么话」,标志位的转发在
// corehandler 与 io 层的测试里覆盖。
func (f *fakeCoreSDK) InjectTextOpts(s, c, t string, o pubsdk.InjectOptions) {
// 记录 opts跨进程测试要断言插件在调用点声明的标志位确实穿过了 RPC。
f.mu.Lock()
f.lastInjectOpts = o
f.mu.Unlock()
f.InjectText(s, c, t)
}
func (f *fakeCoreSDK) InjectInterruptTextOpts(s, c, t string, o pubsdk.InjectOptions) {
f.InjectInterruptText(s, c, t)
}
func (f *fakeCoreSDK) InjectInputSyncOpts(s, c, t string, o pubsdk.InjectOptions) string {
return f.InjectInputSync(s, c, t)
}
func (f *fakeCoreSDK) InjectInputMediaOpts(s, c, t string, b []pubsdk.ContentBlock, o pubsdk.InjectOptions) {
f.InjectInputMedia(s, c, t, b)
}
func (f *fakeCoreSDK) InjectInputMediaSyncOpts(s, c, t string, b []pubsdk.ContentBlock, o pubsdk.InjectOptions) string {
return f.InjectInputMediaSync(s, c, t, b)
}
func (f *fakeCoreSDK) InjectInterruptMediaOpts(s, c, t string, b []pubsdk.ContentBlock, o pubsdk.InjectOptions) {
f.InjectInterruptMedia(s, c, t, b)
}
// SetToolBlocks 记录收到的媒体块,供测试断言共享内存通道真的把内容带到了内核侧。
func (f *fakeCoreSDK) SetToolBlocks(blocks []pubsdk.ContentBlock) {
f.mu.Lock()
@ -779,6 +815,20 @@ func TestPlugin_ArenaAllocFreeAcrossProcess(t *testing.T) {
t.Fatalf("经共享槽读到的内容不一致len(got)=%d len(want)=%d", len(got[0]), len(payload))
}
// 注入标志位必须穿过 RPC 到达内核:插件在调用点声明「不进记忆 / 据此裁剪 /
// 用哪个 cleaner」内核得拿到才能照做。只测 SDK 侧记录不到这一点——
// 字段在 JSON 与参数结构之间丢掉的失败模式是静默的。
opts := core.lastOpts()
if !opts.NoMemory {
t.Errorf("no_memory 未穿过 RPC: %+v", opts)
}
if opts.ContextPolicy != "prune" {
t.Errorf("context_policy 未穿过 RPC: %+v", opts)
}
if opts.CleanerName != "demo_cleaner" {
t.Errorf("cleaner_name 未穿过 RPC: %+v", opts)
}
// 插件已归还槽:池必须回到全空,否则说明 arena.free 没生效。
if used, total := host.Arena().Stats(); used != 0 {
t.Fatalf("插件归还后槽池应全空,实际 used=%d/%d", used, total)

View File

@ -15,19 +15,19 @@ import (
// Windows 侧共享段:命名 FileMapping + 命名 Event。
//
// 与 Unix 的机制差异(不是能力差异):
// Windows 没有 fd 继承语义——os/exec 的 ExtraFiles 在 Windows 实现里不被支持。
// 等价机制是命名内核对象:父进程 CreateFileMappingW 建带名字的段,
// 子进程 OpenFileMappingW 按同名打开,拿到同一份物理页。
// ⚠️ 本文件**已不是可用路径**homed 已放弃 Windows 原生支持
// (见 cmd/homed/platform_windows.go。原因插件体系依赖「继承的 fd」与
// 「统一共享内存区的段内偏移解引用」,而 Windows 既没有 fd 继承语义
// os/exec 的 ExtraFiles 在 Windows 不支持),本文件描述的也仍是**旧的**
// 两段布局StageContext 段 + 事件环段),跟不上 §13.1 的单块统一区域。
//
// **这是 §9.2 的正解**。C ABI 时代 Windows 是第三套独立 ABI 实现
// dynamic_dll_windows.gostage 只下发 3 字段且完全没有写回,
// sanitizer 这类改写型插件静默失效。三套 ABI 收敛为单一 RPC 后,
// Windows 与 Unix 共用同一份 stage 逻辑与同一份共享段布局,
// 平台差异只剩本文件的创建端 + 插件侧模板的打开端
// 保留本文件只为让 GOOS=windows 仍能编译:否则平台门根本跑不起来,
// 用户看到的会是「产物缺失」而不是一句「请用 WSL」。
// allocShm 因此在入口直接报错,不返回一个「看起来能用」的段——
// 让它跑起来只会得到无法解释的握手失败,这比启动失败难查得多
// (与 shmalloc_other.go 的处理方式一致)
//
// 名字带 PID 与递增序号:多个 homed 实例并存时不能撞名,
// 同一实例内 StageContext 段与事件环段也必须分开。
// Windows 用户的正确路径WSL2在 WSL 里就是普通 linux/amd64
var shmNameSeq atomic.Uint64
const (
@ -60,20 +60,15 @@ var (
shmHandlesMu sync.Mutex
)
// allocShm 创建命名共享段并映射
// allocShm 在 Windows 上明确报错homed 不支持 Windows 原生运行
//
// 返回的 *os.File 为 nilWindows 不经 fd 传递段,插件按名字打开。
// 名字通过 procEnvForShm 注入子进程环境变量。
// 返回「能用的段」:本文件实现的是 §13.1 之前的**两段**布局,
// 与当前内核的单块统一区域不兼容。静默返回只会在握手阶段变成一句
// 无法解释的魔数不匹配。报错文案直接给出行动:用 WSL2。
func allocShm(size int) (*os.File, []byte, error) {
name := shmNameForMode()
shm, data, err := createNamedMapping(name, size)
if err != nil {
return nil, nil, err
}
shmHandlesMu.Lock()
shmHandles[uintptr(unsafe.Pointer(&data[0]))] = shm
shmHandlesMu.Unlock()
return nil, data, nil
return nil, nil, fmt.Errorf("proc: homed 不支持 Windows 原生运行" +
"(插件体系依赖 fd 继承与统一共享内存区的段内偏移解引用)——请使用 WSL2" +
"详见 cmd/homed/platform_windows.go")
}
// shmNameForMode 按安全模式生成命名段名。
@ -87,7 +82,7 @@ func shmNameForMode() string {
return fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), seq)
default: // safe / debug
var b [12]byte
if _, err := cryptorand.Read(b[:]); err != nil {
if _, err := rand.Read(b[:]); err != nil {
// 退化为 PID+seq极端情况crypto rand 几乎不会失败)
return fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), seq)
}

View File

@ -3,24 +3,16 @@
package proc
import (
"fmt"
"os"
)
// procEnvForShm 返回子进程挂载共享段所需的环境变量Windows
// procEnvForShm 在 Windows 上返回空:这条路径已不可用
//
// Windows 没有 fd 继承语义os/exec 的 ExtraFiles 在 Windows 不被支持)
// 故段与事件对象的**名字**经环境变量传给子进程,插件侧模板的
// z_proc_shm_windows.go 按同名 OpenFileMappingW / OpenEventW 打开。
//
// 名字带 PID 与递增序号:多个 homed 实例并存时不能撞名。
func (h *Host) procEnvForShm() []string {
return []string{
fmt.Sprintf("HOMEAGENT_SHM_STAGE=%s", shmNameOf(h.data)),
fmt.Sprintf("HOMEAGENT_SHM_EVTRING=%s", shmNameOf(h.evtData)),
fmt.Sprintf("HOMEAGENT_EVT_EVENT=%s", evtEventNameOf(h.evtNotifyFd)),
}
}
// 原实现传的是旧的两段布局SHM_STAGE + SHM_EVTRING的两个名字
// 而 §13.1 之后内核只有一块统一区域;名字的数量本身就是错的。
// 真正的失败发生在更早的 allocShm那里给出明确的「请用 WSL2」
// 所以这里不再返回任何东西——返回半套名字只会让人以为「只是名字没更新」。
func (h *Host) procEnvForShm() []string { return nil }
// procExtraFilesForShm 在 Windows 返回 nil段不经 fd 传递。
func (h *Host) procExtraFilesForShm() []*os.File { return nil }

View File

@ -438,8 +438,13 @@ func main() {
}
copy(region[ref.Offset:ref.Offset+uint32(len(text))], text)
ref.Length = uint32(len(text))
// 注入标志位随参数一起过 RPC内核据此决定这次注入是否
// 记入记忆、是否据此裁剪上下文、用哪个已注册 cleaner 清洗。
callKernel("io.injectText", map[string]interface{}{
"source": "plugin", "channel": "demo", "text_ref": ref,
"no_memory": true,
"context_policy": "prune",
"cleaner_name": "demo_cleaner",
})
arenaFree(ref)

View File

@ -126,6 +126,23 @@ func (c procCore) InjectTextNoMemory(source, channel, text string) {
c.sdk.InjectTextNoMemory(source, channel, text)
}
// ---- 带注入标志位(记忆/裁剪行为由插件在调用点声明)----
func (c procCore) InjectTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
c.sdk.InjectTextOpts(source, channel, text, opts)
}
func (c procCore) InjectInterruptTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
c.sdk.InjectInterruptTextOpts(source, channel, text, opts)
}
func (c procCore) InjectInputSyncOpts(source, channel, text string, opts pubsdk.InjectOptions) string {
// 这里用公共 SDK 的三参数 + opts 形态(返回回复文本),
// 不用内核内部那个 (eventType, payload) → *OutputEvent 的全量签名:
// 它会把内核 IO 事件结构暴露给外部插件。
return c.sdk.InjectInputSyncOpts(source, channel, text, opts)
}
// InjectInputSync 收窄为公开 SDK 的三参数文本形态。
//
// internal/sdk.PluginSDK 的同名方法是 (source, channel, eventType, payload)
@ -159,6 +176,18 @@ func (c procCore) InjectInterruptMedia(source, channel, text string, blocks []pu
c.sdk.InjectInterruptMedia(source, channel, text, blocks)
}
func (c procCore) InjectInputMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
c.sdk.InjectInputMediaOpts(source, channel, text, blocks, opts)
}
func (c procCore) InjectInputMediaSyncOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) string {
return c.sdk.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
}
func (c procCore) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
c.sdk.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
}
// SetToolBlocks 转调 internal/sdk插件工具注入的媒体块内核在下一轮
// tool message 携带。
func (c procCore) SetToolBlocks(blocks []pubsdk.ContentBlock) {

View File

@ -810,8 +810,11 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
flushTicker := time.NewTicker(200 * time.Millisecond)
defer flushTicker.Stop()
// 立即发送首次"终端已启动"通知,让 agent 感知存在
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
// 立即发送首次"终端已启动"通知,让 agent 感知存在
// 用 NoMemory这是状态提示不是对话内容。不关掉的话每开一个终端都会
// 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id),
sdk.InjectOptions{NoMemory: true})
now := time.Now()
t.mu.Lock()
t.lastNotify = now
@ -828,7 +831,8 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
for {
if t.IsExpired() {
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s]", t.id, t.timeout))
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s]", t.id, t.timeout),
sdk.InjectOptions{NoMemory: true})
p.mu.Lock()
delete(p.sessions, t.id)
p.mu.Unlock()
@ -837,9 +841,11 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
if !terminalRunning(t) {
if t.watch.onExit {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id))
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id),
sdk.InjectOptions{NoMemory: true})
} else {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id),
sdk.InjectOptions{NoMemory: true})
}
p.mu.Lock()
delete(p.sessions, t.id)
@ -857,6 +863,8 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
preview := previewTail(t.buf.String(), 120)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
// 刻意**不**用 NoMemory这条带上终端真实输出preview
// 属于该记的内容。只有纯状态通知才关记忆。
fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview))
continue
}
@ -885,7 +893,8 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
case r := <-readCh:
if r.err != nil {
// 读取错误/EOF → 立即通知(进程可能已结束)
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err))
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err),
sdk.InjectOptions{NoMemory: true})
return
}
if r.n > 0 {
@ -927,6 +936,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
preview := previewTail(t.buf.String(), 200)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
// 同样刻意保留记忆preview 是终端新输出,是真实内容。
fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
} else {
t.mu.Unlock()

View File

@ -440,6 +440,27 @@ func (c *injectCapture) InjectInterruptMedia(source, channel, text string, block
c.mu.Unlock()
}
// ---- 带 InjectOptions 的注入1.2.0):同样只记文本 ----
func (c *injectCapture) InjectTextOpts(source, channel, text string, opts sdkpub.InjectOptions) {
c.InjectText(source, channel, text)
}
func (c *injectCapture) InjectInterruptTextOpts(source, channel, text string, opts sdkpub.InjectOptions) {
c.InjectInterruptText(source, channel, text)
}
func (c *injectCapture) InjectInputSyncOpts(source, channel, text string, opts sdkpub.InjectOptions) string {
return ""
}
func (c *injectCapture) InjectInputMediaOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) {
c.InjectInputMedia(source, channel, text, blocks)
}
func (c *injectCapture) InjectInputMediaSyncOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) string {
return ""
}
func (c *injectCapture) InjectInterruptMediaOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) {
c.InjectInterruptMedia(source, channel, text, blocks)
}
func (c *injectCapture) snapshot() []string {
c.mu.Lock()
defer c.mu.Unlock()

View File

@ -107,7 +107,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
select {
case <-time.After(dur):
log.Printf("[timer] firing: %s (%s later)", message, dur)
s.InjectInterruptText("timer", "timer", fmt.Sprintf("timer: %s", message))
// NoMemory定时提醒是系统通知不是记忆内容。
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
sdk.InjectOptions{NoMemory: true})
case <-p.stopCh:
log.Printf("[timer] cancelled: %s", message)
}

View File

@ -2253,7 +2253,11 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
respCh := make(chan *agentIO.OutputEvent, 1)
go func() {
respCh <- h.sdk.InjectTextSync("http", "http", lastMsg.Content)
// NoMemory本端点OpenAI 兼容 /v1/chat/completions的调用方是
// IDE、工具与脚本送来的是**固定的提示词模板**"请分析这段代码"之类),
// 不是人类在对话。记进记忆会把真实对话挤掉,而且同一模板会反复刷屏。
// 原文仍进上下文,模型照旧看得到;只是不参与向量化/关键词提取/蒸馏。
respCh <- h.sdk.InjectTextSyncNoMemory("http", "http", lastMsg.Content)
}()
var response *agentIO.OutputEvent

View File

@ -53,6 +53,15 @@ type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
type InputChannelRegistrar = pubsdk.InputChannelRegistrar
type ChannelDef = pubsdk.ChannelDef
// InjectOptions / 上下文策略常量:内置插件与外部插件必须用同一套类型与取值,
// 否则内核要认两份,而漏认会静默丢失标志位。
type InjectOptions = pubsdk.InjectOptions
const (
ContextPolicyNone = pubsdk.ContextPolicyNone
ContextPolicyPrune = pubsdk.ContextPolicyPrune
)
type DisabledPluginInfo struct {
Name string `json:"name"`
DisabledAt string `json:"disabled_at"`
@ -163,19 +172,29 @@ func (s *PluginSDK) LLM() LLMAPI { return s.llm }
type ioAdapter struct{ iom *agentIO.IOManager }
func (a ioAdapter) InjectInterruptText(source, channel, text string) {
a.InjectInterruptTextOpts(source, channel, text, pubsdk.InjectOptions{})
}
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本,并声明记忆/裁剪行为。
func (a ioAdapter) InjectInterruptTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
if a.iom != nil {
a.iom.InjectInterrupt(source, channel, map[string]interface{}{"type": "text", "content": text})
a.iom.InjectInterruptTextOpts(source, channel, text, opts)
}
}
// InjectInputSync 同步注入输入并等待回复(阻塞直至 agent 处理完成),返回回复文本。
func (a ioAdapter) InjectInputSync(source, channel, text string) string {
return a.InjectInputSyncOpts(source, channel, text, pubsdk.InjectOptions{})
}
// InjectInputSyncOpts 同步注入输入并声明记忆/裁剪行为。
func (a ioAdapter) InjectInputSyncOpts(source, channel, text string, opts pubsdk.InjectOptions) string {
if a.iom == nil {
return ""
}
out := a.iom.InjectInputSyncTo(source, channel, "text", map[string]interface{}{
out := a.iom.InjectInputSyncToOpts(source, channel, "text", map[string]interface{}{
"content": text,
})
}, opts)
if out == nil {
return ""
}
@ -184,15 +203,18 @@ func (a ioAdapter) InjectInputSync(source, channel, text string) string {
}
func (a ioAdapter) InjectText(source, channel, text string) {
a.InjectTextOpts(source, channel, text, pubsdk.InjectOptions{})
}
// InjectTextOpts 注入排队文本,并声明记忆/裁剪行为。
func (a ioAdapter) InjectTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
if a.iom != nil {
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text})
a.iom.InjectTextOpts(source, channel, text, opts)
}
}
func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
if a.iom != nil {
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text, "no_memory": true})
}
a.InjectTextOpts(source, channel, text, pubsdk.InjectOptions{NoMemory: true})
}
// InjectInputMedia 注入带媒体内容块的输入。
@ -202,23 +224,27 @@ func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
// CAS 落盘与媒体记忆绑定。与 SetToolBlocks 的区别:后者只能在工具
// 调用内部用,且媒体要等到下一条 tool message 才到模型手上。
func (a ioAdapter) InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
a.InjectInputMediaOpts(source, channel, text, blocks, pubsdk.InjectOptions{})
}
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
func (a ioAdapter) InjectInputMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
if a.iom != nil {
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{
"content": text,
"media_blocks": blocks,
})
a.iom.InjectInputMediaOpts(source, channel, text, blocks, opts)
}
}
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待回复。
func (a ioAdapter) InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string {
return a.InjectInputMediaSyncOpts(source, channel, text, blocks, pubsdk.InjectOptions{})
}
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
func (a ioAdapter) InjectInputMediaSyncOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) string {
if a.iom == nil {
return ""
}
out := a.iom.InjectInputSyncTo(source, channel, "text", map[string]interface{}{
"content": text,
"media_blocks": blocks,
})
out := a.iom.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
if out == nil {
return ""
}
@ -228,12 +254,13 @@ func (a ioAdapter) InjectInputMediaSync(source, channel, text string, blocks []p
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
func (a ioAdapter) InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
a.InjectInterruptMediaOpts(source, channel, text, blocks, pubsdk.InjectOptions{})
}
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
func (a ioAdapter) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
if a.iom != nil {
a.iom.InjectInterrupt(source, channel, map[string]interface{}{
"type": "text",
"content": text,
"media_blocks": blocks,
})
a.iom.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
}
}

View File

@ -194,23 +194,23 @@ func (e *Embedder) EmbedImageDense(raw []byte, mime string) ([]float64, error) {
// 帧数为奇数时只用得上前 2×floor(n/2) 帧,多余一帧被丢弃(不补重复帧:
// 那会改变跨帧注意力看到的运动)。
func (e *Embedder) EmbedVideoDense(frames [][]byte, mime string) ([]float64, error) {
if err := checkVideoMime(mime); err != nil {
return nil, err
}
if len(frames) < qwenTemporalPatch {
return nil, fmt.Errorf("qwen: video needs at least %d frames, got %d", qwenTemporalPatch, len(frames))
}
groups := len(frames) / qwenTemporalPatch
// 先确认这一档的视觉图确实已导出,再去做昂贵的预处理:
// 不然一个未导出档位会先白算一遍(每组 2304×1536 浮点)才报错。
if _, err := e.visionFor(groups); err != nil {
return nil, err
}
pixels, groups, err := preprocessVideoFrames(frames)
if err != nil {
return nil, err
}
return e.embedVision(pixels, groups)
// 暂时硬失败,而不是算出一个「看起来正常但语义错」的向量。
//
// 原因:本 provider 的 Go 侧视频提示词模板与 HuggingFace processor 不等价
// ——processor 会按时间组插入字面时间戳文本
//`<0.0 seconds>` / `<1.0 seconds>`),而 Go 侧只拼
// `<|vision_start|>{G×576 pads}<|vision_end|>`。实测同一输入 Python seq=1190
//1152 视觉 + 38 文本、Go 只有 22 个文本 token而这些时间戳文本也会
// 占用 M-RoPE 位置。
//
// 差异的后果是向量整体偏移,**不报错**:检索出来的相似度没有任何意义,
// 而且看不出是谁的错。视觉图本身Vision_g2/g3/g4已逐档对过 PyTorch
//cos≥0.999999),差的只是模板。
//
// 修复后删掉这个硬失败,并让 TestEmbedderVideoMatchesONNXReference 不再跳过。
return nil, fmt.Errorf("%w: Qwen3-VL 视频路径尚未完成Go 模板缺 processor 的分组时间戳文本,"+
"会静默产生语义错的向量);详见 docs/zh/multimodal-space.md 与 providers/qwen3vl/embedder_onnx_test.go",
embedding.ErrUnsupportedModality)
}
// checkImageMime 把「不在本空间覆盖范围内」与「参数用错」分开报。

View File

@ -520,7 +520,8 @@ func (p *Plugin) checkReminders() {
p.mu.Unlock()
for _, msg := range injectMsgs {
p.sdk.InjectInterruptText("calendar", "calendar", msg)
// NoMemory日程到点提醒不是记忆内容。
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
}
}

View File

@ -292,8 +292,9 @@ func (p *Plugin) periodicCheck() {
continue
}
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("注意,你还有%d条待办未完成请检查", n))
// NoMemory这是定时提醒不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("注意,你还有%d条待办未完成请检查", n), sdk.InjectOptions{NoMemory: true})
}
}
}

View File

@ -300,7 +300,10 @@ func (p *Plugin) checkFeed(sub FeedSub) {
lines = append(lines, line)
}
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
// 中断注入是「系统通知」NoMemory 写明意图:这类提醒不参与记忆计算,
// 原文仍进上下文(模型当轮看得到)。
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
sdk.InjectOptions{NoMemory: true})
p.saveData()
}

View File

@ -28,6 +28,16 @@ var (
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**
// 不调就不受影响。想用新字段的插件重编即可。
//
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**
// - InjectOptions{NoMemory, ContextPolicy}
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
// - ChannelDef.ContextPolicy顺带给 ChannelDef 补上 JSON tag
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
// 手写字段白名单,新增字段会被静默丢掉)
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
// 不裁剪),因此存量插件不需要改一行也不需要重编。
// 裁剪ContextPolicy=prune必须显式声明——它会归档丢弃低相关事件。
//
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
// release/vX.Y.x 分支与 tag见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
//
@ -56,6 +66,10 @@ var (
// doc.insertWithMedia / io.injectMedia* 这些 RPC调用会返回 unknown method
// 这里仍写 1.0.0因为它是「SDK 能在其上运行」的下限;
// 媒体接口是可选能力,不用就不受影响。
//
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
CoreVersion = "1.0.0"
)

View File

@ -35,12 +35,63 @@ const (
StageAfterOutput Stage = "after_output"
)
// 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
//
// 默认(空串或 ContextPolicyNone**不裁剪**:裁剪会归档丢弃低相关事件,
// 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
// 插件会在背后把别人的内容挤掉,且看不出是谁干的。
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
func ValidContextPolicy(policy string) bool {
switch policy {
case "", ContextPolicyNone, ContextPolicyPrune:
return true
}
return false
}
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
//
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
// 因此调用方只有在确实需要改变行为时才需要填它。
//
// 为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
// 属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
// 也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
//
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
//
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
//
// CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
//
// 空串 = 按注入的 source 查通道定义(既有行为)。
// 为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
// 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
// 不指定就只能退到「按 source 查不到就不清洗」。
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
}
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none不裁剪
//
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshalfunc 不支持),又会诱使调用方手写字段白名单——
// 那样新增字段会被静默丢掉。
type ChannelDef struct {
NoMemory bool
Cleaner func(string) string
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
}
// StageContext provides context for stage handlers.
@ -100,13 +151,13 @@ type ToolResult struct {
// ToolDef describes a tool that the plugin exposes.
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
ContextPolicy string `json:"context_policy,omitempty"` // 工具上下文策略:"none"(默认) / "prune"
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
@ -125,6 +176,17 @@ type IOInjector interface {
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
// 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
//
// 上面那些不带 opts 的方法等价于传零值 InjectOptions记入记忆 + 不裁剪),
// 保留它们是为了不破坏已有插件;新代码应当用 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)
}
// EventType identifies the kind of system event.
@ -505,54 +567,94 @@ func (s *PluginSDK) injector() IOInjector {
}
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
// 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
if io := s.injector(); io != nil {
io.InjectInterruptText(source, channel, text)
}
s.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
}
// InjectText injects a text message into the agent pipeline.
// 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
func (s *PluginSDK) InjectText(source, channel, text string) {
if io := s.injector(); io != nil {
io.InjectText(source, channel, text)
}
s.InjectTextOpts(source, channel, text, InjectOptions{})
}
// InjectTextNoMemory injects a text message without generating memory.
// 等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
if io := s.injector(); io != nil {
io.InjectTextNoMemory(source, channel, text)
}
s.InjectTextOpts(source, channel, text, InjectOptions{NoMemory: true})
}
// InjectInputSync injects a text message and synchronously waits for the agent reply,
// returning the reply text (empty string if none). Replies must be dispatched back
// to the source channel by the caller.
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
io := s.injector()
if io == nil {
return ""
}
return io.InjectInputSync(source, channel, text)
return s.InjectInputSyncOpts(source, channel, text, InjectOptions{})
}
// InjectInputMedia 注入带媒体内容块image_url/audio_url的输入。
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
// 发给 LLM让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
// 的「下一轮 tool message」语义。
// 等价于 InjectInputMediaOpts(..., InjectOptions{})。
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
if io := s.injector(); io != nil {
io.InjectInputMedia(source, channel, text, blocks)
}
s.InjectInputMediaOpts(source, channel, text, blocks, InjectOptions{})
}
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
// 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
return s.InjectInputMediaSyncOpts(source, channel, text, blocks, InjectOptions{})
}
// ---- 带 InjectOptions 的注入(声明记忆/裁剪行为)----
// InjectTextOpts 注入文本到 agent并在这一次注入上声明记忆与裁剪行为。
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectTextOpts(source, channel, text, opts)
}
}
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
//
// 中断也允许声明 ContextPolicyPrune中断同样携带内容进入上下文
// 是否需要据此裁剪由调用方决定(默认不裁剪)。
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectInterruptTextOpts(source, channel, text, opts)
}
}
// InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
io := s.injector()
if io == nil {
return ""
}
return io.InjectInputMediaSync(source, channel, text, blocks)
return io.InjectInputSyncOpts(source, channel, text, opts)
}
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectInputMediaOpts(source, channel, text, blocks, opts)
}
}
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
io := s.injector()
if io == nil {
return ""
}
return io.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
}
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
}
}
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。

View File

@ -43,6 +43,7 @@ type injectCall struct {
channel string
text string
blocks []ContentBlock
opts InjectOptions // 调用点声明的记忆/裁剪行为
}
func (r *recordingInjector) record(c injectCall) {
@ -93,6 +94,41 @@ func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBloc
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
}
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
r.nText.Add(1)
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
r.nSync.Add(1)
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) snapshot() []injectCall {
r.mu.Lock()
defer r.mu.Unlock()
@ -463,11 +499,10 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
data[i] = byte(i * 7 % 256)
}
att := MediaAttachment{
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
Description: "一张紫蓝红三色带图,含 emoji 🎨 与换行\n第二行",
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
}
b, err := json.Marshal(att)
if err != nil {
@ -485,7 +520,7 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
}
}
if back.Name != att.Name || back.Description != att.Description || back.MIME != att.MIME || back.Digest != att.Digest {
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
}
}