ollie/cmd/olliesrv/internal/agent/chat.go

164 lines
4.2 KiB
Go

// chat.go — Chat log storage and streaming.
//
// Two logs are maintained:
// - rawLog: JSONL format (one Block per line) for programmatic access
// - textLog: Rendered plain text for human reading
//
// Both are append-only buffers with overflow handling.
package agent
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"sync/atomic"
"ollie/format"
)
const maxLogBytes = 16 * 1024 * 1024
// NextBlockID returns the next deterministic block ID and increments the counter.
// Block IDs are sha256(sessionID + agentID + counter)[:8].
func (ag *Agent) NextBlockID() string {
n := atomic.AddUint64(&ag.blockCounter, 1) - 1
h := sha256.New()
h.Write([]byte(ag.sessionID))
h.Write([]byte(":"))
h.Write([]byte(ag.id))
h.Write([]byte(":"))
h.Write([]byte(fmt.Sprintf("%d", n)))
return hex.EncodeToString(h.Sum(nil))[:8]
}
// AppendBlock writes a block to both logs and notifies stream readers.
func (ag *Agent) AppendBlock(b format.Block) {
raw := format.MarshalBlock(b)
text := []byte(format.RenderBlock(b))
ag.chatMu.Lock()
ag.rawLog = appendWithOverflow(ag.rawLog, raw, maxLogBytes, &ag.rawStart)
ag.textLog = appendWithOverflow(ag.textLog, text, maxLogBytes, &ag.textStart)
ag.chatVers++
ag.chatMu.Unlock()
ag.chatCond.Broadcast()
ag.chatSignalMu.Lock()
close(ag.chatSignalCh)
ag.chatSignalCh = make(chan struct{})
ag.chatSignalMu.Unlock()
}
func appendWithOverflow(log, data []byte, max int, start *int) []byte {
log = append(log, data...)
if len(log) > max {
drop := len(log) - max
log = append([]byte(nil), log[drop:]...)
*start += drop
}
return log
}
// ChatMu returns the chat log mutex for external locking (streaming).
func (ag *Agent) ChatMu() *sync.RWMutex { return &ag.chatMu }
// ChatCond returns the condvar for blocking chat readers.
func (ag *Agent) ChatCond() *sync.Cond { return ag.chatCond }
// ChatSignal returns the current chat signal channel (closed on new chat data).
func (ag *Agent) ChatSignal() <-chan struct{} {
ag.chatSignalMu.Lock()
ch := ag.chatSignalCh
ag.chatSignalMu.Unlock()
return ch
}
// RawLog returns the JSONL log bytes (caller must hold ChatMu.RLock).
func (ag *Agent) RawLog() []byte { return ag.rawLog }
// TextLog returns the rendered text log bytes (caller must hold ChatMu.RLock).
func (ag *Agent) TextLog() []byte { return ag.textLog }
// RawLogRead returns new JSONL data since the given offset.
func (ag *Agent) RawLogRead(base string) ([]byte, string, error) {
return ag.logRead(base, &ag.rawLog, &ag.rawStart)
}
// TextLogRead returns new rendered text since the given offset.
func (ag *Agent) TextLogRead(base string) ([]byte, string, error) {
return ag.logRead(base, &ag.textLog, &ag.textStart)
}
func (ag *Agent) logRead(base string, log *[]byte, start *int) ([]byte, string, error) {
var offset int
if base != "" {
fmt.Sscanf(base, "%d", &offset)
} else {
// Empty base = start from beginning (for replay)
ag.chatMu.RLock()
offset = *start
ag.chatMu.RUnlock()
}
ag.chatMu.RLock()
if offset < *start {
offset = *start
}
localOffset := offset - *start
if len(*log) <= localOffset {
ag.chatMu.RUnlock()
return nil, fmt.Sprintf("%d", offset), nil
}
data := make([]byte, len(*log)-localOffset)
copy(data, (*log)[localOffset:])
newOffset := *start + len(*log)
ag.chatMu.RUnlock()
return data, fmt.Sprintf("%d", newOffset), nil
}
// BlockByID returns the block with the given ID from the raw log.
func (ag *Agent) BlockByID(blockID string) (format.Block, bool) {
if blockID == "" {
return format.Block{}, false
}
ag.chatMu.RLock()
raw := ag.rawLog
ag.chatMu.RUnlock()
// Scan JSONL lines for matching ID
for _, line := range bytes.Split(raw, []byte("\n")) {
if len(line) == 0 {
continue
}
b, err := format.UnmarshalBlock(line)
if err != nil {
continue
}
if b.ID == blockID && !b.Partial {
return b, true
}
}
return format.Block{}, false
}
// --- Plan methods ---
// Plan returns a copy of the plan.
func (ag *Agent) Plan() []byte {
ag.chatMu.RLock()
p := make([]byte, len(ag.plan))
copy(p, ag.plan)
ag.chatMu.RUnlock()
return p
}
// SetPlan replaces the plan.
func (ag *Agent) SetPlan(data []byte) {
ag.chatMu.Lock()
ag.plan = make([]byte, len(data))
copy(ag.plan, data)
ag.chatMu.Unlock()
}