@m16khb/llm-wiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ package daemon
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "log"
9
+ "net/http"
10
+ "time"
11
+
12
+ "github.com/modelcontextprotocol/go-sdk/mcp"
13
+
14
+ "github.com/m16khb/llm-wiki/internal/mcpserver"
15
+ "github.com/m16khb/llm-wiki/internal/service"
16
+ "github.com/m16khb/llm-wiki/internal/store"
17
+ )
18
+
19
+ // Config configures the long-running daemon process.
20
+ type Config struct {
21
+ VaultRoot string
22
+ DBPath string
23
+ Addr string
24
+ }
25
+
26
+ // Server owns the singleton daemon service and HTTP MCP endpoint.
27
+ type Server struct {
28
+ Service *service.Service
29
+ Addr string
30
+ DBPath string
31
+ Lock *Lock
32
+ HTTP *http.Server
33
+ }
34
+
35
+ func New(cfg Config) (*Server, error) {
36
+ if cfg.Addr == "" {
37
+ cfg.Addr = "127.0.0.1:39233"
38
+ }
39
+ dbPath := cfg.DBPath
40
+ if dbPath == "" {
41
+ dbPath = store.DefaultPath()
42
+ }
43
+ lock, err := AcquireLock(dbPath + ".lock")
44
+ if err != nil {
45
+ return nil, err
46
+ }
47
+ svc, err := service.New(service.Config{VaultRoot: cfg.VaultRoot, DBPath: dbPath})
48
+ if err != nil {
49
+ lock.Close()
50
+ return nil, err
51
+ }
52
+ mux := NewHandler(svc)
53
+ httpServer := &http.Server{Addr: cfg.Addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
54
+ return &Server{Service: svc, Addr: cfg.Addr, DBPath: svc.DBPath(), Lock: lock, HTTP: httpServer}, nil
55
+ }
56
+
57
+ func (s *Server) Serve(ctx context.Context) error {
58
+ if ctx == nil {
59
+ ctx = context.Background()
60
+ }
61
+ errCh := make(chan error, 1)
62
+ go func() {
63
+ log.Printf("llm-wiki daemon listening on http://%s/mcp vault=%s db=%s", s.Addr, s.Service.VaultRoot(), s.DBPath)
64
+ errCh <- s.HTTP.ListenAndServe()
65
+ }()
66
+ select {
67
+ case <-ctx.Done():
68
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
69
+ defer cancel()
70
+ _ = s.HTTP.Shutdown(shutdownCtx)
71
+ <-errCh
72
+ return ctx.Err()
73
+ case err := <-errCh:
74
+ if errors.Is(err, http.ErrServerClosed) {
75
+ return nil
76
+ }
77
+ return err
78
+ }
79
+ }
80
+
81
+ func (s *Server) Close() error {
82
+ var err error
83
+ if s.HTTP != nil {
84
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
85
+ err = s.HTTP.Shutdown(ctx)
86
+ cancel()
87
+ }
88
+ if s.Service != nil {
89
+ if closeErr := s.Service.Close(); err == nil {
90
+ err = closeErr
91
+ }
92
+ }
93
+ if s.Lock != nil {
94
+ if lockErr := s.Lock.Close(); err == nil {
95
+ err = lockErr
96
+ }
97
+ }
98
+ return err
99
+ }
100
+
101
+ func NewHandler(svc *service.Service) http.Handler {
102
+ server := mcpserver.NewWithBackend(svc)
103
+ mcpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{JSONResponse: true, SessionTimeout: 30 * time.Minute})
104
+ mux := http.NewServeMux()
105
+ mux.Handle("/mcp", mcpHandler)
106
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
107
+ if r.Method != http.MethodGet {
108
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
109
+ return
110
+ }
111
+ info, err := svc.Info()
112
+ if err != nil {
113
+ http.Error(w, err.Error(), http.StatusInternalServerError)
114
+ return
115
+ }
116
+ w.Header().Set("Content-Type", "application/json")
117
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "vault": svc.VaultRoot(), "db": svc.DBPath(), "markdown_files": info.MarkdownFiles})
118
+ })
119
+ mux.HandleFunc("/jobs", func(w http.ResponseWriter, r *http.Request) {
120
+ if r.Method != http.MethodGet {
121
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
122
+ return
123
+ }
124
+ jobs, err := svc.RecentJobs(r.Context(), 20)
125
+ if err != nil {
126
+ http.Error(w, fmt.Sprintf("jobs: %v", err), http.StatusInternalServerError)
127
+ return
128
+ }
129
+ w.Header().Set("Content-Type", "application/json")
130
+ _ = json.NewEncoder(w).Encode(jobs)
131
+ })
132
+ return mux
133
+ }
@@ -0,0 +1,162 @@
1
+ package mcpserver
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+
8
+ "github.com/modelcontextprotocol/go-sdk/mcp"
9
+
10
+ "github.com/m16khb/llm-wiki/internal/service"
11
+ "github.com/m16khb/llm-wiki/internal/wiki"
12
+ )
13
+
14
+ const Version = "0.1.0"
15
+
16
+ // Config configures the MCP server.
17
+ type Config struct {
18
+ VaultRoot string
19
+ DBPath string
20
+ }
21
+
22
+ type emptyInput struct{}
23
+
24
+ type infoOutput struct {
25
+ Info wiki.Info `json:"info"`
26
+ }
27
+
28
+ type searchInput struct {
29
+ Query string `json:"query" jsonschema:"keywords to search for"`
30
+ Limit int `json:"limit,omitempty" jsonschema:"maximum results, default 10, max 50"`
31
+ Roots []string `json:"roots,omitempty" jsonschema:"optional vault-relative folders such as 20-wiki or 10-sources"`
32
+ IncludeArchive bool `json:"include_archive,omitempty" jsonschema:"include _archive pages when true"`
33
+ }
34
+
35
+ type searchOutput struct {
36
+ Query string `json:"query"`
37
+ Results []wiki.SearchResult `json:"results"`
38
+ }
39
+
40
+ type readInput struct {
41
+ Page string `json:"page" jsonschema:"wikilink slug or vault-relative markdown path"`
42
+ MaxBytes int `json:"max_bytes,omitempty" jsonschema:"optional maximum bytes to return"`
43
+ }
44
+
45
+ type readOutput struct {
46
+ Page wiki.Page `json:"page"`
47
+ Content string `json:"content"`
48
+ }
49
+
50
+ type lintOutput struct {
51
+ Result wiki.LintResult `json:"result"`
52
+ }
53
+
54
+ type captureOutput struct {
55
+ Result wiki.CaptureResult `json:"result"`
56
+ }
57
+
58
+ // Backend is the shared service surface used by MCP tools.
59
+ type Backend interface {
60
+ Info() (wiki.Info, error)
61
+ Search(wiki.SearchOptions) ([]wiki.SearchResult, error)
62
+ ReadPage(string, int) (wiki.Page, string, error)
63
+ Lint() (wiki.LintResult, error)
64
+ CaptureContext(context.Context, wiki.CaptureInput) (wiki.CaptureResult, error)
65
+ }
66
+
67
+ // New builds an MCP server exposing Obsidian-backed wiki tools backed by the
68
+ // SQLite queue service.
69
+ func New(cfg Config) (*mcp.Server, error) {
70
+ backend, err := service.New(service.Config{VaultRoot: cfg.VaultRoot, DBPath: cfg.DBPath})
71
+ if err != nil {
72
+ return nil, err
73
+ }
74
+ return NewWithBackend(backend), nil
75
+ }
76
+
77
+ // NewWithBackend builds an MCP server around an existing daemon/service backend.
78
+ func NewWithBackend(backend Backend) *mcp.Server {
79
+ server := mcp.NewServer(&mcp.Implementation{Name: "llm-wiki", Version: Version}, nil)
80
+ registerTools(server, backend)
81
+ return server
82
+ }
83
+
84
+ func registerTools(server *mcp.Server, backend Backend) {
85
+ closedWorld := false
86
+ addReadOnly := func(name, title, description string, handler any) {
87
+ switch h := handler.(type) {
88
+ case func(context.Context, *mcp.CallToolRequest, emptyInput) (*mcp.CallToolResult, infoOutput, error):
89
+ mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
90
+ case func(context.Context, *mcp.CallToolRequest, searchInput) (*mcp.CallToolResult, searchOutput, error):
91
+ mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
92
+ case func(context.Context, *mcp.CallToolRequest, readInput) (*mcp.CallToolResult, readOutput, error):
93
+ mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
94
+ case func(context.Context, *mcp.CallToolRequest, emptyInput) (*mcp.CallToolResult, lintOutput, error):
95
+ mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
96
+ default:
97
+ panic("unsupported handler type")
98
+ }
99
+ }
100
+
101
+ addReadOnly("wiki_info", "Wiki info", "Summarize the configured Obsidian LLM Wiki vault.",
102
+ func(ctx context.Context, req *mcp.CallToolRequest, in emptyInput) (*mcp.CallToolResult, infoOutput, error) {
103
+ info, err := backend.Info()
104
+ if err != nil {
105
+ return nil, infoOutput{}, err
106
+ }
107
+ return textResult(info), infoOutput{Info: info}, nil
108
+ })
109
+
110
+ addReadOnly("wiki_search", "Wiki search", "Search Markdown pages in the LLM Wiki by keyword.",
111
+ func(ctx context.Context, req *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
112
+ results, err := backend.Search(wiki.SearchOptions{Query: in.Query, Limit: in.Limit, Roots: in.Roots, IncludeArchive: in.IncludeArchive})
113
+ if err != nil {
114
+ return nil, searchOutput{}, err
115
+ }
116
+ out := searchOutput{Query: in.Query, Results: results}
117
+ return textResult(out), out, nil
118
+ })
119
+
120
+ addReadOnly("wiki_read", "Wiki read", "Read a wiki page by wikilink slug or vault-relative Markdown path.",
121
+ func(ctx context.Context, req *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, readOutput, error) {
122
+ page, content, err := backend.ReadPage(in.Page, in.MaxBytes)
123
+ if err != nil {
124
+ return nil, readOutput{}, err
125
+ }
126
+ out := readOutput{Page: page, Content: content}
127
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: content}}}, out, nil
128
+ })
129
+
130
+ addReadOnly("wiki_lint", "Wiki lint", "Run structural lint checks for frontmatter, duplicate slugs, and broken wikilinks.",
131
+ func(ctx context.Context, req *mcp.CallToolRequest, in emptyInput) (*mcp.CallToolResult, lintOutput, error) {
132
+ result, err := backend.Lint()
133
+ if err != nil {
134
+ return nil, lintOutput{}, err
135
+ }
136
+ out := lintOutput{Result: result}
137
+ return textResult(out), out, nil
138
+ })
139
+
140
+ destructive := false
141
+ mcp.AddTool(server, &mcp.Tool{
142
+ Name: "wiki_capture",
143
+ Title: "Wiki capture",
144
+ Description: "Create or overwrite a curated Markdown note in allowed LLM Wiki folders. Does not write 10-sources or .obsidian.",
145
+ Annotations: &mcp.ToolAnnotations{Title: "Wiki capture", ReadOnlyHint: false, DestructiveHint: &destructive, IdempotentHint: false, OpenWorldHint: &closedWorld},
146
+ }, func(ctx context.Context, req *mcp.CallToolRequest, in wiki.CaptureInput) (*mcp.CallToolResult, captureOutput, error) {
147
+ result, err := backend.CaptureContext(ctx, in)
148
+ if err != nil {
149
+ return nil, captureOutput{}, err
150
+ }
151
+ out := captureOutput{Result: result}
152
+ return textResult(out), out, nil
153
+ })
154
+ }
155
+
156
+ func textResult(v any) *mcp.CallToolResult {
157
+ data, err := json.MarshalIndent(v, "", " ")
158
+ if err != nil {
159
+ data = []byte(fmt.Sprint(v))
160
+ }
161
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}}
162
+ }
@@ -0,0 +1,217 @@
1
+ package service
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "sync"
8
+ "time"
9
+
10
+ "github.com/m16khb/llm-wiki/internal/store"
11
+ "github.com/m16khb/llm-wiki/internal/wiki"
12
+ )
13
+
14
+ // Config wires the daemon/service layer.
15
+ type Config struct {
16
+ VaultRoot string
17
+ DBPath string
18
+ }
19
+
20
+ // Service is the shared daemon backend. All writes go through a single worker
21
+ // queue while reads share a small in-memory metadata cache.
22
+ type Service struct {
23
+ vault *wiki.Vault
24
+ store *store.Store
25
+
26
+ mu sync.RWMutex
27
+ jobs chan captureJob
28
+ done chan struct{}
29
+
30
+ cacheMu sync.Mutex
31
+ cache infoCache
32
+ }
33
+
34
+ type infoCache struct {
35
+ info wiki.Info
36
+ loadedAt time.Time
37
+ valid bool
38
+ expiresIn time.Duration
39
+ }
40
+
41
+ type captureJob struct {
42
+ id int64
43
+ input wiki.CaptureInput
44
+ done chan captureResult
45
+ }
46
+
47
+ type captureResult struct {
48
+ result wiki.CaptureResult
49
+ err error
50
+ }
51
+
52
+ // New creates a service with a SQLite WAL queue and a single write worker.
53
+ func New(cfg Config) (*Service, error) {
54
+ vault, err := wiki.New(cfg.VaultRoot)
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+ st, err := store.Open(cfg.DBPath)
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+ s := &Service{
63
+ vault: vault,
64
+ store: st,
65
+ jobs: make(chan captureJob, 128),
66
+ done: make(chan struct{}),
67
+ }
68
+ s.cache.expiresIn = 2 * time.Second
69
+ go s.captureWorker()
70
+ return s, nil
71
+ }
72
+
73
+ func (s *Service) Close() error {
74
+ if s == nil {
75
+ return nil
76
+ }
77
+ select {
78
+ case <-s.done:
79
+ default:
80
+ close(s.done)
81
+ }
82
+ if s.store != nil {
83
+ return s.store.Close()
84
+ }
85
+ return nil
86
+ }
87
+
88
+ func (s *Service) VaultRoot() string {
89
+ return s.vault.Root
90
+ }
91
+
92
+ func (s *Service) DBPath() string {
93
+ return s.store.Path
94
+ }
95
+
96
+ func (s *Service) Info() (wiki.Info, error) {
97
+ s.cacheMu.Lock()
98
+ if s.cache.valid && time.Since(s.cache.loadedAt) < s.cache.expiresIn {
99
+ info := s.cache.info
100
+ s.cacheMu.Unlock()
101
+ return info, nil
102
+ }
103
+ s.cacheMu.Unlock()
104
+
105
+ s.mu.RLock()
106
+ info, err := s.vault.Info()
107
+ s.mu.RUnlock()
108
+ if err != nil {
109
+ return wiki.Info{}, err
110
+ }
111
+
112
+ s.cacheMu.Lock()
113
+ s.cache.info = info
114
+ s.cache.loadedAt = time.Now()
115
+ s.cache.valid = true
116
+ s.cacheMu.Unlock()
117
+ return info, nil
118
+ }
119
+
120
+ func (s *Service) Search(opts wiki.SearchOptions) ([]wiki.SearchResult, error) {
121
+ s.mu.RLock()
122
+ defer s.mu.RUnlock()
123
+ return s.vault.Search(opts)
124
+ }
125
+
126
+ func (s *Service) ReadPage(ref string, maxBytes int) (wiki.Page, string, error) {
127
+ s.mu.RLock()
128
+ defer s.mu.RUnlock()
129
+ return s.vault.ReadPage(ref, maxBytes)
130
+ }
131
+
132
+ func (s *Service) Lint() (wiki.LintResult, error) {
133
+ s.mu.RLock()
134
+ defer s.mu.RUnlock()
135
+ return s.vault.Lint()
136
+ }
137
+
138
+ func (s *Service) Capture(in wiki.CaptureInput) (wiki.CaptureResult, error) {
139
+ return s.CaptureContext(context.Background(), in)
140
+ }
141
+
142
+ // CaptureContext durably enqueues a capture job and waits for the daemon's
143
+ // single writer to complete it.
144
+ func (s *Service) CaptureContext(ctx context.Context, in wiki.CaptureInput) (wiki.CaptureResult, error) {
145
+ if ctx == nil {
146
+ ctx = context.Background()
147
+ }
148
+ id, err := s.store.Enqueue(ctx, "wiki_capture", in)
149
+ if err != nil {
150
+ return wiki.CaptureResult{}, err
151
+ }
152
+ job := captureJob{id: id, input: in, done: make(chan captureResult, 1)}
153
+ select {
154
+ case s.jobs <- job:
155
+ case <-ctx.Done():
156
+ _ = s.store.MarkFailed(context.Background(), id, ctx.Err())
157
+ return wiki.CaptureResult{}, ctx.Err()
158
+ case <-s.done:
159
+ err := errors.New("service is closed")
160
+ _ = s.store.MarkFailed(context.Background(), id, err)
161
+ return wiki.CaptureResult{}, err
162
+ }
163
+ select {
164
+ case res := <-job.done:
165
+ return res.result, res.err
166
+ case <-ctx.Done():
167
+ return wiki.CaptureResult{}, ctx.Err()
168
+ case <-s.done:
169
+ return wiki.CaptureResult{}, errors.New("service is closed")
170
+ }
171
+ }
172
+
173
+ func (s *Service) RecentJobs(ctx context.Context, limit int) ([]store.Job, error) {
174
+ return s.store.RecentJobs(ctx, limit)
175
+ }
176
+
177
+ func (s *Service) captureWorker() {
178
+ for {
179
+ select {
180
+ case <-s.done:
181
+ return
182
+ case job := <-s.jobs:
183
+ s.runCaptureJob(job)
184
+ }
185
+ }
186
+ }
187
+
188
+ func (s *Service) runCaptureJob(job captureJob) {
189
+ ctx := context.Background()
190
+ if err := s.store.MarkRunning(ctx, job.id); err != nil {
191
+ job.done <- captureResult{err: err}
192
+ return
193
+ }
194
+ s.mu.Lock()
195
+ result, err := s.vault.Capture(job.input)
196
+ s.mu.Unlock()
197
+ if err != nil {
198
+ markErr := s.store.MarkFailed(ctx, job.id, err)
199
+ if markErr != nil {
200
+ err = fmt.Errorf("%w; additionally failed to mark job failed: %v", err, markErr)
201
+ }
202
+ job.done <- captureResult{err: err}
203
+ return
204
+ }
205
+ s.invalidateInfoCache()
206
+ if err := s.store.MarkCompleted(ctx, job.id, result); err != nil {
207
+ job.done <- captureResult{result: result, err: err}
208
+ return
209
+ }
210
+ job.done <- captureResult{result: result}
211
+ }
212
+
213
+ func (s *Service) invalidateInfoCache() {
214
+ s.cacheMu.Lock()
215
+ s.cache.valid = false
216
+ s.cacheMu.Unlock()
217
+ }
@@ -0,0 +1,75 @@
1
+ package service
2
+
3
+ import (
4
+ "context"
5
+ "path/filepath"
6
+ "sync"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/m16khb/llm-wiki/internal/store"
11
+ "github.com/m16khb/llm-wiki/internal/wiki"
12
+ )
13
+
14
+ func TestServiceCaptureSerializesThroughSQLiteQueue(t *testing.T) {
15
+ tmp := t.TempDir()
16
+ s, err := New(Config{VaultRoot: tmp, DBPath: filepath.Join(tmp, "queue.db")})
17
+ if err != nil {
18
+ t.Fatalf("New() error = %v", err)
19
+ }
20
+ defer s.Close()
21
+ s.vault.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
22
+
23
+ const n = 8
24
+ var wg sync.WaitGroup
25
+ errs := make(chan error, n)
26
+ for i := 0; i < n; i++ {
27
+ i := i
28
+ wg.Add(1)
29
+ go func() {
30
+ defer wg.Done()
31
+ _, err := s.Capture(wiki.CaptureInput{Title: "Note", Body: "body", Slug: "note-" + string(rune('a'+i))})
32
+ errs <- err
33
+ }()
34
+ }
35
+ wg.Wait()
36
+ close(errs)
37
+ for err := range errs {
38
+ if err != nil {
39
+ t.Fatalf("Capture() error = %v", err)
40
+ }
41
+ }
42
+
43
+ jobs, err := s.RecentJobs(context.Background(), 20)
44
+ if err != nil {
45
+ t.Fatalf("RecentJobs() error = %v", err)
46
+ }
47
+ if len(jobs) != n {
48
+ t.Fatalf("got %d jobs, want %d: %+v", len(jobs), n, jobs)
49
+ }
50
+ for _, job := range jobs {
51
+ if job.Status != store.StatusCompleted {
52
+ t.Fatalf("job not completed: %+v", job)
53
+ }
54
+ }
55
+ }
56
+
57
+ func TestServiceCaptureAuditsFailedJob(t *testing.T) {
58
+ tmp := t.TempDir()
59
+ s, err := New(Config{VaultRoot: tmp, DBPath: filepath.Join(tmp, "queue.db")})
60
+ if err != nil {
61
+ t.Fatalf("New() error = %v", err)
62
+ }
63
+ defer s.Close()
64
+
65
+ if _, err := s.Capture(wiki.CaptureInput{Title: "Bad", Body: "body", Folder: "10-sources", Slug: "bad"}); err == nil {
66
+ t.Fatalf("expected protected folder capture to fail")
67
+ }
68
+ jobs, err := s.RecentJobs(context.Background(), 1)
69
+ if err != nil {
70
+ t.Fatalf("RecentJobs() error = %v", err)
71
+ }
72
+ if len(jobs) != 1 || jobs[0].Status != store.StatusFailed || jobs[0].Error == "" {
73
+ t.Fatalf("expected failed audited job, got %+v", jobs)
74
+ }
75
+ }