Files
HologramAgent-DSH-Memory-Port/docs/feasibility.md
jianf 28d9d5e929 docs: 三层记忆移植 dsh 插件可行性评估
结论:只能做子集。

逐层结论
- 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 仓库。
2026-10-30 00:39:44 +08:00

66 KiB
Raw Blame History

可行性评估:把 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

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)):

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):

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
)
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 依赖注入内核。插件形态:

// /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

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

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。

另有瀑布式总装口:

'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 才是对等物:

// 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

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):

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

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

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

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):

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):

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)的逻辑:

// 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」这条最小链路成立, 且不引入任何外部服务依赖。

具体切片:

// 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 无输出)。