// 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" "os" "path/filepath" "sort" "strings" "sync" ) 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"` } // 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, } 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 "". // // 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() 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') } fmt.Fprintf(&sb, "\n") sb.WriteString(e.Content) if !strings.HasSuffix(e.Content, "\n") { sb.WriteByte('\n') } sb.WriteString("\n") } return sb.String() }