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:
esengine committed 2026-11-02 17:41:01 -08:00
1 parent 87c91ebd06
commit d0289a4d0f
14 files changed
+327 -13

No files matched your search

+11
View File
@@ -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;
}
+8 -8
View File
@@ -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: [] };
+1
View File
@@ -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,
+3
View File
@@ -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 ?? [];
+1
View File
@@ -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())
+56
View File
@@ -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)
}
}
}
+27
View File
@@ -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
+1
View File
@@ -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)
+5
View File
@@ -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
}
+54 -4
View File
@@ -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)
}
+40
View File
@@ -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)
}
}