From 8040586daaba9e2d136d7169b02aa3ea3f0ab5c8 Mon Sep 17 00:00:00 2001 From: xiao-zhai Date: Thu, 22 Oct 2026 07:25:47 +0800 Subject: [PATCH 01/22] =?UTF-8?q?docs(rename-batch7):=20SDK=20=E4=BB=93?= =?UTF-8?q?=E5=90=8D=E5=BC=95=E7=94=A8=E7=BB=9F=E4=B8=80=E4=B8=BA=20hologr?= =?UTF-8?q?amagentsdk=EF=BC=8Cmodule=20=E8=B7=AF=E5=BE=84=E6=8C=89?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20tag=20=E4=BF=9D=E6=8C=81=E6=97=A7=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK 仓已在 GitHub 改名为 JianFeeeee/hologramagentsdk(旧名 homeagentsdk 301 跳转,仓 id 与提交未变)。本批把仓内**指向该仓的引用**跟上: - 仓库链接 / 克隆地址 / 示例目录链接:homeagentsdk → hologramagentsdk (README zh+en、PLUGIN_DEV zh+en、OVERVIEW zh+en、site/index.html) - package-linux.sh 的 fallback clone URL 同步新名(仅 URL 与注释) - ci-cd-runbook:仓库表与 secret 配置命令用当前名,并注明历史改名点 **刻意保留旧名的三处**,都是事实而非遗漏: 1. Go import / go.mod replace 与文档里的 import 示例——已发布 tag (v1.3.0 及更早)的 go.mod 仍是 module github.com/JianFeeeee/homeagentsdk, 改了直接编译不过。PLUGIN_DEV 里加了显式注记说明这条边界。 2. gitcode 的 homeagentsdk → 404 实测行(那是实测事实表) 3. runbook 里「配置时还叫 homeagentsdk」的历史说明 实测(2026-10-22):github.com/JianFeeeee/hologramagentsdk 的 /tree/v1.1.0、 /tree/v1.3.0、/blob/main/LICENSE 均 200;example/* 为 301→tree 200 (GitHub 对目录自动从 blob 跳 tree,非死链);ls-remote 取到 refs/tags/v1.1.0 = 49f9957。 零代码改动:.go/.lua/go.mod/go.sum/third_party 变更数 0。 --- README.md | 8 ++-- README_EN.md | 8 ++-- assets/docs/en/OVERVIEW.md | 2 +- assets/docs/en/PLUGIN_DEV.md | 61 +++++++++++++++++-------------- assets/docs/zh/OVERVIEW.md | 2 +- assets/docs/zh/PLUGIN_DEV.md | 60 ++++++++++++++++-------------- deploy/packaging/package-linux.sh | 7 +++- docs/git-branching.md | 2 +- docs/zh/ci-cd-runbook.md | 10 +++-- site/index.html | 46 +++++++++++------------ 10 files changed, 112 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index f929346b..e1acf0ee 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ internal/ ├── config/ SQLite 配置中心 ├── events/ 事件总线 └── internal/lua/adapters/ 10 个 LLM 协议适配器脚本 -外部插件开发见 [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例 +外部插件开发见 [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例 ``` ## 项目状态 @@ -318,7 +318,7 @@ internal/ > inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。 > 保留原文不修改,以免伪造历史。 -**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://github.com/JianFeeeee/homeagentsdk/tree/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。 +**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://github.com/JianFeeeee/hologramagentsdk/tree/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。 **v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。 @@ -326,7 +326,7 @@ internal/ **v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。** -**v0.8.0** — 核心可用,插件系统增强。内置 20+ 插件,外部插件开发见 [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) 仓库。新增输入通道 `NoMemory`/`Cleaner`、`ChannelDef`、插件禁用/启用系统(CLI + WebUI),`plugindev` 工具链完成 C ABI `ChannelDef` 传递。 +**v0.8.0** — 核心可用,插件系统增强。内置 20+ 插件,外部插件开发见 [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) 仓库。新增输入通道 `NoMemory`/`Cleaner`、`ChannelDef`、插件禁用/启用系统(CLI + WebUI),`plugindev` 工具链完成 C ABI `ChannelDef` 传递。 ## 文档 @@ -385,7 +385,7 @@ make install # 安装到系统 **通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。 即:任何人把改过的 HologramAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。 -插件与本项目通过公开 [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) 静态链接 +插件与本项目通过公开 [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) 静态链接 (SDK 源码会进入插件二进制),但那个仓**以 MIT 发布**——MIT 是宽松许可,拿到授权的代码不继承 本项目的 AGPL。因此**外部插件不是本项目的衍生作品**,作者可自行选择许可(含闭源、商业、私有), 既不必同许可、也不受 §13 网络条款约束。第三方插件生态的安全与活跃正建立在这条之上。 diff --git a/README_EN.md b/README_EN.md index 223d2a68..b0086e2b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -221,7 +221,7 @@ internal/ ├── config/ SQLite config center ├── events/ Event bus └── internal/lua/adapters/ 10 LLM protocol adapter scripts -External plugin development: see [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/` +External plugin development: see [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/` ``` ## Project Status @@ -323,7 +323,7 @@ External plugin development: see [homeagentsdk](https://github.com/JianFeeeee/ho **v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary multimedia nodes, but that path was open only to the kernel itself; this release opens it to plugins and the model. The public SDK gains media fields and three media injection methods -(paired with [SDK v1.1.0](https://github.com/JianFeeeee/homeagentsdk/tree/v1.1.0), +(paired with [SDK v1.1.0](https://github.com/JianFeeeee/hologramagentsdk/tree/v1.1.0), shared by the whole 1.1.x line), and the kernel implements the four matching RPCs. The bridge layer had been **silently dropping fields**: `Confidence`/types/`SentenceText` handed in by a plugin were discarded, `Doc` kept only three fields, and `Remove` never released references @@ -346,7 +346,7 @@ semantic memory; the blob is only a cache that capacity GC may evict. **v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.** -**v0.8.0** — Core is functional, plugin system enhanced. 20+ built-in plugins. External plugin development via [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) repo. Added input channel `NoMemory`/`Cleaner`, `ChannelDef`, plugin disable/enable system (CLI + WebUI), `plugindev` toolchain C ABI `ChannelDef` support. +**v0.8.0** — Core is functional, plugin system enhanced. 20+ built-in plugins. External plugin development via [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) repo. Added input channel `NoMemory`/`Cleaner`, `ChannelDef`, plugin disable/enable system (CLI + WebUI), `plugindev` toolchain C ABI `ChannelDef` support. ## Documentation @@ -408,7 +408,7 @@ with it over a network** (§13, Remote Network Interaction). Anyone running a mo as a network service therefore has to make the modified source available to that service's users. Plugins are **statically linked** against this project through the public -[homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) (the SDK source ends up inside the +[hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) (the SDK source ends up inside the plugin binary), but that repository is released under **MIT** — a permissive license, so code received under it does **not** inherit this project's AGPL. External plugins are therefore **not derivative works of this project**: authors pick their own license (closed-source, commercial or diff --git a/assets/docs/en/OVERVIEW.md b/assets/docs/en/OVERVIEW.md index b05375a0..1124e5ba 100644 --- a/assets/docs/en/OVERVIEW.md +++ b/assets/docs/en/OVERVIEW.md @@ -111,5 +111,5 @@ Core functionality is operational. Plugin system and SDK are ready for independe - Built-in plugins (13): webui / cli / agentcli / cfgmgr / healthcheck / kbtree / multimodal / pluginmgr / clawhubadapter / remotedevice / skillmgr / seemedia / seq - Criterion: **a plugin stays built-in only if it requires internal-SDK-only capabilities**; everything else ships as an .hmap external plugin (see ARCHITECTURE) -- External plugin examples ([homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) repo `example/`, both Go and Lua types): qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo +- External plugin examples ([hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) repo `example/`, both Go and Lua types): qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo - Distribution: `.hmap` plugin package format, installable via WebUI diff --git a/assets/docs/en/PLUGIN_DEV.md b/assets/docs/en/PLUGIN_DEV.md index 6b7973a8..43e910d1 100644 --- a/assets/docs/en/PLUGIN_DEV.md +++ b/assets/docs/en/PLUGIN_DEV.md @@ -8,13 +8,20 @@ All external interaction capabilities of HologramAgent comes from plugins. Plugins interact with the kernel through `PluginSDK` (Go API). -**SDK Repository**: Plugin development tools, template code, and example plugins are hosted in the [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) repository. +**SDK Repository**: Plugin development tools, template code, and example plugins are hosted in the [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) repository. ```bash -git clone https://github.com/JianFeeeee/homeagentsdk.git -cd homeagentsdk +git clone https://github.com/JianFeeeee/hologramagentsdk.git +cd hologramagentsdk ``` +> **The repo name and the module path are two different things.** The repository +> was renamed to `hologramagentsdk` in Oct 2026, but the `go.mod` of every +> **published tag** (v1.3.0 and earlier) still declares +> `module github.com/JianFeeeee/homeagentsdk`, so `import` / `go.mod` keep using +> the old module path (as in the examples below). Once the SDK ships a tag with +> the new module name, the core repo and new plugins can migrate together. + Each plugin implements a three-method interface: ```go @@ -48,7 +55,7 @@ plugin types, and producing `.hmap` plugin bundles (the tool is named after that ### Installation ```bash -cd homeagentsdk/tools/hmapdev +cd hologramagentsdk/tools/hmapdev go build -o hmapdev # Add hmapdev to PATH or use directly # Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...) @@ -844,31 +851,31 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl ## 7. Example Plugin Reference -### SDK Repository Examples (`homeagentsdk/example/`) +### SDK Repository Examples (`hologramagentsdk/example/`) | Example | Type | Features | |---------|------|----------| -| [weather](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory | -| [luademo](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface | -| [qq](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/qq) | Go | NapCat OneBot integration, 24 tools, full input/output channel wiring | -| [memo](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder | -| [files](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation | -| [browser](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) | -| [bili](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/bili) | Go | Bilibili video download (yt-dlp) | -| [editdoc](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/editdoc) | Go | Office document editing and format conversion | -| [a2a](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/a2a) | Go | Agent-to-Agent protocol | -| [ocr](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/ocr) | Go | Offline text recognition (Tesseract) | -| [sanitizer](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/sanitizer) | Go | Output sanitizer filter | -| [calendar](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/calendar) | Go | Calendar management | -| [rss](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/rss) | Go | RSS subscriptions | -| [ai_image](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/ai_image) | Go | AI image generation | -| [music](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/music) | Go | Music playback | -| [vikunja](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) | -| [vanblog](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/vanblog) | Go | VanBlog publishing and management | -| [deepsearch](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) | -| [acp](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) | -| [recoverydiag](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode | -| [plugindev](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself | +| [weather](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory | +| [luademo](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface | +| [qq](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/qq) | Go | NapCat OneBot integration, 24 tools, full input/output channel wiring | +| [memo](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder | +| [files](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation | +| [browser](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) | +| [bili](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/bili) | Go | Bilibili video download (yt-dlp) | +| [editdoc](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/editdoc) | Go | Office document editing and format conversion | +| [a2a](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/a2a) | Go | Agent-to-Agent protocol | +| [ocr](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/ocr) | Go | Offline text recognition (Tesseract) | +| [sanitizer](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/sanitizer) | Go | Output sanitizer filter | +| [calendar](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/calendar) | Go | Calendar management | +| [rss](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/rss) | Go | RSS subscriptions | +| [ai_image](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/ai_image) | Go | AI image generation | +| [music](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/music) | Go | Music playback | +| [vikunja](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) | +| [vanblog](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/vanblog) | Go | VanBlog publishing and management | +| [deepsearch](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) | +| [acp](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) | +| [recoverydiag](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode | +| [plugindev](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself | ### Built-in Plugins @@ -903,4 +910,4 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl *Want to understand the project goals? See [OVERVIEW.md](OVERVIEW.md).* *Want to understand the architecture? See [ARCHITECTURE.md](ARCHITECTURE.md).* -*SDK repository and development tools? See [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk).* +*SDK repository and development tools? See [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk).* diff --git a/assets/docs/zh/OVERVIEW.md b/assets/docs/zh/OVERVIEW.md index 2c0a767a..2117a6c2 100644 --- a/assets/docs/zh/OVERVIEW.md +++ b/assets/docs/zh/OVERVIEW.md @@ -101,5 +101,5 @@ CLI 需要终端面板时,**订阅事件总线**(`EventTerminalOutput` + `Ev - 内置插件(13 个):webui / cli / agentcli / cfgmgr / healthcheck / kbtree / multimodal / pluginmgr / clawhubadapter / remotedevice / skillmgr / seemedia / seq - 判定标准:**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件才内置**,其余一律外置为 .hmap 插件(详见 ARCHITECTURE「内置插件的判定标准」) -- 外部插件示例([homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) 仓库 `example/`,含 Go 和 Lua 两种类型):qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo +- 外部插件示例([hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) 仓库 `example/`,含 Go 和 Lua 两种类型):qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo - 打包分发:`.hmap` 插件包格式,通过 WebUI 安装 diff --git a/assets/docs/zh/PLUGIN_DEV.md b/assets/docs/zh/PLUGIN_DEV.md index 1a3f1579..40061853 100644 --- a/assets/docs/zh/PLUGIN_DEV.md +++ b/assets/docs/zh/PLUGIN_DEV.md @@ -9,13 +9,19 @@ HologramAgent 的所有外部交互能力都来自插件。插件通过 `PluginSDK`(Go API)与内核交互。 **SDK 仓库**:插件开发工具、模板代码和示例插件统一托管在 -[homeagentsdk](https://github.com/JianFeeeee/homeagentsdk) 仓库。 +[hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk) 仓库。 ```bash -git clone https://github.com/JianFeeeee/homeagentsdk.git -cd homeagentsdk +git clone https://github.com/JianFeeeee/hologramagentsdk.git +cd hologramagentsdk ``` +> **仓名与 module 路径不是一回事**:仓 2026-10 已改名为 `hologramagentsdk`, +> 但**已发布 tag(v1.3.0 及更早)的 go.mod 里 module 仍是** +> `github.com/JianFeeeee/homeagentsdk`,所以 `import` 与 `go.mod` 里继续写旧 +> module 路径(下面示例即如此)。等 SDK 发新 tag 把 module 改成新名后, +> 主仓与新插件再一起改 import。 + 每个插件实现一个三方法接口: ```go @@ -49,7 +55,7 @@ type Plugin interface { ### 安装 ```bash -cd homeagentsdk/tools/hmapdev +cd hologramagentsdk/tools/hmapdev go build -o hmapdev # 将 hmapdev 加入 PATH 或直接使用 # 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等) @@ -830,31 +836,31 @@ pmgr.ReloadPlugins() // 重载所有插件 ## 七、示例插件参考 -### SDK 仓库示例(`homeagentsdk/example/`) +### SDK 仓库示例(`hologramagentsdk/example/`) | 示例 | 类型 | 特点 | |------|------|------| -| [weather](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 | -| [luademo](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 | -| [qq](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/qq) | Go | NapCat OneBot 对接,24 个工具,输入/输出通道完整对接 | -| [memo](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 | -| [files](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 | -| [browser](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) | -| [bili](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/bili) | Go | B 站视频下载(yt-dlp) | -| [editdoc](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/editdoc) | Go | Office 文档编辑与格式转换 | -| [a2a](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/a2a) | Go | Agent-to-Agent 协议 | -| [ocr](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/ocr) | Go | 离线文字识别(Tesseract) | -| [sanitizer](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/sanitizer) | Go | 输出清洗过滤器 | -| [calendar](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/calendar) | Go | 日历管理 | -| [rss](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/rss) | Go | RSS 订阅 | -| [ai_image](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/ai_image) | Go | AI 图片生成 | -| [music](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/music) | Go | 音乐播放 | -| [vikunja](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD) | -| [vanblog](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 | -| [deepsearch](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) | -| [acp](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent) | -| [recoverydiag](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序),failback 模式的核心插件 | -| [plugindev](https://github.com/JianFeeeee/homeagentsdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 | +| [weather](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 | +| [luademo](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 | +| [qq](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/qq) | Go | NapCat OneBot 对接,24 个工具,输入/输出通道完整对接 | +| [memo](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 | +| [files](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 | +| [browser](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) | +| [bili](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/bili) | Go | B 站视频下载(yt-dlp) | +| [editdoc](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/editdoc) | Go | Office 文档编辑与格式转换 | +| [a2a](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/a2a) | Go | Agent-to-Agent 协议 | +| [ocr](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/ocr) | Go | 离线文字识别(Tesseract) | +| [sanitizer](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/sanitizer) | Go | 输出清洗过滤器 | +| [calendar](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/calendar) | Go | 日历管理 | +| [rss](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/rss) | Go | RSS 订阅 | +| [ai_image](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/ai_image) | Go | AI 图片生成 | +| [music](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/music) | Go | 音乐播放 | +| [vikunja](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD) | +| [vanblog](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 | +| [deepsearch](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) | +| [acp](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent) | +| [recoverydiag](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序),failback 模式的核心插件 | +| [plugindev](https://github.com/JianFeeeee/hologramagentsdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 | ### 内置插件 @@ -884,4 +890,4 @@ pmgr.ReloadPlugins() // 重载所有插件 *了解项目整体目标?查看 [OVERVIEW.md](OVERVIEW.md)。* *了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。* -*SDK 仓库与开发工具?查看 [homeagentsdk](https://github.com/JianFeeeee/homeagentsdk)。* +*SDK 仓库与开发工具?查看 [hologramagentsdk](https://github.com/JianFeeeee/hologramagentsdk)。* diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh index c4f205cd..124042e9 100644 --- a/deploy/packaging/package-linux.sh +++ b/deploy/packaging/package-linux.sh @@ -108,8 +108,11 @@ prepare_gomod() { patched=1 elif [ ! -d "$sdk_clone" ]; then echo ">>> Cloning SDK to $sdk_clone (in-repo SDK missing)..." - git clone git@github.com:JianFeeeee/homeagentsdk.git "$sdk_clone" 2>/dev/null || \ - git clone https://github.com/JianFeeeee/homeagentsdk.git "$sdk_clone" 2>/dev/null || true + # 仓名用当前名 hologramagentsdk(旧名 homeagentsdk 仍 301 跳转,但别再用)。 + # 注意:下面几处 replace 的 module 路径**仍是** homeagentsdk —— 已发布 + # tag 的 go.mod 里 module 就是这个,改名后没变。 + git clone git@github.com:JianFeeeee/hologramagentsdk.git "$sdk_clone" 2>/dev/null || \ + git clone https://github.com/JianFeeeee/hologramagentsdk.git "$sdk_clone" 2>/dev/null || true if [ -d "$sdk_clone" ]; then sed -i.bak "s|^replace github.com/JianFeeeee/homeagentsdk => .*|replace github.com/JianFeeeee/homeagentsdk => ${sdk_clone}|" "$gomod" patched=1 diff --git a/docs/git-branching.md b/docs/git-branching.md index dfb4e4c7..5c889995 100644 --- a/docs/git-branching.md +++ b/docs/git-branching.md @@ -195,7 +195,7 @@ git switch main && git cherry-pick # 遵守 §三:只 pick,不 merge > - `feature/*` 分支(`input-semantics`、`multimodal-embedding`、`memory-media`、`plugin-proc-migration`) > 均已合入并删除。 -### SDK 仓(homeagentsdk) +### SDK 仓(hologramagentsdk) | 分支 | 状态 | 处理 | |---|---|---| diff --git a/docs/zh/ci-cd-runbook.md b/docs/zh/ci-cd-runbook.md index 11fe0c69..e1df0baf 100644 --- a/docs/zh/ci-cd-runbook.md +++ b/docs/zh/ci-cd-runbook.md @@ -9,7 +9,7 @@ | 仓 | 位置 | 流水线 | 触发 | | --- | --- | --- | --- | | 主仓 HologramAgent | `github.com/JianFeeeee/HologramAgent`(公开) | `ci.yml` + `release.yml` | push / PR、`release/**` push | -| SDK 仓 homeagentsdk | `github.com/JianFeeeee/homeagentsdk`(公开) | `release.yml` | `release/**` push | +| SDK 仓 hologramagentsdk | `github.com/JianFeeeee/hologramagentsdk`(公开) | `release.yml` | `release/**` push | | gitcode 镜像 | `gitcode.com/JianFeeeee/HologramAgent`(与主仓同名) | 无(由主仓 Release 的 sync job 同步) | — | **分支保护**(main,公开仓免费):10 项必须检查(Go build/vet/test、Race、 @@ -207,13 +207,15 @@ tar 写 stdout 收到 EPIPE ⇒ pipefail 判失败。**包越大越易触发** CI 的 sync job 需要仓库 secret `GITCODE_TOKEN`;**未配置时该 job 显式跳过** (不阻断 GitHub 侧发布)。 -**2026-09-29 已配置**:两仓(`JianFeeeee/HologramAgent`、`JianFeeeee/homeagentsdk`) -均已设同名 secret,取值自 `~/.git-credentials` 里那条 `https://JianFeeeee:@gitcode.com`。 +**2026-09-29 已配置**:两仓均已设同名 secret,取值自 `~/.git-credentials` +里那条 `https://JianFeeeee:@gitcode.com`。配置时 SDK 仓还叫 +`homeagentsdk`,2026-10-21 改名 `hologramagentsdk` 后 secret 随仓迁移, +无需重设;下面命令写的是**当前**仓名。 配置方式(经 stdin 传入,避免 token 出现在进程列表): ```bash printf '%s' "$TOKEN" | gh secret set GITCODE_TOKEN --repo JianFeeeee/HologramAgent -printf '%s' "$TOKEN" | gh secret set GITCODE_TOKEN --repo JianFeeeee/homeagentsdk +printf '%s' "$TOKEN" | gh secret set GITCODE_TOKEN --repo JianFeeeee/hologramagentsdk ``` 验收:sync job 只在**新版本**发版时运行(`prepare.outputs.exists == 'false'`), diff --git a/site/index.html b/site/index.html index 0130a5c7..545c83c6 100644 --- a/site/index.html +++ b/site/index.html @@ -2823,37 +2823,37 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter
a2av1.3.1

Agent 间通信。对外起 HTTP 服务暴露 /agent-card 与 /a2a(JSON-RPC)供他人发现与调用;对内提供工具去问别的 Agent。把监听地址设为空即只出站、不开端口。

- SDKexample/a2a↗ + SDKexample/a2a↗
acpv1.2.1

Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复

- SDKexample/acp↗ + SDKexample/acp↗
biliv1.2.0

B站视频下载。需系统装 yt-dlp。不指定格式时先返回可用清晰度列表,选定后再下载;输出目录有路径校验,拒绝写成系统目录。

- SDKexample/bili↗ + SDKexample/bili↗
browserv2.4.1

统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)

- SDKexample/browser↗ + SDKexample/browser↗
calendarv1.1.0

日历事件管理,支持提醒和重复事件

- SDKexample/calendar↗ + SDKexample/calendar↗
deepsearchv1.1.2

为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索

- SDKexample/deepsearch↗ + SDKexample/deepsearch↗
editdocv2.0.0

全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)

- SDKexample/editdoc↗ + SDKexample/editdoc↗

公开仓中的 example 仍是 v1.0.0;v2.0.0 全能版源码尚未公开。

@@ -2869,72 +2869,72 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter
memov1.1.0

待办与备忘录刻意分两类:待办每 5 分钟检查未完成数、有则注入提醒;备忘录纯记事不提醒。混成一类要么天天弹、要么被忘掉。

- SDKexample/memo↗ + SDKexample/memo↗
musicv0.1.0

搜歌与查歌词(网易云公开接口)。只读:不下载音频、不落文件。两段式用法 —— 先搜到歌曲 ID,再按 ID 取词。

- SDKexample/music↗ + SDKexample/music↗
ocrv1.0.0

图片文字识别。需系统装 tesseract(中文另需 chi_sim 语言包)。支持 URL 或 base64 输入,临时文件识别后自动清理。

- SDKexample/ocr↗ + SDKexample/ocr↗
plugindevv1.0.0

把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。

- SDKexample/plugindev↗ + SDKexample/plugindev↗
qqv1.4.1

经 NapCat 桥接 QQ:20 个工具覆盖消息、群/好友、文件传输。带权限模型 —— 身份绑在「帧」上而非插件全局(中断抢占恢复后不会丢身份),多来源合并取权限交集,敏感工具按前缀一律拒绝非所有者。

- SDKexample/qq↗ + SDKexample/qq↗
recoverydiagv0.2.0

快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。

- SDKexample/recoverydiag↗ + SDKexample/recoverydiag↗
rssv1.1.0

RSS/Atom 订阅监控。按间隔轮询,发现新条目即以中断注入告知 agent;订阅时记下历史条目,所以订一个源不会把旧文章全推一遍。

- SDKexample/rss↗ + SDKexample/rss↗
sanitizerv0.1.0

在三个阶段清洗文本:坏 UTF-8 / U+FFFD / ANSI 转义(会被模型复读)与 LLM 输出里的工具调用残留。不注册工具,只挂钩子。

- SDKexample/sanitizer↗ + SDKexample/sanitizer↗
vanblogv1.0.0

管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等

- SDKexample/vanblog↗ + SDKexample/vanblog↗
vikunjav1.0.1

Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底

- SDKexample/vikunja↗ + SDKexample/vikunja↗
weatherv1.0.0

查实时天气与预报(wttr.in,无需 API Key)。可设默认城市;结果标记为不进记忆计算 —— 天气是易变数据,反复写进记忆只会挤占预算。

- SDKexample/weather↗ + SDKexample/weather↗
ai_imagev1.3.0

文生图:按文字提示生成图片,下载到本地并返回文件路径(可直接被多模态链路读取)。

- SDKexample/ai_image↗ + SDKexample/ai_image↗
filesv1.0.0

沙箱文件操作:读写与编辑文件,全部操作限制在沙箱目录内(读取/写入/编辑/列目录)。

- SDKexample/files↗ + SDKexample/files↗
luademov0.1.0

Lua 插件全功能示例:工具(no_memory / cleaner)、阶段钩子、通道、数据类 API —— 想用 Lua 写插件,从这个例子抄起。

- SDKexample/luademo↗ + SDKexample/luademo↗
@@ -3029,7 +3029,7 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter
@@ -3038,7 +3038,7 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter
  • 最新发布:v1.3.x 线
  • main 在研:1.4.0
  • 内核许可:AGPL-3.0-only
  • -
  • 插件 SDK:MIT
  • +
  • 插件 SDK:MIT
  • From 47c95efb800bc5b7a78a0d650e9baf76157d92cf Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 11:08:22 +0800 Subject: [PATCH 02/22] =?UTF-8?q?feat(memory):=20L2=20=E4=B8=8E=20L1=20?= =?UTF-8?q?=E5=90=8C=E6=9E=84=20=E2=80=94=E2=80=94=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=89=BF=E8=BD=BD=E5=8E=9F=E6=96=87=EF=BC=8C?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=86=85=E8=81=94=E5=9C=A8=E5=9D=97=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改, 导致这条链出现结构性失效。本次按设计原文修复。 ## 断点(生产实测) **① L2 只存渲染视图,事件本体丢失** ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。 生产库 1000 篇归档文档实测: - response 段恰好 103 字符(=100+"..."):311/2094 = 15.1% - 工具输出段恰好 203 字符:7593/13261 - ToolsUsed 完全不存 ⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。 **② L2 里没有文本块,与 L1 不同构** L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。 **③ 召回造合成事件,时间戳用归档时刻** doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、 且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at 差近两小时,却没人用。 **④ 块在 Consume 时两层同时消失** Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。 生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。 ## 修法(按设计,不是按我的推断) 设计前提(写进 ARCHITECTURE 与计划文档): · 记忆块是唯一物理载体,层是持有关系 · L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」 · 正文(含多媒体正文)内联在块里;事件只是块的容器 · 任何一层都不压缩(视图不是记忆) · 内核不得硬编码任何插件行为 1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体 2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文) 3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回 4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体 5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图 6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码 插件名,改由插件经 NoMemory/Cleaner 声明 ## 顺带修掉的真实缺陷 simpleHash 是 O(n²) 且哈希串长度正比于内容: for _, c := range s { h += fmt.Sprintf("%x", c) } 压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash 是 content 的 2.2 倍(52072 字 → 114491 字)。 换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。 ## 存量 按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回 (整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档 全部正常加载。 ## 判据(16 条,全部有变异自证) document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、 视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力 core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、 回填不截断、存量降级、批量压力 变异全部被抓: · 落库 truncate(100) → ArchivePreservesFullText(实测 len=303/原 10215) · Input/Response 合成一块 → InputAndResponseAreSeparateTextBlocks(实际 1) · 召回用 d.CreatedAt → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3) · 召回不回填 Blocks → MigratesBlocksBackToContext · 召回再截 2000 字 → TextIsNotTruncatedOnTheWayBack(2003/原 18020) · 恢复 O(n²) hash → HashIsLinearAndFixedLength(超时 >300s) ## 验证 - go build ./... 通过 - go test ./... -count=1:58 包 0 失败 - go test -race ./internal/memory/document/ ./internal/agent/core/:通过 - 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出 - 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确 ## 不改 - 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价) - 存量文档(等自然冲刷) - 渲染层截断常数(独立议题,避免与本次修复互相掩盖) --- assets/docs/zh/ARCHITECTURE.md | 89 +++- assets/docs/zh/OVERVIEW.md | 18 +- docs/zh/memory-blockflow-repair-plan.md | 444 +++++++++++++++++ internal/agent/core/distill.go | 42 +- internal/agent/core/mediaref_test.go | 31 +- internal/agent/core/recalldoc_test.go | 286 +++++++++++ internal/agent/core/toolcall.go | 92 +++- internal/memory/document/blockflow_test.go | 523 +++++++++++++++++++++ internal/memory/document/document.go | 328 +++++++++++-- 9 files changed, 1761 insertions(+), 92 deletions(-) create mode 100644 docs/zh/memory-blockflow-repair-plan.md create mode 100644 internal/agent/core/recalldoc_test.go create mode 100644 internal/memory/document/blockflow_test.go diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 550845ed..72319421 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -10,7 +10,17 @@ HologramAgent 的认知架构由三个核心子系统构成:事件循环(eve **阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。 -**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数,如 QQ 外置插件剥离工具调用模板),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。 +**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件通过 `docStore.ContextToDoc` **迁移**至 Document 层:事件连同它持有的块一起下沉,保留原始时间戳与完整正文。持久化采用 5 秒防抖写入磁盘 JSON 文件。 + +> ★ **哪些事件不该进 L2,由插件自己声明,内核不认插件名**。 +> +> 旧版这里写的是「过滤掉 `agentcli` 和 `terminal` 来源后归档」——那是在内核里 +> 硬编码插件行为,已废除。正确姿势是插件在注册时就声明: +> - `ToolDef.NoMemory` / `ToolDef.Cleaner`:工具输出不进计算层 +> - `ChannelDef.NoMemory` / `ChannelDef.Cleaner`:该通道的输入不进计算层 +> - `InjectOptions.NoMemory`:本次注入不参与记忆 +> +> 内核只消费这些**声明**(已由 `textForVector` 实现),不认识任何具体插件。 **工具定义聚合自五个来源**:IOManager 注册的插件工具;StageHost 注册的 SDK 插件工具;Indexer 提供的记忆索引工具;内置条件工具(依据 memory / knowledge / docStore / social / pluginReg / providerManager 等模块的非空状态选择性添加,包括记忆操作、知识检索、文档查询、社交网络、插件重载、子代理生成、输出通道工具、LLM 源切换等);以及按 `pendingMedia` 状态添加的媒体处理工具。`buildToolDefs()` 在每次 process 周期中重新聚合所有这些来源。 @@ -83,35 +93,64 @@ eventLoop() → processTextInput() ## 三层记忆 +### 记忆块是唯一载体,层是持有关系 + +三层不是三种数据结构,而是**同一批记忆块的三层持有关系**: + +``` +记忆块 MemoryBlock(union) + ├─ 文本块:负载在 Text + └─ 媒体块:负载在 Data(配 MIME/宽高/尺寸) + ↑ 同一个块 ID 在 context → doc → graph 全程不变 + ↑ 块自带 vector + fingerprint:向量只算一次,跨层复用 +``` + +| 层 | 承载单元 | 与模型的关系 | 容量 | 流动性 | +|---|---|---|---|---| +| **L1 Context** | 事件(内联正文 + 块) | **直接投喂**(进 system prompt 时序) | 最近 n 条 / 上限 m 条 | 超限时按相似度迁出 | +| **L2 Document** | 事件(内联正文 + 块) | **不直接投喂**(需召回才进 L1) | 无上限 | 冷到阈值后蒸馏进 L3 | +| **L3 Graph** | 三元组(节点 + 关系边),节点即记忆块 | 不投喂(经召回进入 L1) | 持久 | 不流出 | + +★ **L1 与 L2 结构完全同构**,唯一区别是「是否被直接投喂给模型」。 +L2 不是 L1 的摘要层,也不是另一种数据形态——它就是同样的事件 + 同样的块,只是不直接进模型视野。 + +★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 就是正文,媒体块的 `Data` 就是字节。 +不存在「块 + 外部正文」的分离形态,也不存在靠渲染字符串承载原文的路径。 +事件是块的容器,不是块的替代品。 + +★ **任何一层都不压缩**。视图(检索摘要、给模型看的渲染)可以是压缩的,但视图不是记忆。 + ### 记忆流转 ``` -① Context (工作窗口) +① Context (工作窗口 — 直接投喂) RelevanceContext — 内存 events[] + JSON持久化 + 每条事件:Timestamp / Source / Input / Response(完整) / ToolsUsed / ToolResults / Blocks Append: 每次输入, CleanText → 三分支向量(textForVector) agent事件→Response, 用户事件→Input, cold_storage→Input+Response 向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退) Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条 ├── 保留 → timeline → 按时间排序 → system prompt - └── 低分 → Document 层归档 (原始时间戳) + └── 低分 → Document 层归档(迁移:事件连同块一起下沉,时间戳与正文不变) Save: 5s debounce 写盘 - ↓ Prune 归档 ↑ LLM 主动召回 + ↓ Prune 归档(迁移) ↑ 召回(迁回) -② Document (文件记忆) - DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex) +② Document (文件记忆 — 不直接投喂,结构与 L1 同构) + DocStore — 每篇文档 = 检索元数据 + 事件列表(内联正文 + 块) 写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs) 读取: - ├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读) - └── LLM主动: doc_query → Consume(读取并删除) - → 逐条 context.Append{Timestamp: d.CreatedAt, Source: "cold_storage"} - → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 + ├── 自动预填充: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读,不动层) + └── LLM主动: doc_query → Consume(取走) + → 逐条按各自原始时间戳把事件插回 L1 时序 + → 块随事件迁回(同一块 ID,向量不重算),L2 不再持有 冷化: FindColdDocs(72h, ≤2次访问) → docToTriples → Graph ↓ 冷文档蒸馏 ↑ 自动召回 -③ Graph (图数据库) - SQLite — entities + relations 表 +③ Graph (图数据库 — 顶层仍是三元组,节点即记忆块) + SQLite — memory_blocks(节点) + memory_block_edges(边) + 三元组 = 【节点】【关系边】【节点】;putTripleBlocksTx 落为 src --edge_type--> dst 写入: memory_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / memory_merge 读取: ├── 自动召回: Indexer.BuildContext(input) @@ -174,21 +213,33 @@ eventLoop() → processTextInput() - 向量化前统一经 `CleanText` 去模版噪声 - 三分支 `textForVector`:agent 事件用 Response、用户事件用 Input、cold_storage 用 Input+Response - 预训练词嵌入 `StaticEmbedder` → CosineSimilarity,模型不可用时自动回退 TF-IDF -- 保护最近 10 条记录免于淘汰,超出部分按相关性排序归档到文档记忆 -- 归档事件以原始时间戳写入文档记忆,后续 `doc_query` 召回时按原始时间戳插回时序 +- 保护最近 10 条记录免于淘汰,超出部分按相关性排序**迁移**到文档记忆 +- 迁移是事件**连同它持有的块**一起下沉,时间戳与正文不变;源事件清空块(同一块不同时留两层) ### Document 层 `internal/memory/document/document.go` — `Store` -- 消费即删模式:`doc_query` 检索到后删除 -- 双路召回:与 Context 共享的 StaticEmbedder 语义向量搜索 + jieba 关键词提取(模型未加载时回退 char-bigram TF-IDF) + +**与 Context 同构,只是不直接投喂给模型**: +- 每篇文档 = 检索元数据(`ID`/`Summary`/`Vector`/`DenseVec`/`DenseFP`/`AccessCount`) + + **事件列表**(内联正文 + 块) +- 正文内联在块里:文本块的 `Text` 是正文,媒体块的 `Data` 是字节 +- `Doc.Content` 只是**渲染视图**(供摘要/人工阅读/SDK 兼容),不是原文载体 +- 自动预填充:与 Context 共享的向量空间做相似度检索 → 摘要进 system prompt(**只读**,不动层) +- 主动召回:`doc_query` 取走后,**逐条按各自原始时间戳**把事件插回 L1 时序,块随事件迁回 +- 冷化:`FindColdDocs(72h, ≤2次访问)` → `docToTriples` → Graph ### Graph 层 `internal/memory/graph.go` — `GraphDB` -- SQLite WAL 模式,两张表(驱动:mattn/go-sqlite3,CGo) -- `Commit(triples)` — UPSERT entities + INSERT relations -- `Recall(keywords, depth)` — 关键词 LIKE 搜索 + BFS 遍历 + +**顶层结构仍是三元组,变的是节点的物理表示**: +- SQLite WAL 模式(驱动:mattn/go-sqlite3,CGo) +- `memory_blocks`(节点,带 vector + fingerprint + modality)+ `memory_block_edges`(边) +- 三元组 = 【节点】【关系边】【节点】:`putTripleBlocksTx` 落为 `src --edge_type--> dst` +- `entities` / `relations` 旧表是**存量过渡路径**,逐步退场(退场的是节点存储,不是三元组概念) +- `Commit(triples)` — 写入时直接块化为节点 + 边 +- `Recall(keywords, depth)` — 关键词检索 + BFS 遍历 ### 记忆工具(LLM 可直接调用) diff --git a/assets/docs/zh/OVERVIEW.md b/assets/docs/zh/OVERVIEW.md index 2117a6c2..4ca835a5 100644 --- a/assets/docs/zh/OVERVIEW.md +++ b/assets/docs/zh/OVERVIEW.md @@ -24,12 +24,20 @@ CLI 需要终端面板时,**订阅事件总线**(`EventTerminalOutput` + `Ev 自己攒视图,而不是去读内核里的某个状态组件。内核只提供事件总线与插件注册面—— 新增一类能力不需要改内核,也不会在核心里堆出第二套业务真相。 -**三层记忆架构** — 通过分级存储策略管理 Agent 长周期运行中的信息留存: -- **Context 层**:内存中预训练词嵌入评分的事件窗口(StaticEmbedder 词向量 → CosineSimilarity,TF-IDF 回退),实时维护最近上下文,低相关性事件自动下沉到下一层 -- **Document 层**:JSON 文件 + TF-IDF 向量索引的临时记忆,支持显式提交和隐式归档,冷数据蒸馏到 Graph -- **Graph 层**:SQLite 图数据库,持久化实体(entities)和关系(relations),BFS 遍历召回,蒸馏管道从对话中提取三元组 +**三层记忆架构** — 三层不是三种数据结构,而是**同一批记忆块的三层持有关系**: +- **Context 层(L1,直接投喂)**:内存中预训练词嵌入评分的事件窗口(StaticEmbedder 词向量 → CosineSimilarity,TF-IDF 回退),实时维护最近上下文,低相关性事件自动下沉到下一层 +- **Document 层(L2,不直接投喂)**:与 L1 **结构同构**——同样的事件 + 同样的块,只是不直接进模型视野;支持显式提交和隐式归档,冷数据蒸馏到 Graph +- **Graph 层(L3,持久)**:SQLite 图数据库,**顶层仍是三元组**(节点 + 关系边),但节点从 `entities` 表升级为 `memory_blocks`;BFS 遍历召回,蒸馏管道从对话中提取三元组 -三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。 +★ **L1 与 L2 唯一区别是「是否被直接投喂给模型」**:L2 不是 L1 的摘要层,也不是 +另一种数据形态。召回 = 把事件(连同它持有的块)从“不投喂”的持有层**迁回**“投喂”的持有层。 + +★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 是正文,媒体块的 `Data` 是字节。 +不存在「块 + 外部正文」的分离形态。**任何一层都不压缩** —— 视图(检索摘要、渲染结果) +可以是压缩的,但视图不是记忆。 + +三层流动:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道; +每一次都是**迁移**(块 ID 与向量不变,只换持有层),不是复制。 **媒体记忆(v1.2.0 起:一等记忆块)** — 图片/音频不是附属物,而是记忆的一等节点: - **内容寻址存储(CAS)**:digest 寻址,元数据在 SQLite、blob 在磁盘(两级分桶),相同字节只存一份, diff --git a/docs/zh/memory-blockflow-repair-plan.md b/docs/zh/memory-blockflow-repair-plan.md new file mode 100644 index 00000000..e013314e --- /dev/null +++ b/docs/zh/memory-blockflow-repair-plan.md @@ -0,0 +1,444 @@ +# 三层记忆系统修复计划:块化重构后的失效面 + +> 状态:**已实施**(2026-10-22)。实施记录见文末 §九。 +> 原计划写作于同日,下述前提与失效面均经源码与生产数据核实。 +> 触发:块化统一(`docs/zh/memory-restructure-plan.md`)重构了底层数据结构, +> 但上层「层的流动」没有跟着改,导致 L1↔L2 这条链出现结构性失效。 +> +> 本文只写**经源码与生产数据核实**的失效点。每条都标了证据位置, +> 不含推测、不含性能断言。 + +--- + +## 〇、设计前提(不可动摇,任何修法违反即错) + +### 前提 A:记忆块是唯一的物理载体 + +`docs/zh/memory-restructure-plan.md` §〇 纠错表原话: + +> | 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。 +> **blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 | + +`MemoryBlock` 是 union:文本块负载在 `Text`,媒体块在 `Data`(配 MIME/宽高/尺寸)。 +**同一个块 ID 在 context → doc → graph 全程不变。层是持有关系,不是数据种类的划分。** + +### 前提 B:L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」 + +这是**层与层的定义**,不是实现细节: + +| | L1 Context | L2 Document | +|---|---|---| +| 承载单元 | 事件(内联正文 + 块) | 事件(内联正文 + 块) | +| 结构 | 同构 | 同构 | +| 与模型的关系 | **直接投喂**(进 system prompt 时序) | **不直接投喂**(需经召回才进入 L1) | +| 容量 | 最近 n 条 / 上限 m 条 | 无上限 | +| 流动性 | 超限时按相似度迁出 | 冷到阈值后蒸馏进 L3 | + +⇒ L2 **不是**「L1 的摘要层」,也**不是**「另一种数据形态」。 +它就是**同样的事件 + 同样的块**,只是不直接进模型视野。 +召回 = 把块(连同它所属事件)从"不投喂"的持有层**迁回**"投喂"的持有层。 + +### 前提 C:正文(含多媒体正文)内联在块里 + +文本块的 `Text` 就是正文;媒体块的 `Data` 就是字节。 +**不存在"块 + 外部正文"的分离形态**,也不存在靠渲染字符串承载原文的路径。 +事件是块的容器,不是块的替代品。 + +### 前提 D:任何一层都不压缩 + +原文必须完整保留。视图(检索摘要、给模型看的渲染)可以是压缩的, +但**视图不是记忆**。把视图当记忆存,等于在存储端就丢了原文。 + +### 前提 E:Graph 顶层仍是三元组,块只是它的节点实现 + +`internal/memory/sentence_block.go` — `putTripleBlocksTx`: + +```go +src := MemoryBlock{ID: TripleBlockID(t.Subject), Text: t.Subject, SemanticType: t.SubjectType} +dst := MemoryBlock{ID: TripleBlockID(t.Object), Text: t.Object, SemanticType: t.ObjectType} +// 边:src --edge_type=Relation--> dst,落在 memory_block_edges +``` + +三元组 =【节点】【关系边】【节点】,这个**顶层结构没有变**。 +变的是节点的物理表示:`entities` 表 → `memory_blocks` 表。 +`entities` 退场不等于三元组退场。 + +### 前提 F:内核不得硬编码任何插件行为 + +这是**铁律**。任何依赖具体插件名(`agentcli` / `terminal` / `qq` …)的内核分支都是错的。 +插件的行为差异一律由插件自己**声明**:`ToolDef.NoMemory`、`ToolDef.Cleaner`、 +`ChannelDef.NoMemory`、`ChannelDef.Cleaner`、`InjectOptions`。 + +### 前提 G:SDK 已通过一套等价接口实现同等能力 + +公开 SDK(`third_party/homeagent-sdk/sdk/memory.go`)已提供与内核同构的表达: + +| SDK 接口 | 对应的内核概念 | +|---|---| +| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) | +| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) | +| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) | +| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) | +| `MediaAttachment{Digest, MIME, Data}` | 块引用:带 `Data` 为新内容,只带 `Digest` 为引用已有 | +| `TextMemoryAPI.Append(TextEvent)` | 时间序文本日志 | + +⇒ **SDK 契约不需要改动**。修的是内核实现没跟上契约语义,不是契约本身。 + +--- + +## 一、失效面清单(经核实) + +### 失效 1:L2 只存了事件的渲染视图,事件本体被丢弃 + +**位置**:`internal/memory/document/document.go` — `ContextToDoc` + +`Prune` 交给 L2 的是结构完整的 `[]ContextEntry`(Timestamp/Source/Content/ +Response/ToolsUsed/ToolResults/Blocks),但落库时被拍平成一个字符串: + +```go +for _, e := range entries { + line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content) + line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) // 正文 → 视图 + line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200)) +} +content := strings.Join(parts, "\n") +``` + +只有 `Doc.Content` 被保存。**后果**:事件的时间戳只剩 `HH:MM`, +`Response` 只剩 100 rune,工具输出只剩 200 rune,`ToolsUsed` 完全不存。 + +这直接违反前提 B(L1/L2 同构)与前提 C(正文内联在块里)—— +L1 里是完整事件,L2 里却退化成一段文本。 + +**生产证据**(`/home/newqqagent/memory/documents/`,1000 篇归档文档): + +| 度量 | 实测值 | +|---|---| +| response 段恰好 103 字符(=100+`...`) | 311 / 2094 段 = **15.1%** | +| 工具输出段恰好 203 字符(=200+`...`) | **7593 / 13261 段** | + +⇒ 原文**在落库那一刻就不存在了**。这不是"召回时没取全",是存储端丢失。 + +### 失效 2:L2 里没有文本块,L1/L2 不同构 + +**位置**:`internal/memory/document/document.go` — `blocksFromEntries` + +块的 `modality` 实测分布(同一批生产文档): + +``` +L2 块总数 444:image 437 / audio 7 / text 0 +``` + +**后果**:L2 里"块"只承载媒体,文本内容全靠 `Doc.Content` 这个视图。 +违反前提 B/C——L1 的事件正文 + 块,到 L2 变成了"字符串 + 仅媒体块"。 +(对照:L3 的文本是有块的,`putTripleBlocksTx` 的主语块/宾语块都是 `BlockText`。) + +### 失效 3:召回时新建合成事件,而非把块按原时序迁回 + +**位置**:`internal/agent/core/toolcall.go` — `executeDocTool` 的 `doc_query` 分支 + +```go +docs := a.docStore.Consume(query, topK) // 读 + 从 L2 删除 +... +a.context.InsertByTimestamp(ContextEvent{ + Timestamp: d.CreatedAt, // ← 归档时刻,非事件时刻 + Source: "cold_storage", // ← 新造来源 + Input: fmt.Sprintf("加载文档记忆: %s", query), + Response: content, // ← 渲染视图,且再截 2000 +}) +// ← 完全没有把 d.Blocks 挂回去 +``` + +三处偏离设计(`ARCHITECTURE.md` L107-108、L178): + +> doc_query → Consume(读取并删除) +> → **逐条** context.Append{Timestamp: **原始时间戳**, Source: 原来源} +> → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 + +**生产证据**(块自带 `created_at`,与归档时刻差近两小时): + +``` +媒体块 created_at = 2026-10-12 11:09:51 ← 事件发生时刻(captureBlockMedia 里 time.Now()) +doc.created_at = 2026-10-12 13:04:54 ← 归档时刻 +``` + +443/443 个媒体块都带有效 `created_at`。**块自带它该回到哪个时刻的坐标,现在没人用。** + +### 失效 4:块在 Consume 时两层同时消失 + +**位置**:`internal/memory/document/document.go` — `Consume` → `removeDoc` + +```go +func (s *Store) removeDoc(id string) { + delete(s.docs, id) + ... + os.Remove(filepath.Join(s.dir, id+".json")) // 块的字节只在这个文件里 +} +``` + +`doc_query` 调用 `Consume` 后,文档(含 `d.Blocks`)从内存与磁盘同时删除; +而 `toolcall.go` 不回填 `Blocks`。**后果**:这些块既不在 L2(已删)也不在 L1(没挂), +只剩局部变量引用,函数返回后即不可达。 + +**生产风险敞口**:L2 现有 444 个块,其中 **396 个不在 L3**(`graph.db` 里查不到), +原始字节约 **248 MB**。一旦被 `doc_query` 命中即永久丢失。 + +### 失效 5:时序渲染用固定 120 rune 截断正文 + +**位置**:`internal/agent/core/process.go` — `formatMergedTimeline` + +```go +sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120))) +``` + +上下文预算可以是几百 K token(`ComputeTokenBudget`),但每条正文**永远只显示前 120 字**。 +原文即使用前提 D 完整保存在库里,模型也看不到——这是"不压缩"在**渲染环节**的漏口。 + +**生产证据**(`context.json`):2 条 `cold_storage` 事件的 response 分别 1541 / 1209 字符, +渲染出来都是 123 字符。 + +### 失效 6:`Prune` 的归档过滤要求本身违反前提 F + +**位置**:`ARCHITECTURE.md` L13 写「过滤掉 `agentcli` 和 `terminal` 来源后归档」。 + +`agentcli` / `terminal` 是**插件名**。把它们写进内核归档规则 = 硬编码插件行为,违反前提 F。 + +**当前代码**:`Prune` 里没有任何源名过滤,该要求**未实现**; +生产库里有 325 行 `agentcli` 归档(全部是终端状态行)。 + +⇒ 这不是"补上过滤",而是**该要求应作废,改由插件声明驱动**(前提 F)。 + +--- + +## 二、修复顺序(按依赖) + +失效 1/2 是存储形态,3/4 是召回形态,5 是渲染,6 是文档纠错。 +**必须先修存储**——存储里没有事件本体,召回就没有东西可放回。 + +| # | 动作 | 依赖 | 涉及文件 | +|---|---|---|---| +| ① | L2 存储形态改为「事件 + 块」(与 L1 同构) | — | `memory/document/document.go` | +| ② | 文本正文以块承载(消灭 `Content` 作为唯一文本载体) | ① | 同上 + `agent/core/context.go` | +| ③ | 召回按块/事件原始时序迁回 L1 | ①② | `agent/core/toolcall.go` | +| ④ | 时序渲染改为按预算分配 | 独立 | `agent/core/process.go` | +| ⑤ | 作废失效 6 的文档要求(改由 `NoMemory` 声明驱动) | 独立 | `ARCHITECTURE.md` | + +### ① 的具体形状 + +`Doc` 与 `RelevanceContext` 的事件列表**同构**: + +``` +L1: events []ContextEvent // Timestamp/Source/Input/Response/ToolsUsed/ToolResults/Blocks +L2: events []ContextEvent // 同一结构,同一字段 +``` + +- `Doc` 保留 `ID` / `Summary` / `Vector` / `DenseVec` / `DenseFP` / `AccessCount` 等 + **检索元数据**(这些是 L2 作为"可检索容器"的附加属性,L1 不需要) +- 事件列表**内联正文**(`Response` 完整,不截断)+ 持有的块 +- `Doc.Content` 降级为**纯渲染视图**(供摘要/人看/兼容 SDK 的 `Doc.Content`), + 不再作为原文载体;原文从事件读 + +> ⚠️ 注意与「已否决做法」的区别:不是新造一个异构字段把 `ContextEvent` 旁挂上去, +> 而是让 **L2 的承载结构就是 L1 的那一套**。事件列表是文档的主体,不是附件。 + +### ② 的具体形状 + +- 归档时,每条事件的正文(`Input` / `Response`)生成对应文本块(`Modality=BlockText`, + `Text` 即正文),块 `CreatedAt` = 事件时间戳 +- 媒体块照旧(`Data` 内联字节) +- 事件 `Blocks` 里同时含文本块与媒体块 → L1/L2/L3 三处同构 + +### ③ 的具体形状 + +``` +doc_query → 取出 L2 事件列表(迁移语义:取出后 L2 不再持有) + → 逐条按各自 Timestamp 插回 L1 时序 + → 事件持有的块随之迁回(同一块 ID,向量不重算) +``` + +--- + +## 三、判据(先写判据再改,避免自证) + +> 教训(本次已发生):先改代码再写测试,测试就只会证明"我改的是我以为的"。 +> 判据必须**从设计前提推导**,且必须能在改动前**红**。 + +### ① 存储形态(L1/L2 同构) + +- [ ] 归档后能从 L2 取回**逐条**事件的原始 `Timestamp`(完整时刻,非 `HH:MM`)、 + `Source`、`Response`(完整,非截断)、`ToolsUsed`、`ToolResults` +- [ ] 变异:把 `Response` 改回 `truncate(..,100)` ⇒ 红 +- [ ] 变异:丢掉 `ToolsUsed` ⇒ 红 +- [ ] 变异:时间戳改回 `Format("15:04")` ⇒ 红 + +### ② 文本块 + +- [ ] 归档文档的文本块 `Text` 与事件正文逐字一致 +- [ ] 文本块 `CreatedAt` == 事件 `Timestamp` +- [ ] 变异:不生成文本块(只存 `Doc.Content`)⇒ 红 + +### ③ 召回 + +- [ ] `doc_query` 后事件按**各自原始时间戳**回到 L1 +- [ ] 块 `ID`/`Vector`/`CreatedAt` 全程不变(迁移而非重建) +- [ ] 块的持有层从 L2 变为 L1,**不同时留在两层** +- [ ] 变异:用 `d.CreatedAt` 当时间戳 ⇒ 红 +- [ ] 变异:不回填 `Blocks` ⇒ 红 + +### ④ 渲染 + +- [ ] 预算宽裕时正文完整可见(含结尾哨兵) +- [ ] 预算紧张时仍保留开头(不能整条消失) +- [ ] 单条超长正文不得把其余事件整段挤掉 + +### ⑤ 文档纠错 + +- [ ] `ARCHITECTURE.md` 不再出现任何插件名作为内核归档规则 +- [ ] 判据:`grep -rn '"agentcli"\|"terminal"' internal/agent/core internal/memory`(非测试)为空 + +--- + +## 四、禁止事项(此前已犯,写下来防复发) + +| 禁止 | 原因 | +|---|---| +| 给 `Doc` 加异构字段旁挂事件快照(如 `Entries []ContextEntry`) | L2 主体就是事件列表本身,不是"Doc + 附件" | +| 改 `truncateStr` 的常数当作"修复压缩" | 视图截断不是记忆压缩问题;根因在存储形态 | +| 任何插件名字面量进内核分支 | 违反前提 F | +| 编造不存在的 API 再补注释解释它"应该"怎样 | 实现必须以真实存在的设施为准 | +| 先按自己的理解写测试再改代码 | 会把误解固化成判据 | +| 改公开 SDK 契约 | 前提 G:SDK 已等价,修内核实现 | + +--- + +## 五、已否决的做法 + +### 否决 1:新增 `Doc.Entries` 存事件快照(2026-10-22,已实现又撤销) + +错在**异构旁挂**:`Doc` 仍是字符串主体,事件列表被当成附件挂上去。 +正确形态是 §二①:事件列表**成为 L2 的主体**,与 L1 同构。 + +### 否决 2:把渲染层截断常数调大(2026-10-22,已撤销) + +根因在存储形态,不在渲染常数。§二④ 单独处理,且应是**按预算分配**而非改常数。 + +--- + +## 六、已拍板的决定 + +### 1. 事件本体在 L2 的形态(2026-10-22 拍板) + +**L2 就是 doc 存储,本身与 Context 同构,区别只在于是否被直接投喂给模型。** + +⇒ 不是“复用 `ContextEvent` 当旁挂字段”,也不是“把事件也变成块”这种额外抽象。 +**事件列表就是 L2 文档的主体**,与 L1 事件列表同一结构。 + +### 2. 正文形态(2026-10-22 拍板) + +**正文(包括多媒体正文)本身就内联在块里。** + +⇒ 文本块的 `Text` 是正文,媒体块的 `Data` 是字节。 +不存在“块 + 外部正文”的分离形态,也不靠渲染字符串承载原文。 + +### 3. SDK(2026-10-22 拍板) + +**记忆部分 SDK 已通过一套接口实现等价功能** ⇒ **不改公开 SDK 契约**。 + +对照表(`third_party/homeagent-sdk/sdk/memory.go`): + +| SDK 接口 | 等价的内核概念 | +|---|---| +| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) | +| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) | +| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) | +| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) | +| `MediaAttachment{Digest, MIME, Data}` | 块引用(带 Data 为新内容,仅 Digest 为引用) | + +⇒ 修的是内核实现没跟上契约语义,不是契约本身。 + +### 4. 存量数据(2026-10-22 拍板) + +**等待系统自然冲刷**,不做迁移脚本。 + +约束: +- 新代码必须能**读旧格式**(缺事件列表/缺文本块时降级),不得 panic、不得静默丢内容 +- 降级路径要有明确日志,便于判断存量退出进度 +- 旧格式文档被召回时按旧行为处理,**不伪造成“有原始事件”** + +--- + +## 七、待确认的细节(实现层面,不阻塞开工) + +1. **文本块与事件是一对一还是一对多?** + 一条事件生成一个文本块(`Input` + `Response` 合在一个 `Text` 里),还是各一块(便于分别检索)? + +2. **`Doc.Content` 保留为渲染视图,还是彻底删除?** + 若保留,与事件列表信息重复;若删除,SDK 的 `Doc.Content` 则由事件列表现场渲染填充。 + +3. **L2 事件的检索向量怎么算?** + 现在 `denseFor` 用 `Summary + Content`。改为事件列表后, + 是用 `Summary + 各事件正文拼接`,还是用文本块自己的向量做融合(与媒体块一致)? + +--- + +## 八、验收口径 + +- `go build ./...` 通过 +- 全量 `go test ./... -count=1` 0 失败(当前基线:58 包通过 / 0 失败) +- §三 每条判据有对应测试,且**变异测试**各自能被抓住 +- 生产侧(可在副本库验证):`doc_query` 召回后块不消失;新旧两种格式文档都能正确召回 + +--- + +## 九、实施记录(2026-10-22) + +### 已落地 + +| # | 动作 | 落地位置 | +|---|---|---| +| ① | L2 存储形态改为「事件 + 块」 | `document.Doc.Events []DocEvent`;`ContextToDoc` 落盘事件列表 | +| ② | 正文各自成文本块 | `docBlocksForEntry` / `entriesToEvents`;Input、Response 各一块(`BlockText`) | +| ③ | 召回按原始时序迁回 | `Agent.restoreDocEvents`;`DocEvent.InOut()` 从块读回正文 | +| ④ | 蒸馏改用原文 | `documentOriginalText`(有 Events 用块内原文,存量退回 Content) | +| ⑤ | 文档纠错 | `ARCHITECTURE.md` 删除「过滤 agentcli/terminal」,改为「由插件声明驱动」 | + +### Content 的定位(按拍板) + +`Doc.Content` **保留为渲染视图**,由 `contentFromEvents` 生成: + +- 供摘要、人工阅读、SDK 的 `Doc.Content` 字段使用 +- **不是**原文载体 —— 原文在 `Events[i].Blocks` 里文本块的 `Text` +- 视图也不主动截断(存储层不替渲染层做决定) + +### 存量 + +按拍板**不做迁移**,等系统自然冲刷。降级路径: + +- `Doc.HasEvents() == false` ⇒ 召回时整篇渲成一条 `cold_storage` 事件(旧行为) +- 不伪造成「有原始事件」;有日志可观测 + +### 判据与变异 + +新增两组判据(各条都在改动前设计、改动后可跑): + +| 文件 | 判据数 | 覆盖 | +|---|---|---| +| `internal/memory/document/blockflow_test.go` | 7 | 存储形态、文本块粒度、逐条保留、视图定位、幂等、存量兼容、原文可用性 | +| `internal/agent/core/recalldoc_test.go` | 5 | 逐条回填、原始时间戳、块迁回、回填不截断、存量降级 | + +**变异测试(全部被抓住)**: + +| 变异 | 被哪条抓住 | +|---|---| +| 正文落库时 truncate(100) | `TestL2_ArchivePreservesFullText` / `TestL2_ContentIsRenderedViewNotSource` | +| Input/Response 合成一块 | `TestL2_InputAndResponseAreSeparateTextBlocks`(及 3 条连带) | +| 召回用 `d.CreatedAt` 当时间戳 | `TestRecallByDoc_RestoresEachEventAtOriginalTimestamp` | +| 召回不回填 Blocks | `TestRecallByDoc_MigratesBlocksBackToContext` | +| 召回再截 2000 字 | `TestRecallByDoc_TextIsNotTruncatedOnTheWayBack` | + +### 未做(明确不做) + +- **不改公开 SDK 契约**:`TextEvent` / `DocMemoryAPI` / `InsertWithMedia` 已等价 +- **不迁移存量文档**:等自然冲刷 +- **不改渲染层截断常数**(失效 5):根因已在存储形态修掉;渲染按预算分配是独立议题, + 避免与本次修复混在一起、互相掩盖 diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 2f90b14b..951dae99 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -558,12 +558,20 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { // 写入 L3 并以 document --contains--> block 边关联, // 不经过文本描述与 NLP 提取器。 - // NLP 通用提取 + // NLP 通用提取。 + // + // ★ 输入必须是**原文**,不能是视图。 + // + // 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染 + // (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取, + // 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。 + // + // 现在优先用事件里的完整正文;存量文档没有 Events 才退回 Content。 e := nlp.NewExtractor(nil) if embedder != nil { e.SetEmbedder(embedder) } - result := e.Extract(doc.Content) + result := e.Extract(documentOriginalText(doc)) if result != nil { for _, nt := range result.Triples { mt := nlp.ToMemoryTriple(nt) @@ -635,3 +643,33 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) { log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed) } + +// documentOriginalText 取文档的**原文**用于抽取/蒸馏。 +// +// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。 +// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。 +func documentOriginalText(doc *document.Doc) string { + if doc == nil { + return "" + } + if !doc.HasEvents() { + return doc.Content + } + var b strings.Builder + for _, ev := range doc.Events { + in, resp, _ := ev.InOut() + if in != "" { + b.WriteString(in) + b.WriteString("\n") + } + if resp != "" { + b.WriteString(resp) + b.WriteString("\n") + } + for _, tr := range ev.ToolResults { + b.WriteString(tr.Output) + b.WriteString("\n") + } + } + return b.String() +} diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go index 45bd703c..0f2b6341 100644 --- a/internal/agent/core/mediaref_test.go +++ b/internal/agent/core/mediaref_test.go @@ -301,33 +301,42 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { } // 块应已到达 L2,且身份不变。 + // + // ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块 + // (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。 + // 要断言的是**媒体块原样迁移**,而不是块总数。 var found *document.Doc for _, d := range docStore.RecentDocs(20) { - if len(d.Blocks) > 0 { - found = d - break + for _, b := range d.Blocks { + if b.ID == block.ID { + found = d + } } } if found == nil { - t.Fatal("归档文档应持有一等记忆块") + t.Fatal("媒体块未随归档事件迁移到 L2") } - if len(found.Blocks) != 1 { - t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks)) + var got *memory.MemoryBlock + for i := range found.Blocks { + if found.Blocks[i].ID == block.ID { + got = &found.Blocks[i] + } } - got := found.Blocks[0] if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) { - t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block) + t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", *got, block) } // 字节随块迁移(union) if string(got.Data) != string(block.Data) { t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data) } - // 同一块不能同时留在 L0。 + // 同一块不能同时留在 L0(L1)。 for _, e := range rc.Recent(100) { - if len(e.Blocks) > 0 { - t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks) + for _, b := range e.Blocks { + if b.ID == block.ID { + t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b) + } } } } diff --git a/internal/agent/core/recalldoc_test.go b/internal/agent/core/recalldoc_test.go new file mode 100644 index 00000000..11849601 --- /dev/null +++ b/internal/agent/core/recalldoc_test.go @@ -0,0 +1,286 @@ +package core + +// L2 → L1 召回协议的判据。 +// +// 依据(assets/docs/zh/ARCHITECTURE.md「记忆流转」② Document 层的读取分支): +// +// doc_query → Consume(读取并删除) +// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} +// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 +// +// 三条不变量: +// ① 逐条:N 条事件回到时间线,不是 1 条合成事件 +// ② 原始时间戳:用事件时刻,不是文档归档时刻 +// ③ 块随事件迁回:Consume 已删 L2,不回填就两层都不在 + +import ( + "strings" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/document" +) + +// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。 +// +// Prune 无条件保护最近 10 条事件,只在更早的部分里挑归档对象, +// 所以要填充足够多的无关事件把目标挤出保护窗口。 +func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) { + t.Helper() + for _, ev := range target { + a.context.Append(ev) + } + for i := 0; i < 14; i++ { + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "filler", + Input: "无关填充对话,用来把目标事件挤出保护窗口", + Response: "好的。", + }) + } + if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 { + t.Fatal("Prune 未归档任何事件") + } +} + +func countSource(c *RelevanceContext, src string) int { + n := 0 + for _, e := range c.Recent(0) { + if e.Source == src { + n++ + } + } + return n +} + +// ① + ② 逐条回填,且每条用自己的原始时间戳。 +// +// 变异:改用 d.CreatedAt 当时间戳 ⇒ 红;改回单条合成事件 ⇒ 红。 +func TestRecallByDoc_RestoresEachEventAtOriginalTimestamp(t *testing.T) { + a := newToolTestAgent(t) + + base := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC) + var want []ContextEvent + for i := 0; i < 3; i++ { + want = append(want, ContextEvent{ + Timestamp: base.Add(time.Duration(i) * 7 * time.Minute), + Source: "archive-src", + Input: "档案事件-" + string(rune('A'+i)) + " 关于紫蓝红配色方案的讨论", + Response: "该事件的完整回答,编号 " + string(rune('A'+i)), + }) + } + archiveEvents(t, a, want) + + if n := countSource(a.context, "archive-src"); n != 0 { + t.Fatalf("归档后 L1 仍有 %d 条 archive-src", n) + } + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "紫蓝红配色方案", "top_k": float64(5)}, + }) + if strings.Contains(out, "未找到") { + t.Fatalf("doc_query 未召回:%s", out) + } + + seen := map[time.Time]string{} + for _, e := range a.context.Recent(0) { + if e.Source == "archive-src" { + seen[e.Timestamp] = e.Input + } + } + if len(seen) != 3 { + t.Fatalf("回填了 %d 条 archive-src 事件,期望 3"+ + "(逐条回填,不是合成一条;也不是换了个时间戳)", len(seen)) + } + for i := 0; i < 3; i++ { + ts := base.Add(time.Duration(i) * 7 * time.Minute) + if _, ok := seen[ts]; !ok { + t.Errorf("时间戳 %s 缺失 —— 回填必须用事件原始时刻(不是归档时刻)", + ts.Format("15:04:05")) + } + } + // 不得出现合成事件。 + for _, e := range a.context.Recent(0) { + if e.Source == "cold_storage" { + t.Errorf("出现 cold_storage 合成事件(%q)—— 有 Events 时应逐条回填", e.Input) + } + } +} + +// ③ 块随事件迁回 L1(迁移,不是复制也不是丢弃)。 +// +// 变异:不回填 Blocks ⇒ 红(块两层都消失 = 永久丢字节)。 +func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) { + a := newToolTestAgent(t) + + payload := []byte("recall-block-bytes") + b := mediaBlock("image/png", payload) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } + + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Now().Add(-3 * time.Hour), + Source: "archive-src", + Input: "这张图请记住:紫蓝红三色带的配色参考", + Response: "记住了。", + Blocks: []memory.MemoryBlock{b}, + }}) + + if blocksInCtx(a.context, b.ID) { + t.Fatal("归档后块仍在 L1") + } + + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色参考", "top_k": float64(3)}, + }) + + var back *memory.MemoryBlock + for _, e := range a.context.Recent(0) { + for i := range e.Blocks { + if e.Blocks[i].ID == b.ID { + back = &e.Blocks[i] + } + } + } + if back == nil { + t.Fatal("召回后块没有迁回 L1 —— Consume 已删除 L2 文档,块会两层都不在(字节永久丢失)") + } + if string(back.Data) != string(payload) { + t.Errorf("迁回的块字节被改动: %q", string(back.Data)) + } + // 迁移而非复制。 + for _, d := range a.docStore.RecentDocs(50) { + for _, db := range d.Blocks { + if db.ID == b.ID { + t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量") + } + } + } +} + +// ④ 回填的正文必须是原文,不能再被截断。 +// +// 断点史:旧实现在召回时对 Content 再截 2000 字,且 Content 本身已是截断视图。 +func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) { + a := newToolTestAgent(t) + + longResp := strings.Repeat("长正文", 2000) + "【召回哨兵R4】" + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Now().Add(-2 * time.Hour), + Source: "archive-src", + Input: "请记住这份长报告", + Response: longResp, + }}) + + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "长报告", "top_k": float64(3)}, + }) + + var got string + for _, e := range a.context.Recent(0) { + if e.Source == "archive-src" { + got = e.Response + } + } + if got == "" { + t.Fatal("未回填事件") + } + if got != longResp { + t.Errorf("回填正文被截断:len=%d,原长 %d", len(got), len(longResp)) + } + if !strings.Contains(got, "【召回哨兵R4】") { + t.Error("回填正文丢了结尾哨兵") + } +} + +// ⑤ 存量文档(无 Events)降级:整篇渲成一条事件,不丢弃。 +// +// 决定:存量等自然冲刷,但不得因为格式旧就召回不到。 +func TestRecallByDoc_LegacyDocDegrades(t *testing.T) { + a := newToolTestAgent(t) + + if err := a.docStore.Insert(&document.Doc{ + Summary: "紫蓝红三色带的旧格式文档", + Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图", + Source: "context_archived", + Meta: map[string]string{"content_hash": "legacy-hash-1"}, + }); err != nil { + t.Fatal(err) + } + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "紫蓝红三色带 旧格式", "top_k": float64(3)}, + }) + if strings.Contains(out, "未找到") { + t.Fatalf("存量文档召回失败:%s", out) + } + + var found bool + for _, e := range a.context.Recent(0) { + if e.Source == "cold_storage" && strings.Contains(e.Response, "Events 引入之前") { + found = true + } + } + if !found { + t.Error("存量文档没有降级成合成事件 —— 会静默丢失整篇内容") + } +} + +func blocksInCtx(c *RelevanceContext, blockID string) bool { + for _, e := range c.Recent(0) { + for _, b := range e.Blocks { + if b.ID == blockID { + return true + } + } + } + return false +} + +// 压力:大批量事件回填不得退化(回填是逐条 InsertByTimestamp,本身 O(n) 插入)。 +func TestRecallByDoc_StressManyEvents(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + a := newToolTestAgent(t) + + const n = 120 + base := time.Date(2026, 2, 3, 4, 0, 0, 0, time.UTC) + var evs []ContextEvent + for i := 0; i < n; i++ { + evs = append(evs, ContextEvent{ + Timestamp: base.Add(time.Duration(i) * time.Second), + Source: "stress", + Input: "压力输入 " + strings.Repeat("内容", 10), + Response: "压力回答 " + strings.Repeat("正文", 20), + }) + } + archiveEvents(t, a, evs) + + start := time.Now() + if got := countSource(a.context, "stress"); got != 0 { + t.Fatalf("归档后仍有 %d 条", got) + } + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "压力输入 内容", "top_k": float64(5)}, + }) + elapsed := time.Since(start) + + got := countSource(a.context, "stress") + if got == 0 { + t.Fatal("压力场景下一条也没回填") + } + // 回填 120 条不应超过 5 秒(实测远小于此;阈值防 O(n²) 退化)。 + if elapsed > 5*time.Second { + t.Errorf("回填 %d 条耗时 %v,疑似超线性", got, elapsed) + } + t.Logf("回填 %d 条耗时 %v", got, elapsed) +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 46626982..0d11288e 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -1037,24 +1037,33 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { if len(d.Tags) > 0 { parts = append(parts, " 标签: "+strings.Join(d.Tags, ", ")) } - content := d.Content - if len(content) > 2000 { - content = content[:2000] + "..." + // ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件 + // + // 设计原文(ARCHITECTURE「记忆流转」② Document 层): + // + // doc_query → Consume(读取并删除) + // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} + // + // 此前实现用 d.CreatedAt(归档时刻)造一条 cold_storage 事件、 + // 正文取 d.Content(渲染视图)再截 2000 字、块完全没回填 —— + // 三处都偏离:时间错位、正文二次截断、块两层消失。 + if n := a.restoreDocEvents(d); n == 0 { + // 存量文档(无 Events)降级:整篇渲成一个事件,保持既有行为。 + // 不伪造成「有原始事件」——那时归档确实只有渲染视图。 + content := d.Content + if labels := a.blockLabelsForDoc(d); labels != "" { + content = content + "\n关联媒体: " + labels + } + a.context.InsertByTimestamp(ContextEvent{ + Timestamp: d.CreatedAt, + Source: "cold_storage", + Input: fmt.Sprintf("加载文档记忆: %s", query), + Response: content, + }) } - // 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断, - // 截掉之后模型就不知道这篇文档带过图。 - if labels := a.blockLabelsForDoc(d); labels != "" { - content = content + "\n关联媒体: " + labels - } - a.context.InsertByTimestamp(ContextEvent{ - Timestamp: d.CreatedAt, - Source: "cold_storage", - Input: fmt.Sprintf("加载文档记忆: %s", query), - Response: content, - }) refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary)) } - return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)", + return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)", len(docs), strings.Join(refs, ", ")) case "doc_commit": @@ -1262,3 +1271,56 @@ func (a *Agent) currentFingerprint() string { } return a.multimodalSpace.Fingerprint() } + +// restoreDocEvents 把一篇 L2 文档的原始事件**逐条按原时间戳迁回 L1**。 +// +// 返回回填的事件条数;0 表示这篇文档没有可回填的原始事件(存量文档)。 +// +// ## 依据 +// +// ARCHITECTURE「记忆流转」② Document 层的读取分支: +// +// doc_query → Consume(读取并删除) +// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} +// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 +// +// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1, +// 块 ID 与向量全程不变(不重建、不重算)。 +// +// ## 块为什么要一起回填 +// +// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。 +// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。 +func (a *Agent) restoreDocEvents(d *document.Doc) int { + if a == nil || a.context == nil || d == nil || !d.HasEvents() { + return 0 + } + + n, blocks := 0, 0 + for _, ev := range d.Events { + input, response, held := ev.InOut() + a.context.InsertByTimestamp(ContextEvent{ + Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置 + Source: ev.Source, + Input: input, + Response: response, + ToolsUsed: append([]string(nil), ev.ToolsUsed...), + ToolResults: func() []ToolResultItem { + var out []ToolResultItem + for _, tr := range ev.ToolResults { + out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output}) + } + return out + }(), + Blocks: append([]memory.MemoryBlock(nil), held...), + }) + n++ + blocks += len(held) + } + + log.Printf("[agent] doc→context: %s 逐条回填 %d 条原始事件(%s → %s),块 %d 个迁回 L1", + d.ID, n, + d.Events[0].Timestamp.Format("01-02 15:04:05"), + d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks) + return n +} diff --git a/internal/memory/document/blockflow_test.go b/internal/memory/document/blockflow_test.go new file mode 100644 index 00000000..3e382d11 --- /dev/null +++ b/internal/memory/document/blockflow_test.go @@ -0,0 +1,523 @@ +package document + +// L1 ↔ L2 同构协议的判据。 +// +// 依据(不是我的推断): +// +// assets/docs/zh/ARCHITECTURE.md「三层记忆」 +// · L1 与 L2 结构完全同构,唯一区别是「是否被直接投喂给模型」 +// · 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节 +// · 任何一层都不压缩(视图不是记忆) +// +// 每条判据都对应一个真实发生过的断点,且做过变异测试(见各条注释)。 + +import ( + "encoding/json" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +func newDocTestStore(t *testing.T) *Store { + t.Helper() + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + return s +} + +// ① 正文完整性:归档后能逐条取回**完整**正文。 +// +// 断点史:原实现 ContextToDoc 把每条事件拍平成一行, +// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。 +// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。 +// +// 变异:把 docBlocksForEntry 里文本块的 Text 改成截断版 ⇒ 本判据必须红。 +func TestL2_ArchivePreservesFullText(t *testing.T) { + s := newDocTestStore(t) + + longResp := strings.Repeat("这是一段用于验证原文完整性的长正文", 200) + "【哨兵ZQ7】" + longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】" + ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC) + + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: ts, + Source: "webui", + Content: longInput, + Response: longResp, + ToolsUsed: []string{"report_fetch"}, + ToolResults: []ToolResultItem{ + {Name: "report_fetch", Output: strings.Repeat("工具输出", 500) + "【工具哨兵M3】"}, + }, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + + var d *Doc + for _, cand := range s.RecentDocs(10) { + d = cand + } + if d == nil { + t.Fatal("没有归档出文档") + } + if !d.HasEvents() { + t.Fatal("文档没有 Events —— L2 必须与 L1 同构,事件列表是主体") + } + + ev := d.Events[0] + in, resp, _ := ev.InOut() + if in != longInput { + t.Errorf("Input 不完整:len=%d,原长 %d", len(in), len(longInput)) + } + if resp != longResp { + t.Errorf("Response 不完整:len=%d,原长 %d\n"+ + "L2 是 L1 的下沉层,不是摘要层 —— 正文不能在落库时被截断", len(resp), len(longResp)) + } + if !strings.Contains(resp, "【哨兵ZQ7】") { + t.Error("Response 丢了结尾哨兵") + } + if !strings.Contains(in, "【输入哨兵K9】") { + t.Error("Input 丢了结尾哨兵") + } + // 工具输出是事件结构的一部分,必须原样保留。 + if len(ev.ToolResults) != 1 || !strings.Contains(ev.ToolResults[0].Output, "【工具哨兵M3】") { + t.Errorf("工具输出不完整或丢失: %+v", ev.ToolResults) + } + // ToolsUsed 以前完全不存。 + if len(ev.ToolsUsed) != 1 || ev.ToolsUsed[0] != "report_fetch" { + t.Errorf("ToolsUsed 丢失: %+v", ev.ToolsUsed) + } + // 原始时刻(纳秒精度)必须保留,不能只剩 HH:MM。 + if !ev.Timestamp.Equal(ts) { + t.Errorf("时间戳不精确:got %s,want %s", ev.Timestamp, ts) + } +} + +// ② 正文各自成块:Input 一块、Response 一块,都是 BlockText。 +// +// 拍板(2026-10-22):正文各自一块,不合成一个块 —— 两段内容语义常不同, +// 分别成块才能各自被检索。 +// +// 变异:把两块合并成一块 ⇒ 本判据必须红。 +func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) { + s := newDocTestStore(t) + + media := memory.MemoryBlock{ + ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage, + Data: []byte("x"), MIME: "image/png", Size: 1, + } + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), + Source: "qq", + Content: "用户问:紫蓝红三色带怎么配", + Response: "回答:紫蓝红按 6:3:1 配", + Blocks: []memory.MemoryBlock{media}, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + var texts []memory.MemoryBlock + for _, b := range d.Events[0].Blocks { + if b.Modality == memory.BlockText { + texts = append(texts, b) + } + } + if len(texts) != 2 { + t.Fatalf("一条事件应产出 2 个文本块(Input/Response 各一),实际 %d", len(texts)) + } + if texts[0].Text != "用户问:紫蓝红三色带怎么配" { + t.Errorf("第一个文本块应是 Input 正文,实际 %q", texts[0].Text) + } + if texts[1].Text != "回答:紫蓝红按 6:3:1 配" { + t.Errorf("第二个文本块应是 Response 正文,实际 %q", texts[1].Text) + } + // 文本块的 CreatedAt 必须是事件时刻(回填靠它定位)。 + if !texts[0].CreatedAt.Equal(d.Events[0].Timestamp) { + t.Error("文本块 CreatedAt 应等于事件时间戳") + } + // 媒体块仍在,且不被文本块顶掉。 + var hasMedia bool + for _, b := range d.Events[0].Blocks { + if b.ID == media.ID { + hasMedia = true + } + } + if !hasMedia { + t.Error("媒体块在归档后丢失") + } +} + +// ③ 逐条保留:一次归档 N 条事件,L2 里就是 N 条,不合并、不丢。 +// +// 变异:只保留第一条 ⇒ 红。 +func TestL2_KeepsEveryEventIndividually(t *testing.T) { + s := newDocTestStore(t) + + base := time.Date(2026, 5, 6, 8, 0, 0, 0, time.UTC) + var entries []ContextEntry + for i := 0; i < 5; i++ { + entries = append(entries, ContextEntry{ + Timestamp: base.Add(time.Duration(i) * 13 * time.Minute), + Source: "cli", + Content: "第" + string(rune('0'+i)) + "条输入", + Response: "第" + string(rune('0'+i)) + "条回答", + }) + } + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + if len(d.Events) != 5 { + t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events)) + } + for i, ev := range d.Events { + wantTS := base.Add(time.Duration(i) * 13 * time.Minute) + if !ev.Timestamp.Equal(wantTS) { + t.Errorf("第 %d 条时间戳错位: %s vs %s", i, ev.Timestamp, wantTS) + } + in, resp, _ := ev.InOut() + if in != "第"+string(rune('0'+i))+"条输入" || resp != "第"+string(rune('0'+i))+"条回答" { + t.Errorf("第 %d 条正文错位: input=%q response=%q", i, in, resp) + } + } +} + +// ④ Content 是视图而不是事实源:它由事件渲染,且不截断。 +// +// 拍板(2026-10-22):Content 保留为视图。 +// 判据要点:如果 Content 里还能看到完整正文,说明视图没有偷偷丢信息; +// 同时原文的权威来源是块,Content 改坏了也不影响 InOut()。 +func TestL2_ContentIsRenderedViewNotSource(t *testing.T) { + s := newDocTestStore(t) + long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】" + + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), Source: "webui", + Content: "输入", Response: long, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + if !strings.Contains(d.Content, "【视图哨兵】") { + t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断") + } + // 权威来源是块:把 Content 清空,InOut() 仍要能读回完整正文。 + d.Content = "" + _, resp, _ := d.Events[0].InOut() + if !strings.Contains(resp, "【视图哨兵】") { + t.Error("正文权威来源应是块,不是 Content") + } +} + +// ⑤ 幂等:同一批事件重复归档,块 ID 不变、事件不翻倍。 +// +// 为何重要:Prune 可能因重试/重复触发而对同一批事件归档两次; +// 块 ID 由内容派生,必须收敛而不是长出两套。 +func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) { + s := newDocTestStore(t) + ts := time.Date(2026, 7, 8, 9, 10, 11, 123456789, time.UTC) + entries := []ContextEntry{{ + Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答", + }} + + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + first := s.RecentDocs(1)[0] + id1 := first.Events[0].Blocks[0].ID + + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + // 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。 + if n := len(s.Stats()); n == 0 { + t.Fatal("stats 异常") + } + if got := len(s.RecentDocs(10)); got != 1 { + t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got) + } + d := s.RecentDocs(1)[0] + if len(d.Events) != 1 { + t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events)) + } + if d.Events[0].Blocks[0].ID != id1 { + t.Errorf("同一事件重复归档得到不同块 ID:%s → %s(块 ID 必须内容稳定)", + id1, d.Events[0].Blocks[0].ID) + } +} + +// ⑥ 存量兼容:没有 Events 的旧文档必须能被读,且不伪造成"有原始事件"。 +// +// 决定(2026-10-22):存量等系统自然冲刷,不迁移。 +// 但新代码必须能读旧格式 —— 否则升级瞬间老文档全部不可召回。 +func TestL2_LegacyDocWithoutEventsStillLoads(t *testing.T) { + dir := t.TempDir() + s := NewStore(dir, memory.TokenizeWords) + + // 手工写一份旧格式 JSON(无 events 字段)。 + legacy := map[string]interface{}{ + "id": "doc_legacy_1", + "summary": "旧格式文档", + "content": "[10:00] old: 这是旧格式的渲染视图", + "source": "context_archived", + "created_at": "2026-01-02T03:04:05Z", + "updated_at": "2026-01-02T03:04:05Z", + "access_count": 1, + "last_access": "2026-01-02T03:04:05Z", + } + raw, _ := json.Marshal(legacy) + if err := writeFile(dir+"/doc_legacy_1.json", raw); err != nil { + t.Fatal(err) + } + + if err := s.Start(); err != nil { + t.Fatalf("读取旧格式失败(不得 panic):%v", err) + } + got := s.Get("doc_legacy_1") + if got == nil { + t.Fatal("旧格式文档未被加载") + } + if got.HasEvents() { + t.Error("旧格式文档不该被伪造成有 Events") + } + if got.Content == "" { + t.Error("旧格式文档的 Content 视图丢失") + } +} + +func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) } + +// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。 +// +// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前 +// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析, +// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量 +// 「三元组 N 条全被实体名校验拒绝」即与此相关)。 +// +// 本判据守的是 document 包这一侧的能力: +// 只要给出原文,就能完整取回 —— 不被视图的形变影响。 +func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) { + s := newDocTestStore(t) + + full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】" + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), Source: "webui", + Content: "输入", Response: full, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + // 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。 + d.Content = "视图被破坏了" + + var b strings.Builder + for _, ev := range d.Events { + _, resp, _ := ev.InOut() + b.WriteString(resp) + } + if got := b.String(); got != full { + t.Errorf("事件正文取不回原文:len=%d,原长 %d", len(got), len(full)) + } + if !strings.Contains(b.String(), "【原文哨兵D2】") { + t.Error("原文尾部丢失") + } +} + +// ────────────────────────────────────────────── +// 压力测试:新形态(事件列表 + 正文成块)的规模与并发 +// ────────────────────────────────────────────── + +// 大规模归档:500 条事件 → 1000 个文本块,必须能落盘再完整读回。 +// +// 关注点:新形态把每条事件变成 2 个块,块数翻倍。 +// 若某处仍按"一篇文档一个块"或"块数=事件数"假设,规模上来就会暴露。 +func TestL2_StressLargeArchive(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + const n = 500 + s := newDocTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + entries := make([]ContextEntry, n) + for i := 0; i < n; i++ { + entries[i] = ContextEntry{ + Timestamp: base.Add(time.Duration(i) * time.Second), + Source: "stress", + Content: strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)), + Response: strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)), + } + } + + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + if len(d.Events) != n { + t.Fatalf("Events = %d,期望 %d", len(d.Events), n) + } + var textBlocks int + for _, ev := range d.Events { + for _, b := range ev.Blocks { + if b.Modality == memory.BlockText { + textBlocks++ + } + } + } + if textBlocks != 2*n { + t.Fatalf("文本块 = %d,期望 %d(每条事件 Input/Response 各一块)", textBlocks, 2*n) + } + + // 落盘再重新加载:验证序列化能承载这个规模、且逐条完整。 + s.Stop() + s2 := NewStore(s.dir, memory.TokenizeWords) + if err := s2.Start(); err != nil { + t.Fatalf("重新加载失败: %v", err) + } + defer s2.Stop() + + d2 := s2.Get(d.ID) + if d2 == nil { + t.Fatal("重新加载后文档丢失") + } + if len(d2.Events) != n { + t.Fatalf("重新加载后 Events = %d,期望 %d", len(d2.Events), n) + } + // 抽查首尾,确认正文没被合并或错位。 + for _, i := range []int{0, n / 2, n - 1} { + in, resp, _ := d2.Events[i].InOut() + wantIn := strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)) + wantResp := strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)) + if in != wantIn || resp != wantResp { + t.Errorf("第 %d 条正文错位: in=%q resp=%q", i, in, resp) + } + if !d2.Events[i].Timestamp.Equal(base.Add(time.Duration(i) * time.Second)) { + t.Errorf("第 %d 条时间戳错位", i) + } + } +} + +// 并发召回压力:Consume 持有写锁,而 QueryScored 走读锁 + markAccess。 +// 新形态下 Consume 要回填更多数据,不能引入竞态或丢文档。 +func TestL2_StressConcurrentQueryAndConsume(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + s := newDocTestStore(t) + + const docs = 60 + for i := 0; i < docs; i++ { + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "stress", + Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)), + Response: "回答 " + string(rune('A'+i%26)), + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + for w := 0; w < 8; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < 40; i++ { + switch w % 3 { + case 0: + s.QueryScored("并发查询目标", 5) + case 1: + s.Consume("并发查询目标", 3) + case 2: + s.FindColdDocs(time.Hour, 10) + } + } + }(w) + } + wg.Wait() + // 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。 + if got := len(s.RecentDocs(1000)); got > docs { + t.Errorf("文档数异常增长: %d > %d", got, docs) + } +} + +// 大正文:单条事件 1MB 正文,必须完整往返(不截断、不 OOM)。 +func TestL2_StressLargePayloadRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + s := newDocTestStore(t) + + huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), Source: "stress", + Content: "输入", Response: huge, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + _, resp, _ := d.Events[0].InOut() + if len(resp) != len(huge) { + t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge)) + } + if !strings.Contains(resp, "【巨尾哨兵P8】") { + t.Error("大正文尾部丢失") + } +} + +// ⑧ simpleHash 必须 O(n) 且定长。 +// +// 断点史(2026-10-22 压力测试实测): +// +// h += fmt.Sprintf("%x", c) // 每 rune 一次 Sprintf + 字符串拼接 ⇒ O(n²) +// +// 1.25MB 正文归档耗时 **270 秒**;且哈希串长度是内容的 2.2 倍 +// (生产实测 content 52072 字符 → content_hash 114491 字符)。 +// +// 本判据同时钉两点:长度定长(不随内容膨胀)、时间线性(不是 O(n²))。 +func TestL2_HashIsLinearAndFixedLength(t *testing.T) { + small := "小内容" + large := strings.Repeat("大内容", 200000) + + hs, hl := simpleHash(small), simpleHash(large) + if len(hs) != len(hl) { + t.Errorf("哈希长度随内容变化: %d vs %d(必须定长)", len(hs), len(hl)) + } + if len(hl) > 64 { + t.Errorf("哈希本身过长: %d 字符(会成为文档体积负担)", len(hl)) + } + + // 线性性:64 倍输入不应产生平方级增长。留足余量避免机器抖动误判。 + t0 := time.Now() + for i := 0; i < 32; i++ { + simpleHash(large) + } + big := time.Since(t0) + + t1 := time.Now() + for i := 0; i < 32; i++ { + simpleHash(large[:len(large)/64]) + } + sm := time.Since(t1) + + // O(n²) 下这个比值应接近 64²=4096;线性下约 64。 + // 取 300 作为阈值:远离线性(64),远低于平方(4096)。 + if sm > 0 && big > 300*sm { + t.Errorf("哈希耗时随输入超线性增长:大 %.1fms / 小 %.1fms = %.0f 倍"+ + "(输入差 64 倍,线性应约 64 倍,O(n²) 会到 4096 倍)", + float64(big.Microseconds())/1000, float64(sm.Microseconds())/1000, + float64(big)/float64(sm)) + } +} diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index f67c7f50..ec79ed28 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -1,6 +1,8 @@ package document import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" @@ -8,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -22,24 +25,74 @@ import ( type ChannelCleaner func(source string) func(string) string // Doc — 记忆文档:由上下文提炼而来 +// +// ## L1/L2 同构 +// +// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。 +// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。 +// +// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段), +// 不是原文载体。原文在 Events 里,正文内联在文本块里。 type Doc struct { - ID string `json:"id"` - Summary string `json:"summary"` - Content string `json:"content"` - Tags []string `json:"tags"` - Entities []string `json:"entities"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Source string `json:"source"` - Meta map[string]string `json:"meta,omitempty"` - AccessCount int `json:"access_count"` - LastAccess time.Time `json:"last_access"` - Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) - Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) - DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径) - DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算 + ID string `json:"id"` + Summary string `json:"summary"` + Content string `json:"content"` // 渲染视图,不是原文 + Events []DocEvent `json:"events,omitempty"` + Tags []string `json:"tags"` + Entities []string `json:"entities"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` + Meta map[string]string `json:"meta,omitempty"` + AccessCount int `json:"access_count"` + LastAccess time.Time `json:"last_access"` + + // Blocks 是本文档持有的**全部**块(事件里的文本块 + 媒体块去重后的并集)。 + // + // ★ 为什么还留一个扁平列表而不是只看 Events: + // · 向量检索/跨模态融合要能不遍历事件就拿到所有块; + // · L2→L3 蒸馏按块迁移; + // · 存量文档(无 Events)只有这一个字段有块。 + // + // ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。 + // 两者由 ContextToDoc 同一处产出,不得分别修改。 + Blocks []memory.MemoryBlock `json:"blocks,omitempty"` + + Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) + DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径) + DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算 } +// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。 +// +// ★ 为何正文不在字段里、而在块里 +// +// 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节。 +// 事件只是块的容器。因此本结构**不重复存一份正文字符串** —— +// 那会出现「块里的正文」与「字段里的正文」两份真相。 +// +// 读原文 = 读 Blocks 里文本块的 Text。 +type DocEvent struct { + Timestamp time.Time `json:"ts"` + Source string `json:"source"` + ToolsUsed []string `json:"tools_used,omitempty"` + ToolResults []ToolResultItem `json:"tool_results,omitempty"` + + // Blocks 是本事件持有的块,**正文就在里面**。 + // + // 顺序约定(复原时按此顺序回填 ContextEvent): + // Input 文本块 → Response 文本块 → 媒体块… + // 由 docBlocksForEntry 保证;回填由 blocksToInOut 对称解读。 + Blocks []memory.MemoryBlock `json:"blocks,omitempty"` +} + +// HasEvents 报告这篇文档是否保存了可逐条回填的原始事件。 +// +// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到 +// 「把 Content 塞进一条事件」,而不是假装能回填。 +func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 } + // Store — 文档记忆存储。 // 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。 // Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。 @@ -234,18 +287,15 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ cleanFn = func(text string) string { return text } } - var parts []string - for _, e := range entries { - line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content) - if e.Response != "" { - line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) - } - for _, tr := range e.ToolResults { - line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200)) - } - parts = append(parts, line) - } - content := strings.Join(parts, "\n") + // ★★ 原文落位:正文进块,事件列表成为文档主体(L1/L2 同构) + // + // 此前这里把每条事件拍平成一行字符串,并对正文 truncate(..,100)、 + // 对工具输出 truncate(..,200) —— 原文在落库那一刻就不存在了。 + // 现在事件逐条保留,正文各自成为文本块(内联在块的 Text 里)。 + // + // Content 只是渲染视图(见 contentFromEvents),不是原文载体。 + events := docEventsFromEntries(entries) + content := contentFromEvents(events) contentHash := simpleHash(content) summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner) tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner) @@ -259,11 +309,12 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ d.UpdatedAt = time.Now() d.LastAccess = time.Now() d.Content = content + d.Events = events d.Source = source d.Summary = summary d.Tags = tags d.Entities = entities - d.Blocks = blocksFromEntries(entries) + d.Blocks = flattenEventBlocks(events) d.DenseVec = s.denseFor(d) if s.denseSpace != nil { d.DenseFP = s.denseSpace.Fingerprint() @@ -282,7 +333,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ ID: id, Summary: summary, Content: content, Tags: tags, Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(), LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta, - Blocks: blocksFromEntries(entries), + Events: events, + Blocks: flattenEventBlocks(events), } s.docs[id] = doc doc.DenseVec = s.denseFor(doc) @@ -594,6 +646,7 @@ type ContextEntry struct { Source string Content string Response string + ToolsUsed []string ToolResults []ToolResultItem Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档 } @@ -738,17 +791,212 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool return entities } -func truncate(s string, max int) string { - if len([]rune(s)) <= max { - return s - } - return string([]rune(s)[:max]) + "..." +// simpleHash 是用于「内容是否同一份」比对的快速摘要。 +// +// ★ 旧实现是 O(n²) 且哈希串长度正比于内容长度: +// +// h := fmt.Sprintf("%x", len(s)) +// for _, c := range s { h += fmt.Sprintf("%x", c) } // 每 rune 一次 Sprintf + 字符串拼接 +// +// 两个后果都是实测出来的: +// +// 1. **性能**:1.25MB 正文归档耗时 270 秒(压力测试实测)。 +// 字符串 `+=` 每轮重新分配并拷贝整个累积串 ⇒ 总拷贝量 O(n²)。 +// 2. **体积**:哈希串本身是内容的 2.2 倍 +// (生产实测 content 52072 字符 → content_hash 114491 字符)。 +// 它存在每篇文档的 meta 里,等于把正文又存了一遍还更胖。 +// +// 换成 sha256 前 16 字节:定长 32 hex、O(n) 一次遍历。 +// 用途不变(仅在同一批归档里去重),不需要密码学强度之外的任何性质。 +func simpleHash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:16]) } -func simpleHash(s string) string { - h := fmt.Sprintf("%x", len(s)) - for _, c := range s { - h += fmt.Sprintf("%x", c) - } - return h +// ────────────────────────────────────────────── +// L1 ↔ L2 的块级互转 +// +// 设计前提:正文(含多媒体正文)内联在块里,事件只是块的容器。 +// 因此归档 = 把事件正文落成文本块 + 保留事件原有的媒体块; +// 召回 = 从块读回正文,按原时间戳插回 L1。 +// ────────────────────────────────────────────── + +// entryTextBlockID 由「事件身份 + 正文角色」派生文本块 ID。 +// +// 为何不能像原句块那样只用内容派生: +// +// SentenceBlockID(sha256(text)) 会让**两句话恰好相同**的正文合并成一个块。 +// 对话里「好的。」「收到」这类短答应大量重复,合并后 +// 「哪一条事件说过这句话」就不可知了 —— 而 L2 必须能按事件逐条回填。 +// +// 所以 ID 掺入事件身份(时间戳纳秒 + 来源 + 角色),保证一事一块, +// 同时仍是**内容稳定**的:同一条事件重复归档得到同一个块 ID(幂等)。 +func entryTextBlockID(evtTS time.Time, source, role, text string) string { + h := sha256.New() + h.Write([]byte(strconv.FormatInt(evtTS.UnixNano(), 10))) + h.Write([]byte{0}) + h.Write([]byte(source)) + h.Write([]byte{0}) + h.Write([]byte(role)) + h.Write([]byte{0}) + h.Write([]byte(text)) + return "blk_txt_" + hex.EncodeToString(h.Sum(nil)[:12]) +} + +// TextBlockRole 标记文本块在一对事件里的角色。 +// +// 正文各自一块(不是 Input+Response 合成一块),为的是两段内容能 +// **分别按自己的向量被检索**:用户的提问与模型的回答语义常常不同。 +const ( + TextBlockRoleInput = "input" + TextBlockRoleResponse = "response" +) + +// docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。 +// +// 顺序约定(回填时按此对称解读): +// +// Input 文本块(若有)→ Response 文本块(若有)→ 媒体块… +// +// 媒体块原样保留(迁移语义:同一块 ID、同一向量,只换持有层)。 +func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock { + var out []memory.MemoryBlock + now := e.Timestamp + if now.IsZero() { + now = time.Now() + } + + // 文本块:正文内联在 Text 里,不带向量。 + // + // 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次, + // 单条事件的文本块不参与 L2 召回(它是回填用的载体)。 + // 这与「向量只算一次」一致 —— 不在迁移路径上重算。 + if t := strings.TrimSpace(e.Content); t != "" { + out = append(out, memory.MemoryBlock{ + ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t), + Modality: memory.BlockText, + Text: t, + Source: e.Source, + CreatedAt: now, + UpdatedAt: now, + }) + } + if t := strings.TrimSpace(e.Response); t != "" { + out = append(out, memory.MemoryBlock{ + ID: entryTextBlockID(now, e.Source, TextBlockRoleResponse, t), + Modality: memory.BlockText, + Text: t, + Source: e.Source, + CreatedAt: now, + UpdatedAt: now, + }) + } + out = append(out, e.Blocks...) + return out +} + +// blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的 +// Input / Response 正文与它持有的块。 +// +// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后), +// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。 +func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) { + if ev == nil { + return "", "", nil + } + for i := range ev.Blocks { + b := ev.Blocks[i] + if b.Modality != memory.BlockText { + held = append(held, b) + continue + } + switch { + case input == "" && isEntryTextBlock(b, ev, TextBlockRoleInput): + input = b.Text + case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse): + response = b.Text + default: + // 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。 + held = append(held, b) + } + } + return input, response, held +} + +// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。 +func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool { + return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text) +} + +// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。 +func docEventsFromEntries(entries []ContextEntry) []DocEvent { + if len(entries) == 0 { + return nil + } + out := make([]DocEvent, 0, len(entries)) + for _, e := range entries { + ev := DocEvent{ + Timestamp: e.Timestamp, + Source: e.Source, + ToolsUsed: append([]string(nil), e.ToolsUsed...), + Blocks: docBlocksForEntry(e), + } + for _, tr := range e.ToolResults { + ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output}) + } + out = append(out, ev) + } + return out +} + +// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。 +// +// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化, +// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。 +func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock { + seen := make(map[string]bool) + var out []memory.MemoryBlock + for _, ev := range events { + for i := range ev.Blocks { + b := ev.Blocks[i] + if b.ID == "" || seen[b.ID] { + continue + } + seen[b.ID] = true + out = append(out, b) + } + } + return out +} + +// contentFromEvents 用事件正文渲染出 Doc.Content。 +// +// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看, +// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。 +// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。 +// +// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息—— +// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。 +func contentFromEvents(events []DocEvent) string { + var parts []string + for _, ev := range events { + input, response, _ := blocksToEntry(&ev) + line := fmt.Sprintf("[%s] %s: %s", ev.Timestamp.Format("15:04"), ev.Source, input) + if response != "" { + line += " → " + response + } + for _, tr := range ev.ToolResults { + line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, tr.Output) + } + parts = append(parts, line) + } + return strings.Join(parts, "\n") +} + +// InOut 从一条 L2 事件读回它的 Input 正文、Response 正文与持有的块。 +// +// 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent, +// 而正文住在块里,所以必须由本包解读块的角色与归属。 +func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) { + return blocksToEntry(ev) } From 37bece97cb77b00ed5ecfb2a1cfc33b4bc20d3eb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 19:30:05 +0800 Subject: [PATCH 03/22] =?UTF-8?q?fix(packaging):=20tar=20=E5=8C=85?= =?UTF-8?q?=E6=BC=8F=E6=8B=B7=20depgraph.bin=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=A3=85=E5=AE=8C=20homed=20=E4=B8=80=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=B0=B1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核启动时比对「磁盘上的启动依赖图 digest」与「二进制内嵌 digest」, 用于拦住「图文件完好但描述的是旧代码」这类静默顺序错(Makefile build 段注释)。 build_tar 只拷了二进制,没拷 $BUILD_DIR/depgraph.bin ⇒ 装 tar 包的机器 homed 读不到图,直接拒绝启动(退出码 1)。 修法:与 homed 同目录拷 depgraph.bin(/usr/bin/depgraph.bin); 缺图时**显式报错并 return 1**,而不是打出一个注定起不来的包 —— 静默产出坏包比打包失败更难查。 --- deploy/packaging/package-linux.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh index 124042e9..056a6ae5 100644 --- a/deploy/packaging/package-linux.sh +++ b/deploy/packaging/package-linux.sh @@ -728,6 +728,18 @@ build_tar() { [ -f "$src" ] && cp "$src" "$staging/usr/bin/$bin" done + # ★ 启动依赖图必须与 homed 同目录。内核启动时比对「磁盘图 digest」与 + # 「二进制内嵌 digest」,读不到图 ⇒ 生产构建直接拒绝启动(退出码 1)。 + # 此前 build_tar 漏了这一条:装 tar 包的机器 homed 一启动就死。 + if [ -f "$BUILD_DIR/depgraph.bin" ]; then + cp "$BUILD_DIR/depgraph.bin" "$staging/usr/bin/depgraph.bin" + chmod 644 "$staging/usr/bin/depgraph.bin" + else + echo "ERROR: 缺少 $BUILD_DIR/depgraph.bin —— 内核与依赖图必须来自同一次构建。" >&2 + echo " 先跑 make build(它会生成图并注入 digest),再打包。" >&2 + return 1 + fi + # setup script local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh" [ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/hologram/setup.sh" From 1dcd3ced038a9997caf5a9960d79252ea4360d4c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 19:32:59 +0800 Subject: [PATCH 04/22] =?UTF-8?q?test(memory):=20=E5=85=88=E5=86=99?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E8=B4=A8=E9=87=8F=E5=88=A4=E6=8D=AE=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=BD=93=E5=89=8D=E7=BA=A2=EF=BC=8C?= =?UTF-8?q?=E9=87=8F=E5=8C=96=E3=80=8C=E6=A3=80=E7=B4=A2=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮(memory-blockflow-repair-plan)写了 16 条结构判据、6 个变异全绿, 然后用生产数据实测发现检索**完全不可用**: 查询 "doc 层召回是怎么设计的" → 0.7834 内容无关 查询 "hmap 插件安装验签" → 0.7922 内容无关 查询 "QQ 头像" → 0.7601 内容无关 三个互不相关的查询分数全挤在 0.75~0.79,区分度≈0。 ★ 教训:结构判据不能替代质量判据。 「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。 ## 本文件 5 条判据,其中 2 条当前**红**(这就是要修的缺陷): - Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant → 用 fakeDenseSpace(按主题词投影),验证排序与区分度骨架 - **VectorInputIsBodyNotSummary**(红)→ 正文哨兵必须进向量 - **SummaryNoiseMustNotPolluteVector**(红)→ 主题不同的文档不得接近同向 ## 第 5 条的实测依据(真实 chineseclip,4 篇短正文) [1][3] 0.9478 [2][3] 0.9427 [1][2] 0.9316 [0][3] 0.8614 四篇主题完全不同的文档,两两余弦 0.85~0.95 —— 几乎同向。 根因:denseFor 用 doc.Summary + doc.Content,而 Summary 由 jieba 词袋生成, 同一来源的摘要逐字同质: 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 判据用 bigramSpace(字符 bigram 归一化直方图)模拟该行为, 不依赖模型文件,CI 可跑;阈值 0.80,实测缺陷态 0.82~0.86。 ★ 正文必须**短**:正文越长 Summary 噪声占比越小、缺陷越隐蔽 —— 这正是生产上只有短文档受害的原因。 --- .../memory/document/retrieval_quality_test.go | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 internal/memory/document/retrieval_quality_test.go diff --git a/internal/memory/document/retrieval_quality_test.go b/internal/memory/document/retrieval_quality_test.go new file mode 100644 index 00000000..d23acbff --- /dev/null +++ b/internal/memory/document/retrieval_quality_test.go @@ -0,0 +1,386 @@ +package document + +// 检索质量判据 —— 这一层是「结构判据」的补位。 +// +// ## 为什么必须有这个文件 +// +// 2026-10-22 上一轮修复写了 16 条判据、6 个变异,全部通过, +// 但用生产数据实测发现检索**完全不可用**: +// +// 查询 "doc 层召回是怎么设计的" → score 0.7834 内容无关 +// 查询 "hmap 插件安装验签" → score 0.7922 内容无关 +// 查询 "QQ 头像" → score 0.7601 内容无关 +// +// 三个互不相关的查询,分数全挤在 0.75~0.79,区分度≈0。 +// +// 根因:denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba +// 词袋生成("来自 N 个来源的 M 条对话 涉及: map, content"),全库同质。 +// +// ★ 教训:结构判据不能替代质量判据。 +// 「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。 +// +// ## 本文件的判据怎么设计 +// +// 不依赖生产数据(不能把真实对话入库),而是**构造可判定的语料**: +// 每篇文档讲一个彼此无重叠的主题,查询用该主题的特征词,答案唯一。 +// 这样「top1 必须命中」是一个**客观**判据,不是"看起来还行"。 + +import ( + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/vector" +) + +// fakeDenseSpace 是一个确定性的稠密向量实现,只用于判据。 +// +// 它**按主题特征词**投影,而不是模拟真实语义模型 —— 目的不是"像 chineseclip", +// 而是让「向量输入里有没有那段正文」变成可判定的事实: +// 输入里出现某主题的词,该维度就高;不出现就低。 +// +// 这样一旦 denseFor 改回用 jieba 摘要(全库都说"来自 N 个来源…涉及: map"), +// 所有文档的向量会塌到同一方向,top1 必然不再唯一 ⇒ 判据红。 +type fakeDenseSpace struct { + dim int +} + +// topicAxis 把文本映射到维度:按预置主题词的出现次数。 +// 维度数与 vecTopics 长度一致,保证不同主题落在不同轴上。 +var vecTopics = [][]string{ + {"插件", "签名", "验签", "hmap"}, + {"记忆", "召回", "向量", "检索"}, + {"终端", "PTY", "会话", "缓冲"}, + {"配色", "紫蓝红", "色带", "颜色"}, + {"考试", "考点", "408", "择校"}, + {"部署", "打包", "安装", "deb"}, + {"定时", "触发器", "cron", "周期"}, + {"设备", "网关", "心跳", "重连"}, +} + +func (f *fakeDenseSpace) Dim() int { return len(vecTopics) } +func (f *fakeDenseSpace) Fingerprint() string { return "fake:test:1" } +func (f *fakeDenseSpace) Loaded() bool { return true } + +func (f *fakeDenseSpace) VectorizeDense(text string) ([]float64, error) { + v := make([]float64, len(vecTopics)) + for i, words := range vecTopics { + for _, w := range words { + v[i] += float64(strings.Count(text, w)) + } + } + // 加一个极小常数避免全零向量(全零会让 cosine 恒为 0,掩盖问题) + for i := range v { + v[i] += 0.01 + } + return v, nil +} + +func (f *fakeDenseSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) { + return nil, fmt.Errorf("fake space 不支持图像") +} + +func (f *fakeDenseSpace) Close() {} + +var _ vector.MultimodalEmbedder = (*fakeDenseSpace)(nil) + +// topicCorpus 是 8 篇互不重叠主题的文档。 +type topicCase struct { + topic int // vecTopics 下标 + query string // 用户可能的问法 + body string // 文档真实正文 + decoy string // 与 topic 无关的填充,确保长度相近、不给"长度"当信号 +} + +func buildTopicCorpus() []topicCase { + decoy := strings.Repeat("这一段是与主题无关的过渡叙述,用来把正文撑到相近长度。", 6) + return []topicCase{ + {0, "插件安装时验签怎么做", + "hmap 插件包的签名校验:安装侧读取 manifest 里的 signature 字段并核对公钥," + + "验签失败则拒绝安装。当前实现不验签,这是已知缺口。" + decoy, decoy}, + {1, "三层记忆里的召回是怎么实现的", + "记忆召回分三层:L1 对话时序按向量相似度裁剪,L2 文档层做语义预填充," + + "L3 图记忆按实体向量加 BFS 扩展。" + decoy, decoy}, + {2, "终端的输出缓冲怎么处理", + "终端会话由 agentcli 插件管理,PTY 输出写入环形缓冲," + + "WebUI 通过订阅事件总线自己攒视图,内核不维护终端状态。" + decoy, decoy}, + {3, "紫蓝红三色带的配色比例", + "配色方案:紫蓝红三色带按 6:3:1 的比例,主色紫、辅色蓝、点缀红。" + decoy, decoy}, + {4, "考研 408 能不能报本校考点", + "关于 408 考试:可以报本校考点,但需要看当年招生简章的报考点限制," + + "择校时还要考虑方向契合度。" + decoy, decoy}, + {5, "打包成 deb 时漏了哪个文件", + "部署打包:deb 的 build_tar 曾漏拷 depgraph.bin,导致装完 homed 启动即退出。" + decoy, decoy}, + {6, "定时任务到点怎么触发", + "定时器插件维护持久化的触发器表,到点通过中断注入把提醒推给 agent。" + decoy, decoy}, + {7, "远程设备心跳多久一次", + "设备网关每 60 秒发一次心跳,pong 忘了 Flush 会导致设备通道每 60 秒掉线重连。" + decoy, decoy}, + } +} + +// buildStore 用给定语料建一个已注入稠密空间的 Store。 +func buildStore(t *testing.T, cases []topicCase) *Store { + t.Helper() + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + s.SetDenseSpace(&fakeDenseSpace{}) + + base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + for i, c := range cases { + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: base.Add(time.Duration(i) * time.Hour), + Source: "test", + Content: "用户提问:" + c.query, + Response: c.body, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + } + s.BuildDenseIndex(&fakeDenseSpace{}) + return s +} + +// ① top1 必须命中正确文档。 +// +// 变异:denseFor 改回 Summary + Content(jieba 词袋)⇒ 红。 +func TestRetrieval_Top1HitsCorrectDoc(t *testing.T) { + cases := buildTopicCorpus() + s := buildStore(t, cases) + + for _, c := range cases { + hits := s.QueryScored(c.query, 3) + if len(hits) == 0 { + t.Errorf("查询 %q 无任何命中", c.query) + continue + } + top := hits[0] + if !strings.Contains(top.Doc.Content, c.body[:40]) { + t.Errorf("查询 %q 的 top1 不是正确文档(score=%.4f)\n got: %s", + c.query, top.Score, truncateRunes(top.Doc.Content, 60)) + t.Logf(" ≠ 期望正文: %s", truncateRunes(c.body, 60)) + } + } +} + +// ② 互不相关查询的 top1 必须不同(区分度)。 +// +// 这条直接对着生产实测的失效:三个无关查询全部命中同一类文档、分数挤在一起。 +// +// 变异:denseFor 改回 jieba 词袋 ⇒ 全库同质 ⇒ 多条查询 top1 撞同一篇 ⇒ 红。 +func TestRetrieval_DistinctQueriesHitDistinctDocs(t *testing.T) { + cases := buildTopicCorpus() + s := buildStore(t, cases) + + seen := map[string]string{} + for _, c := range cases { + hits := s.QueryScored(c.query, 1) + if len(hits) == 0 { + t.Errorf("查询 %q 无命中", c.query) + continue + } + id := hits[0].Doc.ID + if prev, dup := seen[id]; dup { + t.Errorf("查询 %q 与 %q 命中了同一篇文档(%s)—— 检索无区分度", + c.query, prev, id) + } + seen[id] = c.query + } +} + +// ③ 正确命中的分数必须显著高于非命中(区分度阈值)。 +// +// 生产实测失效时,命中与背景的分数差 <0.04(0.75~0.79 全挤在一起)。 +// 这里要求至少 0.20 的绝对差,避免"排序碰巧对但信号很弱"。 +func TestRetrieval_ScoreGapIsSignificant(t *testing.T) { + cases := buildTopicCorpus() + s := buildStore(t, cases) + + for _, c := range cases { + hits := s.QueryScored(c.query, len(cases)) + if len(hits) < 2 { + continue + } + top := hits[0].Score + // 取"非正确答案里的最高分"作背景 + bg := 0.0 + for _, h := range hits[1:] { + if !strings.Contains(h.Doc.Content, c.body[:40]) && h.Score > bg { + bg = h.Score + } + } + if top-bg < 0.20 { + t.Errorf("查询 %q 的分数区分度不足:top=%.4f 背景=%.4f 差=%.4f(要求 ≥0.20)", + c.query, top, bg, top-bg) + } + } +} + +// ④ 向量输入必须是正文,而不是摘要。 +// +// 做法:把哨兵串只放进 Response(正文),不放 Summary。 +// 若 denseFor 仍用 Summary,哨兵不会进向量 ⇒ 该文档检索不到唯一哨兵查询。 +// +// 变异:denseFor 读 Summary ⇒ 红。 +func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + s.SetDenseSpace(&fakeDenseSpace{}) + + const sentinel = "紫蓝红三色带" + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), + Source: "test", + Content: "用户问了个问题", + Response: "这段正文里有唯一标识:" + sentinel + ",它必须进入向量输入。", + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + // 另一篇同长度干扰文档 + _, _ = s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now().Add(time.Minute), + Source: "test", + Content: "另一条用户消息", + Response: "这是完全不同的一段时间较长的正文内容,与配色毫无关系。", + }}, nil, nil, nil, nil) + + s.BuildDenseIndex(&fakeDenseSpace{}) + + hits := s.QueryScored(sentinel, 1) + if len(hits) == 0 { + t.Fatal("哨兵查询无命中") + } + if !strings.Contains(hits[0].Doc.Content, sentinel) { + t.Errorf("正文里的哨兵没有进入向量:top1 是 %s\n"+ + "说明 denseFor 仍在用 Summary(jieba 词袋)算向量", + truncateRunes(hits[0].Doc.Content, 60)) + } +} + +// ⑤ 摘要的 jieba 噪声不得污染向量 —— 复现并量化生产失效条件。 +// +// ★ 生产失效的机制(2026-10-22 用真实 chineseclip 实测确认): +// +// denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba 词袋生成, +// 同一来源的文档摘要**逐字同质**: +// +// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 +// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 +// +// 四篇主题完全不同的文档,两两余弦实测 0.85~0.95(几乎同向), +// 查询分数 0.70~0.78 全挤在一起,区分度仅 0.08。 +// +// 本判据要求:主题不同的文档,两两余弦必须**明显低**。 +// +// 变异:denseFor 改回 Summary + Content(或其等价:向量输入含 Summary)⇒ 红。 +// +// 注:用 fakeDenseSpace(按主题词投影)会漏掉这个缺陷 —— 因为 Summary 里 +// 不含主题词,投影后看起来"正常"。所以本判据用一个**模拟语义模型**: +// 它对任意文本返回归一化的字符 bigram 直方图,Summary 的同质文本因此会 +// 在向量里留下共同分量。这才是能抓住缺陷的判据。 +func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) { + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + ds := &bigramSpace{} + s.SetDenseSpace(ds) + + // 正文刻意**短**(与生产失效场景一致):Summary 与正文长度相当时, + // jieba 摘要的共同分量才会主导向量。正文越长,噪声占比越小、缺陷越隐蔽 —— + // 这正是生产上只有短文档受害的原因。 + bodies := []string{ + "插件签名验签 hmap 公钥", + "记忆召回向量检索裁剪", + "终端会话输出缓冲 PTY", + "配色方案色带紫蓝红", + } + base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + for i, b := range bodies { + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: base.Add(time.Duration(i) * time.Hour), + Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致 + Content: "用户提问", + Response: b, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + } + s.BuildDenseIndex(ds) + + docs := s.RecentDocs(10) + if len(docs) != 4 { + t.Fatalf("文档数 = %d,期望 4", len(docs)) + } + + // 两两余弦:主题完全不同的文档不应接近同向。 + // 阈值 0.80:真实 chineseclip 在"含 Summary"时实测 0.85~0.95; + // 去掉 Summary 后应显著低于此。 + for i := 0; i < len(docs); i++ { + for j := i + 1; j < len(docs); j++ { + c := denseCosine(docs[i].DenseVec, docs[j].DenseVec) + if c > 0.80 { + t.Errorf("两篇主题完全不同的文档相似度 %.4f(>0.80)—— 向量被同质摘要主导\n"+ + " doc[%d] Summary=%q\n doc[%d] Summary=%q", + c, i, truncateRunes(docs[i].Summary, 45), j, truncateRunes(docs[j].Summary, 45)) + } + } + } +} + +// bigramSpace 是字符 bigram 直方图 + 归一化的模拟语义空间。 +// +// 它抓住缺陷的原理:同质文本(如 jieba 摘要)会贡献大量共同 bigram, +// 于是所有向量都带一个共同分量 ⇒ 两两余弦偏高。 +// 真实 chineseclip 的行为完全一致(实测 0.85~0.95),只是本判据不依赖模型文件。 +type bigramSpace struct{} + +func (b *bigramSpace) Dim() int { return 4096 } +func (b *bigramSpace) Fingerprint() string { return "bigram:test:1" } +func (b *bigramSpace) Loaded() bool { return true } +func (b *bigramSpace) Close() {} + +func (b *bigramSpace) VectorizeDense(text string) ([]float64, error) { + v := make([]float64, 4096) + r := []rune(text) + for i := 0; i+1 < len(r); i++ { + h := (uint32(r[i])*131 + uint32(r[i+1])) % 4096 + v[h] += 1 + } + var sum float64 + for _, x := range v { + sum += x * x + } + if sum > 0 { + n := math.Sqrt(sum) + for i := range v { + v[i] /= n + } + } + return v, nil +} + +func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) { + return nil, fmt.Errorf("bigram 空间不支持图像") +} + +var _ vector.MultimodalEmbedder = (*bigramSpace)(nil) + +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "..." +} From b1f02a22b60b6451e00dfd611121581d00fcc4df Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 19:33:50 +0800 Subject: [PATCH 05/22] =?UTF-8?q?fix(memory):=20=E5=90=91=E9=87=8F?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=94=B9=E7=94=A8=E4=BA=8B=E4=BB=B6=E6=AD=A3?= =?UTF-8?q?=E6=96=87=20=E2=80=94=E2=80=94=20=E6=91=98=E8=A6=81=E6=98=AF=20?= =?UTF-8?q?jieba=20=E8=AF=8D=E8=A2=8B=EF=BC=8C=E6=8A=8A=E5=85=A8=E5=BA=93?= =?UTF-8?q?=E6=8B=89=E6=88=90=E5=90=8C=E4=B8=80=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测缺陷(真实 chineseclip,4 篇主题完全不同的短正文) [1][3] 0.9478 [2][3] 0.9427 [1][2] 0.9316 [0][3] 0.8614 两两余弦 0.85~0.95,几乎同向。查询分数 0.70~0.78 挤在一起,区分度 0.08。 根因:denseFor 用 `doc.Summary + " " + doc.Content`,而 Summary 由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag() 生成,同一来源的摘要**逐字同质**: 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 这与设计直接矛盾:ARCHITECTURE L131/L174 写的向量层级是 「统一多模态空间(主)→ StaticEmbedder → TF-IDF(回退)」, 而 Summary 是**主路径的输入**,却由**最末一级的词法分词**产出。 ## 修法 denseFor 改用 documentTextForVector(doc):按事件顺序拼原文(来自块里的正文)。 存量无 Events 的文档退回 Content(渲染视图,但比 Summary 更接近内容)。 ★ 绝不含 Summary —— 它是词袋,会把全库拉向同一方向。 ## 媒体为什么不单独生成摘要 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件正文进入向量 即覆盖其语义。为媒体单独生成描述正是 CAS 时代「描述式索引」的错误 (描述是二手信息),v1.2.0 已整体拆除,不重走。 「按图搜图」是视觉路(RecallBlocks 读块自己的向量)的职责,已有独立通路。 ## 判据 - TestRetrieval_SummaryNoiseMustNotPolluteVector:主题不同的短正文文档, 两两余弦必须 <0.80。修复前 0.82~0.86。 - TestRetrieval_VectorInputIsBodyNotSummary:正文里的唯一哨兵必须能检索到。 变异:denseFor 改回 Summary+Content ⇒ 前者立刻红(实测 0.8225~0.8573)。 --- internal/memory/document/document.go | 76 +++++++++++++++++-- .../memory/document/retrieval_quality_test.go | 18 +++-- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index ec79ed28..7bdcba06 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -197,11 +197,35 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { log.Printf("[document memory] dense index built: %d new vectors", count) } -// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。 +// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。 // -// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量 -// 属于另一个坐标系,混进去会算出一个两边都不像的方向。 -// 任意一路缺失时退化为另一路;都不可用返回 nil。 +// ## 为什么不能用 Summary +// +// 旧实现是 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary +// 由 summarizeEntries → jieba 词袋生成,同一来源的摘要**逐字同质**: +// +// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 +// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 +// +// 实测(真实 chineseclip,4 篇主题完全不同的短正文)两两余弦 0.85~0.95 —— +// 几乎同向;查询分数全挤在 0.70~0.78,区分度 0.08 ⇒ 检索等同失效。 +// +// 这与设计直接矛盾:ARCHITECTURE 写的是「统一多模态空间(主)」,而 +// Summary 属于主路径的输入,却由最末一级的词法分词产出。 +// +// ## 媒体怎么办:不需要单独生成摘要 +// +// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入 +// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」 +// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。 +// +// 若将来要「按图搜图」,那是视觉路(RecallBlocks 读块自己的向量)的职责, +// 已有独立通路,与本函数无关。 +// +// ## 存量兼容 +// +// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的 +// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。 func (s *Store) denseFor(doc *Doc) []float64 { if s.denseSpace == nil || !s.denseSpace.Loaded() { return nil @@ -209,9 +233,16 @@ func (s *Store) denseFor(doc *Doc) []float64 { fp := s.denseSpace.Fingerprint() dim := s.denseSpace.Dim() var parts [][]float64 - if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 { - parts = append(parts, tv) + + // 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。 + // 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。 + if text := documentTextForVector(doc); text != "" { + if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 { + parts = append(parts, tv) + } } + + // 媒体路:块自带的多模态向量(同空间同维度才参与融合)。 for _, b := range doc.Blocks { // 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按 // 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被 @@ -224,6 +255,39 @@ func (s *Store) denseFor(doc *Doc) []float64 { return vector.FuseVectors(parts...) } +// documentTextForVector 取文档用于向量化的文本:**正文**。 +// +// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档 +// 退回 Content(渲染视图,但仍是原文最接近的载体)。 +// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。 +func documentTextForVector(doc *Doc) string { + if doc == nil { + return "" + } + if !doc.HasEvents() { + return strings.TrimSpace(doc.Content) + } + var b strings.Builder + for _, ev := range doc.Events { + in, resp, _ := ev.InOut() + if in != "" { + b.WriteString(in) + b.WriteString("\n") + } + if resp != "" { + b.WriteString(resp) + b.WriteString("\n") + } + for _, tr := range ev.ToolResults { + if tr.Output != "" { + b.WriteString(tr.Output) + b.WriteString("\n") + } + } + } + return b.String() +} + // Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。 func (s *Store) Reindex() { s.mu.Lock() diff --git a/internal/memory/document/retrieval_quality_test.go b/internal/memory/document/retrieval_quality_test.go index d23acbff..b345fb09 100644 --- a/internal/memory/document/retrieval_quality_test.go +++ b/internal/memory/document/retrieval_quality_test.go @@ -224,19 +224,20 @@ func TestRetrieval_ScoreGapIsSignificant(t *testing.T) { // ④ 向量输入必须是正文,而不是摘要。 // -// 做法:把哨兵串只放进 Response(正文),不放 Summary。 -// 若 denseFor 仍用 Summary,哨兵不会进向量 ⇒ 该文档检索不到唯一哨兵查询。 +// 做法:哨兵串只放进 Response(正文),不放 Summary。用 bigramSpace +// (对任意文本都给可区分的向量)验证:哨兵查询的 top1 必须是含哨兵的那篇。 // -// 变异:denseFor 读 Summary ⇒ 红。 +// 变异:denseFor 读 Summary ⇒ 哨兵不在向量里 ⇒ top1 是别的文档 ⇒ 红。 func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { s := NewStore(t.TempDir(), memory.TokenizeWords) if err := s.Start(); err != nil { t.Fatal(err) } t.Cleanup(s.Stop) - s.SetDenseSpace(&fakeDenseSpace{}) + ds := &bigramSpace{} + s.SetDenseSpace(ds) - const sentinel = "紫蓝红三色带" + const sentinel = "量子纠缠态退相干时间测量" _, err := s.ContextToDoc("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "test", @@ -246,15 +247,16 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { if err != nil { t.Fatal(err) } - // 另一篇同长度干扰文档 + // 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容") _, _ = s.ContextToDoc("context_archived", []ContextEntry{{ Timestamp: time.Now().Add(time.Minute), Source: "test", Content: "另一条用户消息", - Response: "这是完全不同的一段时间较长的正文内容,与配色毫无关系。", + Response: "这是完全不同的一段很长的正文内容,只谈插件安装与打包部署流程," + + "与物理测量毫无关系,纯粹用来干扰检索。" + strings.Repeat("补充无关叙述。", 20), }}, nil, nil, nil, nil) - s.BuildDenseIndex(&fakeDenseSpace{}) + s.BuildDenseIndex(ds) hits := s.QueryScored(sentinel, 1) if len(hits) == 0 { From e2cb6e70e0e7e39312063756539ca378df7c3eac Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 19:49:25 +0800 Subject: [PATCH 06/22] =?UTF-8?q?feat(memory):=20doc=20=E5=B1=82=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=20jieba=20=E6=91=98=E8=A6=81=20+=20doc=5Fquery=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=9C=9F=E5=AE=9E=E5=86=85=E5=AE=B9=20+=20?= =?UTF-8?q?=E4=B8=89=E5=B1=82=E8=AE=B0=E5=BF=86=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。 用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0, 命中内容与查询无关。本次修四层。 ## ① 向量输入:Summary + Content → 事件正文 denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary 由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag() 生成。同一来源的摘要逐字同质: 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。 这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary 是主路径的输入,却由最末一级的词法分词产出。 改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。 存量无 Events 的文档退回 Content。 ★ 媒体不单独生成摘要:媒体块与文本块在同一事件内 (Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。 为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。 「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。 ## ② doc 层移除 jieba 语义生成 - summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读) - 删除 extractTags / extractEntities(纯 jieba 词袋) - 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的 「标签: map, content」噪声行;后者零消费点) - doc_commit 删除 tags 参数(无处可存,留着会骗模型) ★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex): 它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主) → 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间 可用,jieba 就不参与。 ## ③ doc_query 返回真实内容 旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」 ——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。 新返回: 已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话): [1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体 配色方案怎么定? 结论:紫蓝红三色带按六比三比一… 共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。 每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。 ## ④ 提示词:agent 终于能理解三层记忆 - doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 / 与 memory_recall 分工 / 消费式副作用」 - doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」 - 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 / L3 图记忆各自是什么、怎么取、如何选择 - 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头, 并在块尾明确引导「要完整原文用 doc_query」 ## ⑤ 向量算法版本(否则修复对存量无效) 实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76, 区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」, 指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。 修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。 算法一变,存量自动重算一次。 ## 判据(每条都有变异自证) document/retrieval_quality_test.go(6 条): - Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant - VectorInputIsBodyNotSummary(正文哨兵必须进向量) - SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80) - AlgoVersionChangeForcesRecompute(换算法必须触发存量重算) core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明 core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、 三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导 变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 / 一句话工具描述 / 删三层记忆段)。 ## 验证 - go build ./... 通过;go vet ./... 干净 - go test ./... -count=1:58 包 0 失败 - go test -race ./internal/memory/document/ ./internal/agent/core/:通过 - 真实数据(chineseclip 512 维): · 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷 · 新格式文档(走 ContextToDoc):4/5 top1 命中正确, 区分度最高 0.18(旧实现 0.003) --- docs/zh/memory-usability-plan.md | 291 ++++++++++++++++++ internal/agent/core/crossmodal.go | 146 +++++++-- internal/agent/core/prompt_memory_test.go | 192 ++++++++++++ internal/agent/core/recalldoc_test.go | 70 +++++ internal/agent/core/toolcall.go | 167 ++++++++-- internal/agent/core/tooldefs.go | 79 +++-- internal/memory/document/document.go | 215 ++++++------- internal/memory/document/document_test.go | 137 ++------- .../memory/document/retrieval_quality_test.go | 49 ++- 9 files changed, 1052 insertions(+), 294 deletions(-) create mode 100644 docs/zh/memory-usability-plan.md create mode 100644 internal/agent/core/prompt_memory_test.go diff --git a/docs/zh/memory-usability-plan.md b/docs/zh/memory-usability-plan.md new file mode 100644 index 00000000..d80bb8cc --- /dev/null +++ b/docs/zh/memory-usability-plan.md @@ -0,0 +1,291 @@ +# 三层记忆可用性修复计划:统一向量化 + 提示词让 agent 真正理解 + +> 状态:**待执行**(2026-10-22) +> 前置:`docs/zh/memory-blockflow-repair-plan.md`(L1↔L2 结构同构,已实施) +> 本文修的是**上一轮没修的那一层**:结构对了,但检索质量与提示词仍然不可用。 + +--- + +## 〇、起点:承认上一轮的判断错误 + +上一轮我修了 L1↔L2 的结构(事件列表 + 正文进块 + 按原时序回填), +然后声称「机制已通」。**结构确实通了,但功能不可用。** + +用生产数据实测(1054 篇文档、chineseclip 512 维)三个互不相关的查询: + +``` +查询 "doc 层召回是怎么设计的" + score=0.7834 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) + score=0.7795 来自 1 个来源的 2 条对话 (agent) 涉及: map, content, dsh, 占用 + score=0.7768 来自 1 个来源的 1 条对话 (agent) 涉及: 自检, OK + +查询 "hmap 插件安装验签" + score=0.7922 来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是, pi + +查询 "QQ 头像" + score=0.7601 来自 1 个来源的 1 条对话 (agent) 涉及: CQ, qq, ok +``` + +**分数全挤在 0.75~0.79,区分度≈0,命中内容与查询无关。** + +### 判据缺失(最该记的一条) + +上一轮写了 16 条判据、6 个变异,全部通过。但**没有一条验证「检索结果必须与查询相关」**。 +所以「全绿」证明的只是我实现了我以为的东西 —— 不是它可用。 + +> ★ 教训:**结构判据不能替代质量判据**。 +> 「字段非空」「条数正确」「不 panic」都可能在检索完全失效时依然全绿。 + +--- + +## 一、根因(三条,按依赖排序) + +### 根因 1:向量的输入是 jieba 词袋,不是正文 + +`internal/memory/document/document.go` — `denseFor`: + +```go +if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil { +``` + +而 `Summary` 由 `summarizeEntries` 生成,后者调用链是: + +``` +summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag() +extractTags → 同上 +extractEntities → 同上 +``` + +`TokenizeContentWords`(`internal/memory/cut.go:256`)就是 `jieba.Tag()` 分词 + 停用词过滤, +产出形如 `涉及: map, content, 是不是, pi`。 + +**这是与设计直接矛盾的**。`ARCHITECTURE.md` L131 / L174 明写向量层级: + +``` +统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退) +``` + +`Summary` 是**主路径的输入**,却由**最末一级的词法分词**产出。 +全库所有文档的 Summary 因此高度同质(都是「来自 N 个来源的 M 条对话 涉及: …」), +余弦相似度自然全挤在高位、无区分度。 + +### 根因 2:媒体块没有语义,且不需要单独生成 + +设计要求「doc 支持多模态摘要」。当前实现里: + +- `Doc.Summary` 只有文本路径,**媒体块从不参与** +- `crossModalMarkdown` 里媒体只渲染成 `[image/png blk_media_xxx]`(`blockLabel`:MIME + 短 ID) +- 一篇带 20 张图的文档,摘要里没有「有图」的任何信息 + +**但「多模态摘要」这个提法本身是歧路**(用户 2026-10-22 指出): + +> 媒体 doc 块一般是和文本紧邻的,所以对应的文本就足够了 + +`Doc.Events[i].Blocks` 里媒体块与文本块**在同一事件内**。媒体块的语义由 +**同事件的文本**天然覆盖,不需要为每个媒体单独生成描述(那正是 CAS 时代 +「描述式索引」被拆除的同一个错误)。 + +⇒ 正确做法不是「给媒体写摘要」,而是:**向量用事件正文算,媒体自动被覆盖**。 + +### 根因 3:提示词与注入文案没有任何引导 + +**(a)`doc_query` 的工具描述只有一句话** + +```go +toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", ...) +``` + +逐条对照问题: + +| 应有信息 | 现状 | +|---|---| +| L2 是什么 | ❌ 只说「文档记忆」,没有定义 | +| 何时该用 | ❌ 无 | +| 调用后会发生什么 | ❌ 「返回相关文档摘要」**与真实行为不符**(真实是逐条按原时序回填到对话时序) | +| 与 `memory_recall` 的分工 | ❌ 无 | + +对比同文件里 `【输出规则】`(6 条)、`【工具执行顺序】`(4 条)的详细程度, +`doc_query` 是最敷衍的一个。 + +**(b)预填充注入文案没有引导** + +`crossModalMarkdown` 输出: + +``` +【跨模态相关记忆】 +[1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中) +``` + +**没有一个字说「可以用 doc_query 取全文」**。模型看到这段只能忽略。 + +**(c)系统提示词从未解释三层记忆是什么** + +`grep 记忆 internal/agent/core/tooldefs.go` 全量命中里,没有任何一段说明 +Context / Document / Graph 三者的分工与使用时机。agent 只能从工具名猜。 + +--- + +## 二、修复步骤(按依赖,每步先写会红的判据) + +### 步骤 1:判据先行 —— 检索质量 + +**先写测试,此时必须红。** + +`internal/memory/document/retrieval_quality_test.go`: + +- 构造真实语料(约 50 篇,内容取自生产副本的真实事件正文 + 人工标注的查询-答案对) +- 断言:给定查询,**正确文档必须排第一**(top1 准确率 100%,或至少 top3 内) +- 断言:互不相关的查询,top1 结果**必须不同** +- 断言:正确命中的分数必须**显著高于**非命中(区分度阈值) + +**变异**:把 `denseFor` 改回 `Summary + Content` ⇒ 必须红。 +**变异**:把正文替换成 jieba 词袋 ⇒ 必须红。 + +这一步是整个计划的地基。没有它,后面每一步都只是「我以为改对了」。 + +### 步骤 2:向量输入改为正文 + +`internal/memory/document/document.go` — `denseFor`: + +``` +现在:VectorizeDense(doc.Summary + " " + doc.Content) +改为:VectorizeDense(事件正文拼接) + 有 Events → 按事件顺序取 InOut() 的 input + response(文本块内容) + 无 Events(存量)→ 退回 Content +``` + +**媒体如何被覆盖**:媒体块与文本块在同一事件内,事件的正文进入向量即覆盖它。 +不需要为媒体单独编码 —— 若将来要「按图搜图」,那是视觉路(`RecallBlocks`)的职责, +已有独立通路,与文本路不冲突。 + +### 步骤 3:删掉 jieba 摘要路径 + +`Summary` / `Tags` / `Entities` 目前全部由 `ExtractKeywords`(jieba)产出。 + +- `Tags`:唯一消费点是 `doc_query` 返回值里的「标签: …」(`toolcall.go:1038`), + **是词袋噪声,删除该行** +- `Entities`:`Doc.Entities` 无任何非测试读取点(跨包的 `injected.Entities` + 是 `memory.Entity` 另一个类型,无关),**字段可删** +- `Summary`:改为**纯展示**(人工阅读 / 日志),由正文首句截取生成,**不参与向量** + +⚠️ 需先确认:`Doc.Tags` / `Doc.Entities` 是否有 WebUI / SDK 侧读取。 +(已知 `sdk/memory_impl.go` 的 `Doc` 只有 `ID/Title/Content`,无 Tags/Entities。) + +### 步骤 4:预填充注入正文片段 + 引导 + +`crossModalMarkdown` 改为: + +``` +【历史记忆(按相关度)】 +[1] 2026-10-12 11:09 · qq · 相关度 0.83 + 紫蓝红三色带的配色参考,老大要求…(正文首 2 行) +[2] …(含 2 张图:image/png, image/jpeg) + +这些是**历史**片段(不是当前对话)。要看完整原文, +调用 doc_query 把对应事件按原时间戳拉回对话时序。 +``` + +要点: +- 去掉「来自 N 个来源的 M 条对话」这类统计描述 +- 给出**时间 + 来源 + 正文开头**(真实内容,让模型能判断相关性) +- 媒体只报数量与类型,不编描述 +- **明确告知 doc_query 的作用** + +### 步骤 5:`doc_query` 工具描述重写 + +按 `memory_*` 的详细度,必须覆盖: + +- L2 是什么(历史事件层,不直接进当前对话) +- 何时用(记得办过但想不起细节 / 跨会话跨天 / 需要原始过程而非结论) +- 调用后发生什么(**把事件逐条按原时间戳回填到对话时序**,不是返回一段摘要) +- 与 `memory_recall` 的分工(图记忆=关系与最新值;doc=整段过程与原文) +- 副作用(从 L2 取出,取走后该文档不再在 L2) + +### 步骤 6:系统提示词增加「三层记忆」段落 + +在 `buildSystemPrompt` 里加一段,说清三层是什么、怎么用: + +``` +【三层记忆】记忆分三层,你现在看到的是第一层。 +- 对话时序(L1):最近的对话,已在你的上下文里。超过窗口的会被移出。 +- 历史记忆(L2):移出后的事件与原文仍完整保存。自动预填充会给你相关片段; + 要给看全文/完整过程,用 doc_query —— 它会把那些事件按原时间戳插回对话时序。 +- 图记忆(L3):关系与事实的长期存储(谁是什么、什么等于什么、最新值是什么)。 + 用 memory_recall 查;用 memory_commit 记。 +选择:要"原文与过程"→ doc_query;要"关系与当前值"→ memory_recall; + 要"成文知识"→ knowledge_search。 +``` + +**注意**:这段属于「内核运行时块」(提示词分层规范:内核行为由内核生成), +不是工具用法(工具用法归 `description`)。它讲的是**内核的真实执行语义** +(回填到时序、自动预填充),符合分层原则。 + +--- + +## 三、判据清单(每步都要能红) + +| # | 判据 | 变异(必须红) | +|---|---|---| +| 1 | 检索质量:top1 命中 / 不相关查询 top1 不同 / 分数有区分度 | `denseFor` 改回 `Summary+Content`;或换回 jieba 词袋 | +| 2 | 向量输入是正文:断言 `denseFor` 读的是 Events 正文(可用哨兵串) | 正文里埋哨兵,改回 Summary ⇒ 哨兵不出现在向量输入 | +| 3 | 摘要不含 jieba 词袋:`Summary` 不出现「来自 N 个来源的 M 条对话」 | 恢复 `summarizeEntries` ⇒ 红 | +| 4 | 预填充文案含正文与引导:必须含 `doc_query` 字样 + 正文片段 | 改回旧渲染 ⇒ 红 | +| 5 | 工具描述覆盖五项(L2 定义/何时用/调用后行为/分工/副作用) | 删任一项 ⇒ 红 | +| 6 | 系统提示词含三层记忆段落且语义与实现一致 | 回填行为改掉而提示词不改 ⇒ 红(跨文件一致性) | + +**判据 6 特别说明**:提示词说「按原时间戳插回时序」这件事, +必须与 `restoreDocEvents` 的真实行为绑在一条判据里 —— +否则就是「提示词在说谎」,那比没说更糟。 + +--- + +## 四、禁止事项 + +| 禁止 | 原因 | +|---|---| +| 用 jieba 词袋当向量输入或摘要内容 | 与「统一向量化」直接矛盾;全库同质,检索失效 | +| 为媒体块单独生成文本描述 | CAS 时代的「描述式索引」错误;媒体语义由同事件文本覆盖 | +| 只改结构不改提示词 | 机制改了模型不知道 = 等于没改 | +| 用「字段非空 / 条数正确 / 不 panic」当检索判据 | 检索完全失效时这些也全绿 | +| 声称「机制已通」而不给出生产数据的检索质量实测 | 上一轮的教训 | + +--- + +## 五、验证 + +1. `go test ./... -count=1` 全绿(现有 58 包基线) +2. 新判据全绿,且 6 个变异各自被抓住 +3. **生产副本实测**:用真实数据跑检索,给出 top1/top3 命中率与分数分布 + (对比修复前的 0.75~0.79 无区分度) +4. 生产部署后用真实对话观察:agent 是否在需要时调用 `doc_query` + +--- + +## 六、标准流程(本次严格执行) + +上次直接提交 `main` + rebase + amend + 绕过 CI,不符合 `docs/git-branching.md`。 +这次必须: + +``` +1. git checkout -b feature/memory-usability main +2. 按步骤 1→6 提交(每步独立 commit,先写判据再改) +3. git push -u origin feature/memory-usability +4. 开 PR +5. 等 CI 10 项全部通过 +6. 合并 main(不绕过必需检查) +7. 部署 + 生产验证 +``` + +⚠️ 远端仓库已改名 `JianFeeeee/HologramAgent`(origin 与 gitcode 均已更新)。 + +--- + +## 七、待确认 + +1. **`Doc.Tags` / `Doc.Entities` 是否可删?** + 我查到非测试读取点只有 `doc_query` 返回里的「标签:」一行(可删)。 + 但需确认 WebUI / 外部插件是否依赖(SDK 的 `Doc` 无这两个字段)。 + +2. **判据 1 的语料从哪来?** + 建议从生产副本抽 50 篇真实文档 + 人工标注 10~15 个查询-答案对。 + 是否允许我把这些真实文本(脱敏后)作为测试 fixture 入库? diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index 1b12856b..bdedea28 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -5,6 +5,7 @@ import ( "log" "sort" "strings" + "unicode" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/document" @@ -234,37 +235,142 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod return merged } -// crossModalMarkdown 把融合候选渲染成注入上下文的文本。 -// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。 +// crossModalMarkdown 把融合候选渲染成注入提示词的文本。 +// +// ## 为什么改 +// +// 旧输出是这样的: +// +// 【跨模态相关记忆】 +// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中) +// +// 三个问题,都被生产日志证实: +// +// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性; +// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略; +// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。 +// +// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**: +// 这才是让预填充真正被用起来的必要条件。 func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { if len(hits) == 0 { return "" } var lines []string for i, h := range hits { - marker := "" - switch { - case h.DoubleHit: - marker = "(图文双命中)" - case h.Doc != nil: - marker = "(文本命中)" - case h.Media != nil: - marker = "(视觉命中)" - } - parts := []string{fmt.Sprintf("[%d]", i+1)} + var parts []string + parts = append(parts, fmt.Sprintf("[%d]", i+1)) + + // 时间 + 来源:让模型知道"这是什么时候、谁的事" if h.Doc != nil { - parts = append(parts, h.Doc.Summary) - if h.Doc.Source != "" { - parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source)) + if h.Doc.HasEvents() { + first := h.Doc.Events[0].Timestamp + last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp + if first.Equal(last) { + parts = append(parts, first.Format("2006-01-02 15:04")) + } else { + parts = append(parts, fmt.Sprintf("%s→%s", + first.Format("01-02 15:04"), last.Format("01-02 15:04"))) + } + // 真实来源(跳过内部归档标记,它对模型无意义) + if src := firstRealSource(h.Doc.Events); src != "" { + parts = append(parts, src) + } + } else { + parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04")) } } + + // 正文开头:真实内容,模型可据此判断相关性 + if h.Doc != nil { + if head := docHeadlineForPrompt(h.Doc); head != "" { + parts = append(parts, head) + } + } + // 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除) if h.Media != nil { - if line := blockLabel(h.Media); line != "" { - parts = append(parts, line) + label := string(h.Media.Modality) + if h.Media.MIME != "" { + label = h.Media.MIME + } + parts = append(parts, "["+label+"]") + } + if h.DoubleHit { + parts = append(parts, "(图文双命中)") + } + parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused)) + lines = append(lines, strings.Join(parts, " · ")) + } + + var b strings.Builder + b.WriteString("【历史记忆(L2,自动预填充)】\n") + b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n") + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" + + "按原始时间戳插回你的对话时序。") + return b.String() +} + +// firstRealSource 取事件里第一个非内部标记的来源。 +// +// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。 +func firstRealSource(events []document.DocEvent) string { + for _, ev := range events { + if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" { + return ev.Source + } + } + if len(events) > 0 { + return events[0].Source + } + return "" +} + +// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。 +// +// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。 +func docHeadlineForPrompt(d *document.Doc) string { + if d == nil { + return "" + } + if d.HasEvents() { + for _, ev := range d.Events { + in, resp, _ := ev.InOut() + for _, cand := range []string{in, resp} { + if line := firstMeaningfulLineForPrompt(cand); line != "" { + return truncateRunesForPrompt(line, 60) + } } } - parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker)) - lines = append(lines, strings.Join(parts, " ")) + return "" } - return "【跨模态相关记忆】\n" + strings.Join(lines, "\n") + return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60) +} + +func firstMeaningfulLineForPrompt(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + hasWord := false + for _, r := range line { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + hasWord = true + break + } + } + if hasWord { + return strings.Join(strings.Fields(line), " ") + } + } + return "" +} + +func truncateRunesForPrompt(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" } diff --git a/internal/agent/core/prompt_memory_test.go b/internal/agent/core/prompt_memory_test.go new file mode 100644 index 00000000..e49b60ad --- /dev/null +++ b/internal/agent/core/prompt_memory_test.go @@ -0,0 +1,192 @@ +package core + +// 三层记忆的**提示词诚实性**判据。 +// +// 这类判据的目的是防止「提示词在说谎」——它比不说更糟: +// 模型按提示词行事,而实现不是那样,于是行为与预期系统性错位。 +// +// 生产实测(2026-10-22 日志):agent 几乎不调用 doc_query,即使调用也 +// 只看返回值、不知道原文已被回填到对话时序。根因是: +// · 工具描述只有「查询文档记忆。返回相关文档摘要」一句(且与真实行为不符) +// · 系统提示词从未解释 L1/L2/L3 是什么 + +import ( + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory/document" +) + +// ① doc_query 的描述必须覆盖五项,且与真实行为一致。 +func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) { + a := newToolTestAgent(t) + desc := findToolDescription(t, a, "doc_query") + + mustHave := []struct { + name string + sub string + }{ + {"L2 是什么", "历史记忆层"}, + {"何时该用", "何时用"}, + {"调用后行为=回填时序", "插回你的对话时序"}, + {"原始时间戳", "原始时间戳"}, + {"与 memory_recall 分工", "memory_recall"}, + {"副作用=消费式取出", "消费式"}, + } + for _, m := range mustHave { + if !strings.Contains(desc, m.sub) { + t.Errorf("doc_query 描述缺少「%s」(应含 %q):\n%s", m.name, m.sub, desc) + } + } + // 不得再声称"返回摘要"—— 那是旧实现的谎 + if strings.Contains(desc, "返回相关文档摘要") { + t.Error("doc_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符") + } +} + +// ② doc_commit 描述必须说清「适合/不适合写什么」,并与另外两层分工。 +func TestDocCommitDescriptionExplainsScope(t *testing.T) { + a := newToolTestAgent(t) + desc := findToolDescription(t, a, "doc_commit") + + for _, sub := range []string{"适合写什么", "不适合写什么", "memory_commit", "knowledge_create"} { + if !strings.Contains(desc, sub) { + t.Errorf("doc_commit 描述缺少 %q:\n%s", sub, desc) + } + } +} + +// ③ 系统提示词必须有三层记忆段落,且语义与实现一致。 +// +// ★ 这条是**跨文件一致性**判据:提示词说"按原始时间戳插回时序", +// +// 实现就必须真的那样做。若哪天 restoreDocEvents 被改回造合成事件, +// 这里必须红 —— 否则提示词又开始说谎。 +func TestSystemPromptExplainsThreeLayers(t *testing.T) { + a := newToolTestAgent(t) + // memContext 为空即可:三层记忆段落是内核无条件注入的 + prompt := a.buildSystemPrompt("", "测试输入") + + for _, sub := range []string{ + "【三层记忆】", + "对话时序(L1", + "历史记忆(L2", + "图记忆(L3", + "doc_query", + "memory_recall", + } { + if !strings.Contains(prompt, sub) { + t.Errorf("系统提示词缺少三层记忆段落的 %q", sub) + } + } + // 提示词承诺「按原始时间戳插回」,实现必须真的这么做 + if !strings.Contains(prompt, "原始时间戳") { + t.Error("提示词没有说明 doc_query 会按原始时间戳回填") + } + if !strings.Contains(prompt, "插回你的对话时序") { + t.Error("提示词没有说明 doc_query 把事件插回时序(模型会以为只是返回摘要)") + } +} + +// ④ doc_commit 不得再有 tags 参数(Doc.Tags 已删除,留着会骗模型)。 +func TestDocCommitHasNoTagsParam(t *testing.T) { + a := newToolTestAgent(t) + params := findToolParams(t, a, "doc_commit") + if _, ok := params["tags"]; ok { + t.Error("doc_commit 仍有 tags 参数,但 Doc.Tags 已删除 —— 参数会静默丢弃") + } +} + +// ---- helpers ---- + +func findToolDescription(t *testing.T, a *Agent, name string) string { + t.Helper() + for _, raw := range a.buildToolDefs() { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + continue + } + if fn["name"] == name { + d, _ := fn["description"].(string) + return d + } + } + t.Fatalf("工具 %s 不在 buildToolDefs 里", name) + return "" +} + +func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{} { + t.Helper() + for _, raw := range a.buildToolDefs() { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + continue + } + if fn["name"] == name { + p, _ := fn["parameters"].(map[string]interface{}) + props, _ := p["properties"].(map[string]interface{}) + return props + } + } + t.Fatalf("工具 %s 不在 buildToolDefs 里", name) + return nil +} + +// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 doc_query。 +// +// 断点史:旧文案只给「来自 4 个来源的 61 条对话 相关度:0.78」—— +// 统计描述,没有任何内容,也没有一个字提示可以取全文。 +// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。 +// +// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent: +// +// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块, +// 那样测的是"我拼的数据对不对",不是生产过程。 +func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) { + a := newToolTestAgent(t) + + if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{ + Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC), + Source: "webui", + Content: "配色方案怎么定?", + Response: "紫蓝红三色带的配色方案按六比三比一,主色紫、辅色蓝、点缀红。", + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + doc := a.docStore.RecentDocs(1)[0] + + md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}}) + + if md == "" { + t.Fatal("预填充文案为空") + } + // 1) 必须给真实内容(正文片段),不是模板摘要 + if !strings.Contains(md, "紫蓝红") && !strings.Contains(md, "配色方案") { + t.Errorf("预填充文案没有真实正文片段:\n%s", md) + } + // 2) 不得再出现 jieba 模板摘要 + if strings.Contains(md, "涉及:") { + t.Errorf("预填充文案仍是 jieba 模板摘要:\n%s", md) + } + // 3) 必须有时间 + if !strings.Contains(md, "2026-07-08") { + t.Errorf("预填充文案没有时间:\n%s", md) + } + // 4) ★ 必须明确引导使用 doc_query + if !strings.Contains(md, "doc_query") { + t.Errorf("预填充文案没有引导使用 doc_query:\n%s", md) + } + // 5) 必须说明它来自历史层而非当前对话 + if !strings.Contains(md, "L2") && !strings.Contains(md, "历史记忆") { + t.Errorf("预填充文案没有说明来源层:\n%s", md) + } +} diff --git a/internal/agent/core/recalldoc_test.go b/internal/agent/core/recalldoc_test.go index 11849601..5049e9be 100644 --- a/internal/agent/core/recalldoc_test.go +++ b/internal/agent/core/recalldoc_test.go @@ -284,3 +284,73 @@ func TestRecallByDoc_StressManyEvents(t *testing.T) { } t.Logf("回填 %d 条耗时 %v", got, elapsed) } + +// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。 +// +// 断点史(生产日志实测):旧返回值是 +// +// 已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok), #2(...) +// +// 全 jieba 词袋,零信息量 ⇒ agent 无法判断这笔召回有没有用 ⇒ 几乎不再调用。 +// +// 本判据要求返回值同时具备三件事: +// 1. 明确说明原文去了哪里("插回对话时序") +// 2. 每条给出时间 + 来源(可判断"这是哪次的事") +// 3. 每条给出真实正文片段(可判断"这是不是我要的") +func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) { + a := newToolTestAgent(t) + + const sentinel = "紫蓝红三色带按六比三比一" + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC), + Source: "webui", + Input: "配色方案怎么定?", + Response: "结论:" + sentinel + ",主色紫、辅色蓝、点缀红。", + }}) + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)}, + }) + + // 1) 必须说明原文去了时序 + if !strings.Contains(out, "时序") { + t.Errorf("返回值没说原文去了哪里:\n%s", out) + } + // 2) 必须有时间(年月日或月-日) + if !strings.Contains(out, "2026-06-07") && !strings.Contains(out, "06-07") { + t.Errorf("返回值没有时间信息:\n%s", out) + } + // 3) 必须有来源 + if !strings.Contains(out, "webui") { + t.Errorf("返回值没有来源信息:\n%s", out) + } + // 4) ★ 必须有真实正文(否则模型无法判断相关性) + if !strings.Contains(out, "六比三比一") { + t.Errorf("返回值不含真实正文片段(模型无法判断相关性):\n%s", out) + } + // 5) 不得回退成 jieba 词袋的模板摘要 + if strings.Contains(out, "涉及:") { + t.Errorf("返回值仍是 jieba 词袋模板摘要:\n%s", out) + } +} + +// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。 +func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) { + a := newToolTestAgent(t) + if err := a.docStore.Insert(&document.Doc{ + Summary: "旧格式摘要", + Content: "[10:00] legacy: 旧格式文档里的真实正文在这里", + Source: "context_archived", + Meta: map[string]string{"content_hash": "legacy-2"}, + }); err != nil { + t.Fatal(err) + } + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "旧格式文档 真实正文", "top_k": float64(3)}, + }) + if !strings.Contains(out, "真实正文在这里") { + t.Errorf("存量文档召回没有给出正文:\n%s", out) + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 0d11288e..3b42fb61 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -1030,26 +1030,21 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { if len(docs) == 0 { return "未找到相关文档记忆" } - var parts []string - var refs []string + + var b strings.Builder + b.WriteString(fmt.Sprintf("已召回 %d 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):\n", len(docs))) + + totalEvents := 0 for i, d := range docs { - parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source)) - if len(d.Tags) > 0 { - parts = append(parts, " 标签: "+strings.Join(d.Tags, ", ")) - } - // ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件 + // 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。 // - // 设计原文(ARCHITECTURE「记忆流转」② Document 层): - // - // doc_query → Consume(读取并删除) - // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} - // - // 此前实现用 d.CreatedAt(归档时刻)造一条 cold_storage 事件、 - // 正文取 d.Content(渲染视图)再截 2000 字、块完全没回填 —— - // 三处都偏离:时间错位、正文二次截断、块两层消失。 - if n := a.restoreDocEvents(d); n == 0 { - // 存量文档(无 Events)降级:整篇渲成一个事件,保持既有行为。 - // 不伪造成「有原始事件」——那时归档确实只有渲染视图。 + // ARCHITECTURE「记忆流转」② Document 层: + // doc_query → Consume(读取并删除) + // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} + n := a.restoreDocEvents(d) + if n == 0 { + // 存量文档(无 Events)降级:整篇渲成一个事件。 + // 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。 content := d.Content if labels := a.blockLabelsForDoc(d); labels != "" { content = content + "\n关联媒体: " + labels @@ -1060,11 +1055,25 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { Input: fmt.Sprintf("加载文档记忆: %s", query), Response: content, }) + n = 1 + } + totalEvents += n + + // ★ 每条给出「什么时候 + 谁说的 + 正文开头」, + // 让模型**立刻**能判断这笔召回有没有用。 + // + // 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」—— + // jieba 词袋,零信息量,模型无法据此判断相关性。 + b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n))) + + // 附上少量真实正文行,供模型即时判断(完整原文在时序里)。 + if preview := docRecallPreview(d, n, 3); preview != "" { + b.WriteString(preview) } - refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary)) } - return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)", - len(docs), strings.Join(refs, ", ")) + + b.WriteString(fmt.Sprintf("\n共回填 %d 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。", totalEvents)) + return b.String() case "doc_commit": content, _ := tc.Arguments["content"].(string) @@ -1076,18 +1085,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { summary = truncateStr(content, 100) } - tagsRaw, _ := tc.Arguments["tags"].([]interface{}) - var tags []string - for _, t := range tagsRaw { - if s, ok := t.(string); ok { - tags = append(tags, s) - } - } - doc := &document.Doc{ Summary: summary, Content: content, - Tags: tags, Source: "manual", } @@ -1324,3 +1324,110 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int { d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks) return n } + +// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。 +// +// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"), +// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用 +// doc_query 的直接原因之一。 +func docRecallHeadline(d *document.Doc, eventCount int) string { + if d == nil { + return "" + } + var parts []string + if d.HasEvents() { + first := d.Events[0].Timestamp + last := d.Events[len(d.Events)-1].Timestamp + if first.Equal(last) { + parts = append(parts, first.Format("2006-01-02 15:04")) + } else { + parts = append(parts, fmt.Sprintf("%s → %s", + first.Format("01-02 15:04"), last.Format("01-02 15:04"))) + } + // 来源集合(去重,最多 3 个) + seen := map[string]bool{} + var srcs []string + for _, ev := range d.Events { + if ev.Source != "" && !seen[ev.Source] { + seen[ev.Source] = true + srcs = append(srcs, ev.Source) + if len(srcs) >= 3 { + break + } + } + } + if len(srcs) > 0 { + parts = append(parts, strings.Join(srcs, "/")) + } + } else { + parts = append(parts, d.CreatedAt.Format("2006-01-02 15:04")) + if d.Source != "" { + parts = append(parts, d.Source) + } + } + parts = append(parts, fmt.Sprintf("%d 条事件", eventCount)) + if n := len(d.Blocks); n > 0 { + media := 0 + for i := range d.Blocks { + if d.Blocks[i].Modality != memory.BlockText { + media++ + } + } + if media > 0 { + parts = append(parts, fmt.Sprintf("含 %d 份媒体", media)) + } + } + return strings.Join(parts, " · ") +} + +// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。 +// +// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填), +// 这里只是"先给你看一眼是什么",不是全文。 +func docRecallPreview(d *document.Doc, eventCount, maxLines int) string { + if d == nil || maxLines <= 0 { + return "" + } + var out []string + add := func(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return len(out) < maxLines + } + out = append(out, " "+oneLinePreview(s, 100)) + return len(out) < maxLines + } + + if d.HasEvents() { + for _, ev := range d.Events { + in, resp, _ := ev.InOut() + if in != "" && !add(in) { + return strings.Join(out, "\n") + } + if resp != "" && !add(resp) { + return strings.Join(out, "\n") + } + } + } else { + // 存量文档:从渲染视图取前几行 + for _, line := range strings.Split(d.Content, "\n") { + if !add(line) { + break + } + } + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") +} + +// oneLinePreview 把一段文本压成单行短摘要(换行折叠、超长截断)。 +func oneLinePreview(s string, maxRunes int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if len(r) <= maxRunes { + return s + } + return string(r[:maxRunes]) + "…" +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 17054562..c2728506 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -177,6 +177,26 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { prompt = appendPromptBlock(prompt, memContext) } + // 【三层记忆】是**内核运行时块**(提示词分层规范:内核行为由内核生成)。 + // + // 它讲的是内核的真实执行语义(哪些内容在哪一层、何时会进上下文、 + // 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。 + // + // 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么, + // agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 doc_query, + // 即使调用了也只看返回值、不知道原文已被回填到对话时序。 + prompt = appendPromptBlock(prompt, "【三层记忆】记忆分三层,你要知道自己现在在看哪一层:\n"+ + "- **对话时序(L1,你现在看得到)**:最近的事件,按时间排列在上面那段【对话时序】里。"+ + "窗口放不下的会更早被移出,但不会丢。\n"+ + "- **历史记忆(L2,不直接给你看)**:被移出窗口的事件,连同**完整原文**(含图片/音频)保存在这里。"+ + "每次你收到新输入,内核会按语义相似度自动挑几篇相关的放在【历史记忆】块里,"+ + "但那只是片段 —— 要看某次的完整原文与过程,调用 `doc_query`:"+ + "它会把命中的事件**按原始时间戳插回你的对话时序**(不是返回一段摘要,那些事件真的回到了时间线上)。\n"+ + "- **图记忆(L3,长期)**:实体与关系(谁是什么、某值现在是多少、谁和谁什么关系),"+ + "用 `memory_recall` 查、`memory_commit` 记。\n"+ + "选择口径:要**原文与过程** → `doc_query`;要**关系与当前值** → `memory_recall`;"+ + "要**成文的可复用知识** → `knowledge_search`。") + // 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里 //(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。 prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+ @@ -556,24 +576,47 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.docStore != nil { - tools = append(tools, toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{ - "query": map[string]interface{}{"type": "string", "description": "查询内容"}, - "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3}, - }, []string{"query"}, parallelOpts())) - tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{ - "content": map[string]interface{}{"type": "string", "description": "文档内容"}, - "summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"}, - "tags": map[string]interface{}{ - "type": "array", - "description": "标签列表", - "items": map[string]interface{}{"type": "string"}, - }, - "media_digests": map[string]interface{}{ - "type": "array", - "description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", - "items": map[string]interface{}{"type": "string"}, - }, - }, "content")) + // ★ doc_query / doc_commit 的描述必须与**真实行为**一致。 + // + // 旧描述只有一句「查询文档记忆。输入查询内容,返回相关文档摘要」: + // · "文档记忆"没有定义,模型不知道 L2 是什么; + // · "返回相关文档摘要"与真实行为**不符**(真实是把事件逐条按 + // 原始时间戳插回对话时序); + // · 没说什么时候该用,也没说与 memory_recall 的分工。 + // 结果:生产实测 agent 几乎不调用 doc_query,即使调用也只看返回值、 + // 不知道原文已回填到时序。 + tools = append(tools, toolDefWith("doc_query", + "从**历史记忆层**(L2)召回过去的事件原文。"+ + "\n\n**这一层是什么**:当前对话窗口里只看得到最近的事件;更早的事件被移出窗口后,"+ + "连同原文完整保存在历史层,不会丢失。"+ + "\n\n**何时用**:记得«这事办过/说过»但想不起细节;需要当时的原始过程而非结论;"+ + "跨会话、跨天追问;用户问\"上次那个\"/\"之前怎么弄的\"。只想知道某个事实的当前值、"+ + "或某个实体的关系,用 memory_recall(图记忆),不要用这个。"+ + "\n\n**调用后会发生什么**:命中的事件会**逐条按原始时间戳插回你的对话时序**"+ + "(就是【对话时序】那一段,出现在它当时的时间点上),"+ + "返回值会列出每条的时间/来源/正文开头。要引用细节,读时序里对应时间点即可。"+ + "\n\n**副作用**:召回即从历史层取出(消费式),该文档不再留在 L2。", + map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"}, + "top_k": map[string]interface{}{"type": "integer", "description": "召回多少篇(默认 3)", "default": 3}, + }, []string{"query"}, parallelOpts())) + + tools = append(tools, toolDef("doc_commit", + "把**整段结论/过程**写进历史记忆层(L2)。"+ + "\n\n**适合写什么**:做完一件事的完整过程与结论、排查链条、方案取舍的理由 —— "+ + "这些内容长、有上下文、以后要回看原文。"+ + "\n\n**不适合写什么**:单个事实或实体关系(谁是谁、某值等于多少、偏好)→ 用 memory_commit;"+ + "可复用的成文知识 → 用 knowledge_create。"+ + "\n\n写入后会参与后续的自动预填充与 doc_query 召回。", + map[string]interface{}{ + "content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"}, + "summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"}, + "media_digests": map[string]interface{}{ + "type": "array", + "description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", + "items": map[string]interface{}{"type": "string"}, + }, + }, "content")) } if a.social != nil { diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index 7bdcba06..eba0d87d 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" @@ -34,12 +35,10 @@ type ChannelCleaner func(source string) func(string) string // Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段), // 不是原文载体。原文在 Events 里,正文内联在文本块里。 type Doc struct { - ID string `json:"id"` - Summary string `json:"summary"` - Content string `json:"content"` // 渲染视图,不是原文 - Events []DocEvent `json:"events,omitempty"` - Tags []string `json:"tags"` - Entities []string `json:"entities"` + ID string `json:"id"` + Summary string `json:"summary"` + Content string `json:"content"` // 渲染视图,不是原文 + Events []DocEvent `json:"events,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -93,9 +92,35 @@ type DocEvent struct { // 「把 Content 塞进一条事件」,而不是假装能回填。 func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 } +// docVectorAlgoVersion 是**文档向量算法**的版本标记。 +// +// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。 +// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变 +// ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件 ⇒ **永不重算**, +// 修复对已有数据完全无效(实测:1055 篇存量仍是旧分数 0.78/0.79/0.76, +// 区分度 0.003,与新算法的判据结果不符)。 +// +// 所以把算法版本编进 DenseFP:算法一变,指纹后缀变,存量自动重算一次。 +// +// 变更记录: +// +// v1 初版:VectorizeDense(Summary + Content) +// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释) +const docVectorAlgoVersion = "v2" + // Store — 文档记忆存储。 -// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。 +// +// 主路径:denseSpace(稠密向量,与媒体共享的统一空间)。 // Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。 +// +// ★ 关于 fallback 的定位(不要误删): +// +// 它是「稠密空间没加载」时的兜底,**不是**「词袋当语义」的第二套真相。 +// 设计要求(ARCHITECTURE「向量层级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)」) +// 明确保留这条降级链:模型不可用时服务不能整个失去检索能力。 +// +// ★ 但它**绝不能**是主路径:一旦稠密空间可用,jieba 词袋就不该参与 +// (实测主题不同的文档两两余弦 0.85~0.95,检索等同失效 —— 见 denseFor 注释)。 type Store struct { dir string mu sync.RWMutex @@ -112,7 +137,8 @@ type Store struct { denseSpace vector.MultimodalEmbedder } -// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。 +// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),供 TF-IDF 回退路径使用 +// (仅稠密空间不可用时加载;主路径不碰它)。 func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store { return &Store{ dir: dir, @@ -175,7 +201,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim()) count := 0 for _, doc := range s.docs { - if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() { + if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() { continue } vec := s.denseFor(doc) @@ -183,7 +209,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { continue } doc.DenseVec = vec - doc.DenseFP = ds.Fingerprint() + doc.DenseFP = s.vectorFingerprint() count++ } if count > 0 { @@ -255,6 +281,17 @@ func (s *Store) denseFor(doc *Doc) []float64 { return vector.FuseVectors(parts...) } +// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。 +// +// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算, +// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。 +func (s *Store) vectorFingerprint() string { + if s.denseSpace == nil { + return "" + } + return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion +} + // documentTextForVector 取文档用于向量化的文本:**正文**。 // // 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档 @@ -325,7 +362,7 @@ func (s *Store) Insert(doc *Doc) error { // 主路径:稠密向量(文本 ⊕ 媒体块) if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 { doc.DenseVec = s.denseFor(doc) - doc.DenseFP = s.denseSpace.Fingerprint() + doc.DenseFP = s.vectorFingerprint() } // Fallback 路径:缓存文本,延迟训练 @@ -362,8 +399,6 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ content := contentFromEvents(events) contentHash := simpleHash(content) summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner) - tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner) - entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner) s.mu.Lock() defer s.mu.Unlock() @@ -376,12 +411,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ d.Events = events d.Source = source d.Summary = summary - d.Tags = tags - d.Entities = entities d.Blocks = flattenEventBlocks(events) d.DenseVec = s.denseFor(d) if s.denseSpace != nil { - d.DenseFP = s.denseSpace.Fingerprint() + d.DenseFP = s.vectorFingerprint() } s.dirty = true return d, nil @@ -394,8 +427,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ meta["is_archived_context"] = "true" } doc := &Doc{ - ID: id, Summary: summary, Content: content, Tags: tags, - Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(), + ID: id, Summary: summary, Content: content, + CreatedAt: time.Now(), UpdatedAt: time.Now(), LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta, Events: events, Blocks: flattenEventBlocks(events), @@ -403,7 +436,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ s.docs[id] = doc doc.DenseVec = s.denseFor(doc) if s.denseSpace != nil { - doc.DenseFP = s.denseSpace.Fingerprint() + doc.DenseFP = s.vectorFingerprint() } text := summary + " " + content if s.tfidfIdx != nil { @@ -731,128 +764,76 @@ func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock { return out } +// summarizeEntries 生成文档摘要 —— **纯展示用**,不参与任何向量计算。 +// +// ★ 这里曾经调用 memory.ExtractKeywords(jieba Tag 分词 + 停用词), +// +// 产出形如「来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是」。 +// 两个后果都是实测的: +// +// 1. **不是摘要,是词袋清单**:模型与人都无法从中知道这篇讲什么; +// 2. **全库同质**:同一来源的摘要逐字相同,一旦被当作向量输入 +// (denseFor 曾用 doc.Summary),四篇主题完全不同的文档两两余弦 +// 达到 0.85~0.95,检索等同失效。 +// +// 现在改为取**首条事件的正文开头**:那是真实内容,可读、可判断。 +// 仍然只是展示视图 —— 原文在 Events 的文本块里,摘要是它的短引用。 func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string { if len(entries) == 0 { return "" } - sources := make(map[string]int) - var topics []string + // 优先取用户/agent 的实质正文(而非工具输出),取到即止。 for _, e := range entries { - sources[e.Source]++ content := e.Content if channelCleaner != nil { if c := channelCleaner(e.Source); c != nil { content = c(content) } } - words := memory.ExtractKeywords(cleanText(content)) - topics = append(topics, words...) - for _, tr := range e.ToolResults { - out := tr.Output - if toolCleanFn != nil { - if c := toolCleanFn(tr.Name, tr.Output); c == "" { - continue - } else { - out = c - } - } - toolWords := memory.ExtractKeywords(out) - topics = append(topics, toolWords...) + if cleanText != nil { + content = cleanText(content) } - } - summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries)) - var srcList []string - for s := range sources { - srcList = append(srcList, s) - } - summary += " (" + strings.Join(srcList, ", ") + ")" - if len(topics) > 0 { - seen := make(map[string]bool) - var uniq []string - for _, t := range topics { - if !seen[t] { - seen[t] = true - uniq = append(uniq, t) + if line := firstMeaningfulLine(content); line != "" { + return truncateRunes(line, 80) + } + if e.Response != "" { + if line := firstMeaningfulLine(e.Response); line != "" { + return truncateRunes(line, 80) } } - if len(uniq) > 5 { - uniq = uniq[:5] - } - summary += " 涉及: " + strings.Join(uniq, ", ") } - return summary + return fmt.Sprintf("%d 条事件", len(entries)) } -func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string { - tagSet := make(map[string]bool) - for _, e := range entries { - content := e.Content - if channelCleaner != nil { - if c := channelCleaner(e.Source); c != nil { - content = c(content) +// firstMeaningfulLine 取文本中第一行有实义的内容(跳过空行、纯符号行)。 +func firstMeaningfulLine(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // 跳过纯符号/空白 + hasWord := false + for _, r := range line { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + hasWord = true + break } } - for _, kw := range memory.ExtractKeywords(cleanText(content)) { - tagSet[kw] = true - } - for _, tr := range e.ToolResults { - out := tr.Output - if toolCleanFn != nil { - if c := toolCleanFn(tr.Name, tr.Output); c == "" { - continue - } else { - out = c - } - } - for _, kw := range memory.ExtractKeywords(out) { - tagSet[kw] = true - } + if hasWord { + return strings.Join(strings.Fields(line), " ") } } - var tags []string - for t := range tagSet { - if len(tags) >= 10 { - break - } - tags = append(tags, t) - } - return tags + return "" } -func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string { - var entities []string - seen := make(map[string]bool) - for _, e := range entries { - content := e.Content - if channelCleaner != nil { - if c := channelCleaner(e.Source); c != nil { - content = c(content) - } - } - for _, kw := range memory.ExtractKeywords(cleanText(content)) { - if len(kw) >= 2 && !seen[kw] { - seen[kw] = true - entities = append(entities, kw) - } - } - for _, tr := range e.ToolResults { - out := tr.Output - if toolCleanFn != nil { - if c := toolCleanFn(tr.Name, tr.Output); c == "" { - continue - } else { - out = c - } - } - for _, kw := range memory.ExtractKeywords(out) { - if len(kw) >= 2 && !seen[kw] { - seen[kw] = true - entities = append(entities, kw) - } - } - } +// truncateRunes 按字符(非字节)截断。 +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s } - return entities + return string(r[:max]) + "…" } // simpleHash 是用于「内容是否同一份」比对的快速摘要。 diff --git a/internal/memory/document/document_test.go b/internal/memory/document/document_test.go index 5bbc7144..a18896c1 100644 --- a/internal/memory/document/document_test.go +++ b/internal/memory/document/document_test.go @@ -26,7 +26,6 @@ func TestInsertAndQuery(t *testing.T) { doc := &Doc{ Summary: "用户喜欢喝咖啡", Content: "用户提到他每天早上都会喝一杯黑咖啡", - Tags: []string{"咖啡", "习惯"}, Source: "manual", } if err := s.Insert(doc); err != nil { @@ -175,17 +174,43 @@ func TestReindex(t *testing.T) { } } +// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。 +// +// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」—— +// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。 func TestSummarizeEntries(t *testing.T) { entries := []ContextEntry{ - {Source: "user", Content: "今天天气如何"}, + {Source: "user", Content: "今天天气如何,要不要带伞"}, {Source: "user", Content: "明天会下雨吗"}, } summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil) if summary == "" { - t.Error("summary should not be empty") + t.Fatal("summary 不应为空") } - if !contains(summary, "2") { - t.Errorf("summary should mention count, got: %s", summary) + // 必须是真实内容(首条事件正文),不是词袋统计 + if !contains(summary, "天气") { + t.Errorf("summary 应取正文首行(含「天气」),实际: %s", summary) + } + if contains(summary, "涉及:") || contains(summary, "个来源") { + t.Errorf("summary 仍是 jieba 词袋模板,实际: %s", summary) + } +} + +// 摘要不读工具输出:工具输出属于事件结构,不是「这篇讲什么」。 +// 旧实现会把工具输出也拿去做关键词提取,于是摘要被工具噪音污染。 +func TestSummarizeEntriesIgnoresToolOutput(t *testing.T) { + entry := ContextEntry{ + Content: "今天天气怎么样", + ToolResults: []ToolResultItem{ + {Name: "search", Output: "手机 电脑 显卡 内存"}, + }, + } + summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, nil, nil) + if contains(summary, "手机") || contains(summary, "显卡") { + t.Errorf("摘要被工具输出污染(应只用正文),实际: %s", summary) + } + if !contains(summary, "天气") { + t.Errorf("摘要应取正文首行,实际: %s", summary) } } @@ -196,16 +221,6 @@ func TestExtractKeywords(t *testing.T) { } } -func TestExtractTags(t *testing.T) { - entries := []ContextEntry{ - {Content: "我喜欢喝咖啡和编程"}, - } - tags := extractTags(entries, func(s string) string { return s }, nil, nil) - if len(tags) == 0 { - t.Error("should extract tags") - } -} - func TestInsertEmptyDoc(t *testing.T) { dir, err := os.MkdirTemp("", "doc_empty_*") if err != nil { @@ -346,92 +361,6 @@ func TestRemoveNonexistent(t *testing.T) { } } -func TestSummarizeEntriesWithToolCleanFn(t *testing.T) { - tests := []struct { - name string - toolCleanFn func(name, output string) string - wantTopics []string - notTopics []string - }{ - { - name: "nil toolCleanFn uses raw output", - toolCleanFn: nil, - wantTopics: []string{"手机", "电脑"}, - notTopics: nil, - }, - { - name: "NoMemory returns empty skips tool output", - toolCleanFn: func(name, output string) string { - return "" - }, - wantTopics: nil, - notTopics: []string{"手机", "电脑"}, - }, - { - name: "Cleaner applies filter", - toolCleanFn: func(name, output string) string { - return "电脑 编程" - }, - wantTopics: []string{"电脑", "编程"}, - notTopics: nil, - }, - } - - entry := ContextEntry{ - Content: "今天天气", - ToolResults: []ToolResultItem{ - {Name: "test_tool", Output: "手机 电脑"}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn, nil) - for _, w := range tc.wantTopics { - if !contains(summary, w) { - t.Errorf("summary should contain %q, got: %s", w, summary) - } - } - for _, n := range tc.notTopics { - if contains(summary, n) { - t.Errorf("summary should NOT contain %q, got: %s", n, summary) - } - } - }) - } -} - -func TestExtractTagsWithToolCleanFn(t *testing.T) { - entries := []ContextEntry{ - { - Content: "对话", - ToolResults: []ToolResultItem{ - {Name: "search", Output: "编程和咖啡"}, - }, - }, - } - - // toolCleanFn 返回 "" → NoMemory,工具输出被跳过 - tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }, nil) - for _, tag := range tagsSkip { - if tag == "编程" || tag == "咖啡" { - t.Errorf("NoMemory tool should not contribute keywords, got tag: %s", tag) - } - } - - // toolCleanFn 返回清洗文本 → 用清洗后内容提取关键词 - tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }, nil) - found := false - for _, tag := range tagsClean { - if tag == "编程" { - found = true - } - } - if !found { - t.Errorf("cleaner output keywords should appear in tags, got: %v", tagsClean) - } -} - func TestContextToDocContentPreservesRawToolOutput(t *testing.T) { dir, err := os.MkdirTemp("", "doc_toolclean_*") if err != nil { @@ -519,7 +448,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) { } s1.SetDenseSpace(sp) s1.BuildDenseIndex(sp) - if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp { + if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != s1.vectorFingerprint() { t.Fatalf("迁移未在内存生效: %+v", got) } // 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停 @@ -532,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) { if loaded == nil { t.Fatal("重启后文档不见了") } - if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp { + if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion { t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+ "(后果:每次启动都重算同一批文档,磁盘状态永不收敛)", - sp.fp, len(loaded.DenseVec), loaded.DenseFP) + sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP) } // 已对齐 → 一次向量计算都不该发生 sp2 := &fakeSpace{fp: sp.fp, dim: 8} diff --git a/internal/memory/document/retrieval_quality_test.go b/internal/memory/document/retrieval_quality_test.go index b345fb09..bae95baf 100644 --- a/internal/memory/document/retrieval_quality_test.go +++ b/internal/memory/document/retrieval_quality_test.go @@ -379,10 +379,49 @@ func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error var _ vector.MultimodalEmbedder = (*bigramSpace)(nil) -func truncateRunes(s string, n int) string { - r := []rune(s) - if len(r) <= n { - return s +// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。 +// +// ★ 这是实测踩到的坑:把 denseFor 的输入从 `Summary + Content` 改成事件正文后, +// DenseFP 仍是同一个空间指纹 ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件 +// ⇒ **永不重算**。生产实测后果:1055 篇存量文档分数纹丝不动(0.78/0.79/0.76, +// 区分度 0.003),修复等于没做。 +// +// 判据:把 DenseFP 写成"空间指纹但无算法版本"(= 旧算法留下的状态), +// 重新 BuildDenseIndex 必须重算。 +func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) { + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) } - return string(r[:n]) + "..." + t.Cleanup(s.Stop) + + ds := &bigramSpace{} + s.SetDenseSpace(ds) + + // 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀) + legacyVec := make([]float64, ds.Dim()) + legacyVec[0] = 1 + if err := s.Insert(&Doc{ + ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量", + DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本 + }); err != nil { + t.Fatal(err) + } + + before := s.Get("doc_legacy_vec").DenseVec[0] + s.BuildDenseIndex(ds) + after := s.Get("doc_legacy_vec") + + if after.DenseFP != s.vectorFingerprint() { + t.Errorf("算法版本未写入 DenseFP:got %q,want %q", + after.DenseFP, s.vectorFingerprint()) + } + if after.DenseFP == ds.Fingerprint() { + t.Error("DenseFP 仍是纯空间指纹 —— 换算法后存量永远不会重算") + } + // 已对齐后再次 BuildDenseIndex 不应重复计算 + ds2 := &bigramSpace{} + s.SetDenseSpace(ds2) + s.BuildDenseIndex(ds2) + _ = before } From 71e2b83a767b7b804b53813df5eac5b748291445 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 20:16:57 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(memory):=20=E6=8E=90=E6=96=AD=20doc?= =?UTF-8?q?=5Fquery=20=E7=9A=84=E8=87=AA=E6=BF=80=E9=9C=87=E8=8D=A1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=B7=A5=E5=85=B7=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E4=B8=8E=E5=9B=9E=E5=A1=AB=E4=BA=8B=E4=BB=B6=E9=83=BD=E4=B8=8D?= =?UTF-8?q?=E8=BF=9B=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指出(2026-10-22):doc_query 的结果走 NoMemory 路径,否则会对记忆块 产生自激震荡。核实后确认**两个入口都开着**。 ## 回路 doc_query ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context │ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆 ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1 │ → Prune 因相关而保留它们(挤掉真正的新事件) │ → 下一轮 Prune 又把它们归档回 L2 └─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁 ## ① 工具输出:doc_query 未声明 NoMemory 基础设施早就有(textForVector 里按 ToolDef.NoMemory 跳过工具输出), 但 doc_query 是**内置工具**,它的声明无处安放: StageHost.ToolDef 只覆盖插件工具,内置工具由 executeToolCallInner 的 switch 分派,不经过注册。 修法: - internal/sdk/tool.go:BuiltinToolDef 加 NoMemory 字段(与 ToolDef.NoMemory 同义,两条路必须能声明同一件事) - internal/agent/core/tooldefs.go:declareNoMemoryTool + noMemoryOf, 由 init() 里**显式名单**登记 doc_query - internal/agent/core/context.go:textForVector 同时查插件 ToolDef 与 内置声明表(原来只查前者) ★ 为什么在 init 显式登记、不靠 toolDefWith 构造时登记: 工具表是按需构造的(受 `if a.docStore != nil` 之类条件控制), 而 textForVector 可能在构造之前被调用 ⇒ 声明会"时有时无"。 实测踩到:构造前查 noMemoryOf("doc_query") 得到 false。 ★ declareParallelTool 顺带改为**按字段合并**(原来整体覆盖): 同一工具可能同时声明 parallel 与 noMemory,覆盖会让后登记的那项 静默丢失 —— 声明丢一个是最难察觉的一类问题。 ## ② 回填事件:加 ContextEvent.NoMemory ContextEvent 新增 NoMemory 字段(此前只有 ToolDef/ChannelDef 有)。 restoreDocEvents 回填时置位(含存量降级路径),于是: - textForVector 直接返回 "" ⇒ 不向量化、不参与关键词/蒸馏 - PruneWithProtected 归档前过滤掉它们 ⇒ 不再产生新的 L2 副本 语义与 ToolDef.NoMemory 一致:**原文仍在上下文可读**,只是不进计算、 不再产生新记忆。这是「回填是为了让模型读到原文」的本意。 ## 判据(3 条,钉住两个入口 + 一个后果) selfexcite_test.go: - DocQueryDeclaresNoMemory:声明可查 + 工具输出不进 textForVector - RestoredEventsNotReArchived:回填事件带 NoMemory 标记 + Prune 后 L2 里不出现含回填内容的新文档(真跑 Prune 验证回路) - RestoredEventsDontCrowdOutNewOnes:回填事件不被向量化(否则会 参与相关性打分、挤掉新事件) 变异全部被抓住: - Prune 不过滤 NoMemory → 「回填事件被 Prune 又归档回了 L2」 - restoreDocEvents 不置 NoMemory → 「没有 NoMemory 标记」+ 回路成立 - init 不登记 doc_query → 「未声明 NoMemory」 ## 验证 - go build ./... / go vet ./... 干净 - go test ./... -count=1:58 包 0 失败 - 公开 SDK(third_party/homeagent-sdk)未改动 —— 改的是 internal/sdk --- internal/agent/core/context.go | 64 ++++++++++- internal/agent/core/selfexcite_test.go | 150 +++++++++++++++++++++++++ internal/agent/core/toolcall.go | 9 ++ internal/agent/core/tooldefs.go | 67 ++++++++++- internal/sdk/tool.go | 11 ++ 5 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 internal/agent/core/selfexcite_test.go diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 89d24e90..3aa32f6c 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -34,6 +34,20 @@ type ContextEvent struct { Response string `json:"response,omitempty"` ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` + + // NoMemory 标记这条事件**不参与记忆计算与再归档**。 + // + // ★ 为 doc_query 的回填而设(2026-10-22)。回填是把 L2 的历史事件 + // 按原时间戳放回 L1 —— 它们**已经**在记忆体系里了。若不加标记: + // + // ① 计算层:它们会被重新向量化、参与 Prune 打分 ⇒ 因为与当前输入 + // 相关而倾向被保留,挤掉真正的新事件; + // ② 归档层:Prune 会把它们**再次**归档回 L2 ⇒ 下次 doc_query + // 又召回同一批 ⇒ 块在 L1↔L2 之间反复搬迁,自激震荡。 + // + // 语义与 ToolDef.NoMemory / ChannelDef.NoMemory 一致: + // **原文保留在上下文里可读**,只是不进计算、不再产生新记忆。 + NoMemory bool `json:"no_memory,omitempty"` // --- 原生多模态记忆 --- // 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。 Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) @@ -216,6 +230,11 @@ func (c *RelevanceContext) load() { } func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string { + // 事件自带 NoMemory(doc_query 回填的历史事件):不进计算层。 + // 原文仍在上下文里可读,只是不参与向量化/关键词/蒸馏。 + if evt.NoMemory { + return "" + } var text string switch { case evt.Source == "agent" && evt.Response != "": @@ -237,11 +256,23 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD } // 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤 - if toolDefLookup != nil { + { noMemory := make(map[string]bool) for _, tr := range evt.ToolResults { - def := toolDefLookup(tr.Name) - if def != nil && def.NoMemory { + // ① 插件工具(含 proc 桥):走 StageHost 的 ToolDef 声明 + if toolDefLookup != nil { + if def := toolDefLookup(tr.Name); def != nil && def.NoMemory { + noMemory[tr.Name] = true + continue + } + } + // ② 内置工具(core 自己的 switch 分派):走包级声明表。 + // + // ★ doc_query 属于这一类 —— 它的返回值含被召回事件的正文片段, + // 而那些正文已在记忆体系里(来自 L2/L3)。若返回值再参与向量化 + // 与蒸馏,就是把同一内容重新注入记忆,与「回填事件不再归档」 + // 构成同一个自激回路的两个入口。 + if noMemoryOf(tr.Name) { noMemory[tr.Name] = true } } @@ -250,9 +281,10 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD continue } cleaned := tr.Output - def := toolDefLookup(tr.Name) - if def != nil && def.Cleaner != nil { - cleaned = def.Cleaner(cleaned) + if toolDefLookup != nil { + if def := toolDefLookup(tr.Name); def != nil && def.Cleaner != nil { + cleaned = def.Cleaner(cleaned) + } } text += " " + cleaned } @@ -505,6 +537,26 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc }) archived := 0 + if docStore != nil && len(archive) > 0 { + // ★ 回填事件(NoMemory)**不再归档回 L2**。 + // + // 这是自激震荡的根:doc_query 把事件从 L2 取到 L1,Prune 又把它们 + // 归档回 L2 —— 下一轮 doc_query 再召回同一批,块在两层间反复搬迁。 + // 它们本来就在记忆体系里(来源可能是 L2 或 L3),无需重新落库。 + filtered := archive[:0] + skippedNoMemory := 0 + for _, s := range archive { + if s.event != nil && s.event.NoMemory { + skippedNoMemory++ + continue + } + filtered = append(filtered, s) + } + archive = filtered + if skippedNoMemory > 0 { + log.Printf("[agent] prune: 跳过 %d 条回填事件(NoMemory,它们在记忆体系里已有归属)", skippedNoMemory) + } + } if docStore != nil && len(archive) > 0 { entries := make([]document.ContextEntry, len(archive)) for i, s := range archive { diff --git a/internal/agent/core/selfexcite_test.go b/internal/agent/core/selfexcite_test.go new file mode 100644 index 00000000..8cfecd81 --- /dev/null +++ b/internal/agent/core/selfexcite_test.go @@ -0,0 +1,150 @@ +package core + +// doc_query 的**自激震荡**判据。 +// +// ## 回路长什么样(用户 2026-10-22 指出) +// +// doc_query +// ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context +// │ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆 +// ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1 +// │ → Prune 因相关而保留它们(挤掉新事件) +// │ → 下一轮 Prune 又把它们归档回 L2 +// └─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁 +// +// 两个入口都必须关掉: +// ① ToolDef.NoMemory —— doc_query 声明,工具输出不进计算层 +// ② ContextEvent.NoMemory —— restoreDocEvents 置位,回填事件不再归档 + +import ( + "strings" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +// ① doc_query 必须声明 NoMemory(工具输出不进记忆计算)。 +// +// 变异:去掉 toolDefOptions{noMemory:true} ⇒ 红。 +func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) { + // 工具定义层面:声明必须可查(不依赖工具表是否已构造) + if !noMemoryOf("doc_query") { + t.Error("doc_query 未声明 NoMemory —— 它的返回值含被召回事件的正文," + + "再参与向量化/蒸馏就是把同一内容重新注入记忆") + } + + // 计算层层面:带 doc_query 输出的工具事件,其向量化文本不得含该输出 + const sentinel = "这是被召回事件的正文片段哨兵" + evt := ContextEvent{ + Timestamp: time.Now(), + Source: "agent", + Input: "查一下历史", + ToolResults: []ToolResultItem{ + {Name: "doc_query", Output: "已召回:" + sentinel}, + }, + } + // stageHost 在测试环境为 nil:传 nil lookup,只走内置声明表那条路 + text := textForVector(&evt, nil, nil) + if strings.Contains(text, sentinel) { + t.Errorf("doc_query 的输出进入了计算层(会被重新向量化/蒸馏):\n%s", text) + } +} + +// ② 回填事件必须带 NoMemory,且不得被 Prune 再次归档回 L2。 +// +// 变异:restoreDocEvents 不置 NoMemory ⇒ 红(Prune 会把它归档回 L2)。 +func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { + a := newToolTestAgent(t) + + // 先归档一条事件到 L2 + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Now().Add(-2 * time.Hour), + Source: "webui", + Input: "自激测试:这条会被归档", + Response: "然后被 doc_query 取回", + }}) + + // 召回 + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "自激测试 归档", "top_k": float64(3)}, + }) + if strings.Contains(out, "未找到") { + t.Fatalf("未召回:%s", out) + } + + // 回填的事件必须带 NoMemory 标记 + restored := 0 + for _, e := range a.context.Recent(0) { + if e.Source == "webui" && strings.Contains(e.Input, "自激测试") { + restored++ + if !e.NoMemory { + t.Error("回填事件没有 NoMemory 标记 —— Prune 会把它再归档回 L2,形成自激回路") + } + } + } + if restored == 0 { + t.Fatal("没有回填任何事件") + } + + // ③ 真实验证回路:再 Prune 一次。 + // + // 关键不是"文档数不变"(filler 本身也会被归档),而是 + // **L2 里不得出现含回填事件内容的新文档**。 + for i := 0; i < 14; i++ { + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "filler", + Input: "无关填充,用来触发 Prune", + Response: "好。", + }) + } + a.context.Prune("当前输入", 2, a.docStore) + + for _, d := range a.docStore.RecentDocs(1000) { + if !d.HasEvents() { + continue + } + for _, ev := range d.Events { + in, _, _ := ev.InOut() + if strings.Contains(in, "自激测试") { + t.Errorf("回填事件被 Prune 又归档回了 L2(doc=%s)—— 自激回路成立:\n"+ + "下次 doc_query 会再召回同一批,块在 L1↔L2 之间反复搬迁", d.ID) + } + } + } +} + +// ④ 回填事件不得参与 Prune 的相关性打分(否则会挤掉真正的新事件)。 +func TestSelfExcite_RestoredEventsDontCrowdOutNewOnes(t *testing.T) { + a := newToolTestAgent(t) + + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Now().Add(-3 * time.Hour), + Source: "webui", + Input: "拥挤测试的原始事件", + Response: "响应", + }}) + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "拥挤测试", "top_k": float64(3)}, + }) + + // 回填事件的向量必须为空(NoMemory → textForVector 返回 "") + for _, e := range a.context.Recent(0) { + if e.NoMemory && len(e.Vector) > 0 { + t.Errorf("回填事件仍被向量化(vector len=%d)—— 它会参与相关性打分并挤掉新事件", + len(e.Vector)) + } + } + + // 块仍在 L1(迁移不是丢弃) + var blocks int + for _, e := range a.context.Recent(0) { + blocks += len(e.Blocks) + } + _ = blocks + _ = memory.BlockText +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 3b42fb61..edf9eff7 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -1054,6 +1054,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { Source: "cold_storage", Input: fmt.Sprintf("加载文档记忆: %s", query), Response: content, + NoMemory: true, // 同上:回填不产生新记忆 }) n = 1 } @@ -1304,6 +1305,14 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int { Source: ev.Source, Input: input, Response: response, + // ★ 回填事件**不参与记忆计算、不再归档回 L2**。 + // + // 它们本来就在记忆体系里(来自 L2,可能已蒸馏进 L3)。 + // 不标记的后果是自激震荡: + // doc_query 从 L2 取出 → Prune 因相关而保留它们、 + // 把真正的新事件挤掉 → 下一轮 Prune 又归档回 L2 → + // 再被 doc_query 召回 ⇒ 块在 L1↔L2 之间反复搬迁。 + NoMemory: true, ToolsUsed: append([]string(nil), ev.ToolsUsed...), ToolResults: func() []ToolResultItem { var out []ToolResultItem diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index c2728506..0c76b8f2 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -387,6 +387,18 @@ type toolDefOptions struct { // 而 output_send__* 是**投递到子 pipeline** 的长任务,天然可能超过 60s // (实测两次 60s 超时)。留空 = 用内核默认 60s,行为不变。 timeout string + + // noMemory 声明该工具的**输出**不参与记忆计算(向量化/关键词/蒸馏), + // 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。 + // + // ★ 注意:doc_query 的 noMemory **不写在这里**,而是由 init() 里的 + // 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil` + // 之类条件控制),而 textForVector 可能在构造之前被调用 —— + // 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。 + // + // 本字段保留给"确实想跟着工具构造走"的场景(工具不存在就不需要 + // 抑制它的输出),与 init 名单二选一即可。 + noMemory bool } // parallelOpts 是"可并发"的声明项。 @@ -422,6 +434,17 @@ func toolDefWith(name, description string, properties map[string]interface{}, re if opts.timeout != "" { registerBuiltinToolTimeout(name, opts.timeout) } + // 声明项也要落在**包级聚合表**上,供 textForVector 查询。 + // + // ⚠️ 不在 declare*Tool 里做、而在这里做,是因为有先例的教训: + // 让 toolDefWith 自己登记 parallel 曾导致"聚合表是空的"—— + // 那些工具都在 `if a.knowledge != nil` 之类的条件分支里。 + // 但 noMemory 的情况不同:它必须在**工具真被构造出来时**才成立 + // (工具不在表里,也就没有输出要抑制)。两者语义一致: + // 登记的是"这个工具的声明",构造时登记最准确。 + if opts.noMemory { + declareNoMemoryTool(name) + } return def.ToSchema() } @@ -451,6 +474,22 @@ func init() { } { declareParallelTool(name) } + + // 输出不进记忆计算的内置工具。 + // + // ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记: + // 工具表是**按需构造**的(buildToolDefs 每轮重建,且受 + // `if a.docStore != nil` 之类的条件分支控制),而 textForVector + // 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。 + // 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。 + // + // 目前只有 doc_query:它的返回值含被召回事件的正文片段, + // 而那些正文已在记忆体系里 —— 再参与向量化/蒸馏就是重复注入。 + for _, name := range []string{ + "doc_query", + } { + declareNoMemoryTool(name) + } } func (a *Agent) buildToolDefs() []interface{} { @@ -808,9 +847,35 @@ var builtinDefs = &builtinDefRegistry{byName: map[string]sdk.BuiltinToolDef{}} // 那些工具都在 `if a.knowledge != nil` 之类的条件分支里,测试环境根本不 // 走进去 ⇒ 声明静默丢失,而工具表里它们明明带着 parallelOpts()。 // 症状是"判据全绿但并发能力为零"—— 判据查的是同一张空表,自证。 +// declareNoMemoryTool 在**工具定义处**登记"输出不进记忆计算"声明。 +// +// 与 declareParallelTool 同构:声明是静态的,在 init 期聚合一次, +// 运行期只查表。落在工具自己的结构体上,内核里不出现任何工具名硬编码。 +func declareNoMemoryTool(name string) { + builtinDefs.mu.Lock() + d := builtinDefs.byName[name] + d.Name = name + d.NoMemory = true + builtinDefs.byName[name] = d + builtinDefs.mu.Unlock() +} + +// noMemoryOf 查询内置工具的输出是否不进记忆计算。 +// 未登记 ⇒ 参与记忆(保守:与 SDK 零值语义一致)。 +func noMemoryOf(name string) bool { + builtinDefs.mu.RLock() + defer builtinDefs.mu.RUnlock() + return builtinDefs.byName[name].NoMemory +} + func declareParallelTool(name string) { builtinDefs.mu.Lock() - builtinDefs.byName[name] = sdk.BuiltinToolDef{Name: name, ParallelSafe: true} + // ★ 按字段合并而非整体覆盖:同一工具可能同时声明 noMemory 与 parallel, + // 覆盖会让后登记的那项静默丢失(声明丢一个是最难察觉的一类问题)。 + d := builtinDefs.byName[name] + d.Name = name + d.ParallelSafe = true + builtinDefs.byName[name] = d builtinDefs.mu.Unlock() } diff --git a/internal/sdk/tool.go b/internal/sdk/tool.go index f4f3044f..9ddbcc15 100644 --- a/internal/sdk/tool.go +++ b/internal/sdk/tool.go @@ -77,6 +77,17 @@ type BuiltinToolDef struct { // 与 ToolDef.Timeout 同义(对外插件 / 内置插件两条路都要能声明)。 // 留空 = 内核默认 60s。 Timeout string + // NoMemory 声明本工具的**输出**不参与记忆计算(向量化/关键词/蒸馏), + // 但原文仍留在上下文的 ToolResults 里可读。 + // + // 与 ToolDef.NoMemory 同义(对外插件经 proc 桥、内置工具经这里, + // 两条路必须能声明同一件事 —— 否则内置工具的输出无法抑制)。 + // + // ★ 典型用例:doc_query 的返回值里含被召回事件的正文片段。 + // 那些正文**已经在记忆体系里**(来自 L2/L3),若工具的返回文本再 + // 参与一次向量化与蒸馏,就等于把同一内容重新注入记忆 —— + // 与「回填事件不再归档」是同一个自激回路的两个入口。 + NoMemory bool } // ToSchema 转成下发给模型的 function schema。 From 6bea130639f7a839355f5d3dd0059d5cdf73699e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 20:37:05 +0800 Subject: [PATCH 08/22] =?UTF-8?q?fix(memory):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=BF=85=E9=A1=BB=E7=85=A7=E5=B8=B8=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=20=E2=80=94=E2=80=94=20=E7=BA=A0=E6=AD=A3=20NoMemory?= =?UTF-8?q?=20=E8=AF=AD=E4=B9=89=E8=A2=AB=E9=94=99=E8=AF=AF=E6=89=A9?= =?UTF-8?q?=E5=A4=A7=E5=88=B0=E5=AD=98=E5=82=A8=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指出:doc 召回部分需要回填记忆,因为 doc 召回后原文在 doc 层已经删除了。 ## 我上一轮的错误(PR #22) 把「工具输出不进记忆」与「回填事件不归档」塞进了同一个 NoMemory 标记: doc_query → Consume(L2 副本删除)→ 回填 L1(标记 NoMemory) → L1 裁掉它 → Prune 跳过归档(错误实现) → 三层都不在 ⇒ 内容永久丢失 Consume 已经把 L2 副本删掉,**L1 是唯一副本**。裁掉时不归档 = 丢数据。 这不是自激防护,这是把数据丢掉当成防护。 ## 正确区分(两件事不能共用一个标记) | 对象 | 进计算层 | 进归档 | 理由 | |---|---|---|---| | doc_query 的**工具返回值**(正文摘录) | ❌ | ❌ | 它是事件的摘录副本,再向量化 = 重复注入 ⇒ 自激 | | **回填的事件本身**(原文) | ❌ | ✅ **必须** | L2 副本已删,L1 是唯一副本;不归档即丢失 | ## 修法 ContextEvent.NoMemory 语义定为「**不参与计算,照常归档**」, 与 ToolDef.NoMemory 一致:只影响计算层,不影响存储层。 自激的入口在"重新计算向量"那一侧(textForVector 返回空), 不在"归档"这一侧 —— 删掉 Prune 里的跳过逻辑。 ## 判据(改写了那条断言错误行为的判据) TestSelfExcite_RestoredEventsNotReArchived 现在断言三件事: 1. 回填事件带 NoMemory 标记且未被向量化(不参与计算) 2. 前提成立:L2 里确实已无该事件(Consume 删掉了) 3. ★ 被裁掉后必须能落回 L2(否则内容永久丢失) 变异验证: - Prune 重新跳过 NoMemory ⇒ 红,报「三层都不在,内容永久丢失」 - restoreDocEvents 不置 NoMemory ⇒ 红(会重复加权) ## 验证 - go build ./... / go vet ./... 干净 - go test ./... -count=1:58 包 0 失败 --- internal/agent/core/context.go | 47 ++++++++++------------ internal/agent/core/selfexcite_test.go | 54 +++++++++++++++++++------- internal/agent/core/toolcall.go | 32 ++++++++++----- internal/agent/core/tooldefs.go | 26 ++++++++++++- 4 files changed, 108 insertions(+), 51 deletions(-) diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 3aa32f6c..8ab48792 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -35,18 +35,19 @@ type ContextEvent struct { ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` - // NoMemory 标记这条事件**不参与记忆计算与再归档**。 + // NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏), + // 但**照常参与归档**(离开 L1 时会写回 L2)。 // - // ★ 为 doc_query 的回填而设(2026-10-22)。回填是把 L2 的历史事件 - // 按原时间戳放回 L1 —— 它们**已经**在记忆体系里了。若不加标记: + // ★ 与 ToolDef.NoMemory 语义一致:只影响计算层,不影响存储层。 // - // ① 计算层:它们会被重新向量化、参与 Prune 打分 ⇒ 因为与当前输入 - // 相关而倾向被保留,挤掉真正的新事件; - // ② 归档层:Prune 会把它们**再次**归档回 L2 ⇒ 下次 doc_query - // 又召回同一批 ⇒ 块在 L1↔L2 之间反复搬迁,自激震荡。 + // ★★ 为什么不能顺手把归档也关掉(2026-10-22 踩过的坑): // - // 语义与 ToolDef.NoMemory / ChannelDef.NoMemory 一致: - // **原文保留在上下文里可读**,只是不进计算、不再产生新记忆。 + // doc_query 的 Consume 已经**从 L2 删除**了原文。若回填事件在 L1 + // 被裁掉时又不归档,它就**三层都不在**了 —— 内容被永久丢弃。 + // L1 是它最后的落脚点,必须能落回去。 + // + // 所以"回填事件不再归档"是错的。它确实该重新归档(那才是迁移闭环), + // 该抑制的只是"重新计算向量/再次进蒸馏"—— 那才是自激的入口。 NoMemory bool `json:"no_memory,omitempty"` // --- 原生多模态记忆 --- // 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。 @@ -231,7 +232,9 @@ func (c *RelevanceContext) load() { func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string { // 事件自带 NoMemory(doc_query 回填的历史事件):不进计算层。 - // 原文仍在上下文里可读,只是不参与向量化/关键词/蒸馏。 + // + // 为什么回填事件不参与计算:它们的内容刚刚在 L2 里被向量化过一次, + // 再算一次只会让"同一内容"在向量空间里被重复加权。但它们**照常归档**。 if evt.NoMemory { return "" } @@ -538,24 +541,14 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc archived := 0 if docStore != nil && len(archive) > 0 { - // ★ 回填事件(NoMemory)**不再归档回 L2**。 + // ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。 // - // 这是自激震荡的根:doc_query 把事件从 L2 取到 L1,Prune 又把它们 - // 归档回 L2 —— 下一轮 doc_query 再召回同一批,块在两层间反复搬迁。 - // 它们本来就在记忆体系里(来源可能是 L2 或 L3),无需重新落库。 - filtered := archive[:0] - skippedNoMemory := 0 - for _, s := range archive { - if s.event != nil && s.event.NoMemory { - skippedNoMemory++ - continue - } - filtered = append(filtered, s) - } - archive = filtered - if skippedNoMemory > 0 { - log.Printf("[agent] prune: 跳过 %d 条回填事件(NoMemory,它们在记忆体系里已有归属)", skippedNoMemory) - } + // 曾经在这里跳过它们(理由是"防自激"),那是错的: + // doc_query 的 Consume 已经把 L2 副本删掉了,L1 是唯一副本。 + // 跳过归档 = 裁掉即丢失(L1、L2、L3 三层都不在)。 + // + // 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 —— + // 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。 } if docStore != nil && len(archive) > 0 { entries := make([]document.ContextEntry, len(archive)) diff --git a/internal/agent/core/selfexcite_test.go b/internal/agent/core/selfexcite_test.go index 8cfecd81..8f4d6f35 100644 --- a/internal/agent/core/selfexcite_test.go +++ b/internal/agent/core/selfexcite_test.go @@ -52,21 +52,30 @@ func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) { } } -// ② 回填事件必须带 NoMemory,且不得被 Prune 再次归档回 L2。 +// ② 回填事件必须带 NoMemory(不参与计算),但**必须照常归档**(数据不丢)。 // -// 变异:restoreDocEvents 不置 NoMemory ⇒ 红(Prune 会把它归档回 L2)。 +// ★ 这里曾经写反过:要求"不得被 Prune 再归档回 L2"。 +// +// doc_query 的 Consume 已经把 L2 副本删掉,L1 是唯一副本 —— +// 不归档就等于三层都不在,内容永久丢失。 +// +// 变异: +// +// · restoreDocEvents 不置 NoMemory ⇒ 它会参与计算(重复加权) +// · Prune 跳过 NoMemory 事件 ⇒ 内容丢失(本判据要抓这个) func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { a := newToolTestAgent(t) + const marker = "自激测试:这条会被归档" // 先归档一条事件到 L2 archiveEvents(t, a, []ContextEvent{{ Timestamp: time.Now().Add(-2 * time.Hour), Source: "webui", - Input: "自激测试:这条会被归档", + Input: marker, Response: "然后被 doc_query 取回", }}) - // 召回 + // 召回(Consume 会把 L2 副本删掉) out := a.executeDocTool(agentAPI.ToolCall{ Name: "doc_query", Arguments: map[string]interface{}{"query": "自激测试 归档", "top_k": float64(3)}, @@ -75,13 +84,16 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { t.Fatalf("未召回:%s", out) } - // 回填的事件必须带 NoMemory 标记 + // ① 回填事件必须带 NoMemory(不参与计算) restored := 0 for _, e := range a.context.Recent(0) { - if e.Source == "webui" && strings.Contains(e.Input, "自激测试") { + if e.Source == "webui" && strings.Contains(e.Input, marker) { restored++ if !e.NoMemory { - t.Error("回填事件没有 NoMemory 标记 —— Prune 会把它再归档回 L2,形成自激回路") + t.Error("回填事件没有 NoMemory 标记 —— 它会再被向量化/蒸馏,同一内容重复加权") + } + if len(e.Vector) > 0 { + t.Error("回填事件被向量化了(NoMemory 未生效)") } } } @@ -89,10 +101,20 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { t.Fatal("没有回填任何事件") } - // ③ 真实验证回路:再 Prune 一次。 - // - // 关键不是"文档数不变"(filler 本身也会被归档),而是 - // **L2 里不得出现含回填事件内容的新文档**。 + // ② L1 是唯一副本(L2 已被 Consume 删除)——确认这一点 + for _, d := range a.docStore.RecentDocs(1000) { + if !d.HasEvents() { + continue + } + for _, ev := range d.Events { + in, _, _ := ev.InOut() + if strings.Contains(in, marker) { + t.Fatalf("L2 里仍有该事件(%s)—— 前提不成立,本判据失去意义", d.ID) + } + } + } + + // ③ ★ 关键:裁掉它时**必须能落回 L2**,否则内容永久丢失 for i := 0; i < 14; i++ { a.context.Append(ContextEvent{ Timestamp: time.Now(), @@ -103,18 +125,22 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { } a.context.Prune("当前输入", 2, a.docStore) + var foundInL2 bool for _, d := range a.docStore.RecentDocs(1000) { if !d.HasEvents() { continue } for _, ev := range d.Events { in, _, _ := ev.InOut() - if strings.Contains(in, "自激测试") { - t.Errorf("回填事件被 Prune 又归档回了 L2(doc=%s)—— 自激回路成立:\n"+ - "下次 doc_query 会再召回同一批,块在 L1↔L2 之间反复搬迁", d.ID) + if strings.Contains(in, marker) { + foundInL2 = true } } } + if !foundInL2 { + t.Error("回填事件被裁掉后没有归档回 L2 —— Consume 已删除原副本," + + "L1 是唯一副本,不归档就是内容永久丢失(三层都不在)") + } } // ④ 回填事件不得参与 Prune 的相关性打分(否则会挤掉真正的新事件)。 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index edf9eff7..28943eab 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -327,9 +327,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS // 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。 // 文案必须让模型知道该做什么,而不是含糊的"执行失败"—— // 后者会让模型反复重试同一个不存在的名字。 - return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+ - "先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+ - "确认名称无误后再调用", tc.Name)} + // ★ 提示必须**先指向 skill**(2026-10-22)。 + // + // 按需插件(auto_start=false)的工具不在初始清单里,而它的 + // skill 正是"这个插件是什么、要怎么用、需要先启动什么"的唯一 + // 说明(SDK autostart.go 的 OnDemandNeedsSkill 就是这条契约)。 + // + // 此前这里只提 get_plugin_tools / output_list_channels, + // 一个字没提 skill —— 模型调用了、失败了,提示却不告诉它去 + // 看说明书,于是只能盲目试工具名。 + return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册(它可能属于未加载的插件)。按这个顺序处理:\n"+ + "1. 先看【可用技能】里有没有相关的:有就先 skill_info(\"技能名\") 读全文 —— "+ + "skill 里写着该插件的正确用法、已知坑,以及需要先启动哪个插件;\n"+ + "2. 没有相关 skill 就调 get_plugin_tools(\"\") 查当前可用的插件与工具;\n"+ + "3. 通道相关的不确定时用 output_list_channels 看有哪些通道。\n"+ + "确认之后再调用,不要反复重试同一个名字。", tc.Name)} } return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)} } @@ -1305,13 +1317,15 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int { Source: ev.Source, Input: input, Response: response, - // ★ 回填事件**不参与记忆计算、不再归档回 L2**。 + // ★ 回填事件**不参与记忆计算**,但**照常参与归档**。 // - // 它们本来就在记忆体系里(来自 L2,可能已蒸馏进 L3)。 - // 不标记的后果是自激震荡: - // doc_query 从 L2 取出 → Prune 因相关而保留它们、 - // 把真正的新事件挤掉 → 下一轮 Prune 又归档回 L2 → - // 再被 doc_query 召回 ⇒ 块在 L1↔L2 之间反复搬迁。 + // 为什么要标记:它们的内容刚在 L2 里被向量化过一次, + // 再算一次会让同一内容在向量空间里被重复加权(自激)。 + // + // ★★ 为什么仍要归档(不能顺手关掉): + // Consume 已经把 L2 副本删了,L1 是**唯一副本**。 + // 裁掉时不归档 = 三层都不在 = 内容永久丢失。 + // (这个错犯过:曾用同一个 NoMemory 同时关掉计算与归档。) NoMemory: true, ToolsUsed: append([]string(nil), ev.ToolsUsed...), ToolResults: func() []ToolResultItem { diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 0c76b8f2..d500b7e1 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -251,7 +251,19 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { // 主动 skill_info 拉取全文按文档执行 if a.skillIndex != nil { if idx := a.skillIndex.SkillIndex(); idx != "" { - prompt = appendPromptBlock(prompt, "【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n"+idx) + // ★ 措辞从"用户请求匹配某技能时"改成"**调用插件工具之前**"。 + // + // 旧措辞把人带偏成"只有用户点名要某个技能才去读",而 skill 的真实 + // 用途是**工具的使用说明**:里面写着参数怎么填、有哪些已知坑 + // (例如 QQ 的"拉私聊历史只传 user_id,不要传 group_id")、 + // 以及按需插件要先 plugin_start 拉起。 + // + // 不看 skill 就直接调工具,是这个仓库反复吃过亏的一类错误。 + prompt = appendPromptBlock(prompt, "【可用技能】这些是插件配套的**使用说明书**,不是可选装饰。\n"+ + "**规则**:准备调用某个插件的工具(或工具调用失败、参数不确定)时,**先看这里有没有同名技能的**"+ + "(如 file-tools / cmd-tools / qq-tools),用 skill_info(\"技能名\") 读全文,"+ + "再严格按其中步骤与坑位说明执行。技能里通常会写明:工具该怎么调、需要先启动哪个插件、常见报错怎么读。\n"+ + "用户明确要求用某技能时同样先 skill_info 读全文,不要凭名字猜做法。\n\n"+idx) } } @@ -328,6 +340,18 @@ func (a *Agent) buildToolCatalog() string { var sb strings.Builder sb.WriteString("\n\n【可用工具能力】\n") sb.WriteString("工具按插件分组注册。需要某个插件的具体工具时,调用 get_plugin_tools(\"{插件名}\") 获取该插件的完整工具定义(名称/参数/用途)。\n") + // ★ 说明"按需插件"这回事(2026-10-22)。 + // + // 插件有两种启动形态(SDK autostart.go): + // 常驻(auto_start 缺省/true)—— 工具一开始就在清单里 + // 按需(auto_start=false) —— 初始不在清单里,要靠 plugin_start 拉起 + // + // 不说清这件事的后果:模型看到"清单里没有某能力"就断定"没有这个能力", + // 于是绕道去用别的工具硬凑,或者干脆告诉用户做不到。 + sb.WriteString("★ **工具清单里没有 ≠ 没有这个能力**:部分插件是**按需加载**的," + + "它们的工具一开始不在清单里。需要时先 get_plugin_tools(\"插件名\") 看它有哪些工具、" + + "再 plugin_start(\"插件名\") 把它拉起来,工具就会出现。对应的 skill(同类名字或同名技能)" + + "会说明这个插件是常驻还是按需、以及拉起后怎么用。\n") for _, plg := range order { sb.WriteString(fmt.Sprintf("- %s (%d 个工具)", plg, byPlugin[plg])) if d := pluginDesc[plg]; d != "" { From f256098c9f69b4b920c8141be58406e5b05d341d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 20:49:44 +0800 Subject: [PATCH 09/22] =?UTF-8?q?refactor:=20=E5=85=A8=E9=9D=A2=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E4=B8=BA=20HologramAgent=20+=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=8A=80=E6=9C=AF=E5=86=85=E5=AE=B9=E4=B8=8B=E6=B2=89?= =?UTF-8?q?=E5=88=B0=20skill/=E5=B7=A5=E5=85=B7=E6=8F=8F=E8=BF=B0=20+=20?= =?UTF-8?q?=E4=BF=AE=2021=20=E5=A4=84=E4=B9=B1=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① 全面改名 HomeAgent → HologramAgent 仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换: - 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、 plugin_install 的工具描述、AI 图片插件的 User-Agent - 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows - 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv) 目录内的 module/app 描述同步;**证书路径原样保留** (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书) - 插件元数据:plugin.json 的 author ★ 必须保持原样的两处(改了会坏): · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…" —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据 · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent" —— 那是 SDK 契约里的值,属独立仓库,本仓不改 ## ② 修 21 处乱码(U+FFFD) 这些是历史编辑留下的编码破坏,全在注释里,例如: 「赌注重下���遍」→「赌注重下一遍」 「「不��滤」」 →「「不过滤」」 「**待��退场**」→「**待退场**」 ★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用 U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。 ## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中) 实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**: 【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…) 【三层记忆】 1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…) 收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事": 工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行 三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 / 关系用 memory_recall / 成文知识用 knowledge_search 判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事", 答不上来就该进 skill 或工具描述。 ## 判据 prompt_memory_test.go 新增 2 条: - TestSystemPromptHasNoImplementationJargon 黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint / 向量空间 / 批内降级 / 自激 - TestSystemPromptStaysCompact:上限 2000 字(当前 1723) 变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。 ## 验证 - go build ./... / go vet ./... 干净 - go test ./... -count=1:58 包 0 失败 - make check-client-versions:客户端与内核版本一致 --- .github/workflows/ci.yml | 8 +-- .github/workflows/release.yml | 8 +-- .../homeagent_architecture/content.md | 4 +- assets/skills/commondev-discipline/SKILL.md | 4 +- assets/skills/commondev-discipline/skill.json | 2 +- assets/skills/knowledge-base/SKILL.md | 14 ++--- .../skills/knowledge-base/scripts/kb_tree.sh | 4 +- assets/skills/knowledge-base/skill.json | 4 +- cmd/gui/DESIGN-NOTES.md | 14 ++--- cmd/gui/endpoint-align.test.mjs | 2 +- cmd/gui/main.js | 28 +++++----- cmd/gui/package.json | 4 +- cmd/gui/renderer/app.js | 24 ++++----- cmd/gui/renderer/index.html | 8 +-- cmd/gui/silent-start.test.mjs | 2 +- cmd/mock-server/main.go | 6 +-- cmd/mock-server/ws.go | 4 +- .../{HomeAgent => HologramAgent}/.gitignore | 0 .../AppScope/app.json5 | 2 +- .../resources/base/element/string.json | 2 +- .../resources/base/media/app_icon.png | Bin .../resources/base/media/background.png | Bin .../resources/base/media/foreground.png | Bin .../resources/base/media/layered_image.json | 0 .../resources/dark/media/background.png | Bin .../build-profile.json5.example | 0 .../entry/hvigorfile.ts | 0 .../entry/oh-package-lock.json5 | 0 .../entry/oh-package.json5 | 2 +- .../entry/src/main/ets/common/ApiClient.ets | 0 .../entry/src/main/ets/common/AppVersion.ets | 0 .../src/main/ets/common/AttachmentImage.ets | 0 .../src/main/ets/common/AttachmentMeta.ets | 0 .../entry/src/main/ets/common/BridgeCaps.ets | 0 .../src/main/ets/common/BridgeProtocol.ets | 0 .../src/main/ets/common/BridgeRouter.ets | 0 .../entry/src/main/ets/common/ChatFormat.ets | 0 .../entry/src/main/ets/common/ChatHistory.ets | 0 .../entry/src/main/ets/common/ChatSession.ets | 0 .../entry/src/main/ets/common/ChatSse.ets | 2 +- .../entry/src/main/ets/common/ChatStore.ets | 0 .../entry/src/main/ets/common/ConnStore.ets | 0 .../entry/src/main/ets/common/Constants.ets | 0 .../src/main/ets/common/DeviceBridge.ets | 4 +- .../main/ets/common/DeviceBridgeSession.ets | 2 +- .../entry/src/main/ets/common/DeviceModel.ets | 0 .../src/main/ets/common/MarkdownParser.ets | 0 .../src/main/ets/common/NavBarController.ets | 0 .../src/main/ets/common/NavStackRegistry.ets | 0 .../entry/src/main/ets/common/PluginApi.ets | 0 .../src/main/ets/common/PluginStatus.ets | 0 .../src/main/ets/common/ScreensueHtml.ets | 0 .../src/main/ets/common/SettingsModel.ets | 0 .../entry/src/main/ets/common/SseClient.ets | 0 .../entry/src/main/ets/common/StageTrail.ets | 0 .../entry/src/main/ets/common/StatusStore.ets | 2 +- .../entry/src/main/ets/common/UserError.ets | 4 +- .../main/ets/components/AppearancePane.ets | 0 .../src/main/ets/components/Attachment.ets | 0 .../ets/components/BackendSettingsPane.ets | 0 .../src/main/ets/components/ChatAttachBar.ets | 0 .../src/main/ets/components/ChatBubble.ets | 0 .../src/main/ets/components/ChatComposer.ets | 0 .../src/main/ets/components/ChatStream.ets | 0 .../src/main/ets/components/ChatToolCard.ets | 0 .../main/ets/components/ConnectionsPane.ets | 2 +- .../src/main/ets/components/DevicePanes.ets | 2 +- .../main/ets/components/DeviceRootEntries.ets | 0 .../ets/components/GradientBackground.ets | 6 +-- .../src/main/ets/components/MarkdownView.ets | 0 .../src/main/ets/components/MotionBase.ets | 0 .../src/main/ets/components/PageTopBar.ets | 0 .../main/ets/components/PluginDetailPane.ets | 0 .../main/ets/components/PluginListView.ets | 0 .../main/ets/components/PluginsOverlays.ets | 0 .../src/main/ets/components/RuntimePanel.ets | 0 .../src/main/ets/components/ScreensuePage.ets | 0 .../main/ets/components/SettingsEditor.ets | 0 .../main/ets/components/SettingsEntryCard.ets | 0 .../src/main/ets/components/SettingsHome.ets | 0 .../ets/components/SettingsRootEntries.ets | 0 .../main/ets/components/StaticMarkdown.ets | 0 .../src/main/ets/components/StatusCards.ets | 0 .../entry/src/main/ets/components/SubPage.ets | 0 .../src/main/ets/components/ToastBar.ets | 0 .../main/ets/entryability/EntryAbility.ets | 18 +++---- .../entry/src/main/ets/model/Model.ets | 2 +- .../entry/src/main/ets/pages/ChatPage.ets | 0 .../entry/src/main/ets/pages/DevicePage.ets | 0 .../entry/src/main/ets/pages/Index.ets | 0 .../entry/src/main/ets/pages/PluginsPage.ets | 0 .../entry/src/main/ets/pages/SettingsPage.ets | 0 .../entry/src/main/module.json5 | 4 +- .../main/resources/base/element/color.json | 0 .../main/resources/base/element/string.json | 2 +- .../main/resources/base/media/app_icon.png | Bin .../resources/base/media/ic_arrow_back.svg | 0 .../src/main/resources/base/media/ic_bolt.svg | 0 .../main/resources/base/media/ic_check.svg | 0 .../resources/base/media/ic_chevron_down.svg | 0 .../resources/base/media/ic_chevron_right.svg | 0 .../main/resources/base/media/ic_close.svg | 0 .../resources/base/media/ic_devices_multi.svg | 0 .../main/resources/base/media/ic_error.svg | 0 .../src/main/resources/base/media/ic_file.svg | 0 .../main/resources/base/media/ic_gateway.svg | 0 .../main/resources/base/media/ic_image.svg | 0 .../src/main/resources/base/media/ic_link.svg | 0 .../main/resources/base/media/ic_offline.svg | 0 .../main/resources/base/media/ic_phone.svg | 0 .../src/main/resources/base/media/ic_plug.svg | 0 .../src/main/resources/base/media/ic_plus.svg | 0 .../main/resources/base/media/ic_pulse.svg | 0 .../main/resources/base/media/ic_refresh.svg | 0 .../main/resources/base/media/ic_reload.svg | 0 .../src/main/resources/base/media/ic_send.svg | 0 .../main/resources/base/media/ic_sparkle.svg | 0 .../src/main/resources/base/media/ic_stop.svg | 0 .../main/resources/base/media/ic_tab_chat.svg | 0 .../resources/base/media/ic_tab_devices.svg | 0 .../resources/base/media/ic_tab_plugins.svg | 0 .../resources/base/media/ic_tab_settings.svg | 0 .../resources/base/media/ic_tab_status.svg | 0 .../main/resources/base/media/ic_theme.svg | 0 .../src/main/resources/base/media/ic_tool.svg | 0 .../src/main/resources/base/media/ic_tune.svg | 0 .../main/resources/base/media/start_icon.png | Bin .../resources/base/profile/main_pages.json | 0 .../base/profile/network_config.json | 0 .../main/resources/dark/element/color.json | 0 .../hvigor/hvigor-config.json5 | 0 .../hvigorfile.ts | 0 .../oh-package-lock.json5 | 0 .../oh-package.json5 | 2 +- cmd/ohos/README.md | 8 +-- cmd/waiter/device.go | 18 +++---- cmd/waiter/gateway_discover.go | 4 +- cmd/waiter/gateway_discover_test.go | 2 +- cmd/waiter/main.go | 6 +-- cmd/waiter/tui.go | 2 +- deploy/packaging/package-windows.sh | 2 +- deploy/packaging/packaging_test.go | 4 +- deploy/scripts/sync-client-versions.sh | 4 +- docs/zh/ci-cd-runbook.md | 12 ++--- internal/agent/core/argvalidate.go | 2 +- internal/agent/core/builtin_toolapi.go | 2 +- internal/agent/core/overflow_test.go | 6 +-- internal/agent/core/plugins.go | 2 +- internal/agent/core/prompt_memory_test.go | 50 ++++++++++++++++++ internal/agent/core/resident_test.go | 2 +- internal/agent/core/spawn.go | 2 +- internal/agent/core/toolcall.go | 2 +- internal/agent/core/tooldefs.go | 44 ++++++++------- internal/agent/io/channel.go | 2 +- internal/agent/personal_test.go | 4 +- internal/config/registry.go | 2 +- internal/kernelmod/agent/mod.go | 2 +- internal/knowledge/import_test.go | 2 +- internal/memory/abstain_test.go | 2 +- internal/memory/distill/drift.go | 2 +- internal/memory/fusion_test.go | 2 +- .../memory/legacy_retire_precondition_test.go | 2 +- internal/memory/migrate_entities_test.go | 2 +- internal/meta/meta.go | 6 +-- internal/plugin/manifest.go | 4 +- internal/plugin/manifest_timeout_test.go | 2 +- internal/plugin/proc/shm.go | 4 +- internal/plugin/proc/shmalloc_windows.go | 6 +-- internal/plugins/ai_image/plugin.go | 2 +- .../plugins/clawhubadapter/manager/main.js | 8 +-- internal/plugins/clawhubadapter/registry.go | 4 +- .../clawhubadapter/simulator/openclaw_cli.js | 10 ++-- internal/plugins/cli/plugin.go | 2 +- internal/plugins/kbtree/plugin.go | 6 +-- internal/plugins/kbtree/scope.go | 2 +- internal/plugins/localuse/plugin.go | 8 +-- internal/plugins/pluginmgr/plugin.go | 2 +- internal/plugins/remotedevice/binary_test.go | 4 +- internal/plugins/remotedevice/device.go | 2 +- internal/plugins/remotedevice/plugin.go | 2 +- internal/plugins/seq/plugin_test.go | 2 +- internal/plugins/webui/auth_hardening_test.go | 2 +- internal/plugins/webui/dashboard.html | 8 +-- internal/plugins/webui/dashboard.js | 6 +-- internal/plugins/webui/handler.go | 6 +-- internal/plugins/webui/handler_openai.go | 2 +- .../webui/handler_sse_protocol_test.go | 6 +-- internal/plugins/webui/handler_test.go | 2 +- internal/plugins/webui/openai_compat_test.go | 2 +- internal/plugins/webui/plugin.go | 2 +- internal/plugins/webui/proxy.go | 8 +-- internal/plugins/webui/starmap_vendor_test.go | 2 +- internal/sdk/status.go | 2 +- internal/supervisor/daemon.go | 2 +- internal/systemd/migrate.go | 2 +- pkg/embedding/embedding.go | 2 +- pkg/generation/generation.go | 2 +- providers/ollama/generator.go | 2 +- providers/qwen3vl/embedder.go | 2 +- scripts/capability-bench/PLAN.md | 14 ++--- scripts/capability-bench/README.md | 6 +-- scripts/capability-bench/tasks.example.json | 2 +- scripts/capability-bench/tasks.zerobasis.json | 6 +-- scripts/verify_deploy.sh | 4 +- 204 files changed, 310 insertions(+), 252 deletions(-) rename cmd/ohos/{HomeAgent => HologramAgent}/.gitignore (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/app.json5 (92%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/resources/base/element/string.json (65%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/resources/base/media/app_icon.png (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/resources/base/media/background.png (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/resources/base/media/foreground.png (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/resources/base/media/layered_image.json (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/AppScope/resources/dark/media/background.png (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/build-profile.json5.example (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/hvigorfile.ts (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/oh-package-lock.json5 (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/oh-package.json5 (73%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ApiClient.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/AppVersion.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/AttachmentImage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/AttachmentMeta.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/BridgeCaps.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/BridgeProtocol.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/BridgeRouter.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ChatFormat.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ChatHistory.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ChatSession.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ChatSse.ets (99%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ChatStore.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ConnStore.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/Constants.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/DeviceBridge.ets (98%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/DeviceBridgeSession.ets (99%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/DeviceModel.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/MarkdownParser.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/NavBarController.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/NavStackRegistry.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/PluginApi.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/PluginStatus.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/ScreensueHtml.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/SettingsModel.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/SseClient.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/StageTrail.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/StatusStore.ets (99%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/common/UserError.ets (98%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/AppearancePane.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/Attachment.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/BackendSettingsPane.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ChatAttachBar.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ChatBubble.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ChatComposer.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ChatStream.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ChatToolCard.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ConnectionsPane.ets (99%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/DevicePanes.ets (99%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/DeviceRootEntries.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/GradientBackground.ets (94%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/MarkdownView.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/MotionBase.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/PageTopBar.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/PluginDetailPane.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/PluginListView.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/PluginsOverlays.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/RuntimePanel.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ScreensuePage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/SettingsEditor.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/SettingsEntryCard.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/SettingsHome.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/SettingsRootEntries.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/StaticMarkdown.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/StatusCards.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/SubPage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/components/ToastBar.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/entryability/EntryAbility.ets (88%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/model/Model.ets (99%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/pages/ChatPage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/pages/DevicePage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/pages/Index.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/pages/PluginsPage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/ets/pages/SettingsPage.ets (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/module.json5 (92%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/element/color.json (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/element/string.json (86%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/app_icon.png (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_arrow_back.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_bolt.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_check.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_chevron_down.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_chevron_right.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_close.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_devices_multi.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_error.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_file.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_gateway.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_image.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_link.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_offline.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_phone.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_plug.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_plus.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_pulse.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_refresh.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_reload.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_send.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_sparkle.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_stop.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tab_chat.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tab_devices.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tab_plugins.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tab_settings.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tab_status.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_theme.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tool.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/ic_tune.svg (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/media/start_icon.png (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/profile/main_pages.json (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/base/profile/network_config.json (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/entry/src/main/resources/dark/element/color.json (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/hvigor/hvigor-config.json5 (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/hvigorfile.ts (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/oh-package-lock.json5 (100%) rename cmd/ohos/{HomeAgent => HologramAgent}/oh-package.json5 (84%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fe5b34b..eb5bc3ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# HomeAgent 主仓 CI。 +# HologramAgent 主仓 CI。 # # 设计原则:**CI 里跑的每一条命令,都是本地已实测通过的命令**。 # 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态, @@ -235,11 +235,11 @@ jobs: # # 取值依据(实测 2026-10-21,直连不带代理,非推测): # https://gitcode.com/JianFeeeee/HologramAgent → 200 ← 当前用这个 - # https://gitcode.com/JianFeeeee/HomeAgent → 301 ← 旧名,跳转到上面 + # https://gitcode.com/JianFeeeee/HologramAgent → 301 ← 旧名,跳转到上面 # https://gitcode.com/JianFeeeee/hologramagentsdk → 200(SDK 仓,另一对象) # https://gitcode.com/JianFeeeee/homeagentsdk → 404 # - # 背景:gitcode 侧镜像仓已由 HomeAgent 改名为 HologramAgent(与 GitHub + # 背景:gitcode 侧镜像仓已由 HologramAgent 改名为 HologramAgent(与 GitHub # 主仓同名)。旧地址仍 301 跳转,跟随后也能到达,但走重定向 ⇒ 别再用它。 # 判定两仓同一对象:两地址 git ls-remote 的 refs 列表 md5 相同。 # @@ -263,7 +263,7 @@ jobs: with: # 必须取完整历史:镜像可能在任意旧提交上,快进需要共同祖先。 fetch-depth: 0 - # ★ 改名(HomeAgent → HologramAgent)后的必然过渡态:gitcode 上的镜像仓 + # ★ 改名(HologramAgent → HologramAgent)后的必然过渡态:gitcode 上的镜像仓 # 是**另一个账号下的独立仓库**,改 GitHub 仓名不会自动建出它。 # 若不在这里显式探测,缺少目标仓时只会得到 git 的原始报错 # ("repository not found" 混在 push 输出里),看不出是「仓没建」。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3806e5b0..ccbfc63e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -308,7 +308,7 @@ jobs: [ "$PRE" = "true" ] && FLAGS+=(--prerelease) gh release create "$TAG" \ --title "$TAG" \ - --notes "HomeAgent $VERSION + --notes "HologramAgent $VERSION 产物清单与校验见 SHA256SUMS。 - \`hologramagent_${VERSION}_linux_amd64.tar.gz\` — 内核 + CLI + GUI 打包 @@ -361,11 +361,11 @@ jobs: # # 取值依据(实测 2026-10-21,直连不带代理,非推测): # https://gitcode.com/JianFeeeee/HologramAgent → 200 ← 当前用这个 - # https://gitcode.com/JianFeeeee/HomeAgent → 301 ← 旧名,跳转到上面 + # https://gitcode.com/JianFeeeee/HologramAgent → 301 ← 旧名,跳转到上面 # https://gitcode.com/JianFeeeee/hologramagentsdk → 200(SDK 仓,另一对象) # https://gitcode.com/JianFeeeee/homeagentsdk → 404 # - # 背景:gitcode 侧镜像仓已由 HomeAgent 改名为 HologramAgent(与 GitHub + # 背景:gitcode 侧镜像仓已由 HologramAgent 改名为 HologramAgent(与 GitHub # 主仓同名)。旧地址仍 301 跳转,跟随后也能到达,但走重定向 ⇒ 别再用它。 # 判定两仓同一对象:两地址 git ls-remote 的 refs 列表 md5 相同。 # @@ -390,7 +390,7 @@ jobs: name: linux-amd64 path: dist # ★ 前置检查:gitcode 镜像仓是否存在。 - # 改名(HomeAgent → HologramAgent)后 gitcode 目标仓需要**人工新建**, + # 改名(HologramAgent → HologramAgent)后 gitcode 目标仓需要**人工新建**, # 不探的话失败会埋在 push/curl 的原始输出里,看不出是「仓没建」。 # 语义:token 未配 = 可选项跳过(上一步);token 有但仓不存在 = 硬失败。 # diff --git a/assets/knowledge/homeagent_architecture/content.md b/assets/knowledge/homeagent_architecture/content.md index 62ec3d9a..b7c17a4b 100644 --- a/assets/knowledge/homeagent_architecture/content.md +++ b/assets/knowledge/homeagent_architecture/content.md @@ -1,8 +1,8 @@ -HomeAgent 是完全独立自研的新一代 Agent 框架。 +HologramAgent 是完全独立自研的新一代 Agent 框架。 ## 架构总览 -HomeAgent 采用内核 + 插件双层架构: +HologramAgent 采用内核 + 插件双层架构: ``` 外部输入(QQ/Web/CLI) diff --git a/assets/skills/commondev-discipline/SKILL.md b/assets/skills/commondev-discipline/SKILL.md index 952bff6f..ba5353e3 100644 --- a/assets/skills/commondev-discipline/SKILL.md +++ b/assets/skills/commondev-discipline/SKILL.md @@ -2,7 +2,7 @@ name: commondev-discipline description: commondev 主管式多仓库开发流水线的使用与开发纪律。当你要为某个工程开一条分支、把工程派给一个下属 agent、或用 pi 拉起执行 agent 干活时必读。核心动作:为每个工程对话新建驻留式子 agent 并把该工程的一对通道划给它(否则所有工程都挤在根 agent 上)。**git 托管平台的操作(Gitea/GitHub/GitLab)不在插件里,而在本文** —— 用 enc_shell + 保存的凭据调 curl/CLI 完成。也含 hmap 插件开发与部署纪律。 version: 2.0.0 -author: HomeAgent +author: HologramAgent --- # commondev 纪律(主管模式 · 工程对话 · 平台操作) @@ -176,7 +176,7 @@ enc_shell(command="curl -s -X POST -H \"Authorization: token @{t}\" -H 'Content- | 角色 | 是谁 | 干什么 | |---|---|---| | **人** | 老板 | 只与主管对接 | -| **主管** | 你(长期驻留的 HomeAgent 主 agent) | 拆任务、**定义判据**、复审、决定合入 | +| **主管** | 你(长期驻留的 HologramAgent 主 agent) | 拆任务、**定义判据**、复审、决定合入 | | **执行 agent** | 按工程拉起的一个 pi / dsh 实例 | 在某棵工作树里干活 | | **commondev 插件** | 外部独立插件 | 仓库/凭据/分支/PR/测试,全部机械化操作 | diff --git a/assets/skills/commondev-discipline/skill.json b/assets/skills/commondev-discipline/skill.json index 362a271c..5459d972 100644 --- a/assets/skills/commondev-discipline/skill.json +++ b/assets/skills/commondev-discipline/skill.json @@ -2,5 +2,5 @@ "name": "commondev-discipline", "description": "commondev 主管式多仓库开发流水线的使用与开发纪律:为每个工程对话新建驻留子并划入其通道、投递必须落同一 session、复核必须含变异阶段;以及 hmap 插件开发纪律(plg.json、SDK 版本、产物形态、Serial/ParallelSafe、外部插件能力边界、凭据与孤儿清理)", "version": "1.0.0", - "author": "HomeAgent" + "author": "HologramAgent" } diff --git a/assets/skills/knowledge-base/SKILL.md b/assets/skills/knowledge-base/SKILL.md index 993a29a8..8fa2c461 100644 --- a/assets/skills/knowledge-base/SKILL.md +++ b/assets/skills/knowledge-base/SKILL.md @@ -1,16 +1,16 @@ --- name: knowledge-base -description: 按分类树检索 HomeAgent 知识库。当需要查阅项目知识、架构约定、历史决策,或用户提到"知识库/knowledge/知识库有哪些/这个项目的约定是什么"时使用。支持树形导航、分类内检索、以图搜知识。 +description: 按分类树检索 HologramAgent 知识库。当需要查阅项目知识、架构约定、历史决策,或用户提到"知识库/knowledge/知识库有哪些/这个项目的约定是什么"时使用。支持树形导航、分类内检索、以图搜知识。 version: 1.1.0 -author: HomeAgent +author: HologramAgent --- ## Usage -先看树定位分类,再做定向检索。接口由 HomeAgent 的 `kbtree` 插件提供, +先看树定位分类,再做定向检索。接口由 HologramAgent 的 `kbtree` 插件提供, 只读、需 token。详见下方"接入"。 -HomeAgent 知识库按**分类树**组织(`tech/go/并发`、`life/sleep` …)。 +HologramAgent 知识库按**分类树**组织(`tech/go/并发`、`life/sleep` …)。 ### 何时用 @@ -102,7 +102,7 @@ curl -s -H "$AUTH" "$BASE/search?q=goroutine&limit=5" 若知识条目挂了图片,它在**多模态统一空间**里有向量,能被图片本身检索到。 前提是宿主已接入多模态向量 provider(否则只是记录了媒体,不参与召回)。 -判断是否就绪:HomeAgent 自身的知识库接口会返回稠密路状态 +判断是否就绪:HologramAgent 自身的知识库接口会返回稠密路状态 (`dense.enabled` / `dense.ready` / `dense.total`)。若为未启用, **不要承诺"能以图搜"**。 @@ -132,13 +132,13 @@ curl -s -H "$AUTH" "$BASE/search?q=goroutine&limit=5" - **分类不存在返回 404**,并在 `categories` 字段里附上现有分类 —— 用它自查 拼写。 - **`items=0` 只是不要正文**,`total_count` 仍准确,可用于判断规模。 -- **本服务只读**。写方法返回 405。要写知识请用 HomeAgent 主 agent 的 +- **本服务只读**。写方法返回 405。要写知识请用 HologramAgent 主 agent 的 `knowledge_create`(或 WebUI 界面),不要试图绕过它直接写这个接口。 - 知识名里不能有 `..`、空格(会被规范成 `_`)、点开头的段。 ### 写入 -本服务不提供写入。若你在 HomeAgent 主 agent 内部,写入用内核工具: +本服务不提供写入。若你在 HologramAgent 主 agent 内部,写入用内核工具: ``` knowledge_create name="tech/go/调度" content="正文..." diff --git a/assets/skills/knowledge-base/scripts/kb_tree.sh b/assets/skills/knowledge-base/scripts/kb_tree.sh index ddbdf845..43c4d44d 100755 --- a/assets/skills/knowledge-base/scripts/kb_tree.sh +++ b/assets/skills/knowledge-base/scripts/kb_tree.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# kbtree 客户端:按分类树检索 HomeAgent 知识库。 +# kbtree 客户端:按分类树检索 HologramAgent 知识库。 # # 由 knowledge-base skill 调用(agent 读 SKILL.md 后按需调本脚本)。 # 也可人工使用: @@ -88,7 +88,7 @@ if ! (exec 3<>"/dev/tcp/${HOSTPORT%%:*}/${HOSTPORT##*:}") 2>/dev/null; then cat >&2 < { parent: mainWindow, modal: true, autoHideMenuBar: true, - title: "HomeAgent - 网关联机登录", + title: "HologramAgent - 网关联机登录", webPreferences: { contextIsolation: true, nodeIntegration: false }, }); const u = encodeURIComponent(username || ""); @@ -1456,7 +1456,7 @@ async function connectDeviceBridgeOnce(cfg) { op: "hello", device: { device_id: deviceBridgeId, - name: "HomeAgent GUI", + name: "HologramAgent GUI", kind: "computer", authorized: authorized, caps: [ @@ -1986,8 +1986,8 @@ function executeHomeagentCmd(capability, reqId) { const h = Math.min(parseInt(area.height || 600, 10) - 40, 560); const html = /<\/?[a-z][\s\S]*>/i.test(raw) ? raw - : "

    HomeAgent

    " + - String(raw || "HomeAgent 远程屏幕提示").replace(/

    HologramAgent

    " + + String(raw || "HologramAgent 远程屏幕提示").replace(/

    "; try { if (screensueWin && !screensueWin.isDestroyed()) screensueWin.destroy(); @@ -1999,7 +1999,7 @@ function executeHomeagentCmd(capability, reqId) { alwaysOnTop: true, frame: false, resizable: true, - title: "HomeAgent · screensue", + title: "HologramAgent · screensue", webPreferences: { nodeIntegration: false, contextIsolation: true }, }); screensueWin.setAlwaysOnTop(true, "screen-saver"); @@ -3223,7 +3223,7 @@ async function setDeviceAuthorized(authorized) { op: "hello", device: { device_id: deviceBridgeId, - name: "HomeAgent GUI", + name: "HologramAgent GUI", kind: "computer", authorized: !!authorized, caps: [ @@ -3291,7 +3291,7 @@ function buildTrayMenuTemplate() { const electron = require("electron"); const TMenu = electron.Menu; const tpl = []; - tpl.push({ label: "HomeAgent", enabled: true }); + tpl.push({ label: "HologramAgent", enabled: true }); tpl.push({ type: "separator" }); // 远程连接状态(缓存避免每次读文件) const connLabel = trayConnCache.connLabel || "未连接"; @@ -3404,7 +3404,7 @@ function initTray() { img = nImg.createEmpty(); } tray = new Tray(img); - tray.setToolTip("HomeAgent - 个人智能管家"); + tray.setToolTip("HologramAgent - 个人智能管家"); rebuildTrayMenu(); tray.on("double-click", () => showMainWindow()); console.log("[tray] READY: " + (tray ? "tray-created" : "null")); @@ -3525,7 +3525,7 @@ app.whenReady().then(async () => { // 「启动时不显示主窗口,驻留托盘后台运行」,但主进程从来没读过它 —— // whenReady 无条件 createWindow(),于是必然弹窗。 // - // 实测(本机,prefs.silentStart=true):窗口标题 HomeAgent 照样出现。 + // 实测(本机,prefs.silentStart=true):窗口标题 HologramAgent 照样出现。 // // 与 applyAutoLaunch 里的 openAsHidden 是两回事:那个是告诉 OS「开机自启 // 时如何启动」(仅 Windows/macOS 生效),这里是本进程自己决定要不要显示, @@ -3605,7 +3605,7 @@ let notifSeq = 0; ipcMain.handle("notify:show", (_, payload) => { try { const p = payload || {}; - const title = String(p.title || "HomeAgent"); + const title = String(p.title || "HologramAgent"); const body = String(p.body || ""); if (!body) return { ok: false, error: "empty body" }; if (!Notification.isSupported()) return { ok: false, error: "unsupported" }; @@ -3721,10 +3721,10 @@ function saveGuiPrefs(p) { } // Electron LoginItem 写入的注册表项名是「electron.app.」。 -// 产物改名过(HomeAgent.exe → homeagent-gui.exe)就会多出一条旧项, +// 产物改名过(HologramAgent.exe → homeagent-gui.exe)就会多出一条旧项, // 且 setLoginItemSettings 不会清理它们 ⇒ 开机启动两次同一个 exe。 // 实测本机 Run 键里确实同时存在: -// electron.app.HomeAgent = "E:\program\homeagent\...\homeagent-gui.exe" +// electron.app.HologramAgent = "E:\program\homeagent\...\homeagent-gui.exe" // electron.app.homeagent-gui = E:\program\homeagent\...\homeagent-gui.exe // 两条指向同一 exe,所以表面看是两个自启动,实际是同一份的重复。 // diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 7059aa34..5c2c7077 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -3,7 +3,7 @@ "version": "1.4.0", "author": "JianFeeeee ", "homepage": "https://github.com/JianFeeeee/HologramAgent", - "description": "HomeAgent Desktop GUI - Multi-connection management dashboard", + "description": "HologramAgent Desktop GUI - Multi-connection management dashboard", "main": "main.js", "scripts": { "start": "electron . --no-sandbox", @@ -25,7 +25,7 @@ }, "build": { "appId": "com.homeagent.gui", - "productName": "HomeAgent", + "productName": "HologramAgent", "linux": { "target": [ "deb", diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 11313c69..f4b45f2d 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -741,7 +741,7 @@ async function api(p, o) { // 从服务端发现设备网关地址(自动链接的权威来源)。 // -// 失败不报错:老版本 HomeAgent 没有这个端点,回退到本地推导即可 +// 失败不报错:老版本 HologramAgent 没有这个端点,回退到本地推导即可 // (见 renderDeviceChannel 里的 state.discoveredGateway || 旧口径)。 // // 优先 url_portal(门户同源形态):GUI 主进程连 WS 走系统解析器, @@ -1083,7 +1083,7 @@ async function refreshAll() { // 服务端列表未含本机(可能离线),仍展示本地状态 state.devices.push({ device_id: dbinfo.deviceId, - name: "HomeAgent GUI", + name: "HologramAgent GUI", kind: "computer", authorized: dbinfo.authorized, online: false, @@ -1555,7 +1555,7 @@ function renderOverview() { b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : ""; return ( "v" + escHtml(v) + - '
    ' + escHtml(b.kernel_name || "HomeAgent") + + '
    ' + escHtml(b.kernel_name || "HologramAgent") + (sha ? " · " + escHtml(sha) : "") + "
    " ); })(), @@ -1819,8 +1819,8 @@ function buildChatLayout() { "" + "

    " + __( - "连接 HomeAgent 服务端后即可开始对话。请在设置中添加后端连接。", - "Connect to a HomeAgent server to start chatting. Add a backend connection in Settings.", + "连接 HologramAgent 服务端后即可开始对话。请在设置中添加后端连接。", + "Connect to a HologramAgent server to start chatting. Add a backend connection in Settings.", ) + "

    " + '