Phase 4b. Wires fade-out for HarborForge kb-block entries using the
new Plexum-sdk-go/fade primitives + RenderDynamicSubblockRequest's
CurrentTurn field.
internal/kbblock/kbblock.go:
- Entry gains Seed int64 (json:"seed") for per-entry RNG seed
- Add() generates a fresh Seed at insert time
- renderLocked() refactored — Render() unchanged behaviour;
RenderFaded(currentTurn, params) added — applies sdkfade.Fade
per entry given (currentTurn - LastRefreshAtTurn) elapsed
- Tick(currentTurn, params) drops entries whose underscore ratio
crossed the m% threshold, returns dropped IDs
- Refresh(ids, currentTurn) resets fade state (regenerates Seed
+ bumps LastRefreshAtTurn) — exposed for a future
dynamic-kb-refresh tool (not in this commit)
cmd/plexum-harborforge-plugin/main.go:
- RenderDynamicSubblock signature now sdkplugin.
RenderDynamicSubblockRequest (per SDK d6fdb9f)
- Each turn: Tick → drop crossed-threshold entries (logs IDs
dropped) → Save (only when something dropped) → RenderFaded
returned. Uses sdkfade.DefaultFadeParams() (5/10/70 per §9 #3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
348 lines
9.8 KiB
Go
348 lines
9.8 KiB
Go
// 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
|
|
//
|
|
// <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
|
|
//
|
|
// (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
|
|
//
|
|
// <PLEXUM_PROFILE_ROOT>/agents/<agentID>/sessions/<sessionID>/
|
|
// 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: <profileRoot>/agents/<agentID>/sessions/<sessionID>/
|
|
// 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 <SessionDir>/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 <kb-block> subblock — a flat
|
|
// sequence of `<kb-fact id=N kb=<code> source=topic:<slug>>\n<content>
|
|
// \n</kb-fact>` 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 <kb-block>...</kb-block>.
|
|
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, "<kb-fact id=%d kb=%s", e.ID, e.KBCode)
|
|
if e.SourceTopic != "" {
|
|
fmt.Fprintf(&sb, " source=topic:%s", e.SourceTopic)
|
|
}
|
|
sb.WriteString(">\n")
|
|
sb.WriteString(content)
|
|
if !strings.HasSuffix(content, "\n") {
|
|
sb.WriteByte('\n')
|
|
}
|
|
sb.WriteString("</kb-fact>\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()
|
|
}
|