diff --git a/cmd/plexum-harborforge-plugin/main.go b/cmd/plexum-harborforge-plugin/main.go
index 282e5a2..00ccda6 100644
--- a/cmd/plexum-harborforge-plugin/main.go
+++ b/cmd/plexum-harborforge-plugin/main.go
@@ -24,6 +24,7 @@ import (
"sync"
"time"
+ sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade"
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar"
@@ -215,35 +216,49 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er
// RenderDynamicSubblock implements sdkplugin.DynamicBlockProvider. The
// host calls this once per turn per declared subblock name from this
// plugin's manifest (DESIGN-DYNAMIC-BLOCK.md §2 / §3.3). For
-// "kb-block" we open the per-session kb-block.json and return its
-// rendered body (host wraps in ...). Empty block
-// → return "" → host omits the subblock for this turn.
+// "kb-block" we open the per-session kb-block.json, Tick fade-out
+// drops on entries that crossed the m% threshold (§9 #3), Save the
+// (possibly mutated) block, and return RenderFaded body for the host
+// to wrap in .... Empty block → return "" → host
+// omits the subblock for this turn.
//
// Side effect: stash agentID → sessionID into p.agentSession so the
// dynamic-kb-cache + dynamic-kb-evict tools can resolve the same
// kb-block file when they fire later in the same turn.
-func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, agentID, sessionID, name string) (string, error) {
- if agentID == "" || sessionID == "" {
+func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, req sdkplugin.RenderDynamicSubblockRequest) (string, error) {
+ if req.AgentID == "" || req.SessionID == "" {
return "", nil
}
// Cache the (agent, session) pair for tool dispatch this turn.
- p.agentSession.Store(agentID, sessionID)
+ p.agentSession.Store(req.AgentID, req.SessionID)
- if name != "kb-block" {
- // Host shouldn't request unknown subblock names per manifest
- // contract, but log defensively so operator notices wiring drift.
+ if req.Name != "kb-block" {
p.host.Log("warn", "dynamic-block render: unknown subblock name",
- map[string]any{"agent": agentID, "session": sessionID, "name": name})
+ map[string]any{"agent": req.AgentID, "session": req.SessionID, "name": req.Name})
return "", nil
}
- block, err := kbblock.Open(p.profileRoot, agentID, sessionID)
+ block, err := kbblock.Open(p.profileRoot, req.AgentID, req.SessionID)
if err != nil {
p.host.Log("warn", "open kb-block", map[string]any{
- "agent": agentID, "session": sessionID, "err": err.Error(),
+ "agent": req.AgentID, "session": req.SessionID, "err": err.Error(),
})
return "", nil
}
- return block.Render(), nil
+ // Apply fade tick (drop entries that crossed the m% threshold).
+ // Uses DESIGN-DYNAMIC-BLOCK.md §9 #3 spec defaults (5/10/70).
+ fadeParams := sdkfade.DefaultFadeParams()
+ if tick := block.Tick(req.CurrentTurn, fadeParams); len(tick.FadedOut) > 0 {
+ p.host.Log("info", "kb-block fade tick", map[string]any{
+ "agent": req.AgentID,
+ "session": req.SessionID,
+ "dropped": tick.FadedOut,
+ })
+ if err := block.Save(); err != nil {
+ p.host.Log("warn", "save kb-block after tick",
+ map[string]any{"err": err.Error()})
+ }
+ }
+ return block.RenderFaded(req.CurrentTurn, fadeParams), nil
}
func (p *harborForgePlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) {
diff --git a/internal/kbblock/kbblock.go b/internal/kbblock/kbblock.go
index 5aed39a..b3d8e61 100644
--- a/internal/kbblock/kbblock.go
+++ b/internal/kbblock/kbblock.go
@@ -28,11 +28,15 @@ 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"
@@ -47,6 +51,10 @@ type Entry struct {
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.
@@ -138,6 +146,7 @@ func (b *Block) Add(id int, kbCode, sourceTopic, content string, atTurn int) *En
InsertSeq: b.NextSeq,
AddedAtTurn: atTurn,
LastRefreshAtTurn: atTurn,
+ Seed: newSeed(),
}
b.NextSeq++
b.Entries = append(b.Entries, e)
@@ -223,13 +232,30 @@ func (b *Block) Save() error {
// 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 "".
+// (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 ""
}
@@ -241,16 +267,81 @@ func (b *Block) Render() string {
if i > 0 {
sb.WriteByte('\n')
}
+ content := e.Content
+ if transform != nil {
+ content = transform(e)
+ }
fmt.Fprintf(&sb, "\n")
- sb.WriteString(e.Content)
- if !strings.HasSuffix(e.Content, "\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()
+}