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
10 changes: 9 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,13 @@
"label": "mind-map Server (devcontainer)",
"onAutoForward": "ignore"
}
}
},
"mounts": [
// Bind-mount the workspace's .mind-map/ as the vscode user's home
// .mind-map/ so the committed config.json (with the pull-only sync
// mapping to the GitHub wiki) is what mind-map serve reads. The
// wiki dir itself (.mind-map/wiki/) is gitignored and populated by
// sync on first launch.
"source=${localWorkspaceFolder}/.mind-map,target=/home/vscode/.mind-map,type=bind,consistency=cached"
]
}
6 changes: 6 additions & 0 deletions .mind-map/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Only the sync config is checked in; everything else is generated at
# runtime (wiki content pulled by sync, SQLite index, shadow clones,
# logs) and stays out of the repo.
*
!.gitignore
!config.json
13 changes: 13 additions & 0 deletions .mind-map/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"sync": {
"enabled": true,
"interval": "30s",
"mappings": [
{
"prefix": "",
"remote": "https://github.com/aniongithub/mind-map.wiki.git",
"direction": "pull"
}
]
}
}
6 changes: 3 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/mind-map",
"args": ["serve", "--addr", "0.0.0.0:4242", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"],
"args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"],
"preLaunchTask": "build-webui"
},
{
Expand All @@ -26,15 +26,15 @@
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/mind-map",
"args": ["serve", "--addr", "0.0.0.0:4242", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"],
"args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"],
},
{
"name": "mind-map (stdio)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/mind-map",
"args": ["serve", "--stdio", "--dir", "${workspaceFolder}/testdata"],
"args": ["serve", "--stdio"],
},
{
"name": "WebUI",
Expand Down
43 changes: 42 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ tools:
- create_page
- update_page
- delete_page
- move_page
- list_pages
- get_backlinks
- register_sync
Expand Down Expand Up @@ -94,6 +95,14 @@ Replaces the full page content. Read the page first to preserve existing content
delete_page(path: "drafts/old-idea")
```

## Renaming / Moving Pages

```
move_page(from: "projects/old-name", to: "projects/new-name")
```

`move_page` is **atomic** — it renames the file on disk, updates the index, and rewrites the page's outgoing-link rows in one step. **Always use `move_page` instead of `create_page` + `delete_page`** to avoid leaving duplicate pages behind. Backlinks from other pages (other pages with `[[old-name]]` in their source) are intentionally not rewritten; if you want those updated, search and edit the source pages explicitly.

## Listing Pages

```
Expand All @@ -111,15 +120,47 @@ get_backlinks(path: "api/tokens")

Use backlinks to discover related context and navigate the wiki.

## Wiki Layout (Recommended)

mind-map doesn't enforce a directory scheme, but **agents should follow this convention** so the wiki stays organizable and so [[git sync mappings|sync prefixes]] line up with meaningful boundaries:

```
<project-or-area>/<category>/<page>
```

Concretely:

```
projects/forgewright/architecture/dag.md
projects/mind-map/concepts/wikilinks.md
notes/reading/2026-05-design-patterns.md
journal/2026-05-18.md
decisions/2026-05-18-pick-sqlite-fts5.md
```

Top-level directories should be **projects** (named after the thing you're building) or **areas** (`notes`, `journal`, `decisions`, `people`, etc.). Inside each, group by category. This:

- Keeps related pages close in the sidebar and graph view.
- Makes `prefix:`-based sync mappings useful: a single mapping like `prefix: "projects/forgewright"` can sync that subtree to its own git repo.
- Lets agents and humans navigate by intuition — "where would I put this?" usually has one obvious answer.

When in doubt, prefer **deeper paths over wider ones**. A page at `projects/foo/decisions/auth.md` is almost always better than `foo-auth-decision.md` at the root.

## Best Practices

- ✅ **Search first** — check what exists before creating new pages
- ✅ **Use frontmatter** — add `title`, `type`, and `status` for structure
- ✅ **Use wikilinks** — connect related pages with `[[target]]` syntax
- ✅ **Organize by prefix** — group pages under meaningful directories
- ✅ **Follow `<project>/<category>` layout** — see above
- ✅ **`move_page`, not `create_page` + `delete_page`** — atomic, preserves the link graph
- ✅ **Get context first** — call `get_wiki_context()` to orient yourself
- ❌ **Don't create duplicates** — search before writing
- ❌ **Don't use file extensions** — paths are without `.md`
- ❌ **Don't dump pages at the root** — pick a project or area

## Sync (read-only or read-write)

`register_sync` ties a path prefix to a git remote. Sync is bidirectional by default but supports `direction: "pull"` (read-only) and `direction: "push"` (write-only) — useful when the wiki content is owned upstream (e.g. a project's GitHub wiki) and the local wiki is a working copy that should never push back. Respect existing sync mappings: don't reshape paths under a prefix that's syncing somewhere else without confirming with the user first.

## Page Format Example

Expand Down
29 changes: 27 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,35 @@ import (
"time"
)

// SyncDirection controls which side of the sync flow is active.
// - "" or "bidirectional" — pull from remote and push local changes (default)
// - "pull" — read-only: pull from remote into the wiki, never push
// - "push" — write-only: push wiki changes upstream, ignore remote changes
type SyncDirection string

const (
SyncBidirectional SyncDirection = "bidirectional"
SyncPull SyncDirection = "pull"
SyncPush SyncDirection = "push"
)

// Normalize returns the canonical direction value. Empty string becomes
// SyncBidirectional. Unknown values also become SyncBidirectional (safe
// default — better to over-sync than to silently no-op).
func (d SyncDirection) Normalize() SyncDirection {
switch d {
case SyncPull, SyncPush, SyncBidirectional:
return d
default:
return SyncBidirectional
}
}

// SyncMapping maps a wiki path prefix to a git remote.
type SyncMapping struct {
Prefix string `json:"prefix"`
Remote string `json:"remote"`
Prefix string `json:"prefix"`
Remote string `json:"remote"`
Direction SyncDirection `json:"direction,omitempty"`
}

// SyncConfig holds git sync settings.
Expand Down
109 changes: 83 additions & 26 deletions internal/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ type Manager struct {

// syncTarget manages a single shadow clone for one remote.
type syncTarget struct {
remote string
cloneDir string
prefixes []string // wiki prefixes that map to this remote
remote string
cloneDir string
prefixes []string // wiki prefixes that map to this remote
direction config.SyncDirection

mu sync.Mutex
lastSync time.Time
Expand Down Expand Up @@ -179,34 +180,59 @@ func (m *Manager) rebuildTargets() {

// rebuildTargetsLocked rebuilds targets. Caller must hold m.mu.
func (m *Manager) rebuildTargetsLocked() {
// Build remote -> prefixes map
remotePrefixes := make(map[string][]string)
// Build remote -> (prefixes, direction) map. Default field is treated
// as a bidirectional mapping at the empty prefix.
type remoteInfo struct {
prefixes []string
direction config.SyncDirection
}
remotes := make(map[string]*remoteInfo)
add := func(remote, prefix string, dir config.SyncDirection) {
if remote == "" {
return
}
ri, ok := remotes[remote]
if !ok {
ri = &remoteInfo{direction: dir}
remotes[remote] = ri
}
ri.prefixes = append(ri.prefixes, prefix)
// First mapping wins for direction. If a later mapping for the
// same remote disagrees, log a warning — there's no sane way to
// run a single shadow clone with two opposing directions.
if ri.direction != dir {
slog.Warn("conflicting sync directions for remote, using first",
slog.String("remote", remote),
slog.String("kept", string(ri.direction)),
slog.String("ignored", string(dir)))
}
}
if m.cfg.Sync.Default != "" {
remotePrefixes[m.cfg.Sync.Default] = append(remotePrefixes[m.cfg.Sync.Default], "")
add(m.cfg.Sync.Default, "", config.SyncBidirectional)
}
for _, mapping := range m.cfg.Sync.Mappings {
if mapping.Remote != "" {
remotePrefixes[mapping.Remote] = append(remotePrefixes[mapping.Remote], mapping.Prefix)
}
add(mapping.Remote, mapping.Prefix, mapping.Direction.Normalize())
}

// Create or update targets
for remote, prefixes := range remotePrefixes {
for remote, ri := range remotes {
if t, exists := m.targets[remote]; exists {
t.prefixes = prefixes
t.prefixes = ri.prefixes
t.direction = ri.direction
} else {
dirName := sanitizeDirName(remote)
m.targets[remote] = &syncTarget{
remote: remote,
cloneDir: filepath.Join(m.syncDir, dirName),
prefixes: prefixes,
remote: remote,
cloneDir: filepath.Join(m.syncDir, dirName),
prefixes: ri.prefixes,
direction: ri.direction,
}
}
}

// Remove targets no longer in config
for remote := range m.targets {
if _, exists := remotePrefixes[remote]; !exists {
if _, exists := remotes[remote]; !exists {
delete(m.targets, remote)
}
}
Expand All @@ -229,41 +255,72 @@ func (m *Manager) syncAll(ctx context.Context) {
}
}

// syncTarget syncs a single remote: pull -> copy in -> reindex -> copy out -> commit -> push.
// syncTarget syncs a single remote. Behavior depends on direction:
// - bidirectional (default): pull → copy in → reindex → copy out → commit → push
// - pull: pull → copy in → reindex (never copies wiki → clone, never pushes)
// - push: copy out → commit → push (never copies clone → wiki, never reindexes
// from remote changes; we still fetch so the merge below is meaningful)
func (m *Manager) syncTarget(ctx context.Context, t *syncTarget) {
t.mu.Lock()
t.lastError = ""
t.mu.Unlock()

// Ensure clone exists
direction := t.direction
if direction == "" {
direction = config.SyncBidirectional
}
wantPull := direction == config.SyncBidirectional || direction == config.SyncPull
wantPush := direction == config.SyncBidirectional || direction == config.SyncPush

// Ensure clone exists. We always need a working clone — even push-only
// targets need somewhere to commit before pushing.
if err := m.ensureClone(ctx, t); err != nil {
t.setError(fmt.Sprintf("clone: %v", err))
return
}

// Pull
// Fetch + (optional) merge. Both pull-only and push-only need this:
// pull-only is obvious; push-only needs a base so `git push` isn't
// rejected as a non-fast-forward against a remote that already has
// commits. The difference between modes is only whether we copy the
// merged remote state INTO the wiki dir and reindex.
if err := gitCmd(ctx, t.cloneDir, "fetch", "origin"); err != nil {
t.setError(fmt.Sprintf("fetch: %v", err))
return
}

// Check if remote branch exists
if err := gitCmd(ctx, t.cloneDir, "rev-parse", "--verify", "origin/main"); err == nil {
if err := gitCmd(ctx, t.cloneDir, "merge", "origin/main", "--allow-unrelated-histories", "--no-edit"); err != nil {
slog.Warn("merge conflict", slog.String("remote", t.remote), slog.Any("error", err))
}
} else if err := gitCmd(ctx, t.cloneDir, "rev-parse", "--verify", "origin/master"); err == nil {
// GitHub wikis default to the 'master' branch — try it as a
// fallback when 'main' doesn't exist.
if err := gitCmd(ctx, t.cloneDir, "merge", "origin/master", "--allow-unrelated-histories", "--no-edit"); err != nil {
slog.Warn("merge conflict", slog.String("remote", t.remote), slog.Any("error", err))
}
}

// Copy from clone to wiki (pull direction)
m.copyToWiki(t)
if wantPull {
// Copy from clone to wiki (pull direction)
m.copyToWiki(t)

// Reindex to pick up pulled changes
if m.reindexer != nil {
if err := m.reindexer.Reindex(ctx); err != nil {
slog.Warn("reindex after pull failed", slog.Any("error", err))
// Reindex to pick up pulled changes
if m.reindexer != nil {
if err := m.reindexer.Reindex(ctx); err != nil {
slog.Warn("reindex after pull failed", slog.Any("error", err))
}
}
}

if !wantPush {
t.mu.Lock()
t.lastSync = time.Now()
t.lastError = ""
t.mu.Unlock()
slog.Debug("sync target complete (pull-only)", slog.String("remote", t.remote))
return
}

// Copy from wiki to clone (push direction)
m.copyFromWiki(t)

Expand Down
Loading