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: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ The web UI is a static Preact app served by `mind-map serve` over HTTP. It uses

Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wiki` by default). Multiple stdio processes can safely share the same wiki via SQLite page locking.

## MCP Tools (10 total)
## MCP Tools (11 total)

| Tool | Description |
|------|-------------|
| `search_pages` | Full-text search across page titles and content (SQLite FTS5) |
| `get_wiki_context` | Wiki overview: page count, top-level directories, recent pages |
| `get_wiki_digest` | Per-conversation orientation: page count, word/phrase cloud, active-use recents LRU, per-area counts, ~4 KB rendered markdown. Call this at the start of every new conversation. |
| `get_wiki_context` | Wiki overview: page count, top-level directories, recent pages (mtime-sorted). Also returns the digest fields for new clients. |
| `get_page` | Read a page with parsed frontmatter, body, outgoing links, and backlinks |
| `create_page` | Create a new page (markdown with optional YAML frontmatter) |
| `update_page` | Update an existing page's content |
Expand All @@ -102,6 +103,7 @@ Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wi

## Wiki Features

- **Per-conversation digest**: a compact orientation blob (cloud of top terms, recents LRU, area counts, rendered markdown) for LLMs to consume at conversation start. Always-current; background job rebuilds every 5 minutes; persisted to SQLite across restarts.
- **YAML frontmatter**: structured metadata on every page (`title`, `type`, `status`, custom fields)
- **Wikilinks**: `[[target]]` and `[[display|target]]` syntax, resolved to clickable links
- **Backlink index**: every page knows what links to it
Expand Down
22 changes: 20 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ description: A wiki for AI agents and humans -- search, read, and write markdown
tools:
- search_pages
- get_wiki_context
- get_wiki_digest
- get_page
- create_page
- update_page
Expand Down Expand Up @@ -47,12 +48,29 @@ Use mind-map as your **persistent memory**:

## Getting Oriented

**Always start by understanding what's already in the wiki:**
**Always start a new conversation with the digest:**
```
get_wiki_digest()
→ returns a compact markdown blob: page count, top word/phrase cloud
(what this wiki is about), pages you or other agents recently
touched (intent, not file-mtime), and per-area page counts.
~4 KB cap, ~1K tokens — designed to fit any context budget.
```

The digest is always-current: a background job rebuilds the cloud
every few minutes and the recents LRU updates on every page op.
Persisted to SQLite so a fresh server restart already has signal.

If you need the legacy mtime-sorted "recently modified pages" list
or the filesystem-derived top-level directory list, call:
```
get_wiki_context()
returns page count, top-level directories, and 20 most recently modified pages
same shape as before, plus the digest fields layered on for free.
```

New clients should prefer `get_wiki_digest`; `get_wiki_context`
remains for backwards compatibility.

## Searching

```
Expand Down
75 changes: 68 additions & 7 deletions cmd/mind-map/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/aniongithub/mind-map/internal/config"
"github.com/aniongithub/mind-map/internal/digest"
"github.com/aniongithub/mind-map/internal/httpapi"
"github.com/aniongithub/mind-map/internal/logging"
mindmcp "github.com/aniongithub/mind-map/internal/mcp"
Expand Down Expand Up @@ -87,17 +88,57 @@ func init() {
func runStdio(cmd *cobra.Command, args []string) error {
dir, _ := cmd.Flags().GetString("dir")

w, err := wiki.Open(dir)
cfgPath := config.DefaultPath()
cfg, err := config.Load(cfgPath)
if err != nil {
slog.Warn("failed to load config, using defaults", slog.Any("error", err))
cfg = config.DefaultConfig()
}

w, err := wiki.Open(dir, wiki.WithOptions(wikiOptionsFromConfig(cfg)))
if err != nil {
return fmt.Errorf("open wiki: %w", err)
}
defer w.Close()

// Spin up the digest's background maintenance (cloud rebuild +
// recents flush) for the duration of the stdio session. Stop
// before Close so a mid-rebuild ticker doesn't race the DB
// shutdown.
dm := digest.NewManager(w, digestOptionsFromConfig(cfg))
dm.Start(cmd.Context())
defer dm.Stop()

s := mindmcp.NewServer(w, nil, getVersion())
slog.Info("mind-map MCP server starting", slog.String("mode", "stdio"), slog.String("wiki", w.Root()))
return s.MCPServer().Run(cmd.Context(), &mcpsdk.StdioTransport{})
}

// wikiOptionsFromConfig maps the digest section of config.Config to
// the construction-time knobs the Wiki cares about (recents capacity,
// render cap, stopword extras). Zero/missing values keep the Wiki's
// own defaults — DigestConfig is documented as fully optional.
func wikiOptionsFromConfig(cfg *config.Config) wiki.Options {
d := cfg.Digest
return wiki.Options{
RecentsSize: d.RecentsSize,
MaxRenderBytes: d.MaxRenderBytes,
StopwordsExtra: d.StopwordsExtra,
}
}

// digestOptionsFromConfig maps the digest section to the runtime
// (ticker / rebuild) knobs the digest.Manager cares about. Same
// "zero means default" contract.
func digestOptionsFromConfig(cfg *config.Config) digest.Options {
d := cfg.Digest
return digest.Options{
CloudRefresh: d.ParseCloudRefresh(),
CloudSize: d.CloudSize,
StopwordsExtra: d.StopwordsExtra,
}
}

func runServe(cmd *cobra.Command, args []string) error {
dir, _ := cmd.Flags().GetString("dir")
logFile, _ := cmd.Flags().GetString("log-file")
Expand Down Expand Up @@ -145,19 +186,39 @@ func runServe(cmd *cobra.Command, args []string) error {
// runHTTPServer wires the HTTP handler from internal/httpapi and serves it.
// Shared by the interactive `serve` command and the system service.
func runHTTPServer(addr, dir, webuiDir string, idleTimeout time.Duration, stopCh chan struct{}) error {
w, err := wiki.Open(dir)
if err != nil {
return fmt.Errorf("open wiki: %w", err)
}
defer w.Close()

cfgPath := config.DefaultPath()
cfg, err := config.Load(cfgPath)
if err != nil {
slog.Warn("failed to load config, using defaults", slog.Any("error", err))
cfg = config.DefaultConfig()
}

w, err := wiki.Open(dir, wiki.WithOptions(wikiOptionsFromConfig(cfg)))
if err != nil {
return fmt.Errorf("open wiki: %w", err)
}
defer w.Close()

// Background digest maintenance runs for the lifetime of the
// HTTP server. We use a context derived from stopCh so that the
// graceful /api/restart path (which closes stopCh) also stops
// the tickers cleanly. Stopping before Close ensures the LRU
// flush in the manager's final tick doesn't race with db.Close.
dctx, dcancel := context.WithCancel(context.Background())
defer dcancel()
go func() {
select {
case <-stopCh:
dcancel()
case <-dctx.Done():
// Normal function return; the defer above cancelled us.
return
}
}()
dm := digest.NewManager(w, digestOptionsFromConfig(cfg))
dm.Start(dctx)
defer dm.Stop()

handler := httpapi.New(httpapi.Deps{
Wiki: w,
CfgPath: cfgPath,
Expand Down
48 changes: 47 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,55 @@ func (s *SyncConfig) Remotes() []string {
return remotes
}

// DigestConfig holds tunables for the per-conversation orientation
// digest (cloud rebuild, recents LRU, render cap, stopword extras).
// All fields are optional; zero or invalid values fall back to the
// built-in defaults. Documented in detail in mind-map/plans/digest.
type DigestConfig struct {
// CloudSize caps the top-K terms surfaced in the word cloud.
// Default 50. Tunable up if your wiki is large enough that 50
// terms feels too sparse; down if context budget is tight.
CloudSize int `json:"cloud_size,omitempty"`

// RecentsSize caps the active-use LRU ring. Default 20. Applied
// at wiki Open; live changes via /api/settings take effect after
// the next server restart.
RecentsSize int `json:"recents_size,omitempty"`

// CloudRefresh controls how often the cloud rebuilds. Default 5m.
// Accepts any time.ParseDuration value; values below 30 seconds
// are clamped up so a busy wiki doesn't burn CPU.
CloudRefresh string `json:"cloud_refresh,omitempty"`

// StopwordsExtra extends the built-in English stopword list.
// Words are case-folded on load. Useful for domain-specific
// noise like "TODO" or "FIXME".
StopwordsExtra []string `json:"stopwords_extra,omitempty"`

// MaxRenderBytes caps the rendered markdown blob. Default 4096
// (~1K tokens for most LLMs). Trim discipline when over: drop
// recents, then cloud, never areas/header/footer.
MaxRenderBytes int `json:"max_render_bytes,omitempty"`
}

// ParseCloudRefresh returns the cloud rebuild interval. Returns the
// default (5m) if empty or invalid. Floor at 30 seconds — anything
// faster is wasted CPU for a signal nobody reads that often.
func (d *DigestConfig) ParseCloudRefresh() time.Duration {
if d.CloudRefresh == "" {
return 5 * time.Minute
}
v, err := time.ParseDuration(d.CloudRefresh)
if err != nil || v < 30*time.Second {
return 5 * time.Minute
}
return v
}

// Config holds all runtime settings.
type Config struct {
Sync SyncConfig `json:"sync"`
Sync SyncConfig `json:"sync"`
Digest DigestConfig `json:"digest,omitempty"`
}

// DefaultConfig returns a Config with sensible defaults.
Expand Down
78 changes: 78 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,81 @@ func TestSaveAndLoad(t *testing.T) {
t.Errorf("loaded mapping prefix = %q", loaded.Sync.Mappings[0].Prefix)
}
}

func TestParseCloudRefresh(t *testing.T) {
tests := []struct {
input string
want time.Duration
}{
{"5m", 5 * time.Minute},
{"10m", 10 * time.Minute},
{"1h", 1 * time.Hour},
// Floor: anything < 30s clamps to the default to protect a
// busy wiki from CPU churn.
{"1s", 5 * time.Minute},
{"", 5 * time.Minute}, // empty → default
{"junk", 5 * time.Minute}, // invalid → default
}
for _, tc := range tests {
d := DigestConfig{CloudRefresh: tc.input}
if got := d.ParseCloudRefresh(); got != tc.want {
t.Errorf("ParseCloudRefresh(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}

func TestDigestConfig_RoundtripJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")

cfg := DefaultConfig()
cfg.Digest.CloudSize = 75
cfg.Digest.RecentsSize = 30
cfg.Digest.CloudRefresh = "10m"
cfg.Digest.StopwordsExtra = []string{"TODO", "FIXME"}
cfg.Digest.MaxRenderBytes = 8192

if err := Save(path, cfg); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded.Digest.CloudSize != 75 {
t.Errorf("CloudSize = %d, want 75", loaded.Digest.CloudSize)
}
if loaded.Digest.RecentsSize != 30 {
t.Errorf("RecentsSize = %d, want 30", loaded.Digest.RecentsSize)
}
if loaded.Digest.ParseCloudRefresh() != 10*time.Minute {
t.Errorf("CloudRefresh = %v, want 10m", loaded.Digest.ParseCloudRefresh())
}
if len(loaded.Digest.StopwordsExtra) != 2 || loaded.Digest.StopwordsExtra[0] != "TODO" {
t.Errorf("StopwordsExtra = %v", loaded.Digest.StopwordsExtra)
}
if loaded.Digest.MaxRenderBytes != 8192 {
t.Errorf("MaxRenderBytes = %d, want 8192", loaded.Digest.MaxRenderBytes)
}
}

func TestDigestConfig_BackwardsCompatible(t *testing.T) {
// A config file written before the digest section existed must
// still load without errors and yield zero-valued digest fields
// (which the consumers treat as "use defaults").
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"sync":{"enabled":false,"interval":"30s"}}`), 0o600); err != nil {
t.Fatalf("write legacy config: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load legacy config: %v", err)
}
if loaded.Digest.CloudSize != 0 {
t.Errorf("expected zero CloudSize on legacy config, got %d", loaded.Digest.CloudSize)
}
if loaded.Digest.ParseCloudRefresh() != 5*time.Minute {
t.Errorf("expected default 5m on legacy config, got %v", loaded.Digest.ParseCloudRefresh())
}
}
Loading