mirror of
https://github.com/lukaszraczylo/compaction-mcp.git
synced 2026-06-05 23:14:02 +00:00
dded4ec04c
- Dockerfile: distroless container for MCP server - GoReleaser: multi-platform binary and Docker builds with cosign signing - GitHub Actions: release workflow using shared actions - Semver config for automatic version calculation - Persistence layer, content indexing, and improved tool handlers
203 lines
4.4 KiB
Go
203 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const stateVersion = 1
|
|
|
|
type persistedState struct {
|
|
SavedAt time.Time `json:"saved_at"`
|
|
Items []persistedItem `json:"items"`
|
|
Version int `json:"version"`
|
|
Budget int `json:"budget"`
|
|
}
|
|
|
|
type persistedItem struct {
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ID string `json:"id"`
|
|
Content string `json:"content"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Importance int `json:"importance"`
|
|
AccessCount int `json:"access_count"`
|
|
Tokens int `json:"tokens"`
|
|
ContentType ContentType `json:"content_type,omitempty"`
|
|
Pinned bool `json:"pinned,omitempty"`
|
|
}
|
|
|
|
// Persister handles file-backed persistence for a Store.
|
|
type Persister struct {
|
|
store *Store
|
|
stopCh chan struct{}
|
|
dir string
|
|
mu sync.Mutex
|
|
dirty bool
|
|
}
|
|
|
|
// snapshot returns a copy of all items and the token budget from the store.
|
|
func (s *Store) snapshot() ([]persistedItem, int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
items := make([]persistedItem, 0, len(s.items))
|
|
for _, item := range s.items {
|
|
items = append(items, persistedItem{
|
|
ID: item.ID,
|
|
Content: item.Content,
|
|
Summary: item.Summary,
|
|
Tags: item.Tags,
|
|
Importance: item.Importance,
|
|
ContentType: item.ContentType,
|
|
Pinned: item.Pinned,
|
|
CreatedAt: item.CreatedAt,
|
|
AccessCount: item.AccessCount,
|
|
Tokens: item.Tokens,
|
|
})
|
|
}
|
|
return items, s.tokenBudget
|
|
}
|
|
|
|
// restore loads persisted items back into the store.
|
|
func (s *Store) restore(items []persistedItem) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for _, pi := range items {
|
|
item := &Item{
|
|
ID: pi.ID,
|
|
Content: pi.Content,
|
|
Summary: pi.Summary,
|
|
Tags: pi.Tags,
|
|
Importance: pi.Importance,
|
|
ContentType: pi.ContentType,
|
|
Pinned: pi.Pinned,
|
|
CreatedAt: pi.CreatedAt,
|
|
AccessCount: pi.AccessCount,
|
|
Tokens: pi.Tokens,
|
|
AccessedAt: time.Now(),
|
|
}
|
|
s.items[item.ID] = item
|
|
s.usedTokens += item.Tokens
|
|
s.index.Add(item.ID, item.Content, item.Tags)
|
|
}
|
|
}
|
|
|
|
// NewPersister creates a Persister that saves store state to the given directory.
|
|
func NewPersister(dir string, store *Store) (*Persister, error) {
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Persister{
|
|
dir: dir,
|
|
store: store,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Persister) stateFile() string {
|
|
return filepath.Join(p.dir, "state.json")
|
|
}
|
|
|
|
func (p *Persister) tmpFile() string {
|
|
return filepath.Join(p.dir, "state.json.tmp")
|
|
}
|
|
|
|
// Save snapshots the store and writes it atomically to state.json.
|
|
func (p *Persister) Save() error {
|
|
items, budget := p.store.snapshot()
|
|
|
|
state := persistedState{
|
|
Version: stateVersion,
|
|
Budget: budget,
|
|
Items: items,
|
|
SavedAt: time.Now(),
|
|
}
|
|
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tmp := p.tmpFile()
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.Rename(tmp, p.stateFile())
|
|
}
|
|
|
|
// Load reads state.json and restores items into the store.
|
|
// Returns nil if the file does not exist (fresh start).
|
|
// Returns an error if the file exists but cannot be parsed.
|
|
func (p *Persister) Load() error {
|
|
data, err := os.ReadFile(p.stateFile())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
var state persistedState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return err
|
|
}
|
|
|
|
p.store.restore(state.Items)
|
|
return nil
|
|
}
|
|
|
|
// Start launches a background goroutine that periodically saves if dirty.
|
|
func (p *Persister) Start(interval time.Duration) {
|
|
p.mu.Lock()
|
|
p.stopCh = make(chan struct{})
|
|
p.mu.Unlock()
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
p.mu.Lock()
|
|
shouldSave := p.dirty
|
|
p.dirty = false
|
|
p.mu.Unlock()
|
|
|
|
if shouldSave {
|
|
_ = p.Save()
|
|
}
|
|
case <-p.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// MarkDirty flags the store state as needing a save.
|
|
func (p *Persister) MarkDirty() {
|
|
p.mu.Lock()
|
|
p.dirty = true
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
// Stop signals the background goroutine to exit and performs a final save if dirty.
|
|
func (p *Persister) Stop() {
|
|
p.mu.Lock()
|
|
if p.stopCh != nil {
|
|
close(p.stopCh)
|
|
}
|
|
shouldSave := p.dirty
|
|
p.dirty = false
|
|
p.mu.Unlock()
|
|
|
|
if shouldSave {
|
|
_ = p.Save()
|
|
}
|
|
}
|