Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
044a625
fix(memory): enforce smart recall source and category filters
Sep 14, 2026
878d343
fix(memory): stabilize recall ranking before bounded selection
Sep 14, 2026
6c30564
fix(memory): retain exact entity matches as recall anchors
Sep 14, 2026
2c3ef52
fix(pi): use native memory guidance and retain correction history
Sep 14, 2026
d61e82c
fix(pi): keep memory context bounded across compaction
Sep 14, 2026
6d64a14
fix(memory): prioritize transition scores before visit limits
Sep 14, 2026
e382be1
test(memory): add frozen long-horizon recall regression cases
Sep 14, 2026
9e45de7
test(pi): add isolated DeepSeek memory acceptance runner
Sep 14, 2026
81aaee6
docs: report complex memory regression findings and live limits
Sep 14, 2026
447874a
test(memory): recognize durable regression inputs in hygiene checks
Sep 14, 2026
2a3f094
test(memory): avoid blocking capture of large CLI output
Sep 14, 2026
f8dadca
test(pi): allow bounded waits for queued model requests
Sep 15, 2026
8e7061e
test(pi): clarify the restricted command interface
Sep 15, 2026
4fe3dbb
test(pi): clarify that separate tool calls remain available
Sep 15, 2026
8dfaee4
test(pi): expose the scoped operation log during recall
Sep 15, 2026
ca821e8
fix(pi): broaden discovery and verify dated updates
Sep 15, 2026
538a7af
fix(memory): show query context in brief excerpts
Sep 15, 2026
f0b3e70
fix(pi): ground persisted facts in stated evidence
Sep 15, 2026
7956226
docs: report completed Pi memory regression and remaining limits
Sep 15, 2026
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,14 @@ mnemon setup --target pi --yes
One command deploys the mnemon skill, prompt files, and a Pi TypeScript extension
to `.pi/`. The extension maps Mnemon's lifecycle reminders onto Pi events
(`resources_discover`, `before_agent_start`, `agent_end`,
`session_before_compact`). Start a new Pi session or run `/reload` to activate.
`context`, `session_compact`). Start a new Pi session or run `/reload` to activate.

Pi uses its own guide at `${MNEMON_DATA_DIR:-$HOME/.mnemon}/prompt/pi/guide.md`,
so installing another host does not replace Pi's instructions. Run setup again
after upgrading, and move any Pi-specific customizations from the old shared
guide into that file. Guidance is supplied once per turn without accumulating
copies in the conversation. After compaction, the next agent request receives
a recall reminder; justified memory writes should finish before the final answer.

### [Hermes Agent](https://github.com/NousResearch/hermes-agent)

Expand Down
16 changes: 0 additions & 16 deletions cmd/memory/brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"unicode/utf8"
)

const defaultBriefExcerptChars = 240
Expand Down Expand Up @@ -53,20 +51,6 @@ func validateBriefExcerptChars(enabled bool, limit int) error {
return nil
}

func makeBriefExcerpt(content string, maxChars int) string {
// Flatten whitespace so one memory cannot turn a discovery row into a large
// multi-line block. strings.Fields is Unicode-aware.
content = strings.Join(strings.Fields(content), " ")
if utf8.RuneCountInString(content) <= maxChars {
return content
}
if maxChars == 1 {
return "…"
}
runes := []rune(content)
return strings.TrimSpace(string(runes[:maxChars-1])) + "…"
}

func scorePointer(score float64) *float64 {
return &score
}
147 changes: 147 additions & 0 deletions cmd/memory/brief_excerpt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package memory

import (
"strings"
"unicode"

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

const (
maxBriefQueryBytes = 4096
maxBriefQueryTerms = 64
maxBriefMatchWidth = 4096
)

func makeBriefExcerpt(content string, maxChars int) string {
return makeQueryBriefExcerpt(content, "", maxChars)
}

// makeQueryBriefExcerpt changes only the discovery projection. It selects one
// continuous passage, retaining nearby context rather than synthesizing facts.
func makeQueryBriefExcerpt(content, query string, maxChars int) string {
if maxChars <= 0 {
return ""
}
runes := []rune(strings.Join(strings.Fields(content), " "))
if len(runes) <= maxChars {
return string(runes)
}
if maxChars <= 2 || len(query) > maxBriefQueryBytes {
return briefPrefix(runes, maxChars)
}
terms := search.Tokenize(query)
if len(terms) == 0 || len(terms) > maxBriefQueryTerms {
return briefPrefix(runes, maxChars)
}
window := briefMatchWindow{terms: terms, width: min(maxChars-2, maxBriefMatchWidth), counts: make(map[string]int)}
window.scan(runes)
if window.bestCount == 0 {
return briefPrefix(runes, maxChars)
}
return renderBriefMatch(runes, window.best, maxChars)
}

func briefPrefix(runes []rune, maxChars int) string {
return strings.TrimSpace(string(runes[:maxChars-1])) + "…"
}

type briefMatch struct {
start, end int
term string
}

type briefMatchWindow struct {
terms map[string]bool
width int
matches []briefMatch
counts map[string]int
best briefMatch
bestCount int
}

// scan recognizes the same word and Han-bigram units used by query Tokenize.
// The active window holds at most width matches, independent of repeated hits.
func (w *briefMatchWindow) scan(runes []rune) {
for i := 0; i < len(runes); {
if unicode.Is(unicode.Han, runes[i]) {
if i+1 < len(runes) && unicode.Is(unicode.Han, runes[i+1]) {
w.observe(runes, i, i+2)
} else if i == 0 || !unicode.Is(unicode.Han, runes[i-1]) {
w.observe(runes, i, i+1)
}
i++
continue
}
if !unicode.IsLetter(runes[i]) && !unicode.IsDigit(runes[i]) {
i++
continue
}
start := i
for i < len(runes) && !unicode.Is(unicode.Han, runes[i]) && (unicode.IsLetter(runes[i]) || unicode.IsDigit(runes[i])) {
i++
}
w.observe(runes, start, i)
}
}

func (w *briefMatchWindow) observe(runes []rune, start, end int) {
if end-start > w.width {
return
}
term := strings.ToLower(string(runes[start:end]))
if !w.terms[term] {
return
}
w.matches = append(w.matches, briefMatch{start: start, end: end, term: term})
w.counts[term]++
for end-w.matches[0].start > w.width || w.counts[w.matches[0].term] > 1 {
first := w.matches[0].term
w.counts[first]--
if w.counts[first] == 0 {
delete(w.counts, first)
}
w.matches = w.matches[1:]
}
span := briefMatch{start: w.matches[0].start, end: end}
if count := len(w.counts); count > w.bestCount || (count == w.bestCount && span.end-span.start < w.best.end-w.best.start) {
w.best, w.bestCount = span, count
}
}

func renderBriefMatch(runes []rune, match briefMatch, maxChars int) string {
// Reserve both ellipses; spend most spare space after the matches so nearby
// values, dates and qualifications stay visible. Keep whole adjacent words.
budget := maxChars - 2
start := max(0, match.start-(budget-(match.end-match.start))/3)
minStart := max(0, match.end-budget)
aligned := start
for aligned > minStart && !briefWordBoundary(runes, aligned) {
aligned--
}
if briefWordBoundary(runes, aligned) {
start = aligned
} else {
for start < match.start && !briefWordBoundary(runes, start) {
start++
}
}
end := min(len(runes), start+budget)
for end > match.end && !briefWordBoundary(runes, end) {
end--
}
excerpt := strings.TrimSpace(string(runes[start:end]))
if start > 0 {
excerpt = "…" + excerpt
}
if end < len(runes) {
excerpt += "…"
}
return excerpt
}

func briefWordBoundary(runes []rune, index int) bool {
return index == 0 || index == len(runes) ||
unicode.IsSpace(runes[index-1]) || unicode.IsSpace(runes[index]) ||
unicode.Is(unicode.Han, runes[index-1]) || unicode.Is(unicode.Han, runes[index])
}
162 changes: 162 additions & 0 deletions cmd/memory/brief_excerpt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package memory

import (
"encoding/json"
"fmt"
"strings"
"testing"
"unicode/utf8"
)

func TestBriefDiscoveryShowsMatchingFactAndQualifiers(t *testing.T) {
fact := "The maintenance cartridge decision dated 2025-04-12 says do not use RT-42; use QZ-73 instead."
for _, tc := range []struct {
name, content, query string
want []string
}{
{"long-header", strings.Repeat("[archive_ref=opaque-772; actor=clerk; cartridge=inventory] ", 12) + fact,
"maintenance cartridge", []string{"2025-04-12", "do not use RT-42", "use QZ-73 instead"}},
{"plain-prose", strings.Repeat("The meeting covered routine floor plans and furniture placement. ", 9) + fact,
"maintenance cartridge", []string{"2025-04-12", "do not use RT-42", "use QZ-73 instead"}},
{"match-near-old-cutoff", strings.Repeat("context ", 27) + strings.TrimPrefix(fact, "The "),
"maintenance cartridge", []string{"2025-04-12", "do not use RT-42", "use QZ-73 instead"}},
{"cjk", strings.Repeat("[归档批次=常规记录; 编辑者=实验员] 🧪 ", 18) + "实验台的冷却泵方案于2025年4月12日调整;不再使用RT-42,改用QZ-73。",
"冷却泵", []string{"2025年4月12日", "不再使用RT-42", "改用QZ-73"}},
} {
t.Run(tc.name, func(t *testing.T) {
db := scopedRecallStore(t)
oldLimit, oldBrief, oldExcerpt := searchLimit, searchBrief, searchExcerpt
t.Cleanup(func() { searchLimit, searchBrief, searchExcerpt = oldLimit, oldBrief, oldExcerpt })
insertTestInsight(t, db, "entry", tc.content, "prod", "2025-04-12T00:00:00Z")
if err := db.Close(); err != nil {
t.Fatal(err)
}
recLimit, recExcerpt, searchLimit, searchExcerpt = 1, 240, 1, 240
for _, surface := range []string{"recall", "basic", "search"} {
t.Run(surface, func(t *testing.T) {
full, _ := runBriefDiscovery(t, surface, tc.query, false)
brief, detail := runBriefDiscovery(t, surface, tc.query, true)
if len(full) != 1 || len(brief) != 1 || full[0]["id"] != brief[0]["id"] || full[0]["score"] != brief[0]["score"] {
t.Fatalf("brief changed discovery selection or scoring: full=%v brief=%v", full, brief)
}
if full[0]["content"] != tc.content || detail != "mnemon show <id>" {
t.Fatal("full content or the detail command changed")
}
excerpt, ok := brief[0]["excerpt"].(string)
if !ok || !utf8.ValidString(excerpt) || utf8.RuneCountInString(excerpt) > 240 {
t.Fatalf("invalid or oversized excerpt: %q", excerpt)
}
for _, want := range tc.want {
if !strings.Contains(excerpt, want) {
t.Errorf("brief hid the fact's adjacent qualifier %q: %q", want, excerpt)
}
}
})
}
})
}
}

func TestQueryBriefExcerptFallbackAndBounds(t *testing.T) {
content := strings.Repeat("neutral archive context 🧪 世界 ", 40) + "MATCHING TERM dated 2025-04-12 is not approved."
for _, query := range []string{"", "unmatched", "the and of", strings.Repeat("x", maxBriefQueryBytes+1)} {
if got, want := makeQueryBriefExcerpt(content, query, 80), makeBriefExcerpt(content, 80); got != want {
t.Errorf("fallback changed for query %q: got %q, want %q", query, got, want)
}
}
terms := make([]string, maxBriefQueryTerms+1)
for i := range terms {
terms[i] = fmt.Sprintf("word%d", i)
}
if got, want := makeQueryBriefExcerpt(content, strings.Join(terms, " "), 80), makeBriefExcerpt(content, 80); got != want {
t.Errorf("excessive query terms did not fall back to the prefix: %q", got)
}
for _, limit := range []int{0, 1, 2, 3, 8, 40, 80, 240, 5000} {
got := makeQueryBriefExcerpt(content, "matching term", limit)
if !utf8.ValidString(got) || utf8.RuneCountInString(got) > limit || strings.ContainsAny(got, "\n\t") {
t.Errorf("limit %d produced invalid excerpt %q", limit, got)
}
if limit >= utf8.RuneCountInString(content) && got != content {
t.Error("short content was changed")
}
}
}

func TestQueryBriefExcerptKeepsContinuousContextAndStableTies(t *testing.T) {
content := strings.Repeat("registry entry ", 30) + "On 2025-04-12, do not approve the QuartzHarbor shipment; the date is 2025-04-19. " + strings.Repeat("other context ", 30)
got := makeQueryBriefExcerpt(content, "QuartzHarbor shipment", 160)
for _, want := range []string{"2025-04-12, do not approve", "QuartzHarbor shipment", "the date is 2025-04-19"} {
if !strings.Contains(got, want) {
t.Errorf("lost adjacent context %q: %q", want, got)
}
}
if !strings.HasPrefix(got, "…") || !strings.HasSuffix(got, "…") {
t.Fatalf("omission is not visible: %q", got)
}
if !strings.Contains(content, strings.Trim(got, "…")) {
t.Fatalf("excerpt synthesized or joined noncontiguous text: %q", got)
}
if other := makeQueryBriefExcerpt(content, "shipment QuartzHarbor QuartzHarbor", 160); other != got {
t.Errorf("query ordering or repetition changed the excerpt: %q vs %q", got, other)
}
content = strings.Repeat("note ", 40) + "alpha beta first passage. " + strings.Repeat("padding ", 40) + "alpha beta second passage."
if got := makeQueryBriefExcerpt(content, "alpha beta", 70); !strings.Contains(got, "first passage") {
t.Errorf("equal coverage and density did not keep the earlier passage: %q", got)
}
}

func TestBriefMatchWindowStaysBoundedUnderRepeatedHits(t *testing.T) {
window := briefMatchWindow{terms: map[string]bool{"alpha": true, "beta": true}, width: 40, counts: make(map[string]int)}
runes := []rune(strings.Repeat("alpha ", 10000) + "alpha beta dated 2025-04-12")
window.scan(runes)
if len(window.matches) > window.width || len(window.counts) > len(window.terms) || window.bestCount != 2 {
t.Fatalf("repeated hits exceeded the active window or hid distinct terms: %+v", window)
}
if got := renderBriefMatch(runes, window.best, 80); !strings.Contains(got, "alpha beta dated 2025-04-12") {
t.Fatalf("repetition displaced the complete matching passage: %q", got)
}
}

func FuzzQueryBriefExcerptBounds(f *testing.F) {
f.Add("prefix 🧪 中文 content not approved on 2025-04-12", "content approved", uint16(32))
f.Add("\xff long\ntext "+strings.Repeat("context ", 100), "long text", uint16(1))
f.Fuzz(func(t *testing.T, content, query string, size uint16) {
limit := int(size%513) + 1
got := makeQueryBriefExcerpt(content, query, limit)
if !utf8.ValidString(got) || utf8.RuneCountInString(got) > limit || strings.ContainsAny(got, "\n\r\t") {
t.Fatalf("invalid bounded excerpt (limit %d): %q", limit, got)
}
if _, err := json.Marshal(newBriefResponse([]briefResult{{ID: "entry", Excerpt: got}}, "")); err != nil {
t.Fatal(err)
}
})
}

func runBriefDiscovery(t *testing.T, surface, query string, brief bool) ([]map[string]any, string) {
t.Helper()
recBasic, recBrief, searchBrief = surface == "basic", brief, brief
command := recallCmd
if surface == "search" {
command = searchCmd
}
var runErr error
out := captureStdout(t, func() { runErr = command.RunE(command, []string{query}) })
if runErr != nil {
t.Fatal(runErr)
}
if !brief && surface != "recall" {
var rows []map[string]any
if err := json.Unmarshal([]byte(out), &rows); err != nil {
t.Fatal(err)
}
return rows, ""
}
var response struct {
Results []map[string]any `json:"results"`
DetailCommand string `json:"detail_command"`
}
if err := json.Unmarshal([]byte(out), &response); err != nil {
t.Fatal(err)
}
return response.Results, response.DetailCommand
}
16 changes: 10 additions & 6 deletions cmd/memory/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,19 +301,23 @@ func hasTemporalEdge(t *testing.T, db *store.DB, sourceID, targetID string) bool
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
// A pipe written before it is drained can block on larger CLI responses,
// especially on platforms with smaller pipe buffers. Keep the full output
// in a test-owned file without adding a concurrent reader.
captured, err := os.CreateTemp(t.TempDir(), "stdout-")
if err != nil {
t.Fatalf("pipe stdout: %v", err)
t.Fatalf("create stdout capture: %v", err)
}
os.Stdout = w
defer captured.Close()
os.Stdout = captured
defer func() { os.Stdout = oldStdout }()

fn()

if err := w.Close(); err != nil {
t.Fatalf("close stdout writer: %v", err)
if _, err := captured.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind stdout capture: %v", err)
}
data, err := io.ReadAll(r)
data, err := io.ReadAll(captured)
if err != nil {
t.Fatalf("read stdout: %v", err)
}
Expand Down
Loading
Loading