mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(memory): expose galaxy, strength and review state to the memory page
The memory list now carries each fact's galaxy, importance, retention state, use count, days left before it turns fuzzy, whether it decays, and whether it is new (written in the last 14 days and not yet reviewed). POST /memory/confirm reinforces a fact and marks it reviewed; a save from the page also marks it reviewed and may move its galaxy, importance and type. The review mark lives in the usage sidecar, so it never rewrites the fact file.
This commit is contained in:
1 parent
87c91ebd06
commit
d0289a4d0f
14 files changed
+327
-13
No files matched your search
@@ -19,6 +19,14 @@ export interface MemoryEntry {
|
||||
expired?: boolean;
|
||||
usedLastTurn?: boolean;
|
||||
why?: string;
|
||||
galaxy?: string;
|
||||
importance?: number;
|
||||
state?: "clear" | "fuzzy" | "faded";
|
||||
uses?: number;
|
||||
lastUsedAt?: string;
|
||||
clearForDays?: number;
|
||||
noDecay?: boolean;
|
||||
new?: boolean;
|
||||
}
|
||||
|
||||
// A memory file that is present but could not be read. It is reported beside
|
||||
@@ -46,4 +54,7 @@ export interface MemoryEdit {
|
||||
body: string;
|
||||
activation: string;
|
||||
keywords?: string;
|
||||
galaxy?: string;
|
||||
importance?: number;
|
||||
type?: string;
|
||||
}
|
||||
@@ -139,12 +139,11 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
async saveMemory(edit: MemoryEdit) {
|
||||
const at = this.mem.findIndex((m) => m.name === edit.name);
|
||||
if (at >= 0) {
|
||||
// Keep what it replaced, the way the store does: a history panel driven
|
||||
// by a fixture that overwrites would show one entry and prove nothing.
|
||||
this.memPast[edit.name] = [...(this.memPast[edit.name] ?? [this.mem[at]]), this.mem[at]];
|
||||
this.mem[at] = { ...this.mem[at], title: edit.title, description: edit.description,
|
||||
body: edit.body, activation: edit.activation, revision: (this.mem[at].revision ?? 1) + 1,
|
||||
updatedAt: new Date().toISOString().slice(0, 10) };
|
||||
const cur = this.mem[at];
|
||||
this.memPast[edit.name] = [...(this.memPast[edit.name] ?? [cur]), cur];
|
||||
this.mem[at] = { ...cur, title: edit.title, description: edit.description, body: edit.body,
|
||||
activation: edit.activation, revision: (cur.revision ?? 1) + 1, updatedAt: new Date().toISOString().slice(0, 10),
|
||||
new: false, galaxy: edit.galaxy || cur.galaxy, importance: edit.importance || cur.importance, type: edit.type || cur.type };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,8 +160,6 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
.map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
// Restoring appends a revision rather than rewinding to one, so the entry a
|
||||
// reader was just looking at is still there afterwards.
|
||||
async restoreMemory(name: string, revision: number) {
|
||||
const at = this.mem.findIndex((m) => m.name === name);
|
||||
const old = (await this.memoryRevisions(name)).find((m) => (m.revision ?? 1) === revision);
|
||||
@@ -176,6 +173,9 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
async forgetMemory(name: string) {
|
||||
this.mem = this.mem.filter((m) => m.name !== name);
|
||||
}
|
||||
async confirmMemory(name: string) {
|
||||
this.mem = this.mem.map((m) => (m.name === name ? { ...m, new: false, state: "clear", uses: (m.uses ?? 0) + 1 } : m));
|
||||
}
|
||||
|
||||
async versions(_refresh?: boolean): Promise<VersionHub> {
|
||||
return { current: "dev", pinned: "", stalePin: false, latest: "", newer: false, versions: [] };
|
||||
|
||||
@@ -257,6 +257,7 @@ export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, Upload
|
||||
usage(query: UsageQuery): Promise<UsageReport>;
|
||||
// Archives rather than deletes: a fact dropped by mistake stays recoverable.
|
||||
forgetMemory(name: string): Promise<void>;
|
||||
confirmMemory(name: string): Promise<void>;
|
||||
saveMemory(edit: MemoryEdit): Promise<void>;
|
||||
// Every save keeps the version it replaced. These are what let a reader see
|
||||
// that history and step back into it; restoring appends rather than rewinds,
|
||||
|
||||
@@ -102,6 +102,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
forgetMemory(name: string) {
|
||||
return this.post("/memory/forget", { name });
|
||||
}
|
||||
confirmMemory(name: string) {
|
||||
return this.post("/memory/confirm", { name });
|
||||
}
|
||||
async memoryRevisions(name: string) {
|
||||
const r = await this.get<{ revisions: MemoryEntry[] }>("/memory/revisions?name=" + encodeURIComponent(name));
|
||||
return r.revisions ?? [];
|
||||
|
||||
@@ -20,6 +20,7 @@ func (s *Server) registerMachineSettingsRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /memory", s.memories)
|
||||
mux.HandleFunc("POST /memory/forget", s.forgetMemory)
|
||||
mux.HandleFunc("POST /memory/save", s.saveMemory)
|
||||
mux.HandleFunc("POST /memory/confirm", s.confirmMemory)
|
||||
mux.HandleFunc("GET /memory/revisions", s.memoryRevisions)
|
||||
mux.HandleFunc("POST /memory/restore", s.restoreMemory)
|
||||
mux.HandleFunc("GET /network", s.network)
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestMachineSettingsSelectLocalWorkspace(t *testing.T) {
|
||||
replacement := open("replacement", "home/chat-b")
|
||||
read(replacement.ID, "replacement")
|
||||
for _, id := range []string{second.ID, remote.ID, "missing"} {
|
||||
for _, path := range []string{"/memory/save", "/memory/forget", "/memory/restore"} {
|
||||
for _, path := range []string{"/memory/save", "/memory/forget", "/memory/confirm", "/memory/restore"} {
|
||||
w := request(id, http.MethodPost, path, `{"name":"fact","body":"wrong","revision":1}`, http.StatusServiceUnavailable)
|
||||
if !strings.Contains(w.Body.String(), `"settings.local_runtime_required"`) {
|
||||
t.Fatalf("invalid owner %s: %s", id, w.Body.String())
|
||||
|
||||
@@ -2,6 +2,7 @@ package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -29,6 +30,14 @@ type memoryEntry struct {
|
||||
// fact to the behaviour the user just saw.
|
||||
UsedLastTurn bool `json:"usedLastTurn,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Galaxy string `json:"galaxy,omitempty"`
|
||||
Importance int `json:"importance,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
ClearForDays int `json:"clearForDays,omitempty"`
|
||||
NoDecay bool `json:"noDecay,omitempty"`
|
||||
New bool `json:"new,omitempty"`
|
||||
}
|
||||
|
||||
// unreadableMemory is one fact file that is present but could not be read. It
|
||||
@@ -77,6 +86,9 @@ func (s *Server) memories(w http.ResponseWriter, r *http.Request) {
|
||||
CreatedAt: stamp(m.CreatedAt), UpdatedAt: stamp(m.UpdatedAt),
|
||||
Expired: !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(now),
|
||||
UsedLastTurn: hit, Why: reason,
|
||||
Galaxy: m.Galaxy, Importance: m.ResolvedImportance(),
|
||||
State: string(m.RetentionState(now)), Uses: m.Uses, LastUsedAt: stamp(m.LastUsedAt),
|
||||
ClearForDays: m.ClearForDays(now), NoDecay: m.NoDecay(), New: m.AwaitingReview(now),
|
||||
})
|
||||
}
|
||||
unreadable := make([]unreadableMemory, 0, len(report.Unreadable))
|
||||
@@ -113,6 +125,30 @@ func (s *Server) forgetMemory(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) confirmMemory(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
missingField(w, "name")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if s.ctl().Memory() == nil {
|
||||
refuse(w, http.StatusConflict, "memory.unavailable", "memory is not enabled for this session", nil)
|
||||
return
|
||||
}
|
||||
if err := s.ctl().ConfirmMemory(name); err != nil {
|
||||
if errors.Is(err, memory.ErrNoSuchFact) {
|
||||
notFound(w, "memory", name)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func stamp(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
@@ -130,6 +166,9 @@ type editable struct {
|
||||
Body string `json:"body"`
|
||||
Activation string `json:"activation"`
|
||||
Keywords string `json:"keywords"`
|
||||
Galaxy string `json:"galaxy,omitempty"`
|
||||
Importance int `json:"importance,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// saveMemory rewrites one fact in place. The store records it as a new audited
|
||||
@@ -147,6 +186,14 @@ func (s *Server) saveMemory(w http.ResponseWriter, r *http.Request) {
|
||||
badValue(w, "activation", "relevant, pinned")
|
||||
return
|
||||
}
|
||||
if edit.Importance != 0 && memory.NormalizeImportance(edit.Importance) == 0 {
|
||||
badValue(w, "importance", "1, 2, 3")
|
||||
return
|
||||
}
|
||||
if t := strings.TrimSpace(edit.Type); t != "" && !memory.ValidType(t) {
|
||||
badValue(w, "type", "user, feedback, project, reference, constraint")
|
||||
return
|
||||
}
|
||||
ctl := s.ctl()
|
||||
set := ctl.Memory()
|
||||
if set == nil {
|
||||
@@ -185,6 +232,15 @@ func (s *Server) saveMemory(w http.ResponseWriter, r *http.Request) {
|
||||
if activation != "" {
|
||||
current.Activation = activation
|
||||
}
|
||||
if g := memory.NormalizeGalaxy(edit.Galaxy); g != "" {
|
||||
current.Galaxy = g
|
||||
}
|
||||
if edit.Importance != 0 {
|
||||
current.Importance = edit.Importance
|
||||
}
|
||||
if t := strings.TrimSpace(edit.Type); t != "" {
|
||||
current.Type = memory.NormalizeType(t)
|
||||
}
|
||||
path, err := ctl.SaveMemory(current)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/memory"
|
||||
)
|
||||
|
||||
func memoryReviewServer(t *testing.T, facts ...memory.Memory) *Server {
|
||||
t.Helper()
|
||||
set := memory.Load(memory.Options{CWD: t.TempDir(), UserDir: t.TempDir()})
|
||||
for _, f := range facts {
|
||||
if _, err := set.Store.Save(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c := control.New(control.Options{Memory: memory.Load(set.LoadOptions())})
|
||||
t.Cleanup(c.Close)
|
||||
return &Server{ctrl: c}
|
||||
}
|
||||
|
||||
func listedMemories(t *testing.T, s *Server) map[string]memoryEntry {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
s.memories(rec, httptest.NewRequest(http.MethodGet, "/memory", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var catalog struct {
|
||||
Memories []memoryEntry `json:"memories"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]memoryEntry{}
|
||||
for _, m := range catalog.Memories {
|
||||
out[m.Name] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func postMemory(s *Server, handler func(http.ResponseWriter, *http.Request), path, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestTheMemoryListCarriesGalaxyStrengthAndNewness(t *testing.T) {
|
||||
s := memoryReviewServer(t,
|
||||
memory.Memory{Name: "deploy", Body: "deploys go through the platform branch", Galaxy: "release"},
|
||||
memory.Memory{Name: "rule", Body: "never force push", Type: memory.TypeConstraint},
|
||||
)
|
||||
got := listedMemories(t, s)
|
||||
deploy := got["deploy"]
|
||||
if deploy.Galaxy != "release" || deploy.Importance != memory.ImportanceNormal || deploy.State != "clear" {
|
||||
t.Fatalf("deploy = %+v, want its galaxy, normal importance and a clear state", deploy)
|
||||
}
|
||||
if !deploy.New || deploy.ClearForDays <= 0 || deploy.NoDecay {
|
||||
t.Fatalf("a fresh fact must read as new, decaying, with clear days left: %+v", deploy)
|
||||
}
|
||||
rule := got["rule"]
|
||||
if !rule.NoDecay || rule.ClearForDays != 0 || rule.Importance != memory.ImportanceHigh {
|
||||
t.Fatalf("a constraint must not decay and defaults to high importance: %+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmingAMemoryMarksItReviewedAndReinforcesIt(t *testing.T) {
|
||||
s := memoryReviewServer(t, memory.Memory{Name: "deploy", Body: "deploys go through the platform branch"})
|
||||
if rec := postMemory(s, s.confirmMemory, "/memory/confirm", `{"name":"deploy"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("confirm = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
deploy := listedMemories(t, s)["deploy"]
|
||||
if deploy.New || deploy.Uses != 1 || deploy.LastUsedAt == "" {
|
||||
t.Fatalf("after confirm = %+v, want reviewed with one use", deploy)
|
||||
}
|
||||
rec := postMemory(s, s.confirmMemory, "/memory/confirm", `{"name":"missing"}`)
|
||||
var reason Reason
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound || reason.Code != "request.not_found" {
|
||||
t.Fatalf("unknown confirm = %d %+v, want 404 request.not_found", rec.Code, reason)
|
||||
}
|
||||
if rec := postMemory(s, s.confirmMemory, "/memory/confirm", `{}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("confirm without a name = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavingAMemoryMovesItsGalaxyImportanceAndType(t *testing.T) {
|
||||
s := memoryReviewServer(t, memory.Memory{Name: "deploy", Body: "deploys go through the platform branch", Galaxy: "release"})
|
||||
body := `{"name":"deploy","body":"deploys go through the platform branch","galaxy":" ops ","importance":3,"type":"constraint"}`
|
||||
if rec := postMemory(s, s.saveMemory, "/memory/save", body); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
deploy := listedMemories(t, s)["deploy"]
|
||||
if deploy.Galaxy != "ops" || deploy.Importance != 3 || deploy.Type != "constraint" || deploy.New {
|
||||
t.Fatalf("after save = %+v, want galaxy ops, importance 3, a constraint, reviewed", deploy)
|
||||
}
|
||||
if rec := postMemory(s, s.saveMemory, "/memory/save", `{"name":"deploy","body":"x"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save without galaxy = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := listedMemories(t, s)["deploy"].Galaxy; got != "ops" {
|
||||
t.Fatalf("a save that names no galaxy moved it to %q", got)
|
||||
}
|
||||
for _, bad := range []string{`{"name":"deploy","body":"x","importance":7}`, `{"name":"deploy","body":"x","type":"rumour"}`} {
|
||||
if rec := postMemory(s, s.saveMemory, "/memory/save", bad); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("save %s = %d, want 400", bad, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,9 +293,36 @@ func (m *memoryManager) saveMemory(fact memory.Memory) (string, error) {
|
||||
m.applyWrite(mem,
|
||||
"Saved memory \""+fact.Name+"\": "+strings.Join(strings.Fields(fact.Description), " ")+"\n"+strings.TrimSpace(fact.Body),
|
||||
result.Memory)
|
||||
if err := mem.Store.MarkReviewed(result.Memory, time.Now()); err != nil {
|
||||
return result.Path, err
|
||||
}
|
||||
return result.Path, nil
|
||||
}
|
||||
|
||||
func (m *memoryManager) confirm(name string) error {
|
||||
m.writeMu.Lock()
|
||||
defer m.writeMu.Unlock()
|
||||
mem := m.current()
|
||||
if mem == nil {
|
||||
return memory.ErrNoSuchFact
|
||||
}
|
||||
report, err := mem.Store.ListActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fact := range report.Facts {
|
||||
if fact.Name != name && fact.ID != name {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
if err := mem.Store.RecordUse([]memory.Memory{fact}, false, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return mem.Store.MarkReviewed(fact, now)
|
||||
}
|
||||
return memory.ErrNoSuchFact
|
||||
}
|
||||
|
||||
// forget removes a saved auto-memory by name — the panel/TUI forget action, the
|
||||
// manual counterpart to the model's `forget` tool. It queues a turn-tail note so
|
||||
// the removal applies this session (the cached prefix still lists the fact until
|
||||
|
||||
@@ -567,6 +567,10 @@ func (c *Controller) ForgetMemory(name string) error {
|
||||
return c.memory.forget(name)
|
||||
}
|
||||
|
||||
func (c *Controller) ConfirmMemory(name string) error {
|
||||
return c.memory.confirm(name)
|
||||
}
|
||||
|
||||
// QueueMemory implements memory.Queue: when the model runs the remember/forget
|
||||
// tool, the tool calls this with the typed facts it wrote. The write refreshes
|
||||
// the snapshot a memory panel reads and records those facts as delivered, so the
|
||||
|
||||
@@ -196,6 +196,7 @@ type MemoryControl interface {
|
||||
SaveDoc(path, body string) (string, error)
|
||||
SaveMemory(m memory.Memory) (string, error)
|
||||
ForgetMemory(name string) error
|
||||
ConfirmMemory(name string) error
|
||||
QueueMemory(note string, delivered ...memory.Memory)
|
||||
MemoryRevisions(ref string) ([]memory.Memory, error)
|
||||
RestoreMemory(ref string, revision int) (memory.Memory, error)
|
||||
|
||||
@@ -47,6 +47,10 @@ const (
|
||||
// normalises to TypeProject.
|
||||
var validTypes = map[Type]bool{TypeUser: true, TypeFeedback: true, TypeProject: true, TypeReference: true, TypeConstraint: true}
|
||||
|
||||
func ValidType(s string) bool {
|
||||
return validTypes[Type(strings.ToLower(strings.TrimSpace(s)))]
|
||||
}
|
||||
|
||||
// NormalizeType coerces an arbitrary string to a known Type, defaulting to
|
||||
// TypeProject so a sloppy tool argument never blocks a save.
|
||||
func NormalizeType(s string) Type {
|
||||
@@ -98,6 +102,7 @@ type Memory struct {
|
||||
Strength float64
|
||||
LastUsedAt time.Time
|
||||
Uses int
|
||||
ReviewedAt time.Time
|
||||
Body string
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package memory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
@@ -26,6 +27,7 @@ type usageRecord struct {
|
||||
Strength float64 `json:"strength,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitempty"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
ReviewedAt time.Time `json:"reviewed_at,omitempty"`
|
||||
}
|
||||
|
||||
type usageData struct {
|
||||
@@ -102,6 +104,9 @@ func applyUsage(memories []Memory, byDir map[string]usageData, dirOf func(Memory
|
||||
if rec.Uses > 0 {
|
||||
memories[i].Uses = rec.Uses
|
||||
}
|
||||
if !rec.ReviewedAt.IsZero() {
|
||||
memories[i].ReviewedAt = rec.ReviewedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,16 +214,21 @@ func retentionWeight(r float64) float64 {
|
||||
return math.Max(r/RetentionClearMin, fadedWeightFloor)
|
||||
}
|
||||
|
||||
func (m Memory) Retention(now time.Time) float64 {
|
||||
if m.NoDecay() {
|
||||
return 1
|
||||
}
|
||||
func (m Memory) retentionAnchor() time.Time {
|
||||
ref := m.LastUsedAt
|
||||
for _, t := range []time.Time{m.LastVerifiedAt, m.UpdatedAt, m.CreatedAt} {
|
||||
if t.After(ref) {
|
||||
ref = t
|
||||
}
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func (m Memory) Retention(now time.Time) float64 {
|
||||
if m.NoDecay() {
|
||||
return 1
|
||||
}
|
||||
ref := m.retentionAnchor()
|
||||
if ref.IsZero() {
|
||||
return 1
|
||||
}
|
||||
@@ -293,3 +303,43 @@ func neighborsOf(seeds []RecallHit, docs []autoRecallDoc, links map[string]float
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const newFactWindow = 14 * 24 * time.Hour
|
||||
|
||||
var ErrNoSuchFact = errors.New("memory.not_found")
|
||||
|
||||
func (m Memory) AwaitingReview(now time.Time) bool {
|
||||
return m.ReviewedAt.IsZero() && !m.CreatedAt.IsZero() && now.Sub(m.CreatedAt) < newFactWindow
|
||||
}
|
||||
|
||||
func (m Memory) ClearForDays(now time.Time) int {
|
||||
if m.NoDecay() {
|
||||
return 0
|
||||
}
|
||||
ref := m.retentionAnchor()
|
||||
if ref.IsZero() {
|
||||
ref = now
|
||||
}
|
||||
left := m.StrengthDays()*math.Log(1/RetentionClearMin) - now.Sub(ref).Hours()/24
|
||||
if left <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Ceil(left))
|
||||
}
|
||||
|
||||
func (s Store) MarkReviewed(m Memory, now time.Time) error {
|
||||
if (s.Dir == "" && s.GlobalDir == "") || m.ID == "" {
|
||||
return nil
|
||||
}
|
||||
release, err := s.lockMutations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
dir := s.usageDirFor(m)
|
||||
data := readUsage(dir)
|
||||
rec := data.Facts[m.ID]
|
||||
rec.ReviewedAt = now.UTC()
|
||||
data.Facts[m.ID] = rec
|
||||
return writeUsage(dir, data)
|
||||
}
|
||||
@@ -139,3 +139,43 @@ func TestOftenUsedTogetherBringsANeighbor(t *testing.T) {
|
||||
t.Fatalf("hits = %+v, want the matched fact and its frequent companion", result.Hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewFactAwaitsReviewUntilMarked(t *testing.T) {
|
||||
store := recallTestStore(t)
|
||||
now := time.Now()
|
||||
m := Memory{ID: "mem-n", Name: "n", Description: "n", Type: TypeProject, Scope: FactScopeProject, Body: "new fact", CreatedAt: now.Add(-time.Hour)}
|
||||
recallTestWrite(t, store.Dir, m)
|
||||
if !m.AwaitingReview(now) {
|
||||
t.Fatal("a fact written an hour ago must await review")
|
||||
}
|
||||
if (Memory{CreatedAt: now.Add(-20 * 24 * time.Hour)}).AwaitingReview(now) {
|
||||
t.Fatal("a fact older than the review window is not new")
|
||||
}
|
||||
if err := store.MarkReviewed(m, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := store.ListActive()
|
||||
if err != nil || len(report.Facts) != 1 {
|
||||
t.Fatalf("list = %+v, %v", report, err)
|
||||
}
|
||||
if report.Facts[0].AwaitingReview(now) {
|
||||
t.Fatal("a reviewed fact still reads as new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearForDaysCountsDownToTheFuzzyLine(t *testing.T) {
|
||||
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
||||
full := int(math.Ceil(30 * math.Log(1/RetentionClearMin)))
|
||||
if got := (Memory{Type: TypeProject, CreatedAt: now}).ClearForDays(now); got != full {
|
||||
t.Fatalf("fresh normal fact = %d days, want %d", got, full)
|
||||
}
|
||||
if got := (Memory{Type: TypeProject, CreatedAt: now.Add(-10 * 24 * time.Hour)}).ClearForDays(now); got != full-10 {
|
||||
t.Fatalf("ten days on = %d, want %d", got, full-10)
|
||||
}
|
||||
if got := (Memory{Type: TypeProject, CreatedAt: now.Add(-60 * 24 * time.Hour)}).ClearForDays(now); got != 0 {
|
||||
t.Fatalf("a fuzzy fact = %d days, want 0", got)
|
||||
}
|
||||
if got := (Memory{Type: TypeConstraint}).ClearForDays(now); got != 0 {
|
||||
t.Fatalf("a constraint = %d days, want 0 since it never decays", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user