chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
notesFile = "notes.gob.gz"
|
||||
maxNotesCap = 5000
|
||||
)
|
||||
|
||||
// NoteEntry is a single session-memory note. Notes are not graph
|
||||
// nodes; they live alongside the graph as a separate, persistent
|
||||
// side-store. A note is created via the `save_note` tool, surfaced
|
||||
// via `query_notes`, and folded into the per-session digest emitted
|
||||
// by `distill_session`.
|
||||
//
|
||||
// Auto-links capture the symbol IDs the agent (or the auto-linker)
|
||||
// determined to be referenced by the note body. Because they are
|
||||
// stored explicitly, queries by symbol stay O(notes) without
|
||||
// re-tokenising every body on every call.
|
||||
type NoteEntry struct {
|
||||
ID string
|
||||
Timestamp time.Time
|
||||
UpdatedAt time.Time
|
||||
SessionID string // MCP session that created the note ("" for shared/embedded session)
|
||||
ClientName string // MCP clientInfo.name at create time (claude-code / cursor / ...)
|
||||
Body string // free-form text the agent wrote
|
||||
SymbolID string // primary attached symbol (optional)
|
||||
FilePath string // primary attached file (optional)
|
||||
RepoPrefix string // repo prefix derived from session scope or attached symbol/file
|
||||
WorkspaceID string // workspace boundary; queries scope by this
|
||||
ProjectID string // project sub-boundary
|
||||
Tags []string // free-form labels — "decision", "bug", "todo", ...
|
||||
AutoLinks []string // symbol IDs referenced by the body (auto-detected + explicit links)
|
||||
Pinned bool // pinned notes are never evicted by the cap
|
||||
}
|
||||
|
||||
// NoteStore is the persisted shape: a versioned, repo-scoped list of
|
||||
// entries. Same persistence shape as FeedbackStore so the cache
|
||||
// directory layout stays consistent.
|
||||
type NoteStore struct {
|
||||
Version string
|
||||
RepoPath string
|
||||
Entries []NoteEntry
|
||||
}
|
||||
|
||||
// NotesDir resolves the cache directory holding the notes file for
|
||||
// the given repo. Mirrors FeedbackDir so the two side-stores share
|
||||
// a per-repo cache subdirectory.
|
||||
func NotesDir(cacheDir, repoPath string) string {
|
||||
return filepath.Join(cacheDir, RepoCacheKey(repoPath))
|
||||
}
|
||||
|
||||
// LoadNotes reads a note store from disk. Returns an empty store
|
||||
// when the file does not exist (cold start is normal).
|
||||
func LoadNotes(dir string) (*NoteStore, error) {
|
||||
path := filepath.Join(dir, notesFile)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &NoteStore{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("persistence: open notes: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persistence: gzip reader notes: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
var store NoteStore
|
||||
if err := gob.NewDecoder(gz).Decode(&store); err != nil {
|
||||
return nil, fmt.Errorf("persistence: gob decode notes: %w", err)
|
||||
}
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
// SaveNotes writes the store to disk with gob+gzip. Trimming
|
||||
// honours pinned notes: when the entry count exceeds maxNotesCap,
|
||||
// the oldest non-pinned entries are dropped first.
|
||||
func SaveNotes(dir string, store *NoteStore) error {
|
||||
if len(store.Entries) > maxNotesCap {
|
||||
store.Entries = trimNotes(store.Entries, maxNotesCap)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("persistence: mkdir notes: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, notesFile)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persistence: create notes: %w", err)
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
enc := gob.NewEncoder(gz)
|
||||
|
||||
if err := enc.Encode(store); err != nil {
|
||||
_ = gz.Close()
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("persistence: gob encode notes: %w", err)
|
||||
}
|
||||
|
||||
if err := gz.Close(); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("persistence: gzip close notes: %w", err)
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// trimNotes drops the oldest non-pinned entries until len(out) <= cap.
|
||||
// Order is preserved so callers iterate chronologically. If pinned
|
||||
// entries alone exceed cap, every pinned entry is still retained
|
||||
// — the cap is a soft ceiling for the unpinned tail.
|
||||
func trimNotes(in []NoteEntry, cap int) []NoteEntry {
|
||||
if len(in) <= cap {
|
||||
return in
|
||||
}
|
||||
excess := len(in) - cap
|
||||
|
||||
// Walk forward dropping non-pinned entries until we have shed
|
||||
// `excess` of them, then return the tail.
|
||||
out := make([]NoteEntry, 0, cap)
|
||||
dropped := 0
|
||||
for _, e := range in {
|
||||
if dropped < excess && !e.Pinned {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user