diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3907bb..cca69451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 @@ -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. diff --git a/cmd/memory/brief.go b/cmd/memory/brief.go new file mode 100644 index 00000000..ce0b4ed5 --- /dev/null +++ b/cmd/memory/brief.go @@ -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 " + } + 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 +} diff --git a/cmd/memory/brief_test.go b/cmd/memory/brief_test.go new file mode 100644 index 00000000..13f8d81a --- /dev/null +++ b/cmd/memory/brief_test.go @@ -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 ") { + 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 " { + 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") + } +} diff --git a/cmd/memory/recall.go b/cmd/memory/recall.go index 4d1c2bef..e37344f0 100644 --- a/cmd/memory/recall.go +++ b/cmd/memory/recall.go @@ -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. @@ -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 { @@ -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, @@ -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) } @@ -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)) }, } @@ -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 ' for full content") + recallCmd.Flags().IntVar(&recExcerpt, "excerpt-chars", defaultBriefExcerptChars, "maximum characters per --brief excerpt") rootCmd.AddCommand(recallCmd) } diff --git a/cmd/memory/root_test.go b/cmd/memory/root_test.go index bc56f876..15263322 100644 --- a/cmd/memory/root_test.go +++ b/cmd/memory/root_test.go @@ -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) } diff --git a/cmd/memory/search.go b/cmd/memory/search.go index 332c9f4a..ae8e9709 100644 --- a/cmd/memory/search.go +++ b/cmd/memory/search.go @@ -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]", @@ -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 { @@ -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"` @@ -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 ' for full content") + searchCmd.Flags().IntVar(&searchExcerpt, "excerpt-chars", defaultBriefExcerptChars, "maximum characters per --brief excerpt") rootCmd.AddCommand(searchCmd) } diff --git a/cmd/memory/show.go b/cmd/memory/show.go new file mode 100644 index 00000000..f474f2c8 --- /dev/null +++ b/cmd/memory/show.go @@ -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) +} diff --git a/docs/USAGE.md b/docs/USAGE.md index f369eea2..5a172f57 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 + # Recall with full verbose output (signals, meta, timestamps) mnemon recall "vector database" --verbose @@ -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 @@ -130,6 +135,8 @@ mnemon forget | `--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 ` | +| `--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 @@ -137,6 +144,11 @@ The default compact output is optimized for LLM/agent consumption. It includes 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 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 8e08fd41..0653a389 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -83,6 +83,10 @@ mnemon remember "原始笔记" --no-diff # Recall — 意图感知的图增强检索(默认输出为紧凑格式) mnemon recall "vector database" --limit 10 +# 仅发现候选 — 返回短摘要,再按 ID 获取完整内容 +mnemon recall "vector database" --brief --excerpt-chars 160 +mnemon show + # 输出完整召回结果(signals、meta、时间戳) mnemon recall "vector database" --verbose @@ -97,6 +101,7 @@ mnemon recall "auth" --basic # Search — 基于 token 评分的关键词搜索 mnemon search "authentication" --limit 10 +mnemon search "authentication" --brief --excerpt-chars 160 # Import — 批量导入 Memory draft(格式与 LLM prompt 见 docs/IMPORT.md) mnemon import memory_draft.json @@ -128,12 +133,18 @@ mnemon forget | `--cat` | | 按分类过滤 | | `--source` | | 按来源过滤 | | `--basic` | `false` | 使用简单 SQL LIKE 匹配代替智能召回 | +| `--brief` | `false` | 输出仅含短摘要的紧凑 JSON;使用 `mnemon show ` 获取选中项全文 | +| `--excerpt-chars` | `240` | 每条 `--brief` 摘要最多包含的 Unicode 字符数 | | `--verbose` | `false` | 输出完整召回响应(signals、meta、时间戳) | 默认紧凑输出针对 LLM/agent 消费优化,包含 `id`、`content`、`category`、 `importance`、`intent`、`matched_via`、`confidence` 和 `score`。使用 `--verbose` 可恢复包含 signals、遍历元数据和时间戳的完整响应。置信度标签只在 紧凑模式输出;完整响应保留原始分数,供调用方自行设置阈值。 +对于长记忆,`--brief` 提供更小的发现投影:折叠空白、限制每条摘要长度、输出 +无缩进 JSON,并只附带一次 `detail_command` 提示。`search` 同样支持这两个标志。 +JSON 继续作为机器可读交换格式,因此既不破坏现有解析器,也无需绑定尚在演进的 +序列化草案。 **Import 标志:** diff --git a/scripts/e2e_test.sh b/scripts/e2e_test.sh index bd964b10..57080f2c 100755 --- a/scripts/e2e_test.sh +++ b/scripts/e2e_test.sh @@ -236,6 +236,19 @@ assert_contains "compact has confidence" "$OUT" '"confidence"' assert_not_contains "compact omits signals" "$OUT" '"signals"' assert_not_contains "compact omits anchor_count" "$OUT" '"anchor_count"' +step "recall --brief → show — bounded discovery then full content" +OUT=$($M --data-dir "$TESTDIR" recall "Qdrant" --brief --excerpt-chars 20) +assert_jq "brief excerpt is bounded" "$OUT" '.results[0].excerpt | length <= 20' 'true' +assert_not_contains "brief omits full content field" "$OUT" '"content"' +assert_contains "brief points to full lookup" "$OUT" 'mnemon show ' +SHOW_OUT=$($M --data-dir "$TESTDIR" show "$ID1") +assert_contains "show restores full content" "$SHOW_OUT" 'User prefers Qdrant for vector DB' +assert_jq "show returns requested id" "$SHOW_OUT" '.id' "$ID1" +OUT=$($M --data-dir "$TESTDIR" recall "Qdrant" --basic --brief --excerpt-chars 20) +assert_jq "basic brief uses the same bounded projection" "$OUT" '.results[0].excerpt | length <= 20' 'true' +OUT=$($M --data-dir "$TESTDIR" recall "Qdrant" --brief --verbose 2>&1 || true) +assert_contains "brief and verbose are mutually exclusive" "$OUT" 'cannot be used together' + step "recall — no match returns sparse hint (compact)" OUT=$($M --data-dir "$TESTDIR" recall "nonexistent_xyz") assert_contains "sparse hint" "$OUT" "sparse_results" @@ -344,6 +357,11 @@ show_json "$OUT" 15 assert_contains "finds decision insight" "$OUT" "Chose Qdrant" assert_contains "has score field" "$OUT" '"score"' +step "search --brief — bounded discovery projection" +OUT=$($M --data-dir "$TESTDIR2" search "Rust performance" --brief --excerpt-chars 18) +assert_jq "search brief excerpt is bounded" "$OUT" '.results[0].excerpt | length <= 18' 'true' +assert_contains "search brief points to full lookup" "$OUT" 'mnemon show ' + step "search — no match returns []" OUT=$($M --data-dir "$TESTDIR2" search "zzz_no_match_zzz") assert_jq "empty array" "$OUT" 'length' '0' diff --git a/test/mnemond/architecture/release_boundary_test.go b/test/mnemond/architecture/release_boundary_test.go index d61bbc2f..06cc1f60 100644 --- a/test/mnemond/architecture/release_boundary_test.go +++ b/test/mnemond/architecture/release_boundary_test.go @@ -199,7 +199,7 @@ func assertCommandHelpSeparation(t *testing.T, root string) { mnemon := commandHelp(t, root) wantMnemon := []string{ "agency", "completion", "embed", "forget", "gc", "help", "import", "link", "log", - "recall", "receipt", "related", "remember", "search", "setup", "status", + "recall", "receipt", "related", "remember", "search", "setup", "show", "status", "store", "viz", } if got := cobraTopLevelCommands(mnemon); !slices.Equal(got, wantMnemon) {