Skip to content

Commit 2c84d1a

Browse files
committed
feat: add Obsidian vault backend with read-write support
Full Obsidian backend: markdown parsing, block-level CRUD, file watching, inverted search index, HTTP/SSE transport, and decision tracking tools. Security hardened: path traversal protection, full mutex locking, input validation. 195 tests passing, race detector clean.
1 parent 872cee6 commit 2c84d1a

37 files changed

+5959
-188
lines changed

README.md

Lines changed: 151 additions & 87 deletions
Large diffs are not rendered by default.

backend/backend.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package backend
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
7+
"github.com/skridlevsky/graphthulhu/types"
8+
)
9+
10+
// Backend is the interface every knowledge graph backend must implement.
11+
// The Logseq client (client.Client) satisfies this interface.
12+
// Future backends (e.g. Obsidian vault) implement the same contract.
13+
type Backend interface {
14+
// Core read operations
15+
GetAllPages(ctx context.Context) ([]types.PageEntity, error)
16+
GetPage(ctx context.Context, nameOrID any) (*types.PageEntity, error)
17+
GetPageBlocksTree(ctx context.Context, nameOrID any) ([]types.BlockEntity, error)
18+
GetBlock(ctx context.Context, uuid string, opts ...map[string]any) (*types.BlockEntity, error)
19+
GetPageLinkedReferences(ctx context.Context, nameOrID any) (json.RawMessage, error)
20+
21+
// Query operations
22+
DatascriptQuery(ctx context.Context, query string, inputs ...any) (json.RawMessage, error)
23+
24+
// Write operations
25+
CreatePage(ctx context.Context, name string, properties map[string]any, opts map[string]any) (*types.PageEntity, error)
26+
AppendBlockInPage(ctx context.Context, page string, content string) (*types.BlockEntity, error)
27+
PrependBlockInPage(ctx context.Context, page string, content string) (*types.BlockEntity, error)
28+
InsertBlock(ctx context.Context, srcBlock any, content string, opts map[string]any) (*types.BlockEntity, error)
29+
UpdateBlock(ctx context.Context, uuid string, content string, opts ...map[string]any) error
30+
RemoveBlock(ctx context.Context, uuid string) error
31+
MoveBlock(ctx context.Context, uuid string, targetUUID string, opts map[string]any) error
32+
33+
// Page management
34+
DeletePage(ctx context.Context, name string) error
35+
RenamePage(ctx context.Context, oldName, newName string) error
36+
37+
// Connectivity
38+
Ping(ctx context.Context) error
39+
}
40+
41+
// HasDataScript is a marker interface for backends supporting DataScript queries.
42+
// Logseq implements this; Obsidian does not.
43+
type HasDataScript interface {
44+
HasDataScript()
45+
}
46+
47+
// TagSearcher is implemented by backends that support tag search without DataScript.
48+
type TagSearcher interface {
49+
FindBlocksByTag(ctx context.Context, tag string, includeChildren bool) ([]TagResult, error)
50+
}
51+
52+
// PropertySearcher is implemented by backends that support property search without DataScript.
53+
type PropertySearcher interface {
54+
FindByProperty(ctx context.Context, key, value, operator string) ([]PropertyResult, error)
55+
}
56+
57+
// JournalSearcher is implemented by backends that support journal search without DataScript.
58+
type JournalSearcher interface {
59+
SearchJournals(ctx context.Context, query string, from, to string) ([]JournalResult, error)
60+
}
61+
62+
// FullTextSearcher is implemented by backends with an inverted index for efficient full-text search.
63+
// When available, tools/search.go uses this instead of brute-force scanning.
64+
type FullTextSearcher interface {
65+
FullTextSearch(ctx context.Context, query string, limit int) ([]SearchHit, error)
66+
}
67+
68+
// SearchHit is a block found by full-text search.
69+
type SearchHit struct {
70+
PageName string `json:"page"`
71+
UUID string `json:"uuid"`
72+
Content string `json:"content"`
73+
}
74+
75+
// TagResult holds a block found by tag search, grouped by page.
76+
type TagResult struct {
77+
Page string `json:"page"`
78+
Blocks []types.BlockEntity `json:"blocks"`
79+
}
80+
81+
// PropertyResult holds a page or block found by property search.
82+
type PropertyResult struct {
83+
Type string `json:"type"` // "page" or "block"
84+
Name string `json:"name,omitempty"`
85+
UUID string `json:"uuid,omitempty"`
86+
Properties map[string]any `json:"properties"`
87+
}
88+
89+
// JournalResult holds a journal entry found by search.
90+
type JournalResult struct {
91+
Date string `json:"date"`
92+
Page string `json:"page"`
93+
Blocks []types.BlockEntity `json:"blocks"`
94+
}

client/logseq.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ func (c *Client) DeletePage(ctx context.Context, name string) error {
166166
return err
167167
}
168168

169+
// RenamePage renames a page. Logseq handles link updates automatically.
170+
func (c *Client) RenamePage(ctx context.Context, oldName, newName string) error {
171+
_, err := c.call(ctx, "logseq.Editor.renamePage", oldName, newName)
172+
return err
173+
}
174+
169175
// --- Block Operations ---
170176

171177
// GetBlock returns a block by UUID.
@@ -289,3 +295,7 @@ func (c *Client) Ping(ctx context.Context) error {
289295
_, err := c.call(ctx, "logseq.Editor.getCurrentPage")
290296
return err
291297
}
298+
299+
// HasDataScript marks the Logseq client as supporting DataScript queries.
300+
// Implements backend.HasDataScript.
301+
func (c *Client) HasDataScript() {}

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ go 1.24.11
55
require github.com/modelcontextprotocol/go-sdk v1.2.0
66

77
require (
8+
github.com/fsnotify/fsnotify v1.9.0 // indirect
89
github.com/google/jsonschema-go v0.3.0 // indirect
10+
github.com/google/uuid v1.6.0 // indirect
911
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
1012
golang.org/x/oauth2 v0.30.0 // indirect
13+
golang.org/x/sys v0.13.0 // indirect
14+
gopkg.in/yaml.v3 v3.0.1 // indirect
1115
)

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
2+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
13
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
24
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
35
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
46
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
57
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
68
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
9+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
711
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
812
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
913
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
1014
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
1115
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
1216
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
17+
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
18+
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1319
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
1420
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
21+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
22+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
23+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

graph/builder.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,57 @@ package graph
33
import (
44
"context"
55
"strings"
6+
"sync"
7+
"time"
68

7-
"github.com/skridlevsky/graphthulhu/client"
9+
"github.com/skridlevsky/graphthulhu/backend"
810
"github.com/skridlevsky/graphthulhu/parser"
911
"github.com/skridlevsky/graphthulhu/types"
1012
)
1113

14+
// Cache holds a recently built graph to avoid rebuilding on every analyze call.
15+
type Cache struct {
16+
mu sync.Mutex
17+
graph *Graph
18+
built time.Time
19+
ttl time.Duration
20+
backend backend.Backend
21+
}
22+
23+
// NewCache creates a graph cache with the given TTL.
24+
func NewCache(b backend.Backend, ttl time.Duration) *Cache {
25+
return &Cache{
26+
backend: b,
27+
ttl: ttl,
28+
}
29+
}
30+
31+
// Get returns a cached graph or builds a fresh one if expired.
32+
func (c *Cache) Get(ctx context.Context) (*Graph, error) {
33+
c.mu.Lock()
34+
defer c.mu.Unlock()
35+
36+
if c.graph != nil && time.Since(c.built) < c.ttl {
37+
return c.graph, nil
38+
}
39+
40+
g, err := Build(ctx, c.backend)
41+
if err != nil {
42+
return nil, err
43+
}
44+
45+
c.graph = g
46+
c.built = time.Now()
47+
return g, nil
48+
}
49+
50+
// Invalidate forces the next Get to rebuild.
51+
func (c *Cache) Invalidate() {
52+
c.mu.Lock()
53+
defer c.mu.Unlock()
54+
c.graph = nil
55+
}
56+
1257
// Graph is an in-memory representation of the knowledge graph's link structure.
1358
type Graph struct {
1459
// Forward links: page name (lowercase) → set of linked page names (original case)
@@ -22,7 +67,7 @@ type Graph struct {
2267
}
2368

2469
// Build fetches all pages and their block trees, constructing the link graph.
25-
func Build(ctx context.Context, c *client.Client) (*Graph, error) {
70+
func Build(ctx context.Context, c backend.Backend) (*Graph, error) {
2671
pages, err := c.GetAllPages(ctx)
2772
if err != nil {
2873
return nil, err

main.go

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import (
44
"context"
55
"flag"
66
"fmt"
7+
"net/http"
78
"os"
89
"path/filepath"
910
"strings"
1011
"time"
1112

1213
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
"github.com/skridlevsky/graphthulhu/backend"
1315
"github.com/skridlevsky/graphthulhu/client"
16+
"github.com/skridlevsky/graphthulhu/vault"
1417
)
1518

1619
var version = "dev"
@@ -57,30 +60,97 @@ func main() {
5760
func runServe(args []string) {
5861
fs := flag.NewFlagSet("serve", flag.ExitOnError)
5962
readOnly := fs.Bool("read-only", false, "Disable all write operations")
63+
backendType := fs.String("backend", "", "Backend type: logseq (default) or obsidian")
64+
vaultPath := fs.String("vault", "", "Path to Obsidian vault (required for obsidian backend)")
65+
dailyFolder := fs.String("daily-folder", "daily notes", "Daily notes subfolder name (obsidian only)")
66+
httpAddr := fs.String("http", "", "HTTP address to listen on (e.g. :8080). Uses streamable HTTP transport instead of stdio.")
6067
fs.Parse(args)
6168

62-
lsClient := client.New("", "")
63-
checkGraphVersionControl(lsClient)
69+
// Resolve backend from flag or environment.
70+
bt := *backendType
71+
if bt == "" {
72+
bt = os.Getenv("GRAPHTHULHU_BACKEND")
73+
}
74+
if bt == "" {
75+
bt = "logseq"
76+
}
6477

65-
srv := newServer(lsClient, *readOnly)
66-
if err := srv.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
67-
fmt.Fprintf(os.Stderr, "graphthulhu: %v\n", err)
78+
var b backend.Backend
79+
switch bt {
80+
case "obsidian":
81+
vp := *vaultPath
82+
if vp == "" {
83+
vp = os.Getenv("OBSIDIAN_VAULT_PATH")
84+
}
85+
if vp == "" {
86+
fmt.Fprintf(os.Stderr, "graphthulhu: --vault or OBSIDIAN_VAULT_PATH required for obsidian backend\n")
87+
os.Exit(1)
88+
}
89+
vc := vault.New(vp, vault.WithDailyFolder(*dailyFolder))
90+
if err := vc.Load(); err != nil {
91+
fmt.Fprintf(os.Stderr, "graphthulhu: failed to load vault: %v\n", err)
92+
os.Exit(1)
93+
}
94+
vc.BuildBacklinks()
95+
96+
// Start file watcher.
97+
if err := vc.Watch(); err != nil {
98+
fmt.Fprintf(os.Stderr, "graphthulhu: failed to start watcher: %v\n", err)
99+
os.Exit(1)
100+
}
101+
defer vc.Close()
102+
103+
b = vc
104+
case "logseq":
105+
lsClient := client.New("", "")
106+
checkGraphVersionControl(lsClient)
107+
b = lsClient
108+
default:
109+
fmt.Fprintf(os.Stderr, "graphthulhu: unknown backend %q (use logseq or obsidian)\n", bt)
68110
os.Exit(1)
69111
}
112+
113+
srv := newServer(b, *readOnly)
114+
115+
if *httpAddr != "" {
116+
// Streamable HTTP transport — serves multiple clients.
117+
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
118+
return srv
119+
}, nil)
120+
fmt.Fprintf(os.Stderr, "graphthulhu: listening on %s\n", *httpAddr)
121+
if err := http.ListenAndServe(*httpAddr, handler); err != nil {
122+
fmt.Fprintf(os.Stderr, "graphthulhu: %v\n", err)
123+
os.Exit(1)
124+
}
125+
} else {
126+
// Default: stdio transport for MCP client integration.
127+
if err := srv.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
128+
fmt.Fprintf(os.Stderr, "graphthulhu: %v\n", err)
129+
os.Exit(1)
130+
}
131+
}
70132
}
71133

72134
func printUsage() {
73-
fmt.Fprintf(os.Stderr, "graphthulhu %s — Logseq knowledge graph server & CLI\n\n", version)
135+
fmt.Fprintf(os.Stderr, "graphthulhu %s — Knowledge graph MCP server & CLI\n\n", version)
74136
fmt.Fprintf(os.Stderr, "Usage:\n")
75-
fmt.Fprintf(os.Stderr, " graphthulhu Start MCP server (default)\n")
76-
fmt.Fprintf(os.Stderr, " graphthulhu serve [--read-only] Start MCP server (explicit)\n")
137+
fmt.Fprintf(os.Stderr, " graphthulhu Start MCP server (default, Logseq)\n")
138+
fmt.Fprintf(os.Stderr, " graphthulhu serve [flags] Start MCP server\n")
77139
fmt.Fprintf(os.Stderr, " graphthulhu journal [flags] TEXT Append block to today's journal\n")
78140
fmt.Fprintf(os.Stderr, " graphthulhu add -p PAGE TEXT Append block to a page\n")
79141
fmt.Fprintf(os.Stderr, " graphthulhu search QUERY Full-text search across the graph\n")
80142
fmt.Fprintf(os.Stderr, " graphthulhu version Print version\n")
143+
fmt.Fprintf(os.Stderr, "\nServe flags:\n")
144+
fmt.Fprintf(os.Stderr, " --backend logseq|obsidian Backend type (default: logseq)\n")
145+
fmt.Fprintf(os.Stderr, " --vault PATH Obsidian vault path\n")
146+
fmt.Fprintf(os.Stderr, " --daily-folder NAME Daily notes folder (default: daily notes)\n")
147+
fmt.Fprintf(os.Stderr, " --read-only Disable write operations\n")
148+
fmt.Fprintf(os.Stderr, " --http ADDR Listen on HTTP (e.g. :8080) instead of stdio\n")
81149
fmt.Fprintf(os.Stderr, "\nAll CLI commands read from stdin when no TEXT argument is given.\n")
82150
fmt.Fprintf(os.Stderr, "Environment: LOGSEQ_API_URL (default http://127.0.0.1:12315)\n")
83151
fmt.Fprintf(os.Stderr, " LOGSEQ_API_TOKEN\n")
152+
fmt.Fprintf(os.Stderr, " GRAPHTHULHU_BACKEND Backend type\n")
153+
fmt.Fprintf(os.Stderr, " OBSIDIAN_VAULT_PATH Obsidian vault path\n")
84154
}
85155

86156
// checkGraphVersionControl warns on stderr if the Logseq graph is not git-controlled.

parser/content.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ var (
1515
blockRefPattern = regexp.MustCompile(`\(\(([0-9a-f-]{36})\)\)`)
1616

1717
// #tag or #[[multi word tag]] — tags
18-
tagPattern = regexp.MustCompile(`(?:^|\s)#([a-zA-Z0-9_-]+)`)
18+
tagPattern = regexp.MustCompile(`(?:^|\s)#([a-zA-Z0-9_-]+)`)
1919
tagBracketPattern = regexp.MustCompile(`#\[\[([^\]]+)\]\]`)
2020

2121
// key:: value — inline properties

0 commit comments

Comments
 (0)