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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Added

- `mnemon recall --brief` and `mnemon search --brief` now provide a bounded,
unindented JSON discovery projection. `--excerpt-chars` controls the per-item
excerpt limit, and `mnemon show <id>` retrieves one selected insight in full.
- `mnemon setup --target zcode` now installs a ZCode-compatible Mnemon skill.
With `--global`, setup also registers user-level `SessionStart`,
`UserPromptSubmit`, and `Stop` process hooks in
Expand All @@ -27,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Tests

- Added unit and CLI end-to-end coverage for Unicode-safe excerpt bounds,
compact encoding, basic and smart recall, search, full-result lookup, and
incompatible output flags.
- Added ZCode coverage for embedded artifacts, POSIX and Windows hook
registration, unrelated configuration preservation, and scoped eject
cleanup.
Expand Down
71 changes: 71 additions & 0 deletions cmd/memory/brief.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package memory

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

const defaultBriefExcerptChars = 240

// briefResult is the intentionally small discovery projection shared by
// recall and search. Full content remains available through `mnemon show`.
type briefResult struct {
ID string `json:"id"`
Excerpt string `json:"excerpt"`
Category string `json:"category,omitempty"`
Score *float64 `json:"score,omitempty"`
Confidence string `json:"confidence,omitempty"`
}

type briefResponse struct {
Results []briefResult `json:"results"`
Hint string `json:"hint,omitempty"`
DetailCommand string `json:"detail_command,omitempty"`
}

func newBriefResponse(results []briefResult, hint string) briefResponse {
if results == nil {
results = []briefResult{}
}
response := briefResponse{Results: results, Hint: hint}
if len(results) > 0 {
response.DetailCommand = "mnemon show <id>"
}
return response
}

func encodeBrief(w io.Writer, response briefResponse) error {
// Brief mode deliberately emits compact JSON. Canonical/default and verbose
// output stay pretty-printed for compatibility and human inspection.
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(response)
}

func validateBriefExcerptChars(enabled bool, limit int) error {
if enabled && limit <= 0 {
return fmt.Errorf("--excerpt-chars must be greater than 0")
}
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
}
78 changes: 78 additions & 0 deletions cmd/memory/brief_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package memory

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

func TestMakeBriefExcerptNormalizesAndTruncatesByRune(t *testing.T) {
got := makeBriefExcerpt(" first\n\tsecond 世界再见 ", 15)
if got != "first second 世…" {
t.Fatalf("excerpt = %q", got)
}
if utf8.RuneCountInString(got) > 15 {
t.Fatalf("excerpt has %d runes, want at most 15", utf8.RuneCountInString(got))
}
if strings.ContainsAny(got, "\n\t") {
t.Fatalf("excerpt retained control whitespace: %q", got)
}
}

func TestMakeBriefExcerptLeavesShortContentWhole(t *testing.T) {
if got := makeBriefExcerpt("short memory", 20); got != "short memory" {
t.Fatalf("excerpt = %q", got)
}
if got := makeBriefExcerpt("long", 1); got != "…" {
t.Fatalf("single-character excerpt = %q", got)
}
}

func TestBriefResponseIsCompactAndPointsToFullResult(t *testing.T) {
score := 0.842
response := newBriefResponse([]briefResult{{
ID: "memory-id", Excerpt: "short", Category: "decision", Score: &score,
}}, "")
var out bytes.Buffer
if err := encodeBrief(&out, response); err != nil {
t.Fatalf("encode brief response: %v", err)
}
if strings.Contains(out.String(), "\n ") {
t.Fatalf("brief JSON was indented: %q", out.String())
}
if !strings.Contains(out.String(), "mnemon show <id>") {
t.Fatalf("brief JSON escaped its command hint: %q", out.String())
}
var decoded briefResponse
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil {
t.Fatalf("decode brief response: %v", err)
}
if decoded.DetailCommand != "mnemon show <id>" {
t.Fatalf("detail command = %q", decoded.DetailCommand)
}
}

func TestBriefResponseKeepsEmptyResultsAsArray(t *testing.T) {
response := newBriefResponse(nil, "sparse_results")
data, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(data), `"results":[]`) {
t.Fatalf("empty results are not an array: %s", data)
}
if response.DetailCommand != "" {
t.Fatalf("empty response has detail command %q", response.DetailCommand)
}
}

func TestValidateBriefExcerptChars(t *testing.T) {
if err := validateBriefExcerptChars(false, 0); err != nil {
t.Fatalf("disabled brief mode rejected unused limit: %v", err)
}
if err := validateBriefExcerptChars(true, 0); err == nil {
t.Fatal("brief mode accepted zero excerpt length")
}
}
37 changes: 36 additions & 1 deletion cmd/memory/recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ var (
recSmart bool //nolint:unused // deprecated: smart is now the default; kept for backward compat
recIntent string
recVerbose bool
recBrief bool
recExcerpt int
)

// compactResult is the LLM-friendly projection of a recall result.
Expand Down Expand Up @@ -106,6 +108,12 @@ var recallCmd = &cobra.Command{
if err := requirePositiveLimit("--limit", recLimit); err != nil {
return err
}
if err := validateBriefExcerptChars(recBrief, recExcerpt); err != nil {
return err
}
if recBrief && recVerbose {
return fmt.Errorf("--brief and --verbose cannot be used together")
}

db, err := openDB()
if err != nil {
Expand All @@ -117,7 +125,7 @@ var recallCmd = &cobra.Command{
enc.SetIndent("", " ")

if recBasic {
// Legacy SQL LIKE recall (not affected by format flags)
// Legacy SQL LIKE recall.
results, err := db.QueryInsights(store.QueryFilter{
Keyword: keyword,
Category: recCategory,
Expand All @@ -132,6 +140,17 @@ var recallCmd = &cobra.Command{
_ = db.IncrementAccessCount(r.ID)
}
db.LogOp("recall:basic", "", fmt.Sprintf("q=%s hits=%d", keyword, len(results)))
if recBrief {
brief := make([]briefResult, 0, len(results))
for _, result := range results {
brief = append(brief, briefResult{
ID: result.ID,
Excerpt: makeBriefExcerpt(result.Content, recExcerpt),
Category: string(result.Category),
})
}
return encodeBrief(os.Stdout, newBriefResponse(brief, ""))
}
return enc.Encode(results)
}

Expand Down Expand Up @@ -172,6 +191,20 @@ var recallCmd = &cobra.Command{
if recVerbose {
return enc.Encode(resp)
}
if recBrief {
brief := make([]briefResult, 0, len(resp.Results))
for _, result := range resp.Results {
score := roundScore(result.Score)
brief = append(brief, briefResult{
ID: result.Insight.ID,
Excerpt: makeBriefExcerpt(result.Insight.Content, recExcerpt),
Category: string(result.Insight.Category),
Score: scorePointer(score),
Confidence: confidenceLabel(score),
})
}
return encodeBrief(os.Stdout, newBriefResponse(brief, resp.Meta.Hint))
}
return enc.Encode(toCompact(resp))
},
}
Expand All @@ -185,5 +218,7 @@ func init() {
_ = recallCmd.Flags().MarkHidden("smart")
recallCmd.Flags().StringVar(&recIntent, "intent", "", "override intent (WHY|WHEN|ENTITY|GENERAL)")
recallCmd.Flags().BoolVar(&recVerbose, "verbose", false, "output full recall response (signals, meta, timestamps)")
recallCmd.Flags().BoolVar(&recBrief, "brief", false, "output short excerpts for discovery; use 'mnemon show <id>' for full content")
recallCmd.Flags().IntVar(&recExcerpt, "excerpt-chars", defaultBriefExcerptChars, "maximum characters per --brief excerpt")
rootCmd.AddCommand(recallCmd)
}
2 changes: 1 addition & 1 deletion cmd/memory/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestNewReturnsComposableMemoryRoot(t *testing.T) {
if cmd.Version != "test-version" {
t.Fatalf("root version = %q, want test-version", cmd.Version)
}
for _, name := range []string{"remember", "recall", "setup", "store"} {
for _, name := range []string{"remember", "recall", "show", "setup", "store"} {
if child, _, err := cmd.Find([]string{name}); err != nil || child == cmd {
t.Fatalf("memory command %q is not registered", name)
}
Expand Down
24 changes: 23 additions & 1 deletion cmd/memory/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import (
"github.com/spf13/cobra"
)

var searchLimit int
var (
searchLimit int
searchBrief bool
searchExcerpt int
)

var searchCmd = &cobra.Command{
Use: "search [query]",
Expand All @@ -22,6 +26,9 @@ var searchCmd = &cobra.Command{
if err := requirePositiveLimit("--limit", searchLimit); err != nil {
return err
}
if err := validateBriefExcerptChars(searchBrief, searchExcerpt); err != nil {
return err
}

db, err := openDB()
if err != nil {
Expand All @@ -42,6 +49,19 @@ var searchCmd = &cobra.Command{
}

db.LogOp("search", "", fmt.Sprintf("q=%s hits=%d", query, len(results)))
if searchBrief {
brief := make([]briefResult, 0, len(results))
for _, result := range results {
score := roundScore(result.Score)
brief = append(brief, briefResult{
ID: result.Insight.ID,
Excerpt: makeBriefExcerpt(result.Insight.Content, searchExcerpt),
Category: string(result.Insight.Category),
Score: scorePointer(score),
})
}
return encodeBrief(os.Stdout, newBriefResponse(brief, ""))
}

type outputItem struct {
ID string `json:"id"`
Expand Down Expand Up @@ -71,5 +91,7 @@ var searchCmd = &cobra.Command{

func init() {
searchCmd.Flags().IntVar(&searchLimit, "limit", 10, "max results")
searchCmd.Flags().BoolVar(&searchBrief, "brief", false, "output short excerpts for discovery; use 'mnemon show <id>' for full content")
searchCmd.Flags().IntVar(&searchExcerpt, "excerpt-chars", defaultBriefExcerptChars, "maximum characters per --brief excerpt")
rootCmd.AddCommand(searchCmd)
}
37 changes: 37 additions & 0 deletions cmd/memory/show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package memory

import (
"encoding/json"
"fmt"
"os"

"github.com/spf13/cobra"
)

var showCmd = &cobra.Command{
Use: "show [id]",
Short: "Show one full insight by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
db, err := openDB()
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer db.Close()

insight, err := db.GetInsightByID(args[0])
if err != nil {
return err
}
_ = db.IncrementAccessCount(insight.ID)
db.LogOp("show", insight.ID, "full insight")

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(insight)
},
}

func init() {
rootCmd.AddCommand(showCmd)
}
12 changes: 12 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ mnemon remember "Raw note" --no-diff
# Recall — intent-aware graph-enhanced retrieval (default: compact output)
mnemon recall "vector database" --limit 10

# Discovery-only recall — short excerpts, then fetch one full result by ID
mnemon recall "vector database" --brief --excerpt-chars 160
mnemon show <id>

# Recall with full verbose output (signals, meta, timestamps)
mnemon recall "vector database" --verbose

Expand All @@ -99,6 +103,7 @@ mnemon recall "auth" --basic

# Search — token-scored keyword search
mnemon search "authentication" --limit 10
mnemon search "authentication" --brief --excerpt-chars 160

# Import — bulk-import a memory draft file (see docs/IMPORT.md for schema and LLM prompt)
mnemon import memory_draft.json
Expand Down Expand Up @@ -130,13 +135,20 @@ mnemon forget <id>
| `--cat` | | Filter by category |
| `--source` | | Filter by source |
| `--basic` | `false` | Use simple SQL LIKE matching instead of smart recall |
| `--brief` | `false` | Emit compact JSON with short excerpts for discovery; fetch selected full content with `mnemon show <id>` |
| `--excerpt-chars` | `240` | Maximum Unicode characters per `--brief` excerpt |
| `--verbose` | `false` | Output full recall response (signals, meta, timestamps) |

The default compact output is optimized for LLM/agent consumption. It includes
`id`, `content`, `category`, `importance`, `intent`, `matched_via`, `confidence`,
and `score`. Use `--verbose` to restore the full payload with signals, traversal
metadata, and timestamps. The confidence label is only emitted in compact mode;
verbose payloads return the raw score for callers that prefer their own thresholds.
For large memories, `--brief` is a smaller discovery projection: it flattens
whitespace, caps each excerpt, emits unindented JSON, and includes one
`detail_command` hint. `search` supports the same two flags. JSON remains the
machine-readable interchange format; the opt-in projection avoids changing
existing parsers or adopting a draft serialization format.

### Graph Operations

Expand Down
Loading
Loading