Files
Algis DumbrisandClaude Opus 4.7 069a985af5 feat(020): 14d window + token-budget circuit breaker + dream-agent + dashboard
Backend (Go, in this commit):
- migration 029_memory_dream_usage: per (date, owner) counters for
  tokens_in/out, jobs_started/succeeded/failed/circuit_broken
- DreamUsageStore + UsageGate (internal/messaging/dream_usage.go).
  Gate inspects today's usage against new env knobs:
  - SYNAPBUS_DREAM_RECENT_WINDOW (default 336h / 14d)
  - SYNAPBUS_DREAM_DAILY_TOKEN_LIMIT_IN (default 1M)
  - SYNAPBUS_DREAM_DAILY_TOKEN_LIMIT_OUT (default 200k)
  - SYNAPBUS_DREAM_DAILY_JOB_LIMIT (default 100)
- Consolidator now bounds watermarks + core_rewrite eligibility by the
  recency window. core_rewrite skipped for owners with no in-window
  activity. ForceRun honors the breaker.
- Recency fallback in BuildContextPacket + memory_list_unprocessed now
  accept RecentWindowDays so injection and dream queries see the same
  14d slice.
- Prometheus metrics registered (internal/metrics/metrics.go):
  synapbus_dream_jobs_total{owner,job_type,status},
  synapbus_dream_tokens_total{owner,direction},
  synapbus_dream_job_duration_seconds{owner,job_type},
  synapbus_dream_circuit_broken_total{owner,reason},
  synapbus_injection_packets_total{tool},
  synapbus_injection_memories_per_packet{tool},
  synapbus_injection_packet_chars{tool},
  synapbus_injection_skipped_total{tool,reason}.
- deploy/kubic/deployment.yaml: liveness/readiness timeoutSeconds: 1→5
  (root-causes the "connection refused" mcpproxy errors at 13:02 today —
  /readyz occasionally exceeded 1s under dream-worker tick load, so the
  pod fell out of the Service endpoints intermittently).

Dream-claude agent (Python, in /dream-agent/):
- dream_runner.py uses claude-agent-sdk 0.1.48 to drive Claude Code
  against SynapBus's MCP server. MCP transport carries
  Authorization: Bearer <api_key> AND X-Synapbus-Dispatch-Token from env
  via the SDK's McpHttpServerConfig.headers field — confirmed supported.
- Tools restricted via allowed_tools to mcp__synapbus__memory_*.
- Final JSON envelope reports tokens_in/out so harness.Usage stays
  populated and the circuit breaker can count consumption.
- Dockerfile builds linux/amd64 at 189 MB, mirroring searcher's
  agents/universal recipe.
- k8s-job-template.yaml: backoffLimit 0, ttl 600s, 512Mi/1CPU,
  Anthropic credentials via secret-ref.

Grafana dashboard (deploy/kubic/grafana/):
- dream-dashboard.json — 14 panels across 5 rows (dream activity,
  token usage vs limit, circuit breaker, injection layer, MCP
  transport health), all templated to ${DS_PROMETHEUS}.
- import.sh: resolves the cluster's Prometheus DS uid and POSTs the
  dashboard via Grafana API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:58:28 +03:00

561 lines
18 KiB
Go

// Proactive-memory injection retrieval — builds the relevant-context
// packet attached to every injection-eligible MCP tool response (per
// `contracts/mcp-injection.md`).
package search
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"time"
"github.com/synapbus/synapbus/internal/agents"
)
// MemoryItem is one entry in the `relevant_context.memories[]` array.
// Field shape is contractual (`contracts/mcp-injection.md`).
type MemoryItem struct {
ID int64 `json:"id"`
FromAgent string `json:"from_agent"`
Channel string `json:"channel,omitempty"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
Score float64 `json:"score"`
MatchType string `json:"match_type"`
Pinned bool `json:"pinned"`
Truncated bool `json:"truncated,omitempty"`
}
// ContextPacket is the body of the `relevant_context` field. Returned
// from BuildContextPacket; rendered verbatim into the wrapped tool
// response.
type ContextPacket struct {
Memories []MemoryItem `json:"memories"`
CoreMemory string `json:"core_memory,omitempty"`
PacketChars int `json:"packet_chars"`
PacketTokenEstimate int `json:"packet_token_estimate"`
RetrievalQuery string `json:"retrieval_query"`
SearchMode string `json:"search_mode"`
}
// CoreMemoryProvider is the seam US2 plugs into. When non-nil and
// `opts.IncludeCore` is true, BuildContextPacket calls Get() and
// includes the result verbatim in the packet.
type CoreMemoryProvider interface {
Get(ctx context.Context, ownerID, agentName string) (string, error)
}
// PinProvider returns the owner's pinned message ids. Set on
// InjectionOpts via US3 wiring; when nil the overlay is skipped.
type PinProvider interface {
ListForOwner(ctx context.Context, ownerID string) ([]int64, error)
}
// StatusProvider returns the memory_status of each id in the input
// slice. Ids that do not appear in the returned map are implicitly
// `active`. Set on InjectionOpts via US3 wiring.
type StatusProvider interface {
Statuses(ctx context.Context, msgIDs []int64) (map[int64]MemoryStatusInfo, error)
}
// MemoryStatusInfo mirrors messaging.MemoryStatus without importing
// the messaging package (avoids a cycle). The injection retrieval
// layer needs only the Status string and the active/non-active bit.
type MemoryStatusInfo struct {
Status string
}
// MessageLookup resolves message ids → MemoryItem fields for the pin
// overlay. The overlay needs body / from_agent / channel for pinned
// messages that did NOT come back from the search; loading them
// directly from the messages table keeps this independent of the
// search index.
type MessageLookup interface {
LookupForInjection(ctx context.Context, ids []int64) ([]MemoryItem, error)
}
// InjectionOpts captures the per-call configuration for
// BuildContextPacket. Sourced from messaging.MemoryConfig at wrap time.
type InjectionOpts struct {
// BudgetTokens is the soft cap on the assembled packet. 0 disables
// injection: BuildContextPacket returns (nil, nil).
BudgetTokens int
// MaxItems caps the number of memory items in the packet.
MaxItems int
// MinScore is the relevance floor. Items below are dropped.
MinScore float64
// IncludeCore enables the core-memory lookup. Only session-start
// tools (i.e. my_status) should set this.
IncludeCore bool
// CoreProvider is consulted when IncludeCore is true. May be nil
// (US2 not yet wired) — then no core memory is included.
CoreProvider CoreMemoryProvider
// PinProvider, when non-nil, supplies owner-pinned message ids
// that are spliced into the packet with Score=1.0 regardless of
// the score floor. Status filter still drops soft_deleted /
// superseded pins so retrieval never surfaces tombstoned facts.
PinProvider PinProvider
// StatusProvider, when non-nil, supplies the memory_status of
// each candidate; results with status soft_deleted/superseded are
// dropped (unless pinned).
StatusProvider StatusProvider
// MessageLookup, when non-nil, resolves pinned message ids that
// did not surface through retrieval. When nil, only pins already
// present in the retrieval results are highlighted.
MessageLookup MessageLookup
// RecentWindowDays bounds the recency fallback (FR-009) to the
// last N days of memory-channel activity. 0 → 14d default.
// Mirrored from messaging.MemoryConfig.DreamRecentWindow by the
// MCP wrapper at request time.
RecentWindowDays int
// Now is overridable for tests. Defaults to time.Now.
Now func() time.Time
}
// EstimateTokens returns the char-based token estimate. Matches R4:
// `(chars + 3) / 4`, ceil-equivalent for positive integers.
func EstimateTokens(chars int) int {
if chars <= 0 {
return 0
}
return (chars + 3) / 4
}
// BuildContextPacket retrieves owner-scoped memories matching `query`,
// applies the score floor + token budget + max items cap, optionally
// resolves the per-(owner, agent) core memory blob, and returns a
// ContextPacket ready to merge into the tool response.
//
// Returns (nil, nil) when `opts.BudgetTokens == 0` (feature disabled)
// or when no memories pass the filter AND no core memory is set —
// callers omit the `relevant_context` field entirely in that case.
func BuildContextPacket(
ctx context.Context,
svc *Service,
agent *agents.Agent,
query string,
opts InjectionOpts,
) (*ContextPacket, error) {
if opts.BudgetTokens == 0 {
return nil, nil
}
if agent == nil {
return nil, fmt.Errorf("build context packet: nil agent")
}
if opts.Now == nil {
opts.Now = time.Now
}
maxItems := opts.MaxItems
if maxItems <= 0 {
maxItems = 5
}
callerOwner := strconv.FormatInt(agent.OwnerID, 10)
if agent.OwnerID == 0 {
// Unowned agent: skip retrieval. We still surface a (possibly
// non-empty) core memory if the provider returns one for the
// empty owner — but that's an edge case the provider can decide
// on.
callerOwner = ""
}
// Over-fetch x3 to absorb owner-scope filtering + score floor drop.
wantedLimit := maxItems * 3
if wantedLimit < 15 {
wantedLimit = 15
}
searchMode := ModeAuto
var memories []MemoryItem
if svc != nil && callerOwner != "" {
if query != "" {
// Drive retrieval through the existing hybrid path so we inherit
// access control + ranking. We still need a stricter owner filter
// on top of canAgentAccessMessage because the memory pool
// (open-brain) is broadly readable across agents within the same
// system, and we must enforce owner isolation (SC-008).
resp, err := svc.Search(ctx, agent.Name, SearchOptions{
Query: query,
Mode: ModeAuto,
Limit: wantedLimit,
MinSimilarity: opts.MinScore,
})
if err != nil {
return nil, fmt.Errorf("build context packet: search: %w", err)
}
if resp != nil {
searchMode = resp.SearchMode
}
if resp != nil && len(resp.Results) > 0 {
items, err := filterAndScore(ctx, svc, resp.Results, callerOwner, opts)
if err != nil {
return nil, err
}
memories = items
}
} else {
// FR-009 recency fallback: no explicit query → return the N
// most recent memory-channel messages whose author belongs to
// the caller's owner. Bypasses the hybrid index (which has no
// notion of "no query") and goes directly to SQL. Window is
// bounded by opts.RecentWindowDays (defaults to 14).
windowDays := opts.RecentWindowDays
if windowDays <= 0 {
windowDays = 14
}
items, err := recentMemoriesForOwner(ctx, svc.db, callerOwner, wantedLimit, windowDays)
if err != nil {
return nil, fmt.Errorf("build context packet: recency: %w", err)
}
memories = items
searchMode = "recent"
}
}
// Apply memory_status filter (US3 T031): drop soft_deleted /
// superseded results unless they will be pinned in the next step.
memories = applyStatusFilter(ctx, memories, nil, opts)
// Pin overlay (US3 T029/T031): owner-pinned message ids bypass the
// score floor and are spliced in with Score=1.0 / Pinned=true. We
// build the pin set up-front so the status filter knows to spare
// them.
pinIDs, _ := loadPinIDs(ctx, opts, callerOwner)
if len(pinIDs) > 0 {
memories = applyStatusFilter(ctx, memories, pinIDs, opts)
memories = applyPinOverlay(ctx, memories, pinIDs, opts)
}
// Apply token budget: greedy fill in descending score (results are
// already sorted). Truncate the last admitted item to fit when it
// would otherwise overflow.
memories = applyTokenBudget(memories, maxItems, opts.BudgetTokens)
// Core memory lookup (US2 hook). Provider may be nil — that's fine.
var coreBlob string
if opts.IncludeCore && opts.CoreProvider != nil && callerOwner != "" {
blob, err := opts.CoreProvider.Get(ctx, callerOwner, agent.Name)
if err == nil {
coreBlob = blob
} else if !errors.Is(err, sql.ErrNoRows) {
// Surface unexpected errors so callers can log; an absent
// core blob (typical "no rows") is not an error.
return nil, fmt.Errorf("build context packet: core memory: %w", err)
}
}
if len(memories) == 0 && coreBlob == "" {
// Empty + no core → caller should omit the relevant_context field.
return nil, nil
}
if memories == nil {
memories = []MemoryItem{}
}
packet := &ContextPacket{
Memories: memories,
CoreMemory: coreBlob,
RetrievalQuery: query,
SearchMode: searchMode,
}
packet.PacketChars = packetChars(packet)
packet.PacketTokenEstimate = EstimateTokens(packet.PacketChars)
return packet, nil
}
// filterAndScore drops non-owner messages and items below MinScore,
// then assembles MemoryItem entries in the existing RRF-sorted order.
//
// Owner of each candidate message is resolved via agents.OwnerFor on
// `from_agent`. This is the stricter filter referenced in
// `contracts/mcp-injection.md`'s cross-owner safety note.
func filterAndScore(
ctx context.Context,
svc *Service,
results []*SearchResult,
callerOwnerID string,
opts InjectionOpts,
) ([]MemoryItem, error) {
out := make([]MemoryItem, 0, len(results))
for _, r := range results {
if r == nil || r.Message == nil {
continue
}
// Score selection: prefer SimilarityScore (semantic / hybrid),
// fall back to RelevanceScore (fulltext).
score := r.SimilarityScore
if score == 0 {
score = r.RelevanceScore
}
if opts.MinScore > 0 && score < opts.MinScore {
continue
}
owner, err := agents.OwnerFor(ctx, svc.db, r.Message.FromAgent)
if err != nil {
// Unowned or unknown sender → exclude from injection pool.
continue
}
if owner != callerOwnerID {
continue
}
item := MemoryItem{
ID: r.Message.ID,
FromAgent: r.Message.FromAgent,
Body: r.Message.Body,
CreatedAt: r.Message.CreatedAt,
Score: score,
MatchType: r.MatchType,
}
if r.Message.ChannelID != nil {
if name, err := channelName(ctx, svc.db, *r.Message.ChannelID); err == nil {
item.Channel = name
}
}
out = append(out, item)
}
return out, nil
}
// applyTokenBudget enforces both MaxItems and the token budget. Items
// are admitted greedily in input order (caller passes them already
// score-sorted). The first item that would overflow is truncated to
// fit; all later items are skipped.
func applyTokenBudget(items []MemoryItem, maxItems, budgetTokens int) []MemoryItem {
if len(items) == 0 {
return nil
}
if budgetTokens <= 0 {
return nil
}
out := make([]MemoryItem, 0, len(items))
used := 0
for i, it := range items {
if i >= maxItems {
break
}
cost := EstimateTokens(itemChars(it))
if used+cost <= budgetTokens {
used += cost
out = append(out, it)
continue
}
// Truncate this item to whatever remains in the budget.
remaining := budgetTokens - used
if remaining <= 0 {
break
}
// Reserve the per-item overhead in the remaining budget so that
// post-truncate EstimateTokens(itemChars(it)) <= remaining.
overhead := itemChars(it) - len(it.Body) // = from_agent + channel + 32
// max chars we can place into Body so that total item tokens fit.
maxBodyChars := remaining*4 - overhead
if maxBodyChars <= 0 {
break
}
if maxBodyChars >= len(it.Body) {
// Whole body still fits — admit unchanged.
used += EstimateTokens(itemChars(it))
out = append(out, it)
continue
}
truncated := it
truncated.Body = it.Body[:maxBodyChars]
truncated.Truncated = true
used += EstimateTokens(itemChars(truncated))
out = append(out, truncated)
break
}
return out
}
// itemChars approximates the rendered size of one MemoryItem so the
// budget gate stays self-consistent with packetChars.
func itemChars(it MemoryItem) int {
// Body dominates; from_agent + channel + delimiters add a small
// per-item overhead we approximate at 32 chars.
return len(it.Body) + len(it.FromAgent) + len(it.Channel) + 32
}
func packetChars(p *ContextPacket) int {
total := 0
for _, m := range p.Memories {
total += itemChars(m)
}
total += len(p.CoreMemory)
return total
}
// loadPinIDs queries the configured PinProvider, if any, and returns
// the owner's pinned message ids. Returns nil on any error so that pin
// retrieval failure never breaks the wider injection path.
func loadPinIDs(ctx context.Context, opts InjectionOpts, ownerID string) ([]int64, error) {
if opts.PinProvider == nil || ownerID == "" {
return nil, nil
}
ids, err := opts.PinProvider.ListForOwner(ctx, ownerID)
if err != nil {
return nil, err
}
return ids, nil
}
// applyStatusFilter drops items whose memory_status is soft_deleted or
// superseded. `sparedIDs` is the set of message ids that bypass the
// filter (pinned ids). When opts.StatusProvider is nil this is a no-op.
func applyStatusFilter(ctx context.Context, items []MemoryItem, sparedIDs []int64, opts InjectionOpts) []MemoryItem {
if opts.StatusProvider == nil || len(items) == 0 {
return items
}
ids := make([]int64, 0, len(items))
for _, it := range items {
ids = append(ids, it.ID)
}
statuses, err := opts.StatusProvider.Statuses(ctx, ids)
if err != nil {
return items
}
spared := map[int64]struct{}{}
for _, id := range sparedIDs {
spared[id] = struct{}{}
}
out := make([]MemoryItem, 0, len(items))
for _, it := range items {
st, ok := statuses[it.ID]
if !ok || st.Status == "" || st.Status == "active" {
out = append(out, it)
continue
}
if _, isPinned := spared[it.ID]; isPinned {
out = append(out, it)
continue
}
// Drop soft_deleted / superseded non-pinned.
}
return out
}
// applyPinOverlay marks any item already present and whose id is pinned
// as Pinned=true / Score=1.0; pinned ids that are NOT in the input set
// are fetched via MessageLookup (if configured) and prepended.
func applyPinOverlay(ctx context.Context, items []MemoryItem, pinIDs []int64, opts InjectionOpts) []MemoryItem {
if len(pinIDs) == 0 {
return items
}
pinSet := map[int64]struct{}{}
for _, id := range pinIDs {
pinSet[id] = struct{}{}
}
// Mark items already present.
present := map[int64]struct{}{}
for i := range items {
if _, ok := pinSet[items[i].ID]; ok {
items[i].Pinned = true
items[i].Score = 1.0
}
present[items[i].ID] = struct{}{}
}
// Fetch missing pinned ids via MessageLookup (if any).
var missing []int64
for id := range pinSet {
if _, ok := present[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 && opts.MessageLookup != nil {
extra, err := opts.MessageLookup.LookupForInjection(ctx, missing)
if err == nil {
// Status filter on the freshly-loaded pinned messages: if
// the provider says they are soft_deleted / superseded, do
// not surface them either, even though pinned.
if opts.StatusProvider != nil {
statuses, _ := opts.StatusProvider.Statuses(ctx, missing)
filtered := extra[:0]
for _, m := range extra {
if st, ok := statuses[m.ID]; ok && st.Status != "" && st.Status != "active" {
continue
}
filtered = append(filtered, m)
}
extra = filtered
}
// Prepend in stable id order (newest first by convention —
// pins are sorted DESC by pinned_at in the store).
for i := range extra {
extra[i].Pinned = true
extra[i].Score = 1.0
extra[i].MatchType = "pinned"
}
items = append(extra, items...)
}
}
return items
}
// recentMemoriesForOwner returns the N most recent messages on memory
// channels (open-brain, reflections-*, or any channel flagged
// is_memory=true) whose author is owned by `ownerID`. Used when the
// injection layer has no explicit retrieval query (e.g. my_status).
//
// Recency is approximated as "ORDER BY messages.id DESC" — id is
// monotonically increasing per SQLite INSERT and matches created_at
// ordering on this schema.
func recentMemoriesForOwner(ctx context.Context, db *sql.DB, ownerID string, limit, windowDays int) ([]MemoryItem, error) {
if db == nil || ownerID == "" || limit <= 0 {
return nil, nil
}
if windowDays <= 0 {
windowDays = 14
}
// SQLite datetime modifier needs the value embedded in the string,
// not bound, so build a literal `-N days`. windowDays is bounded
// (int from int(Duration/(24h))) — safe to format.
windowExpr := fmt.Sprintf("-%d days", windowDays)
const q = `
SELECT m.id, m.from_agent, COALESCE(c.name,''), m.body, m.created_at
FROM messages m
JOIN agents a ON a.name = m.from_agent
LEFT JOIN channels c ON c.id = m.channel_id
WHERE CAST(a.owner_id AS TEXT) = ?
AND m.channel_id IS NOT NULL
AND c.name IN ('open-brain')
AND m.body IS NOT NULL AND m.body != ''
AND m.created_at > datetime('now', ?)
ORDER BY m.id DESC
LIMIT ?`
rows, err := db.QueryContext(ctx, q, ownerID, windowExpr, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MemoryItem
for rows.Next() {
var it MemoryItem
if err := rows.Scan(&it.ID, &it.FromAgent, &it.Channel, &it.Body, &it.CreatedAt); err != nil {
return nil, err
}
it.Score = 1.0
it.MatchType = "recent"
out = append(out, it)
}
return out, rows.Err()
}
// channelName resolves a channel ID to a name for the optional
// `MemoryItem.Channel` field. Best-effort: returns ("", err) on lookup
// failure and BuildContextPacket then omits the field entirely.
func channelName(ctx context.Context, db *sql.DB, id int64) (string, error) {
var name string
err := db.QueryRowContext(ctx,
`SELECT name FROM channels WHERE id = ?`, id,
).Scan(&name)
if err != nil {
return "", err
}
return name, nil
}