Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
directory and the legacy `~/.mavis/` directory migrated by current releases.
- `mnemon setup --eject --target minimax-code` removes only the Mnemon skill
while preserving other MiniMax Code skills.
- Optional entity extraction model. With `MNEMON_EXTRACT_MODEL` and
`MNEMON_EXTRACT_ENDPOINT` set (for example Cloudflare Clairic on Workers AI),
`remember` and `import` ask the model for the entities of memories stored
without any, keep only names that appear in the memory as whole tokens, and
use them in place of the all-caps acronym guess. A missing or failing model
falls back to heuristic extraction with a one-line warning.

### Tests

Expand All @@ -46,6 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
cleanup.
- Added MiniMax Code coverage for skill installation, current and legacy data
directory detection, and scoped eject cleanup.
- Added extraction client coverage for the Workers AI and bare response
shapes, whole-token grounding, limits, and error redaction, plus CLI coverage
for `remember` and `import` with a working, skipped, and failing model.

### Note

Expand All @@ -59,6 +68,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
stanza. `brew` commands that load the cask printed "Calling `postflight` is
deprecated! Use `postflight_steps` instead." on every invocation. The
quarantine strip now runs through the declarative `postflight_steps` DSL.
- Entity extraction no longer treats numbers, versions, and addresses with no
letter (`0.52`, `127.0.0.1`) or dotted abbreviations (`e.g`, `i.e`) as file
paths, and no longer admits ordinary words written in capitals (`NEVER`,
`ONLY`, `NOTE`, `FIXED`) as acronyms. Such tokens linked unrelated memories
through entity edges.

## [0.2.7] - 2026-09-01

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ DETERMINISTIC_PKGS := \
./internal/agency/authority \
./internal/agency/artifact \
./internal/memory/embed \
./internal/memory/extract \
./internal/memory/graph \
./internal/memory/importdraft \
./internal/memory/internal/topk \
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,29 @@ export MNEMON_EMBED_API_KEY=pa-...
mnemon embed --status
```

**Entity extraction model** (optional, opt-in by environment):

| Environment Variable | Default | Description |
|---|---|---|
| `MNEMON_EXTRACT_MODEL` | *(empty = disabled)* | System One extraction model name, e.g. `clairic` |
| `MNEMON_EXTRACT_ENDPOINT` | *(none, required)* | Full URL that requests are POSTed to, e.g. `https://api.cloudflare.com/client/v4/accounts/<account-id>/ai/run/@cf/cloudflare/clairic` |
| `MNEMON_EXTRACT_API_KEY` | *(none)* | Bearer token, e.g. a Cloudflare API token with Workers AI access |
| `MNEMON_EXTRACT_TIMEOUT` | `15s` | Time budget for one extraction (Go duration) |

When `MNEMON_EXTRACT_MODEL` is set, `mnemon remember` and `mnemon import` ask
the model for the entities of every memory that arrives without any (no
`--entities` in `merge` mode, or `--entity-mode auto`). Mnemon keeps only names
that appear in the memory verbatim as whole tokens. They replace the all-caps
acronym guess; CamelCase, file path, URL, @mention, title-mark, dictionary, and
known-entity matches still apply. Memories that arrive with entities, and
`recall` queries, are unchanged. If the model is missing or fails, mnemon
prints a one-line warning to stderr and falls back to heuristic extraction;
`import` stops asking after the first failure.

> **Privacy:** extraction sends the full text of each such memory to
> `MNEMON_EXTRACT_ENDPOINT`. Leave `MNEMON_EXTRACT_MODEL` unset if memories must
> not leave the machine.

## Development

```bash
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Mnemon runs locally and stores data in `~/.mnemon/`. Key security considerations
- **SQLite database** — contains all stored insights; protected by filesystem permissions (`0644`).
- **Hook scripts** — shell scripts executed by the LLM CLI at lifecycle events; written with `0755` permissions.
- **Embedding provider connection** — optional requests send insight or query text to the configured Ollama or OpenAI-compatible server. The default local Ollama endpoint does not use TLS. If `MNEMON_EMBED_ENDPOINT` points outside a trusted local network, use HTTPS to protect content and any `MNEMON_EMBED_API_KEY` bearer token in transit.
- **Entity extraction model connection** — only when `MNEMON_EXTRACT_MODEL` is set, `remember` and `import` send the full text of each memory stored without entities to `MNEMON_EXTRACT_ENDPOINT`. A cloud endpoint therefore receives that text. Use HTTPS to protect content and the `MNEMON_EXTRACT_API_KEY` bearer token.

## Supported Versions

Expand Down
42 changes: 42 additions & 0 deletions cmd/memory/extract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package memory

import (
"context"
"fmt"
"os"

"github.com/mnemon-dev/mnemon/internal/memory/extract"
"github.com/mnemon-dev/mnemon/internal/memory/graph"
)

// entityExtractor asks the opt-in extraction model (MNEMON_EXTRACT_MODEL) for
// a memory's entities before the write transaction. It fails open: after the
// first failure it warns once and stops asking for the rest of the command, so
// a batch import does not wait out the timeout on every insight.
type entityExtractor struct {
client *extract.Client
}

func newEntityExtractor() *entityExtractor {
client, err := extract.NewClient()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: entity extraction disabled: %v\n", err)
}
return &entityExtractor{client: client}
}

// entities returns the model's entities for content, or nil when no model ran
// because extraction is not configured, the provided entities make it
// unnecessary, or it failed.
func (x *entityExtractor) entities(content string, provided []string, mode graph.EntityMode) []string {
if x.client == nil || !graph.ReliesOnExtraction(mode, provided) {
return nil
}
names, err := x.client.Entities(context.Background(), content)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: entity extraction unavailable, using heuristic extraction: %v\n", err)
x.client = nil
return nil
}
return names
}
190 changes: 190 additions & 0 deletions cmd/memory/extract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package memory

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"

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

const extractContent = "Jane Rivera briefed the CEO of Acme Foundation; notes in docs/plan.md"

// startExtractServer fakes a Workers AI extraction endpoint that answers every
// request with items, or fails with status. It returns the request counter.
func startExtractServer(t *testing.T, status int, items []string) *atomic.Int32 {
t.Helper()
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if status != http.StatusOK {
http.Error(w, `{"success":false,"errors":[{"message":"model unavailable"}]}`, status)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{
"answers": map[string]any{"entities": map[string]any{"type": "list", "value": items}},
}})
}))
t.Cleanup(srv.Close)
t.Setenv("MNEMON_EXTRACT_MODEL", "test-model")
t.Setenv("MNEMON_EXTRACT_ENDPOINT", srv.URL)
t.Setenv("MNEMON_EXTRACT_API_KEY", "")
t.Setenv("MNEMON_EXTRACT_TIMEOUT", "")
return &hits
}

// stderrOf runs fn and returns what it wrote to stderr.
func stderrOf(t *testing.T, fn func()) string {
t.Helper()
old := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stderr: %v", err)
}
os.Stderr = w
defer func() { os.Stderr = old }()
fn()
if err := w.Close(); err != nil {
t.Fatalf("close stderr writer: %v", err)
}
data, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read stderr: %v", err)
}
return string(data)
}

func entitySet(entities []string) map[string]bool {
set := make(map[string]bool, len(entities))
for _, e := range entities {
set[e] = true
}
return set
}

func rememberEntities(t *testing.T) (map[string]bool, string) {
t.Helper()
var out string
var runErr error
stderr := stderrOf(t, func() {
out = captureStdout(t, func() { runErr = rememberCmd.RunE(rememberCmd, []string{extractContent}) })
})
if runErr != nil {
t.Fatalf("remember: %v", runErr)
}
var result struct {
Entities []string `json:"entities"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("decode remember output: %v\n%s", err, out)
}
return entitySet(result.Entities), stderr
}

func TestRememberUsesModelEntitiesWhenNoneProvided(t *testing.T) {
// Given an extraction model that finds two real names and invents one
configureRememberTest(t)
hits := startExtractServer(t, http.StatusOK, []string{"Jane Rivera", "Acme Foundation", "Invented Inc"})

// When a memory is remembered without entities
entities, stderr := rememberEntities(t)

// Then the real names replace the acronym guess, exact paths stay, and the invented name is dropped
for _, want := range []string{"Jane Rivera", "Acme Foundation", "docs/plan.md"} {
if !entities[want] {
t.Errorf("want entity %q, got %v", want, entities)
}
}
if entities["CEO"] || entities["Invented Inc"] {
t.Errorf("entities = %v; want no acronym guess and no invented name", entities)
}
if hits.Load() != 1 || stderr != "" {
t.Fatalf("requests = %d, stderr = %q", hits.Load(), stderr)
}
}

func TestRememberSkipsModelWhenEntitiesProvided(t *testing.T) {
// Given a configured model and caller-provided entities
configureRememberTest(t)
hits := startExtractServer(t, http.StatusOK, []string{"Jane Rivera"})
remEntities = "zebra"

// When the memory is remembered
entities, _ := rememberEntities(t)

// Then the model is never asked and heuristic extraction is unchanged
if hits.Load() != 0 || !entities["zebra"] || !entities["CEO"] || entities["Jane Rivera"] {
t.Fatalf("requests = %d, entities = %v", hits.Load(), entities)
}
}

func TestRememberFallsBackToHeuristicsWhenModelFails(t *testing.T) {
// Given an extraction model that errors
configureRememberTest(t)
startExtractServer(t, http.StatusInternalServerError, nil)

// When a memory is remembered without entities
entities, stderr := rememberEntities(t)

// Then heuristic extraction applies and one warning is printed
if !entities["CEO"] || entities["Jane Rivera"] {
t.Fatalf("entities = %v; want heuristic extraction", entities)
}
if !strings.HasPrefix(stderr, "warning: entity extraction unavailable") || strings.Count(stderr, "\n") != 1 {
t.Fatalf("stderr = %q, want a single warning line", stderr)
}
}

func TestImportUsesModelOnlyForDraftsWithoutEntities(t *testing.T) {
// Given a model and two drafts, one already carrying entities
db := setupImportDiffTest(t, true)
hits := startExtractServer(t, http.StatusOK, []string{"Acme Foundation"})
draft := importdraft.MemoryDraft{SchemaVersion: "1", Insights: []importdraft.DraftInsight{
{Content: "Jane Rivera joined the Acme Foundation"},
{Content: "the Acme Foundation funds Project Lark", Entities: []string{"Project Lark"}},
}}

// When the draft is imported
runImportDiffDraft(t, draft)

// Then only the first draft asked the model, and its entities came from it
if hits.Load() != 1 {
t.Fatalf("requests = %d, want 1", hits.Load())
}
insights, err := db.GetAllActiveInsights()
if err != nil {
t.Fatal(err)
}
for _, ins := range insights {
got := entitySet(ins.Entities)
if strings.HasPrefix(ins.Content, "Jane") && !got["Acme Foundation"] {
t.Errorf("model entities missing: %v", ins.Entities)
}
if strings.HasPrefix(ins.Content, "the Acme") && !got["Project Lark"] {
t.Errorf("provided entities missing: %v", ins.Entities)
}
}
}

func TestImportStopsAskingAfterModelFailure(t *testing.T) {
// Given a failing model and two drafts without entities
setupImportDiffTest(t, true)
hits := startExtractServer(t, http.StatusInternalServerError, nil)
draft := importdraft.MemoryDraft{SchemaVersion: "1", Insights: []importdraft.DraftInsight{
{Content: "Jane Rivera joined the Acme Foundation"},
{Content: "Zebra deploys to staging-east"},
}}

// When the draft is imported
stderr := stderrOf(t, func() { runImportDiffDraft(t, draft) })

// Then the model was asked once and one warning was printed
if hits.Load() != 1 || strings.Count(stderr, "warning: entity extraction unavailable") != 1 {
t.Fatalf("requests = %d, stderr = %q", hits.Load(), stderr)
}
}
8 changes: 6 additions & 2 deletions cmd/memory/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ exports are documented in docs/IMPORT.md.`,
importedSources := make(map[string]bool)
refreshIDs := make(map[string]bool)
results := make([]importResult, 0, len(draft.Insights))
extractor := newEntityExtractor()

for idx, di := range draft.Insights {
cat := model.Category(di.Category)
Expand Down Expand Up @@ -149,6 +150,8 @@ exports are documented in docs/IMPORT.md.`,
continue
}

modelEntities := extractor.entities(insight.Content, insight.Entities, graph.EntityModeMerge)

var writeErr error
err = db.InTransaction(func() error {
if err := db.InsertInsight(insight); err != nil {
Expand All @@ -163,8 +166,9 @@ exports are documented in docs/IMPORT.md.`,
}
}
engine := graph.NewEngineWithOptions(db, embedCache, graph.EngineOptions{
EntityMode: graph.EntityModeMerge,
TemporalMode: graph.TemporalDisabled,
EntityMode: graph.EntityModeMerge,
TemporalMode: graph.TemporalDisabled,
ModelEntities: modelEntities,
})
engine.OnInsightCreated(insight)
if len(insight.Entities) > 0 {
Expand Down
8 changes: 7 additions & 1 deletion cmd/memory/remember.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ var rememberCmd = &cobra.Command{
return enc.Encode(output)
}

// Like the embedding, ask the extraction model before the transaction.
modelEntities := newEntityExtractor().entities(content, entities, entityMode)

// 3. All DB writes in a single atomic transaction
var (
edgeStats graph.EdgeStats
Expand All @@ -205,7 +208,10 @@ var rememberCmd = &cobra.Command{
}

// Run graph edge engine (includes auto semantic edges when embedded)
engine := graph.NewEngineWithEntityMode(db, embedCache, entityMode)
engine := graph.NewEngineWithOptions(db, embedCache, graph.EngineOptions{
EntityMode: entityMode,
ModelEntities: modelEntities,
})
edgeStats = engine.OnInsightCreated(insight)

// Update entities extracted by the engine
Expand Down
12 changes: 12 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,21 @@ Nodes are colored by category (decision, fact, insight, preference, context); ed
| `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_EXTRACT_MODEL` | (empty = disabled) | System One extraction model (`clairic`) that `remember` and `import` ask for the entities of memories stored without any |
| `MNEMON_EXTRACT_ENDPOINT` | (none, required) | Full URL that extraction requests are POSTed to |
| `MNEMON_EXTRACT_API_KEY` | (none) | Bearer token for the extraction endpoint |
| `MNEMON_EXTRACT_TIMEOUT` | `15s` | Time budget for one extraction (Go duration) |
| `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 |

`MNEMON_EXTRACT_MODEL` sends one request per memory stored without entities,
with the full memory text as `state` and one `list` field named `entities`. For
Clairic on Cloudflare Workers AI the endpoint is
`https://api.cloudflare.com/client/v4/accounts/<account-id>/ai/run/@cf/cloudflare/clairic`.
Only names found in the memory as whole tokens are kept, and they replace the
all-caps acronym guess. On failure mnemon warns once on stderr and falls back to
heuristic extraction.

Auto-prune may temporarily leave the active count above the ceiling when every
eligible insight is still inside the grace period. Age is measured from local
store insertion, so newly imported historical memories are protected too. Each deletion is soft,
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ Sub-agent 委派是可选执行策略。当 runtime 支持时,主 agent 可以
| `MNEMON_EMBED_API_KEY` | *(无)* | OpenAI 兼容服务器(oMLX、vLLM 等)的 Bearer 令牌 |
| `MNEMON_EMBED_KEEP_ALIVE` | `30m` | 每次请求后 Ollama 保持嵌入模型加载的时长(OpenAI 兼容服务器忽略此项) |
| `MNEMON_EMBED_DIMENSIONS` | *(原生维度)* | 可选的 Matryoshka 维度截断 |
| `MNEMON_EXTRACT_MODEL` | *(空 = 禁用)* | 抽取模型(如 `clairic`);`remember` 与 `import` 对未带实体的记忆调用它抽取实体 |
| `MNEMON_EXTRACT_ENDPOINT` | *(无,必填)* | 抽取请求 POST 的完整 URL;云端端点会收到这些记忆的全文 |
| `MNEMON_EXTRACT_API_KEY` | *(无)* | 抽取端点的 Bearer 令牌 |
| `MNEMON_EXTRACT_TIMEOUT` | `15s` | 单次抽取的时间预算(Go duration) |

每次自动删除均为软删除,以 `prune` 操作记录到 oplog,并通过触发命令的
`auto_pruned_ids` 字段返回具体 ID。
Expand Down
Loading
Loading