feat: 段页式记忆流转、coldcontext 重命名与安全治理

记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

View File

@ -96,44 +96,46 @@ func (e *eventsAPI) EventPolicy() security.EventPolicy {
return security.DefaultEventPolicy()
}
// Publish 以**本插件身份**发布事件。
//
// ## ★ 为什么必须走 PublishAs(2026-10-29 收敛)
//
// 此前这里调的是 `bus.Publish`,而它的实现是 `publishAs(Publisher{}, ...)` ——
// 空 Publisher 的 `IsKernel()` 为真,于是 security 的 `AllowPublish`
// **第一行就直接放行**。也就是说:总线上的事件策略对这条路径从未生效过,
// 它之所以“安全”,只是因为本函数自己又查了一遍策略。
//
// 后果是**同一条策略有两份实现**(本文件 + security.eventGuard),
// 一旦口径漂移就会出现“总线放行、适配层拦截”或反之的分裂。
//
// 现在只保留一份:
//
// e.bus.PublishAs(Publisher{Name: e.pluginName}, ...)
// → security.eventGuard.AllowPublish (授权,唯一一处)
// → security.eventGuard.ScrubPayload (出向脱敏)
// → 投递时 security.eventGuard.ScrubInbound(入向清洗)
//
// 本文件只剩“把身份如实告诉总线”这一件职责 —— 插件模块不再持有策略判断
// (用户裁定:所有安全部分归安全模块)。
func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error {
if e.unavailable != "" {
return fmt.Errorf("events: %s", e.unavailable)
}
if d := e.checkPublish(eventType); !d.Allowed {
return fmt.Errorf("%s", d.Reason)
}
if payload == nil {
payload = map[string]interface{}{}
}
// 载荷键数上限仍在这里判:它是**当前唯一实现**(eventGuard.AllowPublish
// 只拿到 eventType、看不到 payload,故无法在那里做)。
// 删掉它 = 直接失去这项保护,所以不属“重复”而不应一并丢弃。
if d := e.checkSize(len(payload)); !d.Allowed {
return fmt.Errorf("%s", d.Reason)
}
// ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到,
// 插件顺手把凭据放进去就等于绕过凭据闸门。
e.bus.Publish(&events.Event{
return e.bus.PublishAs(events.Publisher{Name: e.pluginName}, &events.Event{
Type: events.EventType(eventType),
Source: e.pluginName,
Payload: e.scrubOut(eventType, payload),
Payload: payload,
})
return nil
}
func (e *eventsAPI) checkPublish(t string) security.Decision {
if e.sec != nil {
return e.sec.CheckEventPublish(e.pluginName, t)
}
return security.DefaultEventPolicy().CheckEventPublish(e.pluginName, t)
}
func (e *eventsAPI) checkSubscribe(t string) security.Decision {
if e.sec != nil {
return e.sec.CheckEventSubscribe(e.pluginName, t)
}
return security.DefaultEventPolicy().CheckEventSubscribe(e.pluginName, t)
}
func (e *eventsAPI) checkSize(keys int) security.Decision {
if e.sec != nil {
return e.sec.CheckEventPayloadSize(keys)
@ -141,66 +143,26 @@ func (e *eventsAPI) checkSize(keys int) security.Decision {
return security.DefaultEventPolicy().CheckEventPayloadSize(keys)
}
// scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。
func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} {
if e.sec == nil {
return payload
}
out := make(map[string]interface{}, len(payload))
for k, v := range payload {
if s, ok := v.(string); ok {
if clean, n := e.sec.PrepareOutbound(security.Inspection{
Direction: security.DirectionOutbound,
Stage: "event:" + eventType,
Source: e.pluginName,
}, s); n > 0 {
out[k] = clean
continue
}
}
out[k] = v
}
return out
}
func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) {
if e.unavailable != "" {
return nil, fmt.Errorf("events: %s", e.unavailable)
}
if d := e.checkSubscribe(eventType); !d.Allowed {
return nil, fmt.Errorf("%s", d.Reason)
}
if handler == nil {
return nil, fmt.Errorf("events: handler must not be nil")
}
unsub := e.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) {
handler(e.scrubIn(eventType, evt.Payload))
})
// 走 SubscribeAs:授权交给 security.eventGuard.AllowSubscribe(唯一一处),
// 入向清洗已由总线在投递前统一执行 —— 这里不再自己洗一遍。
unsub, err := e.bus.SubscribeAs(events.Publisher{Name: e.pluginName},
events.EventType(eventType), func(evt *events.Event) {
handler(evt.Payload)
})
if err != nil {
return nil, err
}
e.unsubs = append(e.unsubs, unsub)
return unsub, nil
}
// scrubIn 对入向载荷做清洗(事件可能带其他模块写进来的内容)。
func (e *eventsAPI) scrubIn(eventType string, p map[string]interface{}) map[string]interface{} {
if e.sec == nil || p == nil {
return p
}
out := make(map[string]interface{}, len(p))
for k, v := range p {
if s, ok := v.(string); ok {
if clean, n := e.sec.ScrubInbound(security.Inspection{
Direction: security.DirectionInbound,
Stage: "event:" + eventType,
}, s); n > 0 {
out[k] = clean
continue
}
}
out[k] = v
}
return out
}
// stop 解除本插件建立的全部订阅(插件卸载时由宿主调用)。
func (e *eventsAPI) stop() {
for _, u := range e.unsubs {