Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/memory/brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type briefResult struct {
Category string `json:"category,omitempty"`
Score *float64 `json:"score,omitempty"`
Confidence string `json:"confidence,omitempty"`
Superseded bool `json:"superseded,omitempty"`
}

type briefResponse struct {
Expand Down
60 changes: 60 additions & 0 deletions cmd/memory/import_supersedes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package memory

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/mnemon-dev/mnemon/internal/memory/store"
)

func TestImportDeduplicationCannotSupersedeItself(t *testing.T) {
t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1")
t.Setenv("MNEMON_EMBED_PROTOCOL", "ollama")
oldDir, oldStore, oldReadOnly := dataDir, storeName, readOnly
oldNoDiff, oldDryRun := importNoDiff, importDryRun
t.Cleanup(func() {
dataDir, storeName, readOnly = oldDir, oldStore, oldReadOnly
importNoDiff, importDryRun = oldNoDiff, oldDryRun
})
dataDir, storeName, readOnly = t.TempDir(), "dedup-supersedes", false
importNoDiff, importDryRun = false, false
path := filepath.Join(t.TempDir(), "draft.json")
if err := os.WriteFile(path, []byte(`{
"schema_version":"1",
"insights":[{"content":"release cache ttl is seven days"},{"content":"release cache ttl is seven days"}],
"edges":[{"source_index":1,"target_index":0,"edge_type":"supersedes","weight":1}]
}`), 0o600); err != nil {
t.Fatal(err)
}
var runErr error
output := captureStdout(t, func() { runErr = importCmd.RunE(importCmd, []string{path}) })
if runErr != nil {
t.Fatal(runErr)
}
var summary struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
EdgesInserted int `json:"edges_inserted"`
}
if err := json.Unmarshal([]byte(output), &summary); err != nil {
t.Fatal(err)
}
if summary.Imported != 1 || summary.Skipped != 1 || summary.EdgesInserted != 0 {
t.Fatalf("deduplicated import must not write a self supersedes edge: %s", output)
}
db, err := store.Open(store.StoreDir(dataDir, storeName))
if err != nil {
t.Fatal(err)
}
defer db.Close()
active, err := db.GetAllActiveInsights()
if err != nil || len(active) != 1 {
t.Fatalf("expected one retained insight: %v, %v", active, err)
}
superseded, err := db.GetSupersededIDs([]string{active[0].ID})
if err != nil || len(superseded) != 0 {
t.Fatalf("the only current fact was superseded: %v, %v", superseded, err)
}
}
36 changes: 23 additions & 13 deletions cmd/memory/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"time"

"github.com/mnemon-dev/mnemon/internal/memory/model"
Expand All @@ -28,7 +29,8 @@ var linkCmd = &cobra.Command{
// Validate edge type
edgeType := model.EdgeType(linkType)
if !model.ValidEdgeTypes[edgeType] {
return fmt.Errorf("invalid edge type %q; valid: temporal, semantic, causal, entity", linkType)
return fmt.Errorf("invalid edge type %q; valid: %s",
linkType, strings.Join(model.EdgeTypeNames(), ", "))
}

// Validate weight
Expand Down Expand Up @@ -63,7 +65,7 @@ var linkCmd = &cobra.Command{

now := time.Now().UTC()

// Create bidirectional edges (INSERT OR REPLACE)
// Mutual relations are recorded in both directions.
err = db.InsertEdge(&model.Edge{
SourceID: sourceID,
TargetID: targetID,
Expand All @@ -76,16 +78,23 @@ var linkCmd = &cobra.Command{
return fmt.Errorf("create edge %s→%s: %w", sourceID, targetID, err)
}

err = db.InsertEdge(&model.Edge{
SourceID: targetID,
TargetID: sourceID,
EdgeType: edgeType,
Weight: linkWeight,
Metadata: metadata,
CreatedAt: now,
})
if err != nil {
return fmt.Errorf("create edge %s→%s: %w", targetID, sourceID, err)
// A directed relation is recorded once. Writing the reverse of a
// supersedes edge would mark the correction as superseded too, so
// both insights would be demoted and the stale one would keep its
// lead over the correction -- the exact ordering this type exists
// to fix.
if !edgeType.IsDirected() {
err = db.InsertEdge(&model.Edge{
SourceID: targetID,
TargetID: sourceID,
EdgeType: edgeType,
Weight: linkWeight,
Metadata: metadata,
CreatedAt: now,
})
if err != nil {
return fmt.Errorf("create edge %s→%s: %w", targetID, sourceID, err)
}
}

db.LogOp("link", sourceID, fmt.Sprintf("%s→%s type=%s weight=%.2f", truncID(sourceID), truncID(targetID), linkType, linkWeight))
Expand All @@ -105,7 +114,8 @@ var linkCmd = &cobra.Command{
}

func init() {
linkCmd.Flags().StringVar(&linkType, "type", "semantic", "edge type (temporal|semantic|causal|entity)")
linkCmd.Flags().StringVar(&linkType, "type", "semantic",
fmt.Sprintf("edge type (%s)", strings.Join(model.EdgeTypeNames(), "|")))
linkCmd.Flags().Float64Var(&linkWeight, "weight", 0.5, "edge weight (0.0-1.0)")
linkCmd.Flags().StringVar(&linkMeta, "meta", "", `optional metadata JSON (e.g. '{"reason":"similar topic"}')`)
rootCmd.AddCommand(linkCmd)
Expand Down
80 changes: 80 additions & 0 deletions cmd/memory/link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package memory

import (
"slices"
"sort"
"strings"
"testing"

"github.com/mnemon-dev/mnemon/internal/memory/store"
)

// A supersedes edge asserts something about one of the two insights. Recorded
// both ways it marks the correction as superseded too, so recall demotes both
// and the stale insight keeps its lead over the correction -- the ordering the
// type exists to fix. Mutual types must still be recorded in both directions.
func TestLinkRecordsDirectedEdgeOnceAndMutualEdgesBothWays(t *testing.T) {
oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly
oldType, oldWeight, oldMeta := linkType, linkWeight, linkMeta
t.Cleanup(func() {
dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly
linkType, linkWeight, linkMeta = oldType, oldWeight, oldMeta
})
dataDir, storeName, readOnly = t.TempDir(), "", false
linkWeight, linkMeta = 0.5, ""

db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("open store: %v", err)
}
insertTestInsight(t, db, "fresh", "the correction", "test", "2026-01-02T00:00:00Z")
insertTestInsight(t, db, "stale", "the corrected claim", "test", "2026-01-01T00:00:00Z")
if err := db.Close(); err != nil {
t.Fatalf("close seed db: %v", err)
}

cases := []struct {
edgeType string
want []string // source id of every edge of this type
}{
{edgeType: "supersedes", want: []string{"fresh"}},
{edgeType: "semantic", want: []string{"fresh", "stale"}},
}
for _, tc := range cases {
t.Run(tc.edgeType, func(t *testing.T) {
linkType = tc.edgeType
captureStdout(t, func() {
if err := linkCmd.RunE(linkCmd, []string{"fresh", "stale"}); err != nil {
t.Fatalf("link RunE: %v", err)
}
})

db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer db.Close()
edges, err := db.GetAllEdges()
if err != nil {
t.Fatalf("read edges: %v", err)
}

var got []string
for _, e := range edges {
if string(e.EdgeType) == tc.edgeType {
got = append(got, e.SourceID)
}
}
sort.Strings(got)
if !slices.Equal(got, tc.want) {
t.Errorf("%s edges by source = %v, want %v", tc.edgeType, got, tc.want)
}
})
}
t.Run("reject self supersession", func(t *testing.T) {
linkType = "supersedes"
if err := linkCmd.RunE(linkCmd, []string{"fresh", "fresh"}); err == nil || !strings.Contains(err.Error(), "distinct insights") {
t.Fatalf("self supersedes link must fail: %v", err)
}
})
}
6 changes: 6 additions & 0 deletions cmd/memory/recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ type compactResult struct {
MatchedVia string `json:"matched_via,omitempty"`
Confidence string `json:"confidence"`
Score float64 `json:"score"`
// Superseded warns that another insight claims to replace this one.
// It must survive the compact projection: an agent reading only this
// shape would otherwise be handed corrected content with no signal.
Superseded bool `json:"superseded,omitempty"`
}

// compactResponse wraps compact results with an optional hint.
Expand Down Expand Up @@ -89,6 +93,7 @@ func toCompact(resp search.RecallResponse) compactResponse {
MatchedVia: r.Via,
Confidence: confidenceLabel(rounded),
Score: rounded,
Superseded: r.Superseded,
}
results = append(results, cr)
}
Expand Down Expand Up @@ -212,6 +217,7 @@ meta.intent and meta.intent_source (auto or override). --basic bypasses intent.`
Category: string(result.Insight.Category),
Score: scorePointer(score),
Confidence: confidenceLabel(score),
Superseded: result.Superseded,
})
}
return encodeBrief(os.Stdout, newBriefResponse(brief, resp.Meta.Hint))
Expand Down
80 changes: 80 additions & 0 deletions cmd/memory/recall_supersedes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package memory

import (
"encoding/json"
"testing"
"time"

"github.com/mnemon-dev/mnemon/internal/memory/model"
"github.com/mnemon-dev/mnemon/internal/memory/store"
)

func TestRecallPreservesSupersededInEverySmartProjection(t *testing.T) {
oldDir, oldStore, oldReadOnly := dataDir, storeName, readOnly
oldBasic, oldBrief, oldVerbose, oldLimit := recBasic, recBrief, recVerbose, recLimit
oldCategory, oldSource, oldIntent, oldExcerpt := recCategory, recSource, recIntent, recExcerpt
t.Cleanup(func() {
dataDir, storeName, readOnly = oldDir, oldStore, oldReadOnly
recBasic, recBrief, recVerbose, recLimit = oldBasic, oldBrief, oldVerbose, oldLimit
recCategory, recSource, recIntent, recExcerpt = oldCategory, oldSource, oldIntent, oldExcerpt
})
t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1")
t.Setenv("MNEMON_EMBED_PROTOCOL", "ollama")
dataDir, storeName, readOnly = t.TempDir(), "supersedes-projection", true
recBasic, recLimit = false, 5
recCategory, recSource, recIntent, recExcerpt = "", "", "GENERAL", 240
db, err := store.Open(store.StoreDir(dataDir, storeName))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
insertTestInsight(t, db, "stale", "release cache ttl defaults to thirty days", "test", "2026-01-01T00:00:00Z")
insertTestInsight(t, db, "fresh", "release cache ttl was corrected: use seven days", "test", "2026-01-02T00:00:00Z")
if err := db.InsertEdge(&model.Edge{
SourceID: "fresh", TargetID: "stale", EdgeType: model.EdgeSupersedes,
Weight: 1, CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}

for _, mode := range []string{"compact", "verbose", "brief"} {
t.Run(mode, func(t *testing.T) {
recBrief, recVerbose = mode == "brief", mode == "verbose"
var runErr error
out := captureStdout(t, func() { runErr = recallCmd.RunE(recallCmd, []string{"release cache ttl thirty days"}) })
if runErr != nil {
t.Fatal(runErr)
}
var response struct {
Results []struct {
ID string `json:"id"`
Insight model.Insight
Superseded *bool `json:"superseded"`
}
}
if err := json.Unmarshal([]byte(out), &response); err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, result := range response.Results {
id := result.ID
if mode == "verbose" {
id = result.Insight.ID
}
seen[id] = true
if id == "stale" && (result.Superseded == nil || !*result.Superseded) {
t.Errorf("superseded marker missing from stale result: %s", out)
}
if id == "fresh" && result.Superseded != nil {
t.Errorf("current insight must omit superseded: %s", out)
}
}
if !seen["stale"] || !seen["fresh"] {
t.Fatalf("both current and superseded memories must remain retrievable: %s", out)
}
})
}
}
7 changes: 5 additions & 2 deletions cmd/memory/related.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/mnemon-dev/mnemon/internal/memory/graph"
"github.com/mnemon-dev/mnemon/internal/memory/model"
Expand Down Expand Up @@ -40,7 +41,8 @@ var relatedCmd = &cobra.Command{
if relEdgeType != "" {
et := model.EdgeType(relEdgeType)
if !model.ValidEdgeTypes[et] {
return fmt.Errorf("invalid edge type %q; valid: temporal, semantic, causal, entity", relEdgeType)
return fmt.Errorf("invalid edge type %q; valid: %s",
relEdgeType, strings.Join(model.EdgeTypeNames(), ", "))
}
edgeFilter = et
}
Expand Down Expand Up @@ -83,7 +85,8 @@ func bfsTraverse(db *store.DB, startID string, edgeFilter model.EdgeType, maxDep
}

func init() {
relatedCmd.Flags().StringVar(&relEdgeType, "edge", "", "filter by edge type (temporal|semantic|causal|entity)")
relatedCmd.Flags().StringVar(&relEdgeType, "edge", "",
fmt.Sprintf("filter by edge type (%s)", strings.Join(model.EdgeTypeNames(), "|")))
relatedCmd.Flags().IntVar(&relDepth, "depth", 2, "max traversal depth")
rootCmd.AddCommand(relatedCmd)
}
1 change: 1 addition & 0 deletions docs/IMPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Chat export / Markdown -> LLM extraction prompt -> memory_draft.json -> mnemon i
| `causal` | Causal influence; A caused or affected B |
| `semantic` | Semantic similarity; A and B discuss the same topic |
| `entity` | Entity co-occurrence; A and B mention the same named subject |
| `supersedes` | Authority claim; A replaces B and B is demoted at recall |

---

Expand Down
3 changes: 2 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ selection entirely.
mnemon link <source_id> <target_id> --type semantic --weight 0.85
mnemon link <source_id> <target_id> --type causal --weight 0.8 \
--meta '{"sub_type":"causes","reason":"..."}'
mnemon link <new_id> <old_id> --type supersedes --weight 1.0

# Related — BFS traversal from an insight
mnemon related <id> --edge causal --depth 2
Expand Down Expand Up @@ -345,7 +346,7 @@ mnemon viz --format html -o graph.html
open graph.html
```

Nodes are colored by category (decision, fact, insight, preference, context); edges are colored by type (temporal, semantic, causal, entity).
Nodes are colored by category (decision, fact, insight, preference, context); edges are colored by type (temporal, semantic, causal, entity, supersedes).

---

Expand Down
1 change: 1 addition & 0 deletions docs/zh/IMPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
| `causal` | 因果关系,A 导致或影响 B |
| `semantic` | 语义相似关系,A 与 B 讨论同一主题 |
| `entity` | 实体共现关系,A 与 B 涉及同一命名主体 |
| `supersedes` | 权威声明:A 取代 B,召回时 B 被降权 |

---

Expand Down
Loading
Loading