// Package kbblock implements the per-session knowledge-base block // owned by the HarborForge Plexum plugin per Plexum DESIGN-DYNAMIC- // BLOCK.md §3.3 / §4.4. // // The kb-block stores HarborForge KB facts the agent has cached for // the current session. Each entry renders as // // source=topic:>content // // (id = HF backend's DB primary key for the fact, NOT a per-session // monotonic). InsertSeq controls cache-insertion-order rendering per // §9 #4. Duplicate Add(id, ...) silently no-ops per §9 #4. // // Storage location: plugin-managed file at // // /agents//sessions// // plugins/harbor-forge/kb-block.json // // Plexum core's session cleanup (del-session) cascades this directory // alongside the rest of the session state. // // Fade-out (§9 #3 — KB fade params = memory fade params n=5/w=10/m=70) // is NOT applied in v1. Phase 2 v1 stores raw content + agent // dynamic-kb-evict is the only eviction path. Fade will be added once // we have prod data on whether KB facts need it. package kbblock import ( "encoding/json" "fmt" "math/rand" "os" "path/filepath" "sort" "strings" "sync" "time" sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade" ) const FileName = "kb-block.json" const currentVersion = 1 // Entry is one cached KB fact. type Entry struct { ID int `json:"id"` // HF backend DB primary key KBCode string `json:"kb_code"` // e.g. "KB-PAYROT" SourceTopic string `json:"source_topic"` // human slug from HF topic, e.g. "debugging" Content string `json:"content"` InsertSeq int `json:"insert_seq"` // per-session monotonic, for render order AddedAtTurn int `json:"added_at_turn"` LastRefreshAtTurn int `json:"last_refresh_at_turn"` // Seed is the per-entry RNG seed for deterministic fade-out // (DESIGN-DYNAMIC-BLOCK.md §9 #3 / Plexum decision #30). Populated // at Add() time; reproducible across restarts. Seed int64 `json:"seed"` } // Block is the in-memory representation of kb-block.json. type Block struct { mu sync.Mutex path string NextSeq int `json:"next_seq"` Entries []*Entry `json:"entries"` } // SessionDir returns the plugin-scoped session subdir under the Plexum // profile root: /agents//sessions// // plugins/harbor-forge. func SessionDir(profileRoot, agentID, sessionID string) string { return filepath.Join(profileRoot, "agents", agentID, "sessions", sessionID, "plugins", "harbor-forge") } // Open loads the block at /kb-block.json. Missing file → // empty block (NextSeq=1). Caller is expected to Close-free; Block // can be re-Opened cheaply per-call (small JSON). func Open(profileRoot, agentID, sessionID string) (*Block, error) { if profileRoot == "" || agentID == "" || sessionID == "" { return nil, fmt.Errorf("kbblock.Open: profileRoot/agentID/sessionID all required") } path := filepath.Join(SessionDir(profileRoot, agentID, sessionID), FileName) b := &Block{path: path, NextSeq: 1} raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return b, nil } return nil, fmt.Errorf("kbblock: read %s: %w", path, err) } if len(raw) == 0 { return b, nil } var loaded struct { NextSeq int `json:"next_seq"` Entries []*Entry `json:"entries"` } if err := json.Unmarshal(raw, &loaded); err != nil { return nil, fmt.Errorf("kbblock: parse %s: %w", path, err) } if loaded.NextSeq < 1 { loaded.NextSeq = 1 } b.NextSeq = loaded.NextSeq b.Entries = loaded.Entries return b, nil } // Path returns the underlying file path. func (b *Block) Path() string { return b.path } // Len returns the entry count. func (b *Block) Len() int { b.mu.Lock() defer b.mu.Unlock() return len(b.Entries) } // Has reports whether a fact with this ID is already cached. func (b *Block) Has(id int) bool { b.mu.Lock() defer b.mu.Unlock() for _, e := range b.Entries { if e.ID == id { return true } } return false } // Add appends a new entry. Duplicate ID → silent no-op, returns nil. // Does NOT persist; caller must Save. func (b *Block) Add(id int, kbCode, sourceTopic, content string, atTurn int) *Entry { b.mu.Lock() defer b.mu.Unlock() for _, e := range b.Entries { if e.ID == id { return nil } } e := &Entry{ ID: id, KBCode: kbCode, SourceTopic: sourceTopic, Content: content, InsertSeq: b.NextSeq, AddedAtTurn: atTurn, LastRefreshAtTurn: atTurn, Seed: newSeed(), } b.NextSeq++ b.Entries = append(b.Entries, e) return e } // Remove drops entries by ID. Returns the IDs actually removed. func (b *Block) Remove(ids ...int) []int { b.mu.Lock() defer b.mu.Unlock() want := map[int]bool{} for _, id := range ids { want[id] = true } kept := b.Entries[:0] var removed []int for _, e := range b.Entries { if want[e.ID] { removed = append(removed, e.ID) continue } kept = append(kept, e) } b.Entries = kept return removed } // Lookup returns the entry with the given ID, or nil. func (b *Block) Lookup(id int) *Entry { b.mu.Lock() defer b.mu.Unlock() for _, e := range b.Entries { if e.ID == id { return e } } return nil } // AllIDs returns the cached fact IDs sorted by InsertSeq ascending. func (b *Block) AllIDs() []int { b.mu.Lock() defer b.mu.Unlock() out := make([]*Entry, len(b.Entries)) copy(out, b.Entries) sort.Slice(out, func(i, j int) bool { return out[i].InsertSeq < out[j].InsertSeq }) ids := make([]int, len(out)) for i, e := range out { ids[i] = e.ID } return ids } // Save atomically writes (tmp+rename, 0600). Empty block with no prior // file = no-op. Parent dirs auto-created. func (b *Block) Save() error { b.mu.Lock() defer b.mu.Unlock() if len(b.Entries) == 0 { if _, err := os.Stat(b.path); os.IsNotExist(err) { return nil } } if err := os.MkdirAll(filepath.Dir(b.path), 0o755); err != nil { return err } payload := struct { Version int `json:"version"` NextSeq int `json:"next_seq"` Entries []*Entry `json:"entries"` }{Version: currentVersion, NextSeq: b.NextSeq, Entries: b.Entries} data, err := json.MarshalIndent(payload, "", " ") if err != nil { return err } tmp := b.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("kbblock: write tmp: %w", err) } return os.Rename(tmp, b.path) } // Render returns the inner body of the subblock — a flat // sequence of ` source=topic:>\n // \n` separated by single blank lines, ordered by InsertSeq // (DESIGN-DYNAMIC-BLOCK.md §9 #4). Empty block returns "". No fade // applied; use RenderFaded. // // The plugin's DynamicBlockProvider.RenderDynamicSubblock returns this // to the Plexum host, which wraps in .... func (b *Block) Render() string { b.mu.Lock() defer b.mu.Unlock() return b.renderLocked(nil) } // RenderFaded returns the body with sdkfade.Fade applied per entry // given currentTurn + params (DESIGN-DYNAMIC-BLOCK.md §9 #3). The // block itself is NOT mutated by render; pair with Tick to actually // drop entries that crossed the m% drop threshold. func (b *Block) RenderFaded(currentTurn int, params sdkfade.FadeParams) string { b.mu.Lock() defer b.mu.Unlock() return b.renderLocked(func(e *Entry) string { return sdkfade.Fade(e.Content, e.Seed, currentTurn-e.LastRefreshAtTurn, params).Rendered }) } func (b *Block) renderLocked(transform func(*Entry) string) string { if len(b.Entries) == 0 { return "" } ordered := make([]*Entry, len(b.Entries)) copy(ordered, b.Entries) sort.Slice(ordered, func(i, j int) bool { return ordered[i].InsertSeq < ordered[j].InsertSeq }) var sb strings.Builder for i, e := range ordered { if i > 0 { sb.WriteByte('\n') } content := e.Content if transform != nil { content = transform(e) } fmt.Fprintf(&sb, "\n") sb.WriteString(content) if !strings.HasSuffix(content, "\n") { sb.WriteByte('\n') } sb.WriteString("\n") } return sb.String() } // TickResult summarizes one fade tick across the whole block. type TickResult struct { FadedOut []int // entry IDs auto-removed because fade crossed threshold } // Tick advances fade by computing each entry's current rendered form // at currentTurn and DROPPING entries whose underscore ratio crosses // the m% threshold. Returns the IDs dropped so caller can log/report. // Caller must Save() after Tick. func (b *Block) Tick(currentTurn int, params sdkfade.FadeParams) TickResult { b.mu.Lock() defer b.mu.Unlock() if len(b.Entries) == 0 { return TickResult{} } var faded []int kept := b.Entries[:0] for _, e := range b.Entries { d := currentTurn - e.LastRefreshAtTurn fr := sdkfade.Fade(e.Content, e.Seed, d, params) if fr.ShouldDrop(params) { faded = append(faded, e.ID) continue } kept = append(kept, e) } b.Entries = kept return TickResult{FadedOut: faded} } // Refresh resets fade state for the given IDs (regenerates Seed + // sets LastRefreshAtTurn = currentTurn). Returns IDs actually // refreshed. func (b *Block) Refresh(ids []int, currentTurn int) []int { b.mu.Lock() defer b.mu.Unlock() want := map[int]bool{} for _, id := range ids { want[id] = true } var refreshed []int for _, e := range b.Entries { if want[e.ID] { e.LastRefreshAtTurn = currentTurn e.Seed = newSeed() refreshed = append(refreshed, e.ID) } } return refreshed } // newSeed generates a fresh int64 seed for fade RNG. var seedRng = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec var seedMu sync.Mutex func newSeed() int64 { seedMu.Lock() defer seedMu.Unlock() return seedRng.Int63() }