From 28d9d5e9297db14bfb1c24867c8b6b33c76fcfef Mon Sep 17 00:00:00 2001
From: jianf <2198972886@qq.com>
Date: Fri, 30 Oct 2026 00:39:44 +0800
Subject: [PATCH] =?UTF-8?q?docs:=20=E4=B8=89=E5=B1=82=E8=AE=B0=E5=BF=86?=
=?UTF-8?q?=E7=A7=BB=E6=A4=8D=20dsh=20=E6=8F=92=E4=BB=B6=E5=8F=AF=E8=A1=8C?=
=?UTF-8?q?=E6=80=A7=E8=AF=84=E4=BC=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
结论:只能做子集。
逐层结论
- L1 (context 热上下文) ≈20%:dsh 的上下文压缩是「摘要替换区间」
(dsh-compaction-basic 的 compactSurfaceRegion),不是「按 query 相关性
淘汰 + 迁出到冷层 + 按原时间戳迁回」。CompactionTrigger 接口拿不到
query;MessageBase 无 timestamp 字段,surface 是 append-only + replace,
无法语义插入。
- L2 (coldcontext 冷文档) ≈80%:存储/召回/注入可落,缺
「Peek → 回灌 → Remove」的回收站语义。
- L3 (stablemem 图记忆) ≈85%:memory_blocks/memory_block_edges 结构
完全可映射,node:sqlite 实测可用(含 FTS5);受限于无 embedding 服务。
关键发现
- dsh 无任何 embedding/vector service,需自建(推荐复用 HA 的 http
provider HTTP 协议转发,2 人天)。
- systemPrompt.context() 的 AssembleContext 拿不到本步消息正文,
「按本次输入召回后注入」必须走 agent/pre-step waterfall
(参考 dsh-time-context 的用法)。
- dsh attachment 是 CAS,与 HA「媒体字节内联在块里、不做引用账本」
的设计冲突 → 媒体块必须裁掉。
工作量(含判据与实测)
- L2+L3 子集:36-53 人天
- MVP(L3 + 工具 + 注入面):19-25 人天
- 完全等价(含迁出-迁回):不可行,不报价
报告含 7 章 + 两份附录(HA/dsh 双侧证据索引、环境实测结果)。
原文全程只读,未修改 HologramAgent 仓库。
---
.gitignore | 1 +
README.md | 32 +-
docs/feasibility.md | 1138 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 1170 insertions(+), 1 deletion(-)
create mode 100644 .gitignore
create mode 100644 docs/feasibility.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8c8cbe7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.pi-sessions/
diff --git a/README.md b/README.md
index 370f77b..b815139 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,33 @@
# HologramAgent-DSH-Memory-Port
-评估 HologramAgent 三层记忆系统移植为 dsh 插件的可行性(调研/评估,不产出实现)
\ No newline at end of file
+评估 HologramAgent 三层记忆系统移植为 dsh 插件的可行性(调研/评估,不产出实现)
+
+## 报告
+
+→ [`docs/feasibility.md`](docs/feasibility.md)
+
+## 结论摘要
+
+**只能做子集**(四选一中的第三档)。
+
+| 层 | 可移植性 | 瓶颈 |
+|---|---|---|
+| L1 context 热上下文 | ≈20% | dsh 的上下文压缩是「摘要替换区间」,不是「按 query 相关性淘汰 + 迁移到冷层 + 按原时间戳迁回」;`CompactionTrigger` 接口拿不到 query,`MessageBase` 无时间戳 |
+| L2 coldcontext 冷文档 | ≈80% | 存储/召回/注入都能落,缺「回收站式 `Peek→回灌→Remove`」 |
+| L3 stablemem 图记忆 | ≈85% | 结构完全可映射(`node:sqlite` 实测可用),受限于无 embedding 服务 |
+
+**可整体搬运的资产**:`MemoryBlock` 值对象、确定性 ID 派生、仲裁/拒答/符号检索算法——这三块与宿主无关。
+
+**推荐落地范围**:L2 + L3 子集,L1 交给 dsh 自己的 compaction。
+工作量(含判据与实测)**36–53 人天**;压到 MVP(L3 + 工具 + 注入面)**19–25 人天**。
+
+**最小第一步切片**(1–2 人天):插件骨架 + `Service` 注册 + `node:sqlite` 建 `memory_blocks`
++ FTS5 召回 + `agent/pre-step` 注入 + 一个最小工具,**全程不引入 embedding 依赖**。
+详见报告 §7。
+
+## 取证方式
+
+- HologramAgent 源码与 `docs/zh/` 设计文档:**全程只读,未做任何修改**
+- dsh 接口:只读 `lib/types/*.d.ts`(权威接口),不从 `lib/*.js` 实现倒推
+- 关键环境结论均有实测(`node:sqlite` + FTS5、`onnxruntime-node` 缺失、`libonnxruntime.so` 位置)
+- 每条断言标注文件路径与符号名;仅三处标注「推断」:`turn`≈`FrameTS` 映射、`Intl.Segmenter` 中文分词质量、人天估算
diff --git a/docs/feasibility.md b/docs/feasibility.md
new file mode 100644
index 0000000..4c1abd4
--- /dev/null
+++ b/docs/feasibility.md
@@ -0,0 +1,1138 @@
+# 可行性评估:把 HologramAgent 三层记忆系统移植为 dsh 插件
+
+> 状态:**调研评估,不含实现**
+> 评估对象:HologramAgent 内核 `internal/memory/` + `internal/agent/core/` 的三层记忆
+> 目标宿主:dsh(deepseek-harness)`0.2.1-rc.2`,cordis 依赖注入内核,插件形态为导出 `apply(ctx, config)`
+> 取证方式:只读源码 + `lib/*.d.ts` 权威类型。**本文每条断言都标注了文件路径与符号名**,
+> 无标注的推断一律显式写「推断」。
+
+---
+
+## 0. 结论先行
+
+| 结论四选一 | **只能做子集** |
+|---|---|
+| 一句话理由 | L2 与 L3 能落到 dsh 的真实扩展点上(`systemPrompt.context` 注入 + `ctx.tools.register` 工具面 + 自建 `Service` 做检索);**L1 的「按 query 相关性淘汰页、把整帧迁出、再按原始时间戳迁回」在 dsh 里没有对应钩子**——dsh 的上下文压缩是「摘要替换区间」(`dsh-compaction-basic` 的 `compactSurfaceRegion`),不是「迁移低相关帧到冷层再按需迁回」,语义相反且接口签名里拿不到 query。 |
+| 分层可移植性 | L3 ≈ 85%(结构完全可映射,受限于向量依赖)
L2 ≈ 80%(存储与召回可映射,缺「回收站式 Consume + 迁回原时序」)
L1 ≈ 20%(只有「进 prompt 的注入点」可挂,淘汰/迁移机制挂不上) |
+| 可整体搬运的资产 | **`Block` 数据结构本体**(`memory.MemoryBlock`)+ 确定性 ID 派生 + 仲裁/拒答/符号检索算法。这三块与宿主无关,是移植成本最低、收益最高的部分。 |
+| 硬阻塞(不可绕过) | ① dsh 无 embedding 服务;② dsh 消息是无时间戳的、只能追加的 surface,无法「按原始时间戳把事件插回时序」;③ dsh 无「迁出-迁回」的双层持有模型 |
+| 工作量粗估 | **L2+L3 子集(含判据)36–53 人天**;压到 MVP(L3 图记忆 + 工具 + 注入面,不含 L2/仲裁/拒答)**19–25 人天**;含 L1 降级版(`form:'recall'` 追加,不做淘汰)再加 8–12 人天。**「完全等价带迁出-迁回」不可行**,不建议报价。详见 §6.2。 |
+
+> 工作量口径:§6.2 的分阶段表是**含判据与实测**的报价口径(请以此为准);
+> 早前草稿里的 28–40 人天是不含判据、不含工具 `output` 声明的乐观值,已废弃。
+
+---
+
+## 1. L1 / L2 / L3 的核心数据结构与数据流,最小可移植单元
+
+### 1.1 三层的统一载体:`MemoryBlock`
+
+**权威定义**:`/home/program/TrueAgent/internal/memory/block.go:66` `type MemoryBlock struct`
+
+```go
+type MemoryBlock struct {
+ ID string // 内容派生(确定性)
+ Modality BlockModality // text | image | video | audio
+ Text string // 文本块负载(union 的一半)
+ Data []byte // 媒体块负载(union 的另一半)
+ MIME string
+ Size int
+ Width, Height int
+ Vector []float64
+ Fingerprint string // 向量空间指纹
+ Source, Tool, Scene string
+ SemanticType string
+ // ── 段页式身份(2026-10-29)──
+ FrameTS time.Time // 帧号 = 时间戳,不另造自增号
+ PageSeq int // 帧内页序号(1..n)
+ PageKind PageKind // llm_round | tool_round
+ BlockRole BlockRole // request | response
+ CallID string // tool_round 的配对键
+ Inactive bool // 本次渲染不参与(标注,非删除)
+ LastRecalledAt time.Time
+ CreatedAt, UpdatedAt time.Time
+}
+```
+
+关键设计约束(原文注释,`block.go:55-56` 与 `62-65`):
+
+- 块**不携带 Layer / Owner / RefCount**:「块当前由哪个层的容器持有,哪个层就是唯一事实源。
+ Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。」
+- 负载是 **union**:文本块负载在 `Text`,媒体块在 `Data`。「不存在『块 + 外挂字节层』。」
+- `FrameTS` 是帧号,**不跨帧**(`block.go:100-125`):同一 `FrameTS` 的块整批待在同一层,
+ 因为「帧是最小可渲染单元,拆开就会出现『有 call 没 result』的破帧」。
+
+**最小可移植单元 = `MemoryBlock` 这个纯数据结构本身**(一个 JSON 可序列化的值对象)。
+它没有任何 Go 接口依赖,可以直接翻译成 TypeScript interface。这是整个移植里
+唯一「零成本」的部分。
+
+### 1.2 L1 = Context 热上下文
+
+**位置**:`/home/program/TrueAgent/internal/agent/core/context.go`
+
+| 符号 | 行 | 职责 |
+|---|---|---|
+| `type ContextEvent struct` | 64 | 一条事件:`Timestamp/Source/Input/Response/ToolsUsed/ToolResults/ToolCalls/Blocks/NoMemory/InactivePages` |
+| `type RelevanceContext struct` | 145 | 持有 `events []*ContextEvent` |
+| `(*RelevanceContext).Append` | 967 | 唯一入口:`scrubEventSecrets` → `computeVector` → 追加 → `claimPendingToolResults` → `save()` |
+| `(*RelevanceContext).Prune` / `PruneWithProtected` | 1059 / 1075 | 按预算裁剪,把低分事件归档到 L2 |
+| `(*RelevanceContext).InsertByTimestamp` | 998 | 召回时按原始时间戳插回 |
+| `type ColdRecallPlan struct` | 453 | `planColdRecall` 的产物:超预算时按页相似度标 `inactive` |
+
+**数据流(`Prune` 的真实实现,`context.go:1075-1210`(函数体至 :1207))**:
+
+```go
+func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int,
+ coldcontextStore *coldcontext.Store, pCount int) int {
+ c.resetInactivePagesLocked() // 剔除标记是渲染期状态,每次重置
+ if len(c.events) <= topK { return 0 }
+ protected := c.events[len(c.events)-pCount:] // 最近 N 条无条件保护
+ candidates := c.events[:len(c.events)-pCount]
+
+ // ① 用当前 query 给每条候选事件打相关性分
+ for i, evt := range candidates {
+ if useDense && evt.DenseFP == queryFP { // 只比同指纹向量
+ score = vector.DenseCosine(queryDense, evt.DenseVec)
+ } else {
+ score = vector.CosineSimilarity(queryVec, evt.Vector) // 退化稀疏
+ }
+ }
+ // ② 保留 topK,其余 archive
+ keep := scoredEvents[:keepCount]
+ archive := scoredEvents[keepCount:]
+
+ // ③ archive 的**整条事件(连同它持有的块)**迁往 L2
+ doc, _ := coldcontextStore.ContextToColdContext("context_archived", entries, ...)
+ // ④ 迁移语义:宿主侧清空块,确保同一块不同时留在两层
+ for _, s := range archive { s.event.Blocks = nil }
+}
+```
+
+L1 的三个不可替代特征:
+
+1. **事件有 `Timestamp`**,且 `InsertByTimestamp` 允许**乱序插入到历史位置**——这是「召回 =
+ 把事件迁回原时序」的前提。
+2. **按「页」淘汰**(`InactivePages []int`,`json:"-"`,「不持久化、不迁移」),
+ 而不是按「消息」淘汰——因为 `assistant(tool_calls)` 后必须紧跟配对的 `tool` 消息。
+3. **淘汰是「迁移」不是「摘要」**:`Prune` 把整条事件原样(正文不截断)交给
+ `ContextToColdContext`,L1 里删掉、L2 里完整存在。
+
+### 1.3 L2 = coldcontext 冷文档
+
+**位置**:`/home/program/TrueAgent/internal/memory/coldcontext/coldcontext.go`
+
+| 符号 | 行 | 职责 |
+|---|---|---|
+| `type ColdDoc struct` | 37 | 文档 = 检索元数据 + `Events []ColdEvent` |
+| `type ColdEvent struct` | 75 | 与 `ContextEvent` 同构;**正文不在字段里,在 `Blocks` 里** |
+| `(*Store).ContextToColdContext` | 385 | L1→L2 迁移的写法 |
+| `(*Store).Peek` / `Consume` / `Query` | 480 / 526 / 537 | 读(只读 / 读+删 / 只读) |
+| `(*Store).QueryScored` / `denseSearchScored` | 556 / 616 | 带分数的向量召回 |
+| `docBlocksForEntry` | 974 | 把一条 `ContextEntry` 拆成 request/response/tool 块,**填 FrameTS/PageSeq/PageKind/BlockRole/CallID** |
+| `(*ColdEvent).InOut` | 1259 | 从块读回 `(input, response, held)` —— 召回时的对称解读 |
+
+**关键点**:`ColdDoc.Content` 只是**渲染视图**(`coldcontext.go:33-36` 注释),
+原文在 `Events[i].Blocks` 里文本块的 `Text`。这是「L1/L2 同构」的落地方式。
+
+**流转方向(`ARCHITECTURE.md:200-230`)**:
+
+```
+L1 --Prune 归档(迁移)--> L2
+L2 --coldcontext_query(迁回)--> L1 ※ 块 ID / 向量 / 时间戳全程不变
+L2 --FindColdContexts(72h, ≤2次) 冷化--> L3
+```
+
+### 1.4 L3 = stablemem 图记忆
+
+**位置**:`/home/program/TrueAgent/internal/memory/block.go` + `graph.go`
+
+存储 schema(`graph.go:2577` `ddlMemoryBlocks`):
+
+```sql
+CREATE TABLE memory_blocks (
+ id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT,
+ data BLOB, mime TEXT, size INTEGER, width INTEGER, height INTEGER,
+ vector TEXT, -- JSON 编码的 []float64
+ fingerprint TEXT, source TEXT, tool TEXT, scene TEXT, semantic_type TEXT,
+ frame_ts TIMESTAMP, page_seq INTEGER, page_kind TEXT, block_role TEXT,
+ call_id TEXT, inactive INTEGER, last_recalled_at TIMESTAMP,
+ created_at, updated_at
+)
+```
+
+```sql
+CREATE TABLE memory_block_edges (
+ id, source_kind, source_id, target_kind, target_id, type,
+ confidence, session_id, turn_id, status, merged_into, created_at
+)
+```
+
+| 符号 | 行 | 职责 |
+|---|---|---|
+| `(*GraphDB).PutMemoryBlocks` | block.go:203 | 事务写入,`ON CONFLICT` 全部走「空值不覆盖」策略 |
+| `(*GraphDB).AddMemoryBlockEdge` | block.go:497 | 端点存在性校验 + 唯一性 |
+| `(*GraphDB).RecallBlocks` | block_recall.go:78 | 全表扫向量 + 余弦 + 指纹/维度跳过 + 模态过滤下沉 SQL |
+| `(*GraphDB).RecallBlocksWithArbitration` | block_recall.go:229 | 放大 TopK → 仲裁 → 截断 |
+| `(*GraphDB).RecallBlocksGuarded` | abstain.go:179 | 拒答闸门 → 召回 |
+| `arbitrate` | arbitration.go:375 | 同属性多值的时序仲裁 |
+| `AbstainCheck` | abstain.go:98 | 符号零命中 ⇒ 拒答 |
+| `QuerySymbols` | fusion.go:145 | jieba 分词 + 数字/Latin 正则 → 符号集 |
+| `LoadCentroid` / `centerBoth` | centroid_store.go:144 / 443 | 各向异性校正(去均值) |
+| `(*GraphDB).RecallByScene` | scene.go:344 | 场景前缀匹配召回 |
+
+**L3 的 5 条召回链路**(全部读 `memory_blocks`,同一份数据五种取法):
+
+1. **向量路**:`RecallBlocks` → 余弦,带 fingerprint/dim 跳过
+2. **符号路**:`QuerySymbols` → `exactHit` 布尔置顶(`fusion.go:427` `Score = 1.0 + VectorHit*0.01`)
+3. **融合**:`RecallBlocksFused` → `0.7×vector + 0.3×symbol`,精确串单独置顶
+4. **仲裁**:`arbitrate` → 同属性取最新(用 `parseFact` + `supersedes`)
+5. **拒答**:`AbstainCheck` → 符号零命中且含数字串 ⇒ 拒答
+
+**重要实测边界**(`docs/zh/recall-capability-tiers.md`):纯泛化描述(「公网地址是什么」)
+目标块排名 389/2686;纯中文「库里没有」无法检测,会编造。移植这些**缺陷也一起移植**。
+
+### 1.5 最小可移植单元汇总
+
+| 层 | 最小可移植单元 | 依赖 | 难度 |
+|---|---|---|---|
+| 共同 | `MemoryBlock` 值对象 + 确定性 ID 派生(`distill.BlockID` = `sha256(sentence␀subject␀dim␀value)[:12]`) | 无 | 极低 |
+| L1 | `ContextEvent` + 「按页淘汰 + 整帧迁移」策略 | 需要宿主提供「可淘汰的上下文」抽象 | **高(dsh 没有)** |
+| L2 | `ColdDoc{Events[]}` + `docBlocksForEntry`/`InOut` 的对称拆装 | 需要一个 KV/文件存储 + 向量 | 中 |
+| L3 | `memory_blocks` + `memory_block_edges` + 5 条召回链路 | 需要 SQLite + 向量 + 分词 | 中高 |
+
+---
+
+## 2. dsh 的扩展点盘点:哪些能挂记忆、哪些挂不上
+
+dsh 是 cordis 依赖注入内核。插件形态:
+
+```ts
+// /usr/lib/node_modules/@deepseek-ai/dsh-storage-json/lib/types/index.d.ts
+export declare function apply(ctx: Context, config: Config): void;
+```
+
+`ctx` 是 cordis 代理,服务通过 `Service` 基类构造时注册
+(`cordis/lib/types/service.d.ts:38` `constructor(ctx, name)` →
+`ctx.reflect.provide(name, this, check)`),随 fiber 卸载自动注销。
+
+### 2.1 service 注册
+
+**位置**:`cordis/lib/types/service.d.ts` + `cordis/lib/types/context.d.ts`
+
+```ts
+abstract class Service {
+ constructor(ctx: Context, name: string); // 立即注册,随 fiber 卸载
+ [symbols.resolveConfig](base?, head?): T; // 祖先 intercept 配置合并
+}
+// Context 上还有:
+ctx.isolate(name, label?) // 独立服务作用域
+ctx.intercept(name, config) // 按插件注入配置
+ctx.set(name, value) // 直接写
+```
+
+**能挂记忆**:✔ **这是最关键的可挂点**。L3 图记忆可以做成
+`class StableMem extends Service` → `super(ctx, 'stablemem')`,
+下游通过 `ctx.stablemem.recall(...)` 使用。dsh 自己的
+`ctx.tools` / `ctx.sessions` / `ctx.compaction` / `ctx.attachments` / `ctx.storage` /
+`ctx.systemPrompt` 全部是这个模式。
+
+**挂不上**:无。这是通用机制。
+
+### 2.2 agent 生命周期钩子
+
+**位置**:`dsh-agent/lib/types/runtime-types.d.ts`(`declare module '@deepseek-ai/cordis' { interface Events { ... } }`)
+
+| 事件 | mode | 参数 | 对记忆的用处 |
+|---|---|---|---|
+| `agent/created` | serial | `{agent, source, signal}` | **初始化本 session 的记忆命名空间**;`source: 'startup'\|'resume'\|'clear'\|'compact'` 可直接决定「这是新会话还是恢复」 |
+| `agent/disposed` | emit | `{agent}` | 冲刷未落盘的块 |
+| `agent/status` | emit | `{agent, status: 'idle'\|'running'}` | 只在 idle 时跑蒸馏(对应 HA 的 `distillLoop`) |
+| `agent/inbox/inserted` | emit | `{agent, message}` | 用户输入到达 → 预填充召回的触发点 |
+| `agent/inbox/claimed` | emit | `{agent, message, turn}` | 输入被消费进 step |
+| `agent/pre-step` | **waterfall** | `{agent, messages, turn, step, signal}` → `PreStepDecision` | **★ 可替换本步进入模型的消息列表**(`{kind:'enter', messages}`)。这是唯一能「改写本次请求内容」的钩子 |
+| `agent/request` | waterfall | `{...}` → `LlmCallConfig` | 只能换 provider/model,**不能改消息**(注释明确:「this waterfall cannot mutate messages」) |
+| `agent/turn-stopping` | serial | `{agent, turn, signal}` | 轮末:可 `agent.steer(...)` 插入新内容,或做维护 |
+| `agent/assistant-stream` | emit | `{agent, frame}` | 只读观测,可做流式蒸馏输入 |
+| `agent/error` / `agent/request-error` | emit / waterfall | — | 错误恢复,与记忆无关 |
+
+**能挂记忆**:✔ `agent/pre-step`(改本步消息)、`agent/turn-stopping`(轮末蒸馏)、
+`agent/created`(命名空间初始化)。
+
+**挂不上**:✘ **没有「上下文即将超限、请把低相关事件迁出」的钩子**。
+dsh 把这件事完全交给 compaction 后端(见 2.4)。
+
+### 2.3 context 组装点
+
+**位置**:`dsh-system-prompt/lib/types/index.d.ts`
+
+```ts
+class SystemPrompt extends Service {
+ section(section: PromptSection): () => void; // 静态 system prompt 段
+ context(context: PromptContext): () => void; // ★ 动态上下文,落成 durable user-role 快照
+ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void;
+ variable(name, provider): () => void;
+ assemble(context?: AssembleContext): Promise;
+}
+interface PromptContext {
+ name: string; // 唯一名,重复注册抛错
+ order: number; // 升序拼接
+ text: string | ((context: AssembleContext) => string); // ★ 每次组装求值
+ required?: boolean;
+ interpolate?: boolean;
+}
+
+// AssembleContext(:37-44)—— ★ 基定义只有两个字段;agent 由 dsh-agent declaration merging 扩展
+interface AssembleContext {
+ scope?: ScopeKey;
+ signal?: AbortSignal;
+ // agent?: Agent; ← 由 `dsh-agent/lib/types/runtime-types.d.ts`
+ // `declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext }` 合并进来
+}
+```
+
+`CONTEXT_ORDERS` 已经分配了槽位(`index.d.ts:155-163`):
+`WORKING_DIRECTORY: 100` / `SANDBOX_POLICY: 110` / `APPROVAL_POLICY: 115` /
+`SUBAGENT_DELEGATION: 120` / `TEAM_IDENTITY: 125` / `HARNESS_SOURCE: 200` / `WEB_SURFACE: 210`。
+
+另有瀑布式总装口:
+
+```ts
+'system-prompt/assemble'(this: Scoped,
+ assembly: PromptAssembly, context: AssembleContext,
+ next: () => Promise): Promise
+```
+
+**能挂记忆**:✔✔ **这是常驻内容的最佳落点,但不是「按本次输入召回」的落点**。
+
+必须区分两件事(★ 实测核对源码后修正了初稿的判断):
+
+| 需求 | 钩子 | 依据 |
+|---|---|---|
+| 「与本次输入无关的常驻段」(记忆总量统计、已存块数、维护提示) | `systemPrompt.context()` | 用法见 `dsh-working-directory/lib/index.js:41-47` |
+| **「用本次输入做召回,把结果注入本轮」** | **`agent/pre-step` waterfall** | 用法见 `dsh-time-context/lib/index.js:218-243` |
+
+**为什么不能用 `context()` 做召回**:`PromptContext.text(context)` 拿到的
+`AssembleContext` 的基定义只有 `{scope, signal}`(`dsh-system-prompt/lib/types/index.d.ts:37-44`),
+`agent` 是 `dsh-agent` 通过 declaration merging 后加的(`runtime-types.d.ts` 的
+`declare module '@deepseek-ai/dsh-system-prompt'`)——**两者都没有本步消息正文**,
+**没有本步消息正文**。而 HA 的 `crossModalMarkdown` 需要 query
+(`internal/agent/core/tooldefs.go:51` 传的是 `input`)。
+所以 `agent/pre-step` 才是对等物:
+
+```ts
+// dsh-time-context/lib/index.js:218 —— 真实用法(已核对)
+ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
+ const decision = await next()
+ if (decision.kind === 'reject' || signal.aborted) return decision
+ // ... 拼 text ...
+ return {
+ ...decision,
+ messages: [...decision.messages, createUserMessage({
+ content: [{ type: 'text', text }],
+ source: { kind: name, form: 'snapshot', sections: [{ name, text }] },
+ })],
+ }
+}, { prepend: true })
+```
+
+**挂不上**:✘ `section` / `context` 都是**追加**,无法**移除**已有内容。
+所以「把超限事件从上下文里移出」不可能通过这个扩展点做到。
+
+**另一个有用的发现**:`ctx.sessionProjections`(`dsh-session-projection/lib/types/index.d.ts:25`)
+提供**纯同步 fold**(`ProjectionDefinition.apply(state, event)`,
+「All functions MUST be synchronous」),并自带订阅、水位与变更通知。
+很适合用来维护「本 session 已写过哪些块的 ID」「当前向量空间 fingerprint」这类
+派生状态——比自己在 `session/event` 上手工累加安全。`dsh-time-context` 就是用它
+记录 `lastMessageTime` / `lastInjectionTime`。
+
+同时注意一个硬约束(`dsh-system-prompt/lib/types/index.d.ts:14-27` 的 `system-prompt/assemble` 注释):
+
+> A registered complete section is restored after this waterfall,
+> so listeners cannot add to or replace that scope's system prompt.
+
+即如果有 `complete: true` 的 section 被注册,瀑布层的增补会被丢弃。
+
+### 2.4 compaction
+
+**位置**:`dsh-compaction/lib/types/index.d.ts` + `dsh-compaction-basic/lib/types/region.d.ts`
+
+```ts
+abstract class CompactionEngine extends Service {
+ abstract compactIfNeeded(agent: CompactionAgentContext,
+ trigger: 'pressure' | 'context-overflow',
+ signal: AbortSignal): Promise;
+ abstract compactNow(...): Promise;
+ abstract compactRegion(start: SessionSeq, end: SessionSeq, ...): Promise;
+}
+```
+
+实际执行者(`region.d.ts`):
+
+```ts
+export declare function compactSurfaceRegion(
+ dependencies: RegionDependencies, session: Session,
+ start: SessionSeq, end: SessionSeq, agent: Agent,
+ options: CompactionTransactionOptions, signal?: AbortSignal
+): Promise;
+```
+
+注释里写清了它做的事(`region.d.ts:40-46`):
+
+> Resolve the next range starting at the first non-system surface node while
+> retaining a priced recent tail and **never splitting an assistant
+> tool-call/result pair**. […] A successful run **replaces the selected surface
+> span with one summary node** and prevents concurrent compaction of the same session.
+
+**★ 这是 L1 移植的核心阻塞点**,必须逐条对比:
+
+| | HA 的 `Prune` | dsh 的 `compactSurfaceRegion` |
+|---|---|---|
+| 裁掉的去向 | **迁移**到 L2,原文完整保留,可召回 | **替换成一段 LLM 摘要**,原文留在日志里但**不在 surface 上** |
+| 可逆性 | 可逆(`coldcontext_query` 迁回原时序) | 模型视角不可逆(要回滚只能看原始日志重建) |
+| 评分依据 | 当前 query 的向量余弦 | `ctx.tokenMeter` 的压力测量 + retention ratio,**与 query 无关** |
+| 保留策略 | 最近 `protectedCount` 条 + 相关度 topK | `retainRatio`(默认 0.16)× 窗口的尾部预算 |
+| 摘要方式 | **不摘要**(原文进 L2) | 一次额外 LLM 调用(`summarize()`,`BasicCompactionEngine` 唯一可覆写钩子) |
+| 页边界 | 按页淘汰(`InactivePages`) | 「never splitting an assistant tool-call/result pair」(`selectCompactableRange`) |
+
+**能挂记忆**:△ 有第二个 compaction 后端 `dsh-compaction-tool-result-pruner`
+(`pruneSession(session)`,确定性 head/middle/tail 裁剪,**不调模型**),
+这证明「不调模型的压缩」是可行的。但它的产物仍是**替换 surface 节点**,
+不是「迁出到另一个层」。
+
+**挂不上**:✘ **无法实现「按 query 相关性做淘汰决策」**。
+`CompactionEngine` 的接口签名里没有 query,`CompactionTrigger` 只有
+`'pressure' | 'context-overflow'` 两个值。要做 query 相关的淘汰,
+只能绕过 compaction 服务自己改 surface——而 `session.append` 是 append-only,
+没有「删除事件」的 API(见 2.5)。
+
+### 2.5 session log / surface
+
+**位置**:`dsh-session/lib/types/index.d.ts` + `surface.d.ts`
+
+```ts
+class Session {
+ get surface(): SessionSurface { readonly nodes: readonly SessionSeq[]; ... }
+ append(type: T, data: SessionEventMap[T], ...opts): SessionEvent;
+ deriveMessages(): Message[]; // 行 347
+}
+interface SessionSurface {
+ readonly nodes: readonly SessionSeq[]; // 模型可见的事件序
+ readonly replaceGeneration: number;
+ readonly contentGeneration: number;
+}
+```
+
+注释明确(`index.d.ts:1-5`):
+
+> Event-sourced session service: **append-only** session log, in-memory store,
+> and the derived LLM message history.
+
+surface 是「事件日志之上的一层有序视图」(`surface.d.ts:1-6`):
+「The append-only log remains the source of truth.」
+
+**能挂记忆**:△ `session/event` 事件(emit)可以 tap 到每一条追加的事件
+→ 这可以做**增量蒸馏输入**(等价 HA 的 `pipeline.Append`)。
+`session/flush`(parallel)可以做**落盘检查点**。
+
+**挂不上**:✘✘ **这是第二个硬阻塞**:
+
+1. **消息没有时间戳**。`MessageBase`(`dsh-llm/lib/types/message.d.ts:124-136`)只有
+ `id / content / source`,**没有 `timestamp`**。HA 的「按原始时间戳把事件插回时序」
+ (`InsertByTimestamp`)在 dsh 里无对应物——surface 是**有序数组**,
+ `replace` 只能替换**已存在的某个位置**,不能「插到历史中间」。
+2. **没有「消费式删除」**。HA 的 `coldcontext_query` 是 `Peek → 回灌 → Remove`
+ (`toolcall.go:1109-1190`),迁移语义。dsh 的 `session.append` 只能追加,
+ surface 只能 `replace` 一个位置为别的消息,不能「移出一批事件到另一个存储」。
+3. **没有 `FrameTS`/`PageSeq` 概念**。dsh 用 `turn` / `step`(`'turn/start'`, `'step/start'`,
+ `dsh-agent/lib/types/types.d.ts` `TurnBoundaryProjection`)作为边界。
+ 两者可以映射(**推断**:`turn` ≈ `FrameTS`,`step` ≈ `PageSeq`),
+ 但 dsh 的边界是**单调递增的整数**,不是时间戳,且**不参与块的身份**。
+
+### 2.6 attachment(媒体块)
+
+**位置**:`dsh-attachment/lib/types/index.d.ts`
+
+```ts
+abstract class AttachmentStore extends Service {
+ abstract saveImage(input: SaveImageAttachment): Promise;
+ abstract readImage(ref: ImageAttachmentRef, signal?): Promise;
+ saveFile(input: SaveFileAttachment): Promise; // 内容寻址、逐字节
+ readFileStream(ref, signal?): AsyncIterable;
+ admitPromptContent(content): Promise;
+}
+```
+
+对比 HA 的媒体块(`block.go:62-65` 与 `data` 字段注释):
+
+> L0/L1 的块内联在 JSON 里(Data 序列化为 base64),L3 的块落 SQLite 的 BLOB 列。
+> 无论哪层,字节都不脱离块单独存在。
+
+**能挂记忆**:△ **部分可挂,但语义不同**:
+
+| | HA 媒体块 | dsh attachment |
+|---|---|---|
+| 字节位置 | **随块**(块外无字节存储) | **块外**,`AttachmentRef` 是引用(content-addressed) |
+| 生命周期 | 随块同生同灭 | 独立(「already published content-addressed objects may stay unreachable until a future retention policy collects them」) |
+| 引用账本 | **刻意不做**(`block.go:62-65`:「会凭空多出一层…引用账本…并与『块是唯一记忆载体』直接冲突」) | 没有账本,靠 GC |
+
+**挂不上**:✘ HA 明确否决过 CAS 形态(`block.go:62-65`),
+而 dsh attachment **就是 CAS**。移植时若用 attachment 存媒体块,
+就必然引入 HA 刻意避免的「第二套生命周期」。反向选择是「把 `Data` base64 进 KV」,
+代价是 `ctx.storage` 的 JSON backend 会把大 blob 序列化进文档
+(`dsh-storage-json/lib/types/index.d.ts`:「one human-readable document per unit」)。
+
+**结论**:媒体块是**必须裁剪**的部分(详见 §5 降级方案)。
+
+### 2.7 tool 注册
+
+**位置**:`dsh-tools/lib/types/index.d.ts`
+
+```ts
+class ToolRuntime extends Service {
+ register(definition: ToolDefinition): () => void; // 行 637
+ restrict(filter: ToolRestriction): () => void; // 行 654
+ schemas(scope?: ScopeKey): ToolSchema[]; // 行 712
+}
+
+interface ToolDefinition extends ToolSchema {
+ readonly output: ToolOutputDefinition; // ★ 强制:schema + render
+ execute(args: unknown, exec: ToolRunContext): Promise;
+ timeoutMs?: number;
+ isConcurrencySafe?(args: unknown): boolean;
+ presentCall?(args): ToolCallView | undefined;
+ presentResult?(args, result): ToolResultView | undefined;
+}
+```
+
+工具管线事件(`declare module '@deepseek-ai/cordis' { interface Events { ... } }`):
+
+- `tools/pre-execute`(waterfall)→ `PreToolDecision`:allow / deny / cancel / ask
+- `tools/execute`(waterfall):超时、重试、指标
+- `tools/post-execute`(waterfall)→ `PostToolDecision`:**接受/替换/阻断结果**
+- `tools/result`(emit):只读观测最终结果
+- `tools/change`(emit,**未做 scope 过滤**):工具集变化
+
+**能挂记忆**:✔✔ **这是最完整可挂的扩展点**。HA 的
+`stablemem_*` / `coldcontext_*` 工具面(`internal/agent/core/tooldefs.go:610-700`)
+可以逐个 1:1 翻成 `ToolDefinition`。`tools/post-execute` 还能实现
+HA 的「工具输出触发的自动召回」(`memoryPass`)。
+
+**挂不上**:无。**注意一个额外成本**:`ToolDefinition.output` 是**强制**的
+(`output.schema` + `output.render`),每个工具都要写一份 JSON Schema 与 render 函数。
+HA 的工具只有 `description` + `parameters`(`toolDefWith(...)`)。
+
+### 2.8 汇总表
+
+| 扩展点 | 位置 | L1 | L2 | L3 | 备注 |
+|---|---|---|---|---|---|
+| `Service` 注册 / `ctx.` | `cordis/service.d.ts:38` | — | ✔ 存储 | ✔✔ 召回服务 | 通用机制 |
+| `agent/pre-step` (waterfall) | `dsh-agent/runtime-types.d.ts` | ✔ 注入 | ✔ 注入 | ✔ 注入 | 可替换本步消息 |
+| `agent/turn-stopping` (serial) | 同上 | ✘ | △ 蒸馏触发 | ✔ 蒸馏触发 | |
+| `agent/created` / `disposed` | 同上 | △ | ✔ 命名空间 | ✔ 命名空间 | `source` 区分 startup/resume |
+| `session/event` (emit) | `dsh-session/index.d.ts` | △ tap 增量 | ✔ 归档输入 | ✔ 蒸馏输入 | |
+| `systemPrompt.context()` | `dsh-system-prompt/index.d.ts:269` | △ 常驻段 | △ | ✔ 记忆统计 | 纯追加,不能移除;**拿不到本步输入** |
+| `agent/pre-step` + `createUserMessage` | `dsh-time-context/lib/index.js:218` | ✔✔ **召回注入** | ✔✔ 自动预填充 | ✔ 记忆索引 | `AssembleContext` 无输入文本的替代方案 |
+| `ctx.sessionProjections` | `dsh-session-projection/index.d.ts:25` | △ | ✔ 派生状态 | ✔ 派生状态 | 纯同步 fold,自带水位 |
+| `system-prompt/assemble` (waterfall) | 同上 :27 | △ | △ | △ | 有 `complete` section 时失效 |
+| `ctx.compaction` 子类 | `dsh-compaction/index.d.ts` | ✘ **语义相反** | ✘ | ✘ | 摘要替换 ≠ 迁移 |
+| `ctx.toolResultPruner` | `dsh-compaction-tool-result-pruner` | △ 可参考 | ✘ | ✘ | 不调模型但仍是 replace |
+| `ctx.attachments` | `dsh-attachment/index.d.ts` | ✘ CAS 冲突 | ✘ | ✘ | 违反 HA「不做引用账本」 |
+| `ctx.tools.register` | `dsh-tools/index.d.ts:637` | ✔ | ✔✔ | ✔✔ | 需补 `output` 声明 |
+| `tools/post-execute` | 同上 | — | ✔ | ✔ | 工具输出触发召回 |
+| `session.append` | `dsh-session/index.d.ts` | ✘ **无时间戳/无删除** | ✘ | ✘ | 硬阻塞 |
+
+---
+
+## 3. 逐层对应关系
+
+### 3.1 L3(stablemem 图记忆)→ 可映射
+
+| HA 概念 | HA 符号 | dsh 落点 |
+|---|---|---|
+| `memory_blocks` 表 | `ddlMemoryBlocks`(`graph.go:2577`) | `ctx.storage.domain` 的一个 domain,或自建 `ctx.sqlite` 服务 |
+| `memory_block_edges` 表 | `graph.go:320` | 同 domain 的第二张表 |
+| `PutMemoryBlocks`(事务+空值不覆盖) | `block.go:203` | domain 的 `put` + 逐字段 merge 逻辑(需手写,dsh 无 `ON CONFLICT` 语义) |
+| `RecallBlocks`(全表扫+余弦) | `block_recall.go:78` | 自定义 `StableMem` service 的方法 |
+| `arbitrate`(时序仲裁) | `arbitration.go:375` | 纯函数,**可直接翻译**(无依赖) |
+| `AbstainCheck`(拒答) | `abstain.go:98` | 纯函数,**可直接翻译** |
+| `QuerySymbols`(符号提取) | `fusion.go:145` | 需 jieba(见 §4) |
+| `centerBoth`(中心化) | `centroid_store.go:443` | 纯数值,可直接翻译 |
+| `RecallByScene` | `scene.go:344` | SQL 前缀匹配,可翻译 |
+| `stablemem_recall` 等工具 | `tooldefs.go:610-700` | `ctx.tools.register` |
+| 图记忆索引注入 | `Indexer.FormatContext`(`indexer.go:358`) | `systemPrompt.context()` |
+
+**不能映射的部分**:
+- `BlocksForNode` / `NeighbourEdgesOfBlock` 的 BFS 联想(`recall-as-association.md`)
+ → 需要先有边表 + 递归 CTE,SQLite 支持(`WITH RECURSIVE`),**可做但 dsh 无现成服务**。
+
+### 3.2 L2(coldcontext 冷文档)→ 大体可映射,缺「迁回」
+
+| HA 概念 | dsh 落点 | 可行性 |
+|---|---|---|
+| `ColdDoc` 存储(文件 JSON,`Store.dir`) | `ctx.storage.domain`(KV)或直接 `node:fs` | ✔ |
+| `ContextToColdContext`(L1→L2 写出) | 在 `agent/turn-stopping` 里把本 turn 的消息落成 `ColdDoc` | ✔(但只能「复制」,不能「移出」L1) |
+| `QueryScored` / `denseSearchScored`(召回) | 自定义 service 方法 | ✔ |
+| 自动预填充(`crossModalMarkdown`) | `systemPrompt.context()` | ✔✔ |
+| `coldcontext_commit` 工具 | `ctx.tools.register` | ✔ |
+| `coldcontext_query` 的 `Peek→回灌→Remove` | **无对应物** | ✘ |
+
+**为什么 `coldcontext_query` 挂不上**(最关键的一条):
+
+HA 的实现(`toolcall.go:1121-1195`):
+
+```go
+docs := a.coldcontextStore.Peek(query, topK) // 只读,不删
+plan := a.context.planColdRecall(docs, query, budget) // 超预算按页标 inactive
+for i, d := range docs {
+ n := a.restoreDocEvents(d, plan.Evict[d.ID]) // 逐条 InsertByTimestamp
+ if n > 0 { a.coldcontextStore.Remove(d.ID) } // 确认落进 L1 才删 L2
+}
+```
+
+其中 `restoreDocEvents`(`toolcall.go:1428`)调用
+`a.context.InsertByTimestamp(ContextEvent{Timestamp: ev.Timestamp, ...})`
+—— **按事件原始时间戳插进历史位置**。
+
+dsh 侧的等价物需要:
+1. `session.append('user/message', ...)` —— 只能**追加到末尾**,位置固定
+2. 或者 `surfaceOp: 'replace'` —— 只能**替换一个已存在的位置**
+
+两者都做不到「插到 turn 3 和 turn 4 之间」。
+`MessageBase` 没有时间戳字段,所以即便插进去了,也无法表达「这条属于 3 小时前」。
+
+**降级方案(§5 会展开)**:把召回结果作为**一条新的 user 消息**追加
+(用 `source.form: 'recall'` —— dsh 已为这种语义预留了枚举,
+`message.d.ts:58` `| 'recall';` 与 `:92` `readonly form: 'recall';`)。
+语义从「迁回原时序」退化为「在末尾附一段『历史记忆(时间戳见正文)』」。
+**这是有损的**:模型看不到真实的时序关系,但内容不丢。
+
+### 3.3 L1(context 热上下文)→ 只有注入面可映射
+
+| L1 机制 | dsh 对应 | 可行 |
+|---|---|---|
+| 「进 system prompt 的时序渲染」 | `systemPrompt.context()` / `agent/pre-step` | ✔ |
+| 「每次输入计算向量」 | `agent/inbox/inserted` 里做 | ✔ |
+| 「超限时按相关度淘汰」 | **无** | ✘ |
+| 「淘汰 = 迁移到 L2(不是摘要)」 | **无** | ✘ |
+| 「按页淘汰,不破 tool_call/result 配对」 | compaction 有页意识但产物是摘要 | ✘ |
+| 「`InsertByTimestamp` 乱序插回」 | **无**(surface 无语义插入) | ✘ |
+| 「`InactivePages` 渲染期剔除」 | **无**(surface 只能 replace 整节点) | ✘ |
+
+**根因总结**:dsh 的上下文管理哲学是「**日志只追加,视图用摘要替换**」;
+HA 的哲学是「**日志可迁移,视图是持有关系**」。
+两者对「裁掉的内容去哪了」的答案不同:
+
+- dsh:压缩成摘要(`compactSurfaceRegion`),原文在 append-only 日志里但模型看不到
+- HA:迁到另一个层(`Prune` → `ContextToColdContext`),可被下次召回迁回
+
+**这不是「接口没暴露」,是设计取向不同**。所以 L1 的 L1 层机制无法移植,
+只能移植它的**效果的一半**(把记忆注入 prompt),另一半(淘汰+迁回)必须重新设计。
+
+---
+
+## 4. 依赖盘点:插件形态下谁提供,缺什么
+
+| 依赖 | HA 侧 | dsh 侧现状 | 结论 |
+|---|---|---|---|
+| **向量化(embedding)** | `pkg/embedding` SPI(`Provider.Embed/Info/Close`)+ 4 个 provider:`chineseclip`(512 维 ONNX)、`qwen3vl`(2048 维 ONNX)、`http`(外部服务)、`StaticEmbedder`(fastText/GloVe 词向量兜底,`internal/memory/static_embedder.go`) | **完全没有**。全仓 `grep -rn "embedding" --include=*.d.ts` 只命中 UI 主题/样式相关文件,无任何 embedding/vector service | **缺。必须自建** |
+| **ONNX Runtime** | `github.com/yalue/onnxruntime_go` + `-tags onnxruntime`(`providers/chineseclip/embedder.go:1`) | 无 npm `onnxruntime-node`(`find / -name onnxruntime-node` 无结果);但 `/opt/onnxruntime/libonnxruntime.so` 存在,`sherpa-onnx-linux-x64` 也自带 `libonnxruntime.so`(27MB,导出 `OrtGetApiBase`);Python 侧 `onnxruntime 1.28.0` 可用 | **缺 npm 绑定**。三条出路见下 |
+| **SQLite** | `github.com/mattn/go-sqlite3`(cgo) | `node:sqlite` 内建(**实测可用**:`DatabaseSync` + FTS5 建表/匹配均通过,Node v22.23.3,仅一条 ExperimentalWarning);`dsh-session-query-sqlite` 已在用(`schema.d.ts:2` `import type { DatabaseSync } from 'node:sqlite'`) | **✔ 已具备**。可直接建 `memory_blocks` 等价表 |
+| **中文分词(jieba)** | `github.com/yanyiwu/gojieba` + 词典目录(`internal/memory/jiebadict/`,`cut.go:73 GetJieba()`) | 无 `nodejieba`/`jieba-js`(`ls /usr/lib/node_modules \| grep -i jieba` 无结果) | **缺。** 三条出路:① 重写为纯 JS 词典(`QuerySymbols` 只需要词边界,不要求精确);② 用 `Intl.Segmenter`(Node 22 已内建 `zh` 词边界,**推断质量低于 jieba 但可用**);③ 只保留正则符号路(数字/Latin 串),放弃中文词 |
+| **依存句法(distill)** | `internal/nlp/onnx.go`(`-tags onnxruntime`,内嵌 `dep_parser.onnx`)+ `fallback.go` 规则引擎 | 无 | **缺,但可裁剪**。HA 自己的文档(`memory-restructure-plan.md` §〇)记过:「jieba/ONNX 那条自动蒸馏路实测产出 **0 条**」。真正在用的是 LLM 拆解(`distill.Extractor.Split` 走 `pkg/generation`)+ 规则闸门 |
+| **生成侧 LLM(蒸馏)** | `pkg/generation` provider(ollama/qwen3vlgen) | **✔ `ctx.llm`**(`dsh-llm` `LlmRuntime.stream(options)`);`BasicCompactionEngine.summarize()` 就是范例 | **✔ 具备**,但要走通 `registerAdapter` 或复用已有 provider |
+| **媒体存储** | 块内 `Data []byte`(SQLite BLOB / JSON base64) | `ctx.attachments`(CAS,content-addressed) | **语义冲突**,见 §2.6 |
+| **HTTP 外部向量服务** | `internal/memory/vector/http_embedder.go`(POST endpoint,`{modality,side,text,data,mime}` → `{embedding}`) | 无对应物,但**协议极简**(见下) | **✔ 可移植**,这是最省事的路 |
+
+### 4.1 embedding 的三条落地路径
+
+HA 的 `http` provider 协议(`http_embedder.go:73-86`):
+
+```go
+type httpEmbedRequest struct {
+ Model string `json:"model,omitempty"`
+ Modality string `json:"modality"`
+ Side string `json:"side"` // query | document
+ Text string `json:"text,omitempty"`
+ Data string `json:"data,omitempty"` // base64(非文本模态)
+ MIME string `json:"mime,omitempty"`
+}
+type httpEmbedResponse struct {
+ Embedding []float64 `json:"embedding"`
+ Data []struct{ Embedding []float64 `json:"embedding"` } `json:"data,omitempty"`
+}
+```
+
+**路径 A(推荐):复用 HA 的 `http` provider 契约**。
+在 dsh 插件里实现同一个 HTTP 客户端,指向一个独立进程(可以是 HA 的 `homed`
+或任何 Python/Go 服务)。成本 ≈ **2 人天**。缺点:多一个常驻服务,多一个部署单元。
+
+**路径 B:Node 内跑 ONNX**。`onnxruntime-node` 未安装,但可以 `npm i` 或
+用 `ffi-napi` 绑 `/opt/onnxruntime/libonnxruntime.so`(`OrtGetApiBase` 已导出)。
+成本 ≈ **6–10 人天**(C API 绑定 + 图输入预处理,Chinese-CLIP 要自己做 tokenizer + 图像预处理)。
+风险:**高**。HA 的 `chineseclip` provider 有 900+ 行(`providers/chineseclip/embedder.go`),
+含 tokenizer、图像 resize、归一化、session 管理。全部重写在 JS 里性价比极低。
+
+**路径 C:纯 TF-IDF / 词袋兜底**。HA 有这条退化路径
+(`ARCHITECTURE.md` 向量层级第三级)。dsh 里可用 `node:sqlite` 的 FTS5
+(已实测可用)替代。成本 ≈ **2 人天**。代价:按 `memory-usability-plan.md` 的实测,
+**词法检索的区分度≈0**(分数全挤在 0.75–0.79),这条路等于放弃语义召回。
+
+### 4.2 分词的两条落地路径
+
+`QuerySymbols`(`fusion.go:145-238`)的逻辑:
+
+```go
+// 1) 正则提取数字串 / Latin 串
+for _, m := range symbolNumericRe.FindAllString(query, -1) { add(m) }
+for _, m := range symbolLatinRe.FindAllString(query, -1) { add(strings.ToLower(m)) }
+
+// 2) 中文:jieba 分词(成功则用),失败退化滑窗
+cut := GetJieba()
+if cut != nil {
+ for _, run := range cjkRuns {
+ for _, w := range cut.Cut(run, true) {
+ if w == "" || cjkFunctionWords[w] { continue }
+ add(w)
+ }
+ }
+ usedJieba = len(out) > 0
+}
+if !usedJieba { // 滑窗补充:2~4 字重叠窗口 + symbolStopWords 过滤
+ for _, run := range cjkRuns { for _, w := range cjkWindows(run) { add(w) } }
+}
+```
+
+注意它**已经内置了退化路径**(`cjkWindows` 滑窗)—— 这部分是**纯字符串操作,可直接翻译**。
+所以分词不是硬阻塞:**最差情况退回滑窗**,代码注释也承认「滑窗是今天的既有行为,不会更差」。
+
+`Intl.Segmenter`(Node 22)可作为中间档:比滑窗好,比 jieba 差。
+
+### 4.3 依赖结论
+
+| 依赖 | 状态 | 阻塞级别 |
+|---|---|---|
+| SQLite | ✔ 具备(`node:sqlite`,实测通过) | 无 |
+| embedding | ✘ 缺,需自建(路径 A/B/C) | **P0 阻塞**(决定 L2/L3 能否语义召回) |
+| 分词 | △ 缺 jieba,有滑窗退化 | P2(可降级) |
+| 依存句法 | ✘ 缺,但 HA 自己也没验证有效 | P3(直接砍) |
+| LLM 蒸馏 | ✔ `ctx.llm` | 无 |
+| 媒体 CAS | △ 语义冲突 | P1(裁掉媒体块) |
+
+---
+
+## 5. 硬阻塞项清单(按严重程度排序)+ 降级方案
+
+### 5.1 P0 阻塞:dsh 无 embedding 服务(**必须自建**)
+
+**证据**:全仓 `*.d.ts` 无 embedding/vector service;HA 的 `pkg/embedding` 是 200+ 行 SPI + 4 个 provider。
+
+**影响**:L2 的 `DenseVec` 召回、L3 的 `RecallBlocks` 向量路、中心化校正、
+指纹隔离**全部依赖向量**。没有向量,L2/L3 退化为 LIKE + 符号路。
+
+**已知后果**(HA 自己的实测,`recall-capability-tiers.md`):
+- 无向量 ⇒ 只有「形态 1:带精确串」可用(端口/IP/版本号)
+- 「形态 2:领域词 + 上下文」从「可用」掉到「弱」
+- 「形态 3:纯泛化描述」完全不可用
+
+**降级方案**:
+- **A(推荐)**:实现 §4.1 路径 A(HTTP 转发到独立进程)→ 保留全部向量能力,2 人天
+- **B**:FTS5 全文检索替代向量路 → 只保留符号路 + 精确串置顶,**L3 价值降到「精确串查找+图遍历」**
+- **C**:**先不移植 L3 的向量路,只移植结构(块+边+BFS)** → 见 §5.9 汇总表
+
+### 5.2 P0 阻塞:dsh 的 surface 没有时间戳、不能语义插入(**L1 与 L2 召回的核心语义丢失**)
+
+**证据**:
+- `dsh-llm/lib/types/message.d.ts:124-136` `MessageBase` 只有 `id / content / source`
+- `dsh-session/lib/types/index.d.ts:1-5`「Event-sourced session service: **append-only** session log」
+- `dsh-session/lib/types/surface.d.ts:1-6`「The append-only log remains the source of truth」
+- HA 侧:`context.go:998 InsertByTimestamp`、`toolcall.go:1428 restoreDocEvents`
+
+**影响**:HA 的「召回 = 把事件按原始时间戳迁回时序」这条路径**无法表达**。
+用户问「三小时前说的那个方案」,HA 把那段对话插回真实位置;dsh 只能把它写成末尾一条消息。
+
+**降级方案**:
+1. **用 `source.form: 'recall'`**(`message.d.ts:46` `export type ContextForm` 含此枚举,`:58`/`:92` 分别定义其 `form` 字面量与用途)—— 语义上
+ 承认「这是从别处搬来的」,而非「这是新发生的事」。
+2. **在正文里显式写时间戳**:「[2026-10-29 20:00 · qq] …」,让模型自己理解时序。
+ 代价:模型可能误判因果顺序。
+3. **不做「迁出」**:L2 只存「另外记下来的」内容(`coldcontext_commit` 的等价物),
+ 不存「被淘汰的对话」。**这等于放弃 HA 的 L2 主业**(对比见 §5.8 的子集表)。
+
+### 5.3 P1 阻塞:compaction 语义与 Prune 相反(**L1 淘汰机制不可移植**)
+
+**证据**:`dsh-compaction-basic/lib/types/region.d.ts:40-46`
+「A successful run **replaces the selected surface span with one summary node**」;
+`CompactionTrigger = 'pressure' | 'context-overflow'`(`dsh-compaction/index.d.ts:25`),
+**接口签名里没有 query**。
+
+**影响**:无法做「按当前 query 相关性决定淘汰谁」。
+dsh 的 compaction 是 query-agnostic 的(只看 token 压力 + retention ratio),
+且产物是 LLM 摘要而非可回收的原文块。
+
+**降级方案**:
+- **A**:接受 dsh 的 compaction,记忆系统**不介入 L1 淘汰**。L2/L3 变成
+ 「主动写入的长期记忆」而非「L1 的溢出层」。→ **这是最诚实的取舍**
+- **B**:自己实现一个 `CompactionEngine` 子类,在 `summarize()` 里
+ 额外把被替换的区间**落成 `ColdDoc`**(而不是只丢给摘要)。
+ 这在 API 上可行(`summarize(input, agent, signal)` 拿得到 `SummarizationInput`),
+ 但**拿不到 query**,所以淘汰依然与 query 无关。成本 +8 人天,收益有限。
+- **C**:不用 compaction,用 `agent/pre-step` 自己判预算、自己 `replace` surface 节点。
+ **风险高**:`session.append` 的 surface 校验(`surface.d.ts` 的 `SurfaceOp`)
+ 对「replace 必须完整覆盖被 shadow 的节点」有硬要求,自己拼容易造出必崩的帧。
+
+### 5.4 P1 阻塞:媒体块与 dsh attachment 的 CAS 语义冲突
+
+**证据**:`block.go:62-65`(HA 明确否决 CAS);`dsh-attachment/index.d.ts`(attachment 就是 CAS)。
+
+**影响**:媒体块要么违反 HA 设计(用 attachment 引用),要么违反 dsh 惯例
+(base64 进 KV,把 `dsh-storage-json` 的「human-readable document」撑爆)。
+
+**降级方案**:**砍掉媒体块**。`MemoryBlock.Data` 字段保留但永不填充,
+`Modality ∈ {text}` only。代价:失去跨模态召回(HA 的 `RecallBlocks` 的
+`Modality` / `ExcludeText` 参数,`block_recall.go:60/63`)。
+**收益**:省掉图像预处理、视觉 tokenizer、跨模态对齐的全部成本。
+
+### 5.5 P2 阻塞:jieba 缺失
+
+**证据**:无 npm jieba;HA 用 `github.com/yanyiwu/gojieba` + 内置词典。
+
+**降级方案**:滑窗(HA 已有此退化路径,`fusion.go:240-268`(`cjkWindows`))或 `Intl.Segmenter`。
+**影响可控**:`AbstainCheck` 的实测结论(`abstain.go:61-80`「判据的能力边界」)已经承认
+「纯中文的零命中分不清『真没有』与『滑窗伪词提取失败』」——所以滑窗退化是**已知边界内**。
+
+### 5.6 P2 阻塞:`ToolDefinition.output` 强制声明
+
+**证据**:`dsh-tools/lib/types/index.d.ts:115` `export interface ToolDefinition extends ToolSchema` + `:117` `readonly output: ToolOutputDefinition;`(无 `?`,不是可选的)。
+
+**影响**:每个工具多写一份 JSON Schema + render 函数。HA 侧 10+ 个 `stablemem_*` 工具
+(`tooldefs.go:610-700`)每个都要补。成本 +3 人天。
+
+### 5.7 P3 阻塞:`FrameTS` / `PageSeq` 无对应物
+
+**证据**:dsh 用 `turn` / `step`(`TurnBoundaryProjection`,`dsh-agent/types.d.ts`),
+是单调整数而非时间戳,且不参与块身份。
+
+**降级方案**:**推断可行** —— 用 `(agent.id, turn, step)` 组合作 `FrameTS`/`PageSeq` 的等价物。
+丢失的是「跨 session 的时间可比性」,但 HA 的 `FrameTS` 主要用途(同帧块不跨层、
+页内配对)在单 session 内可满足。
+
+### 5.8 降级方案:只移植某几层成不成立?
+
+| 子集 | 成立? | 理由 | 工作量 |
+|---|---|---|---|
+| **只有 L3**(图记忆) | ✔✔ **最成立** | L3 自包含:块+边+5 条召回链路全是纯算法 + SQLite。不依赖 L1/L2 的任何机制。作为「事实与关系库」独立存在完全合理。 | **19–25 人天**(= §6.2 的 MVP 口径:阶段 0+1+2+3+7) |
+| **只有 L2**(冷文档) | △ 半成立 | 存储/召回/预填充都能做,但**放弃「迁出-迁回」**后,L2 只能靠显式写入(等价 `coldcontext_commit`)填充,不能自动从 L1 溢出。价值从「对话历史归档」降为「手工笔记库」。 | 12–17 人天 |
+| **只有 L1**(热上下文) | ✘ **不成立** | L1 的全部价值在「淘汰+迁回」这对机制上。只剩下「往 prompt 注入东西」,那不需要三层记忆,一个 `PromptContext` 就够。 | — |
+| **L2 + L3** | ✔✔ **推荐** | 二者都以「块」为单元、都以「召回进 prompt」为出口,共享 embedding + SQLite。L1 完全交给 dsh 自己的 compaction。 | **36–53 人天**(= §6.2 合计) |
+| **L1 + L2** | △ | 缺 L3 的关系能力,但至少「归档+召回」闭环成立。**前提是接受 §5.2 的时间戳降级**。 | 24–34 人天 |
+| **三层全搬** | ✘ | L1 的两条核心机制(按 query 淘汰、时间戳迁回)在 dsh 里无对应扩展点,必须改 dsh 内核或另造一套 surface 管理。**这不是插件能做的事**。 | — |
+
+### 5.9 硬阻塞汇总表
+
+| # | 阻塞项 | 严重度 | 可绕过? | 绕过代价 |
+|---|---|---|---|---|
+| 1 | dsh 无 embedding 服务 | **P0** | ✔ 自建(HTTP 转发最省) | 2 人天 + 一个常驻服务 |
+| 2 | 消息无时间戳、surface 不能语义插入 | **P0** | △ 只能降级(`form:'recall'` + 正文写时间) | **语义损失,不可完全恢复** |
+| 3 | compaction = 摘要替换 ≠ Prune 迁移 | **P1** | △ 接受它 / 再写一个后端 | L1 淘汰机制放弃 |
+| 4 | attachment CAS ≠ 块内联字节 | **P1** | ✔ 砍掉媒体块 | 失去跨模态召回 |
+| 5 | 无 jieba | P2 | ✔ 滑窗 / `Intl.Segmenter` | 中文符号提取变弱(已知边界内) |
+| 6 | `ToolDefinition.output` 强制 | P2 | ✘ 必须写 | +3 人天 |
+| 7 | `FrameTS` 无对应物 | P3 | ✔ `(session,turn,step)` 组合 | 跨 session 时间可比性丢失 |
+| 8 | `ON CONFLICT ... 空值不覆盖` 语义 | P3 | ✘ 手写 merge 逻辑 | +2 人天(易出错,需测试) |
+
+---
+
+## 6. 结论与工作量
+
+### 6.1 结论:**只能做子集**
+
+**理由链**(逐条对应证据):
+
+1. **L3 可以几乎完整移植**,因为它的依赖(SQLite + 纯算法 + 工具注册)在 dsh 全部具备。
+ `node:sqlite` 实测可用(`DatabaseSync` + FTS5),`ToolRuntime.register` 是完备的工具面,
+ `arbitrate` / `AbstainCheck` / `QuerySymbols` / `centerBoth` 都是纯函数,可 1:1 翻译。
+
+2. **L2 可以移植 80%**,缺的是「回收站语义」(`Peek → 回灌 → Remove`)。
+ dsh 侧可以用 `ctx.storage.domain`(或直接 `node:sqlite`)建出等价的 `ColdDoc` 存储,
+ 可以存、可以查、可以注入,但**不能把内容从 L1 移出**,
+ 所以「L2 作为 L1 的溢出层」这个定位无法成立。降级为「主动写入的长期记忆」仍可用。
+
+3. **L1 无法移植**。这是决定性的一条:L1 的两个不可替代机制
+ ——(a)按当前 query 相关性淘汰、(b)按原始时间戳把事件插回历史位置 ——
+ 在 dsh 的扩展点上**都没有落点**。`ctx.compaction` 的接口签名里没有 query
+ (`CompactionTrigger` 只有两个枚举值),`MessageBase` 没有时间戳字段。
+ 要支持这两点必须改 dsh 内核(新增 surface 操作或扩展 compaction 接口),
+ 那已经不是「插件」的范畴。
+
+4. **因此「三层移植」这个命题本身要修正**:能移植的是
+ 「**块化的长期记忆(L2+L3)**」,L1 应替换为 dsh 自己的 compaction。
+
+### 6.2 工作量粗估
+
+| 阶段 | 内容 | 人天 |
+|---|---|---|
+| 0 | 骨架:插件包、`Service` 注册、配置、`node:sqlite` 建表(`memory_blocks` / `memory_block_edges` / `graph_centroid`) | 3 |
+| 1 | **embedding 桥**(HTTP 转发 + fingerprint + 维度/指纹跳过逻辑) | 2–3 |
+| 2 | **块与边**:`MemoryBlock` 值对象、确定性 ID 派生、`PutMemoryBlocks` 的「空值不覆盖」merge、`AddMemoryBlockEdge` 端点校验 | 5–7 |
+| 3 | **L3 召回**:`RecallBlocks` + 中心化 + 指纹隔离 + 模态过滤 | 4–5 |
+| 4 | **L3 增强**:`QuerySymbols`(含滑窗退化)+ 融合 + `arbitrate` + `AbstainCheck` | 5–7 |
+| 5 | **L2 存储**:`ColdDoc`/`ColdEvent` + `docBlocksForEntry`/`InOut` 对称拆装 + 向量召回 | 4–6 |
+| 6 | **注入面**:`agent/pre-step` 的自动预填充(等价 `crossModalMarkdown`)+ `systemPrompt.context()` 的常驻记忆统计 | 2–3 |
+| 7 | **工具面**:`stablemem_recall/commit/merge/purge` + `coldcontext_query/commit`(每个都要 `output` 声明) | 5–7 |
+| 8 | **落盘与恢复**:`agent/turn-stopping` 触发归档、`agent/disposed` 冲刷、`session/flush` | 2–3 |
+| 9 | 判据与实测 | 4–6 |
+| | **合计(L2+L3 子集)** | **36–53** |
+
+**压缩到最小可用(MVP)**:阶段 0+1+2+3+7(去掉 L2 与仲裁/拒答)≈ **19–25 人天**。
+
+**若强行包含 L1 的降级版**(`form:'recall'` + 正文写时间戳,不做淘汰):
++8–12 人天,**但价值增量很低**(因为淘汰是 L1 的核心,注入面本来就有)。
+
+> 工作量口径统一说明:**请用上表**(含判据与实测)。§5.8 的子集表已按此口径对齐。
+
+---
+
+## 7. 建议落地阶段 + 可验证的最小第一步切片
+
+### 7.1 阶段划分
+
+**阶段 A:第一步切片(1–2 人天,必须先做)**
+
+目标:**证明「块能进 dsh、能被召回、能进 prompt」这条最小链路成立**,
+且**不引入任何外部服务依赖**。
+
+具体切片:
+
+```ts
+// A1. 新建插件包 dsh-memory,导出 apply(ctx, config)
+import { Context } from '@deepseek-ai/cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+
+export const name = 'memory'
+export const inject = ['systemPrompt', 'tools'] // 与 dsh-working-directory 同构
+
+export function apply(ctx: Context, config: Config) {
+ const mem = new StableMem(ctx) // super(ctx, 'stablemem')
+
+ // A2a. 注入面:静态/动态 context 段(已核对 dsh-working-directory/lib/index.js:41)
+ ctx.systemPrompt.context({
+ name: 'memory:index',
+ order: ctx.systemPrompt.getContextOrder('WEB_SURFACE'), // 或直接给 210
+ interpolate: false,
+ // ⚠ text() 只拿得到 AssembleContext{scope, signal, agent?},拿不到“本次输入文本”
+ text: (c) => (c.agent === undefined ? '' : mem.renderIndex(c.agent.session)),
+ })
+
+ // A2b. ★ 真正能拿到“当前这一步要进模型的消息”的钩子是 agent/pre-step(waterfall)。
+ // 已核对 dsh-time-context/lib/index.js:218 的用法:
+ // const decision = await next() → 改 decision.messages → return
+ ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
+ const decision = await next()
+ if (decision.kind === 'reject' || signal.aborted) return decision
+ const injected = mem.recallForStep(agent, decision.messages) // 用本步输入做召回
+ if (injected === '') return decision
+ return {
+ ...decision,
+ messages: [...decision.messages, createUserMessage({
+ content: [{ type: 'text', text: injected }],
+ source: { kind: name, form: 'snapshot', sections: [{ name, text: injected }] },
+ })],
+ }
+ }, { prepend: true })
+
+ // A2c. 工具面(output.schema + output.render 是强制的)
+ ctx.tools.register(defineTool({
+ name: 'stablemem_commit',
+ description: '...',
+ parameters: { /* ... */ },
+ output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
+ async execute(args, exec) { return mem.commit(args) },
+ }))
+}
+
+// A3. StableMem 用 node:sqlite 建 memory_blocks(**只建 text 模态**)
+// import { DatabaseSync } from 'node:sqlite' ← 已实测可用(含 FTS5)
+// A4. 召回先**不走向量**:FTS5 + 精确串置顶(把 QuerySymbols 的
+// 数字/Latin 正则部分 + 滑窗部分翻成 TS)
+// A5. 判据(必须能红):
+// ✔ 工具写入一个块 → 下个 pre-step 的 messages 里出现它
+// ✔ 变异:recallForStep 返回 '' ⇒ 判据红
+// ✔ 变异:把 FTS5 MATCH 改成 LIKE '%x%' 全命中 ⇒ 判据红(区分度)
+```
+
+> **★ A2a / A2b 的分工必须说清**(这是阶段 A 最重要的发现):
+> `systemPrompt.context()` 适合**与本次输入无关**的常驻内容(工作目录、沙箱策略、
+> 记忆总量统计);需要**按本次输入做召回再注入**时,必须走 `agent/pre-step`,
+> 因为 `PromptContext.text(context: AssembleContext)` 的 `AssembleContext`
+> 只有 `{scope?, signal?}` + 合并进来的 `agent?`,**没有本步消息正文**。`dsh-time-context` 正是因此选择在 `agent/pre-step`
+> 里 `createUserMessage` 后追加到 `decision.messages`(`lib/index.js:218-243`)。
+
+**为什么这个切片是对的**:
+- 它**故意不碰 embedding**(P0 阻塞),用 FTS5 拿掉第一个硬依赖
+- 它**故意不碰 L1**(不可移植),只验证注入面
+- 它**故意不碰 L2 的召回**(需要时间戳降级方案定型)
+- 它验证的正是「最有价值且最可移植」的部分:**块 + 存储 + 召回 + 注入**
+- 失败成本极低:如果 `systemPrompt.context()` 的求值时机或
+ `node:sqlite` 的并发行为不符预期,**在 2 天内就能发现**
+
+**验证方式**(可直接跑的 checkpoints):
+
+| # | 检查 | 命令/方法 |
+|---|---|---|
+| V1 | `node:sqlite` 在插件进程内可用、WAL 模式正常 | 建库 + 并发写 100 次 + 读回 |
+| V2 | `node:sqlite` FTS5 对中文的召回质量 | 用 HA 的 50 条真实块文本做语料,10 个查询测 top1 |
+| V3 | `systemPrompt.context()` 的 `text(ctx)` 能拿到 `agent.session`,但**验证它拿不到本步消息** | 在 text 里 log `Object.keys(context)`,确认只有 `scope/signal/agent` |
+| V3b | `agent/pre-step` 的 `decision.messages` 可被追加且模型的下一轮确实看到 | 最小 echo 注入 + 观察 `/dump-config` 或 session log 里是否新增 `user/message` |
+| V4 | `ctx.tools.register` 的 `output.schema`/`render` 契约 | 最小 echo 工具跑通 |
+| V5 | 插件卸载后 sqlite 句柄释放 | `ctx.dispose` + 文件锁检查 |
+
+**阶段 A 的 exit criteria**:V1–V5 全绿 + 判据 A5 三条(含 2 条变异)
++ **V3/V3b 的分工结论写入插件 README**(哪个钩子能拿输入,哪个不能)。
+
+**阶段 B:向量落地(3–4 人天)**
+接 §4.1 路径 A(HTTP 转发)或路径 C(确认放弃向量)。
+在此阶段把 `RecallBlocks` 的余弦 + 指纹/维度跳过 + 中心化 补齐。
+
+**阶段 C:L3 完整召回(8–12 人天)**
+`QuerySymbols` 完整版 + 融合 + 仲裁 + 拒答 + 场景召回。
+
+**阶段 D:L2 冷文档(6–9 人天)**
+`ColdDoc`/`ColdEvent` + 对称拆装 + 向量召回 + `systemPrompt.context()` 的预填充。
+**前置条件**:§5.2 的时间戳降级方案已拍板(`form:'recall'` + 正文写时间)。
+
+**阶段 E:工具面 + 归档触发(7–10 人天)**
+全部 `stablemem_*` / `coldcontext_*` 工具;`agent/turn-stopping` 触发归档。
+
+**阶段 F:判据与实测(4–6 人天)**
+按 HA 的判据风格(「变异必须能红」)建测试;
+用 HA 的生产库快照做 A/B 对比(`recall-capability-tiers.md` 的四形态 + 排名基线)。
+
+### 7.2 必须写进设计文档的三条「不可移植」声明
+
+避免后续被当成 bug 修:
+
+1. **「召回不会把事件插回原时序」** —— dsh surface 不支持;只能 `form:'recall'` 追加。
+2. **「L1 淘汰由 dsh compaction 负责,不由记忆插件负责」** —— 语义是摘要不是迁移。
+3. **「媒体块不支持」** —— dsh attachment 是 CAS,与 HA「块内联字节」的设计冲突。
+
+这三条都应在插件 README 的第一屏写明,并在 `systemPrompt.context()` 的注入文案里
+向模型说明(否则模型会按 HA 的语义预期行为)。
+
+---
+
+## 附录 A:证据索引
+
+### A.1 HologramAgent 侧(只读,未修改)
+
+| 主题 | 文件:符号 |
+|---|---|
+| `MemoryBlock` 定义 | `internal/memory/block.go:66` |
+| 块 union 负载 / 无引用账本(否决 CAS) | `internal/memory/block.go:55-56`、`62-65` |
+| 段页式身份(FrameTS/PageSeq/CallID) | `internal/memory/block.go:100-159` |
+| `PutMemoryBlocks`(空值不覆盖) | `internal/memory/block.go:203-300` |
+| `AddMemoryBlockEdge` | `internal/memory/block.go:497` |
+| `memory_blocks` DDL | `internal/memory/graph.go:2577` |
+| `memory_block_edges` DDL | `internal/memory/graph.go:320` |
+| `RecallBlocks` | `internal/memory/block_recall.go:78` |
+| `RecallBlocksWithArbitration` | `internal/memory/block_recall.go:229` |
+| `largeTopK = 2000` | `internal/memory/block_recall.go:263` |
+| `Centroid` / `BuildCentroid` | `internal/memory/centroid.go:61/199` |
+| `centerBoth` | `internal/memory/centroid_store.go:443` |
+| `AbstainCheck` | `internal/memory/abstain.go:98` |
+| `RecallBlocksGuarded` | `internal/memory/abstain.go:179` |
+| `arbitrate` | `internal/memory/arbitration.go:375` |
+| `parseFact` / `supersedes` | `internal/memory/arbitration.go:88/148` |
+| `QuerySymbols` | `internal/memory/fusion.go:145` |
+| 精确串布尔置顶 | `internal/memory/fusion.go:427` |
+| `RecallByScene` | `internal/memory/scene.go:344` |
+| `ContextEvent` | `internal/agent/core/context.go:64` |
+| `RelevanceContext` | `internal/agent/core/context.go:145` |
+| `Append` | `internal/agent/core/context.go:967` |
+| `InsertByTimestamp` | `internal/agent/core/context.go:998` |
+| `PruneWithProtected` | `internal/agent/core/context.go:1075` |
+| `planColdRecall` / `ColdRecallPlan` | `internal/agent/core/context.go:453/489` |
+| `ColdDoc` / `ColdEvent` | `internal/memory/coldcontext/coldcontext.go:37/75` |
+| `ContextToColdContext` | `internal/memory/coldcontext/coldcontext.go:385` |
+| `Peek` / `Consume` | `internal/memory/coldcontext/coldcontext.go:480/526` |
+| `docBlocksForEntry` | `internal/memory/coldcontext/coldcontext.go:974` |
+| `ColdEvent.InOut` | `internal/memory/coldcontext/coldcontext.go:1259` |
+| `executeDocTool` | `internal/agent/core/toolcall.go:1090` |
+| `restoreDocEvents` | `internal/agent/core/toolcall.go:1428` |
+| `recallByBlocks` | `internal/agent/core/toolcall.go:1257` |
+| `crossModalMarkdown` | `internal/agent/core/crossmodal.go:256` |
+| L2 预填充调用点 | `internal/agent/core/tooldefs.go:51` |
+| `buildSystemPrompt` | `internal/agent/core/tooldefs.go:176` |
+| `BlockID` 派生 | `internal/memory/distill/blocks.go:99` |
+| `FieldBlock.ToMemoryBlock` | `internal/memory/distill/blocks.go:126` |
+| `Extractor.Blocks` | `internal/memory/distill/blocks.go:69` |
+| `embedding.Provider` SPI | `pkg/embedding/embedding.go`(`Input`/`Info`/`Provider`/`Register`) |
+| `http` embedding provider 协议 | `internal/memory/vector/http_embedder.go:72-90` |
+| `chineseclip` provider(ONNX) | `providers/chineseclip/embedder.go:1`(`//go:build onnxruntime`) |
+| `StaticEmbedder`(词向量兜底) | `internal/memory/static_embedder.go:32` |
+| jieba 获取 | `internal/memory/cut.go:73 GetJieba()` |
+| 依赖清单 | `go.mod`(`go-sqlite3` / `gojieba` / `onnxruntime_go`) |
+| 设计文档 | `docs/zh/memory-restructure-plan.md`, `memory-blockflow-repair-plan.md`, `memory-usability-plan.md`, `recall-as-association.md`, `recall-capability-tiers.md`, `multimodal-space.md`, `context-overflow-l4-design.md`, `kernel-modularization-design.md`, `input-scheduler-design.md` |
+| 权威架构 | `assets/docs/zh/ARCHITECTURE.md:180-330` |
+
+### A.2 dsh 侧
+
+| 主题 | 文件 |
+|---|---|
+| cordis `Context` / `isolate` / `intercept` | `@deepseek-ai/cordis/lib/types/context.d.ts` |
+| `Service` 注册基类 | `@deepseek-ai/cordis/lib/types/service.d.ts:38` |
+| 事件总线模式(emit/parallel/serial/bail/waterfall) | `@deepseek-ai/cordis/lib/types/events.d.ts` |
+| `AgentRegistry` | `@deepseek-ai/dsh-agent/lib/types/index.d.ts` |
+| Agent 生命周期事件全部枚举 | `@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts` |
+| `agent/pre-step` → `PreStepDecision` | 同上(`'agent/pre-step'` waterfall) |
+| `Agent` 的 `send/followup/steer/inject/runMaintenance/cancel` | 同上(`declare module './types.ts' { interface Agent { ... } }`) |
+| agent-loop 工厂 / `TurnBoundaryProjection` | `@deepseek-ai/dsh-agent-loop/lib/types/index.d.ts`、`agent.d.ts` |
+| `SystemPrompt.section/context/tools/variable/assemble` | `@deepseek-ai/dsh-system-prompt/lib/types/index.d.ts:236-320`(`context()` 在 :269) |
+| `SECTION_ORDERS` / `CONTEXT_ORDERS` | 同上 :119-163 |
+| `system-prompt/assemble` waterfall | 同上 :27 |
+| `ToolRuntime.register/restrict/schemas` | `@deepseek-ai/dsh-tools/lib/types/index.d.ts:637/654/712` |
+| `ToolDefinition`(`output` 强制,:117) | 同上 :115-200 |
+| `tools/pre-execute` / `execute` / `post-execute` / `result` / `change` | 同上 :48-105 |
+| `Session` / `append` / `deriveMessages` | `@deepseek-ai/dsh-session/lib/types/index.d.ts:135/347` |
+| `SessionSurface`(append-only) | `@deepseek-ai/dsh-session/lib/types/surface.d.ts:1-30` |
+| `MessageBase`(**无时间戳**) | `@deepseek-ai/dsh-llm/lib/types/message.d.ts:124-136` |
+| `ContextForm` / `ContextFormed`(含 `'recall'`) | `@deepseek-ai/dsh-llm/lib/types/message.d.ts:46-93` |
+| `MessageSourceMap` | 同上 :98-105 |
+| `CompactionEngine` | `@deepseek-ai/dsh-compaction/lib/types/index.d.ts` |
+| `compactSurfaceRegion`(摘要替换) | `@deepseek-ai/dsh-compaction-basic/lib/types/region.d.ts:40-60` |
+| `CompactionTrigger` | `@deepseek-ai/dsh-compaction/lib/types/index.d.ts:25` |
+| `BasicCompactionEngine.summarize`(唯一钩子) | `@deepseek-ai/dsh-compaction-basic/lib/types/index.d.ts` |
+| `ToolResultPruner.pruneSession` | `@deepseek-ai/dsh-compaction-tool-result-pruner/lib/types/index.d.ts` |
+| `AttachmentStore`(CAS) | `@deepseek-ai/dsh-attachment/lib/types/index.d.ts` |
+| `Storage` hub / `StorageBackend` / `KvFacet` | `@deepseek-ai/dsh-storage/lib/types/index.d.ts`、`backend.d.ts` |
+| `DomainFacility` / `defineDomain` | `@deepseek-ai/dsh-storage-domain/lib/types/index.d.ts` |
+| JSON backend(整 unit 一文档) | `@deepseek-ai/dsh-storage-json/lib/types/index.d.ts` |
+| `node:sqlite` 先例 | `@deepseek-ai/dsh-session-query-sqlite/lib/types/schema.d.ts:2` |
+| `SessionQueryEngine`(FTS5 全文检索) | `@deepseek-ai/dsh-session-query/lib/types/index.d.ts` |
+| `TokenMeter` | `@deepseek-ai/dsh-token-meter/lib/types/index.d.ts` |
+| `LlmRuntime` / `LlmAdapter` / `stream` | `@deepseek-ai/dsh-llm/lib/types/index.d.ts:130/245` |
+| `Scoped` / `ScopeKey` | `@deepseek-ai/dsh-scope/lib/types/index.d.ts` |
+| Loader / Include / Entry | `@deepseek-ai/cordis-plugin-loader/lib/types/index.d.ts`、`cordis-plugin-include/lib/types/index.d.ts` |
+| `dsh-base`(无运行期 API,纯 patch bundle) | `@deepseek-ai/dsh-base/lib/types/index.d.ts` |
+
+### A.3 环境实测
+
+| 项 | 结果 | 方法 |
+|---|---|---|
+| `node:sqlite` 可用 + FTS5 | ✔ 通过(`CREATE VIRTUAL TABLE ... USING fts5` + `MATCH`) | `node -e` 直跑,Node v22.23.3 |
+| `onnxruntime-node` npm 包 | ✘ 未安装 | `find / -name onnxruntime-node -type d` 无结果 |
+| `libonnxruntime.so` | ✔ 存在两处:`/opt/onnxruntime/`(24MB)、`/usr/lib/node_modules/sherpa-onnx-linux-x64/`(27MB,导出 `OrtGetApiBase`) | `ls` + `nm -D` |
+| jieba (JS) | ✘ 未安装 | `ls /usr/lib/node_modules \| grep -i jieba` 无结果 |
+| Python `onnxruntime` | ✔ 1.28.0 | `python3 -c "import onnxruntime"` |
+| dsh 版本 | `0.2.1-rc.2` | `@deepseek-ai/dsh/package.json` |
+| cordis 版本 | `4.0.5` | `@deepseek-ai/cordis/package.json` |
+
+---
+
+## 附录 B:本文的取证纪律
+
+- **所有 dsh 接口结论均来自 `lib/types/*.d.ts`**(权威接口),未从 `lib/*.js` 实现倒推。
+- **所有 HA 结论均来自源码或 `docs/zh/*.md`**,并标注了符号名(行号仅作辅助锚点,
+ 文档本身也说明「行号随改动漂移,函数名才是稳定锚点」)。
+- 标「**推断**」的地方只有三处:`turn`≈`FrameTS` 的映射、`Intl.Segmenter` 的中文分词质量、
+ §6.2 的人天估算。其余均为可直接复核的事实。
+- HA 仓库(`/home/program/TrueAgent/`)**全程只读**,未做任何修改(`git status` 无输出)。