结论:只能做子集。 逐层结论 - 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 仓库。
1139 lines
66 KiB
Markdown
1139 lines
66 KiB
Markdown
# 可行性评估:把 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%(结构完全可映射,受限于向量依赖)<br>L2 ≈ 80%(存储与召回可映射,缺「回收站式 Consume + 迁回原时序」)<br>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<out T = never> {
|
||
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<PromptAssembly>;
|
||
}
|
||
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<SystemPrompt>,
|
||
assembly: PromptAssembly, context: AssembleContext,
|
||
next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||
```
|
||
|
||
**能挂记忆**:✔✔ **这是常驻内容的最佳落点,但不是「按本次输入召回」的落点**。
|
||
|
||
必须区分两件事(★ 实测核对源码后修正了初稿的判断):
|
||
|
||
| 需求 | 钩子 | 依据 |
|
||
|---|---|---|
|
||
| 「与本次输入无关的常驻段」(记忆总量统计、已存块数、维护提示) | `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<CompactionResult | null>;
|
||
abstract compactNow(...): Promise<CompactionResult | null>;
|
||
abstract compactRegion(start: SessionSeq, end: SessionSeq, ...): Promise<CompactionResult>;
|
||
}
|
||
```
|
||
|
||
实际执行者(`region.d.ts`):
|
||
|
||
```ts
|
||
export declare function compactSurfaceRegion(
|
||
dependencies: RegionDependencies, session: Session,
|
||
start: SessionSeq, end: SessionSeq, agent: Agent,
|
||
options: CompactionTransactionOptions, signal?: AbortSignal
|
||
): Promise<CompactionResult>;
|
||
```
|
||
|
||
注释里写清了它做的事(`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<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts): SessionEvent<T>;
|
||
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<ImageAttachmentRef>;
|
||
abstract readImage(ref: ImageAttachmentRef, signal?): Promise<StoredImageAttachment>;
|
||
saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef>; // 内容寻址、逐字节
|
||
readFileStream(ref, signal?): AsyncIterable<Uint8Array>;
|
||
admitPromptContent(content): Promise<AdmittedPromptContentPart[]>;
|
||
}
|
||
```
|
||
|
||
对比 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<unknown>;
|
||
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.<name>` | `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` 无输出)。
|