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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ reported by ID in the triggering command's `auto_pruned_ids` field.
| `MNEMON_EMBED_MODEL` | `nomic-embed-text` | Embedding model name |
| `MNEMON_EMBED_PROTOCOL` | *(auto-detect)* | `ollama` or `openai`; auto-detected from an endpoint ending in `/v1` |
| `MNEMON_EMBED_API_KEY` | *(none)* | Bearer token for OpenAI-compatible servers (oMLX, vLLM, etc.) |
| `MNEMON_EMBED_KEEP_ALIVE` | `30m` | How long Ollama keeps the embedding model loaded after each request (ignored for OpenAI-compatible servers) |
| `MNEMON_EMBED_DIMENSIONS` | *(native)* | Optional Matryoshka dimension truncation |

The embedding client speaks the Ollama API by default and the
Expand Down
28 changes: 27 additions & 1 deletion cmd/memory/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var (
gcThreshold float64
gcLimit int
gcKeepID string
gcCompact bool
)

var gcCmd = &cobra.Command{
Expand All @@ -27,7 +28,13 @@ Suggest mode (default):

Keep mode:
mnemon gc --keep <id>
Boosts an insight's retention (access_count +3, refreshes timestamp).`,
Boosts an insight's retention (access_count +3, refreshes timestamp).

Compact mode:
mnemon gc --compact
Rewrites the database with VACUUM. Recall bumps access counters on every
hit, which scatters table pages across the file and makes cold recalls
slow; compacting restores sequential layout. Run it weekly or so.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := requirePositiveLimit("--limit", gcLimit); err != nil {
return err
Expand All @@ -42,6 +49,24 @@ Keep mode:
}
defer db.Close()

if gcCompact {
if err := requireWritableDB(db, "gc --compact"); err != nil {
return err
}
before, after, err := db.Compact()
if err != nil {
return err
}
db.LogOp("gc_compact", "", fmt.Sprintf("bytes %d -> %d", before, after))
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(map[string]interface{}{
"status": "compacted",
"bytes_before": before,
"bytes_after": after,
})
}

// Keep mode: boost retention for a specific insight
if gcKeepID != "" {
if err := requireWritableDB(db, "gc --keep"); err != nil {
Expand Down Expand Up @@ -106,5 +131,6 @@ func init() {
gcCmd.Flags().Float64Var(&gcThreshold, "threshold", 0.5, "effective_importance threshold (insights below this are candidates)")
gcCmd.Flags().IntVar(&gcLimit, "limit", 20, "max candidates to return")
gcCmd.Flags().StringVar(&gcKeepID, "keep", "", "boost retention for this insight ID")
gcCmd.Flags().BoolVar(&gcCompact, "compact", false, "rewrite the database with VACUUM to speed up cold recalls")
rootCmd.AddCommand(gcCmd)
}
13 changes: 8 additions & 5 deletions cmd/memory/recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,15 @@ meta.intent and meta.intent_source (auto or override). --basic bypasses intent.`
// Extract query entities at cmd layer (avoid graph->search circular dep).
// Load the known-entity set so the indexed extractor's fourth path can
// admit user vocabulary (single-segment CamelCase, lowercase project
// names) that techDictionary does not cover. The lookup is read-only;
// on error we fall through to the default regex+dictionary extractor.
knownEntities, _ := db.LoadKnownEntities()
queryEntities := graph.ExtractEntitiesIndexed(keyword, knownEntities)
// names) that techDictionary does not cover. The set is derived from the
// same insight snapshot recall ranks, so the table is scanned once.
all, err := db.GetAllActiveInsights()
if err != nil {
return fmt.Errorf("recall: %w", err)
}
queryEntities := graph.ExtractEntitiesIndexed(keyword, store.KnownEntities(all))

resp, err := search.IntentAwareRecall(db, keyword, queryVec, queryEntities, recLimit, intentOverride)
resp, err := search.IntentAwareRecallFrom(db, all, keyword, queryVec, queryEntities, recLimit, intentOverride)
if err != nil {
return fmt.Errorf("recall: %w", err)
}
Expand Down
4 changes: 4 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ mnemon gc --threshold 0.5 --limit 20

# GC keep — boost an insight's retention
mnemon gc --keep <id>

# GC compact — rewrite the store with VACUUM to keep cold recalls fast
mnemon gc --compact
```

### Store Management
Expand Down Expand Up @@ -360,6 +363,7 @@ Nodes are colored by category (decision, fact, insight, preference, context); ed
| `MNEMON_EMBED_MODEL` | `nomic-embed-text` | Embedding model |
| `MNEMON_EMBED_PROTOCOL` | (auto-detect) | `ollama` or `openai`; endpoints ending in `/v1` select `openai` |
| `MNEMON_EMBED_API_KEY` | (none) | Bearer token for OpenAI-compatible servers |
| `MNEMON_EMBED_KEEP_ALIVE` | 30m | How long Ollama keeps the embedding model loaded after each request |
| `MNEMON_EMBED_DIMENSIONS` | (native) | Embedding dimensions; set to truncate (e.g., `256` for Matryoshka models) |
| `MNEMON_MAX_INSIGHTS` | `1000` | Active-insight ceiling before auto-pruning starts; `0` disables auto-pruning |
| `MNEMON_AUTO_PRUNE_MIN_AGE` | `24h` | Minimum age before automatic pruning; accepts durations such as `24h`, integer days such as `7d`, or `0` to disable the grace period |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ Sub-agent 委派是可选执行策略。当 runtime 支持时,主 agent 可以
| `MNEMON_EMBED_MODEL` | `nomic-embed-text` | 嵌入模型名称 |
| `MNEMON_EMBED_PROTOCOL` | *(自动探测)* | `ollama` 或 `openai`;端点以 `/v1` 结尾时自动切换 |
| `MNEMON_EMBED_API_KEY` | *(无)* | OpenAI 兼容服务器(oMLX、vLLM 等)的 Bearer 令牌 |
| `MNEMON_EMBED_KEEP_ALIVE` | `30m` | 每次请求后 Ollama 保持嵌入模型加载的时长(OpenAI 兼容服务器忽略此项) |
| `MNEMON_EMBED_DIMENSIONS` | *(原生维度)* | 可选的 Matryoshka 维度截断 |

每次自动删除均为软删除,以 `prune` 操作记录到 oplog,并通过触发命令的
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ mnemon gc --threshold 0.5 --limit 20

# GC keep — 提升某个洞察的保留度
mnemon gc --keep <id>

# GC compact — 以 VACUUM 重写存储,保持冷启动召回速度
mnemon gc --compact
```

### 记忆体管理
Expand Down Expand Up @@ -330,6 +333,7 @@ open graph.html
| `MNEMON_EMBED_MODEL` | `nomic-embed-text` | 嵌入模型 |
| `MNEMON_EMBED_PROTOCOL` | (自动探测) | `ollama` 或 `openai`;以 `/v1` 结尾的端点自动选择 `openai` |
| `MNEMON_EMBED_API_KEY` | (无) | OpenAI 兼容服务器的 Bearer 令牌 |
| `MNEMON_EMBED_KEEP_ALIVE` | 30m | 每次请求后 Ollama 保持嵌入模型加载的时长 |
| `MNEMON_EMBED_DIMENSIONS` | (原生维度) | 嵌入向量维度;可设置截断值(例如 Matryoshka 模型使用 `256`) |
| `MNEMON_MAX_INSIGHTS` | `1000` | 触发自动清理的活跃洞察数量上限;设为 `0` 可关闭自动清理 |
| `MNEMON_AUTO_PRUNE_MIN_AGE` | `24h` | 可被自动清理前的最短存活时间;支持 `24h`、整数天 `7d`,设为 `0` 可关闭保护期 |
Expand Down
45 changes: 30 additions & 15 deletions internal/memory/embed/ollama.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,27 @@ const DefaultModel = "nomic-embed-text"
// DefaultEndpoint is the default Ollama API endpoint.
const DefaultEndpoint = "http://localhost:11434"

// DefaultKeepAlive is how long Ollama keeps the embedding model loaded after
// a request. Ollama's own five-minute default lets a large embedding model
// unload between agent turns, and reloading it costs seconds on a cold recall.
const DefaultKeepAlive = "30m"

// Client communicates with an embedding server (an Ollama instance or an
// OpenAI-compatible server) for embedding generation.
type Client struct {
endpoint string
model string
dims int // 0 means use native dimensions
apiKey string
protocol Protocol
http *http.Client
endpoint string
model string
dims int // 0 means use native dimensions
apiKey string
keepAlive string
protocol Protocol
http *http.Client
}

// NewClient creates an embedding client.
// It checks MNEMON_EMBED_ENDPOINT, MNEMON_EMBED_MODEL,
// MNEMON_EMBED_DIMENSIONS, MNEMON_EMBED_API_KEY, and
// MNEMON_EMBED_PROTOCOL env vars.
// MNEMON_EMBED_DIMENSIONS, MNEMON_EMBED_API_KEY, MNEMON_EMBED_KEEP_ALIVE,
// and MNEMON_EMBED_PROTOCOL env vars.
func NewClient() *Client {
return NewClientWithModel("")
}
Expand Down Expand Up @@ -75,6 +81,10 @@ func NewClientWithModel(model string) *Client {
dims = v
}
}
keepAlive := os.Getenv("MNEMON_EMBED_KEEP_ALIVE")
if keepAlive == "" {
keepAlive = DefaultKeepAlive
}
protocol := ProtocolOllama
explicit := false
if p := os.Getenv("MNEMON_EMBED_PROTOCOL"); p != "" {
Expand All @@ -97,11 +107,12 @@ func NewClientWithModel(model string) *Client {
}
}
return &Client{
endpoint: endpoint,
model: model,
dims: dims,
apiKey: os.Getenv("MNEMON_EMBED_API_KEY"),
protocol: protocol,
endpoint: endpoint,
model: model,
dims: dims,
apiKey: os.Getenv("MNEMON_EMBED_API_KEY"),
keepAlive: keepAlive,
protocol: protocol,
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Expand Down Expand Up @@ -216,6 +227,9 @@ func (c *Client) embedWithContext(ctx context.Context, text string) ([]float64,
if c.dims > 0 {
req.Dimensions = c.dims
}
if c.protocol == ProtocolOllama {
req.KeepAlive = c.keepAlive
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
Expand Down Expand Up @@ -267,6 +281,7 @@ type embedRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Dimensions int `json:"dimensions,omitempty"`
KeepAlive string `json:"keep_alive,omitempty"`
}

type ollamaEmbedResponse struct {
Expand All @@ -288,8 +303,8 @@ func (c *Client) embedRequestRoute() string {
}

// Embed generates an embedding vector for the given text.
// The request body is identical for both protocols; only the endpoint
// path and the response shape differ.
// The request body is identical for both protocols apart from Ollama's
// keep_alive; the endpoint path and the response shape differ.
func (c *Client) Embed(text string) ([]float64, error) {
return c.embedWithContext(context.Background(), text)
}
Expand Down
48 changes: 48 additions & 0 deletions internal/memory/embed/ollama_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package embed

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -119,3 +120,50 @@ func TestOllamaEndpointWithTrailingSlash(t *testing.T) {
t.Fatalf("expected 3 dims, got %d", len(vec))
}
}

// keepAliveSent embeds once against a stub server under the given protocol
// and returns the keep_alive field of the request body ("" when absent).
func keepAliveSent(t *testing.T, protocol, route string) string {
t.Helper()
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
if protocol == "openai" {
_, _ = w.Write([]byte(`{"data":[{"embedding":[0.1]}]}`))
return
}
_, _ = w.Write([]byte(`{"embeddings":[[0.1]]}`))
}))
defer srv.Close()
t.Setenv("MNEMON_EMBED_PROTOCOL", protocol)
t.Setenv("MNEMON_EMBED_ENDPOINT", srv.URL+route)
if _, err := NewClient().Embed("hello"); err != nil {
t.Fatalf("Embed: %v", err)
}
s, _ := got["keep_alive"].(string)
return s
}

func TestOllamaEmbed_SendsDefaultKeepAlive(t *testing.T) {
t.Setenv("MNEMON_EMBED_KEEP_ALIVE", "")
if got := keepAliveSent(t, "ollama", ""); got != DefaultKeepAlive {
t.Fatalf("keep_alive = %q, want %q", got, DefaultKeepAlive)
}
}

func TestOllamaEmbed_KeepAliveEnvOverride(t *testing.T) {
t.Setenv("MNEMON_EMBED_KEEP_ALIVE", "2h")
if got := keepAliveSent(t, "ollama", ""); got != "2h" {
t.Fatalf("keep_alive = %q, want %q", got, "2h")
}
}

func TestOpenAIEmbed_OmitsKeepAlive(t *testing.T) {
t.Setenv("MNEMON_EMBED_KEEP_ALIVE", "")
if got := keepAliveSent(t, "openai", "/v1"); got != "" {
t.Fatalf("keep_alive = %q, want it omitted", got)
}
}
21 changes: 18 additions & 3 deletions internal/memory/search/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ func TestBeamSearchFromAnchor_ScorePropagation(t *testing.T) {
params := TraversalParams{BeamWidth: 10, MaxDepth: 3, MaxVisited: 100}

scoreMap["bs-1"] = 1.0
beamSearchFromAnchor(db, "bs-1", 1.0, nil, weights, params, scoreMap, viaMap, insightMap, nil)
beamSearchFromAnchor(db, "bs-1", 1.0, nil, weights, params, scoreMap, viaMap, insightMap, nil, activeMap(t, db), map[string][]recallTransition{})

// Neighbor should be discovered with score > 0
if _, ok := scoreMap["bs-2"]; !ok {
Expand Down Expand Up @@ -484,7 +484,7 @@ func TestBeamSearchFromAnchor_BeamWidthPruning(t *testing.T) {
params := TraversalParams{BeamWidth: 3, MaxDepth: 3, MaxVisited: 500}

scoreMap["bw-center"] = 1.0
beamSearchFromAnchor(db, "bw-center", 1.0, nil, weights, params, scoreMap, viaMap, insightMap, nil)
beamSearchFromAnchor(db, "bw-center", 1.0, nil, weights, params, scoreMap, viaMap, insightMap, nil, activeMap(t, db), map[string][]recallTransition{})

// Count deep nodes discovered — should be limited by beam width
deepCount := 0
Expand Down Expand Up @@ -522,7 +522,7 @@ func TestBeamSearchFromAnchor_MaxVisitedBudget(t *testing.T) {
params := TraversalParams{BeamWidth: 10, MaxDepth: 20, MaxVisited: 5}

scoreMap["mv-0"] = 1.0
beamSearchFromAnchor(db, "mv-0", 1.0, nil, weights, params, scoreMap, viaMap, insightMap, nil)
beamSearchFromAnchor(db, "mv-0", 1.0, nil, weights, params, scoreMap, viaMap, insightMap, nil, activeMap(t, db), map[string][]recallTransition{})

// scoreMap includes the anchor itself, so discovered nodes (excluding anchor) should be <= 4
discovered := len(scoreMap) - 1 // subtract anchor
Expand Down Expand Up @@ -612,3 +612,18 @@ func scoreOf(results []RecallResult, id string) float64 {
}
return 0
}

// activeMap indexes the store's active insights the way IntentAwareRecallFrom
// hands them to beam search.
func activeMap(t *testing.T, db *store.DB) map[string]*model.Insight {
t.Helper()
all, err := db.GetAllActiveInsights()
if err != nil {
t.Fatal(err)
}
m := make(map[string]*model.Insight, len(all))
for _, ins := range all {
m[ins.ID] = ins
}
return m
}
Loading
Loading