@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.
- package/README.md +170 -0
- package/cmd/llm-wiki/main.go +272 -0
- package/go.mod +23 -0
- package/go.sum +41 -0
- package/internal/daemon/lock.go +50 -0
- package/internal/daemon/lock_test.go +19 -0
- package/internal/daemon/server.go +133 -0
- package/internal/mcpserver/server.go +162 -0
- package/internal/service/service.go +217 -0
- package/internal/service/service_test.go +75 -0
- package/internal/store/store.go +245 -0
- package/internal/store/store_test.go +48 -0
- package/internal/wiki/capture.go +177 -0
- package/internal/wiki/frontmatter.go +158 -0
- package/internal/wiki/lint.go +91 -0
- package/internal/wiki/search.go +183 -0
- package/internal/wiki/vault.go +279 -0
- package/internal/wiki/vault_test.go +188 -0
- package/npm/bin/llm-wiki.js +2 -0
- package/npm/lib/runner.js +167 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# llm-wiki
|
|
2
|
+
|
|
3
|
+
`llm-wiki`는 Claude Code와 Codex가 같은 Obsidian-backed LLM Wiki vault를 안전하게 공유하도록 하는 Go 기반 MCP 서버/CLI다.
|
|
4
|
+
|
|
5
|
+
현재 기본 구조는 **단일 daemon process + streamable HTTP MCP + SQLite WAL queue + in-memory metadata cache**다. 여러 Claude Code/Codex 세션은 각자 파일을 직접 쓰지 않고 같은 daemon의 MCP endpoint에 붙는다.
|
|
6
|
+
|
|
7
|
+
## 핵심 원칙
|
|
8
|
+
|
|
9
|
+
- Single writer: `llm-wiki serve` daemon 하나가 write를 직렬화한다.
|
|
10
|
+
- SQLite WAL queue: write job은 durable queue/audit DB에 먼저 기록된다.
|
|
11
|
+
- Local-first: Markdown vault가 canonical source다. SQLite는 queue/index/cache/audit state이며 rebuild 가능해야 한다.
|
|
12
|
+
- Read-safe: `.obsidian/`, hidden/runtime directory, path traversal은 차단한다.
|
|
13
|
+
- Write-minimal: 기본 write는 `wiki_capture`의 additive Markdown 생성뿐이다.
|
|
14
|
+
- Source 보호: `10-sources/` body 수정/삭제 도구는 제공하지 않는다.
|
|
15
|
+
|
|
16
|
+
## 빠른 시작
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# repo root
|
|
20
|
+
./scripts/install-local.sh
|
|
21
|
+
|
|
22
|
+
# singleton daemon 시작
|
|
23
|
+
./bin/llm-wiki serve --vault ~/workspace/knowledge-base/llm-wiki
|
|
24
|
+
|
|
25
|
+
# 별도 shell에서 health check
|
|
26
|
+
curl http://127.0.0.1:39233/healthz
|
|
27
|
+
|
|
28
|
+
# read-only CLI smoke
|
|
29
|
+
./bin/llm-wiki info --vault ~/workspace/knowledge-base/llm-wiki --json
|
|
30
|
+
./bin/llm-wiki search --vault ~/workspace/knowledge-base/llm-wiki --limit 5 karpathy
|
|
31
|
+
./bin/llm-wiki lint --vault ~/workspace/knowledge-base/llm-wiki --json
|
|
32
|
+
|
|
33
|
+
# queue audit 확인
|
|
34
|
+
./bin/llm-wiki jobs --limit 20
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--vault`를 생략하면 `LLM_WIKI_ROOT`, `LLM_WIKI_VAULT`, `~/workspace/knowledge-base/llm-wiki`, `~/Workspace/knowledge-base/llm-wiki` 순서로 찾는다.
|
|
38
|
+
|
|
39
|
+
`--db`를 생략하면 `LLM_WIKI_DB` 또는 OS user config directory 아래 `llm-wiki/llm-wiki.db`를 사용한다.
|
|
40
|
+
|
|
41
|
+
## CLI commands
|
|
42
|
+
|
|
43
|
+
| Command | Purpose |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `llm-wiki serve` | singleton daemon + streamable HTTP MCP endpoint 시작 |
|
|
46
|
+
| `llm-wiki mcp` | legacy stdio MCP server; daemon HTTP가 기본 권장 경로 |
|
|
47
|
+
| `llm-wiki info` | vault root/count 요약 |
|
|
48
|
+
| `llm-wiki search QUERY` | Markdown lexical search |
|
|
49
|
+
| `llm-wiki read PAGE_OR_PATH` | slug 또는 vault-relative path로 page 읽기 |
|
|
50
|
+
| `llm-wiki lint` | frontmatter/wikilink/slug 구조 점검 |
|
|
51
|
+
| `llm-wiki capture` | SQLite queue를 통해 allowed folder에 curated note 생성 |
|
|
52
|
+
| `llm-wiki jobs` | SQLite queue 최근 job audit 출력 |
|
|
53
|
+
|
|
54
|
+
## MCP tools
|
|
55
|
+
|
|
56
|
+
| Tool | Type | Purpose |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `wiki_info` | read-only | vault count/root 확인 |
|
|
59
|
+
| `wiki_search` | read-only | keyword search |
|
|
60
|
+
| `wiki_read` | read-only | page content 읽기 |
|
|
61
|
+
| `wiki_lint` | read-only | structural lint |
|
|
62
|
+
| `wiki_capture` | queued additive write | `20-wiki/`, `30-sessions/`, `00-meta/reports/` 하위에 note 생성 |
|
|
63
|
+
|
|
64
|
+
## Claude Code 설정
|
|
65
|
+
|
|
66
|
+
먼저 daemon을 실행한다.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
/Users/m16khb/Workspace/llm-wiki/bin/llm-wiki serve \
|
|
70
|
+
--vault /Users/m16khb/Workspace/knowledge-base/llm-wiki
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
그 다음 `config/claude-code.mcp.json`을 target project의 `.mcp.json`에 복사한다.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"llm-wiki": {
|
|
79
|
+
"type": "http",
|
|
80
|
+
"url": "http://127.0.0.1:39233/mcp"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Claude Code는 local stdio뿐 아니라 HTTP MCP server 설정도 지원한다. 공식 문서는 `claude mcp add`와 `.mcp.json` scope를 설명한다: https://code.claude.com/docs/en/mcp
|
|
87
|
+
|
|
88
|
+
### npx / stdio compatibility 설정
|
|
89
|
+
|
|
90
|
+
HTTP daemon을 공유하는 구성이 기본 권장 경로지만, stdio MCP만 허용되는 환경에서는 npm wrapper로 `llm-wiki mcp`를 실행할 수 있다.
|
|
91
|
+
|
|
92
|
+
로컬 repo를 바로 쓰는 `.mcp.json` 예시:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {
|
|
97
|
+
"llm-wiki": {
|
|
98
|
+
"command": "npx",
|
|
99
|
+
"args": [
|
|
100
|
+
"-y",
|
|
101
|
+
"--package",
|
|
102
|
+
"/Users/m16khb/Workspace/llm-wiki",
|
|
103
|
+
"llm-wiki",
|
|
104
|
+
"mcp",
|
|
105
|
+
"--vault",
|
|
106
|
+
"/Users/m16khb/Workspace/knowledge-base/llm-wiki"
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
npm registry에 publish한 뒤에는 package path 대신 package name을 쓴다.
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"mcpServers": {
|
|
118
|
+
"llm-wiki": {
|
|
119
|
+
"command": "npx",
|
|
120
|
+
"args": [
|
|
121
|
+
"-y",
|
|
122
|
+
"--package",
|
|
123
|
+
"@m16khb/llm-wiki",
|
|
124
|
+
"llm-wiki",
|
|
125
|
+
"mcp",
|
|
126
|
+
"--vault",
|
|
127
|
+
"/Users/m16khb/Workspace/knowledge-base/llm-wiki"
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
wrapper는 기존 `bin/llm-wiki`가 있으면 그대로 실행하고, 없으면 Go toolchain으로 CLI를 cache에 빌드한 뒤 실행한다. MCP stdio 안전을 위해 wrapper 자체는 stdout에 진단을 쓰지 않는다.
|
|
135
|
+
|
|
136
|
+
## Codex 설정
|
|
137
|
+
|
|
138
|
+
먼저 daemon을 실행한다. 그 다음 `config/codex.config.toml` 내용을 `~/.codex/config.toml`에 병합한다.
|
|
139
|
+
|
|
140
|
+
```toml
|
|
141
|
+
[mcp_servers.llm-wiki]
|
|
142
|
+
url = "http://127.0.0.1:39233/mcp"
|
|
143
|
+
startup_timeout_sec = 10
|
|
144
|
+
tool_timeout_sec = 60
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Codex config는 MCP streamable HTTP server endpoint를 `mcp_servers.<id>.url`로 설정한다: https://developers.openai.com/codex/config-reference
|
|
148
|
+
|
|
149
|
+
## 개발 문서
|
|
150
|
+
|
|
151
|
+
작업 전 `AGENTS.md`와 `agent_docs/CONSTITUTION.md`를 읽는다.
|
|
152
|
+
|
|
153
|
+
- `agent_docs/ARCHITECTURE.md` — daemon/queue/MCP 구조와 계층 책임
|
|
154
|
+
- `agent_docs/CONVENTIONS.md` — 구현 컨벤션
|
|
155
|
+
- `agent_docs/GO_GUIDE.md` — Go 작성 가이드
|
|
156
|
+
- `agent_docs/TESTING.md` — 검증 기준
|
|
157
|
+
- `agent_docs/CAUTIONS.md` — 반복 주의사항
|
|
158
|
+
- `agent_docs/TECH_STACK.md` — 버전/명령/설정
|
|
159
|
+
- `agent_docs/ADR.md` — 유지할 결정 기록
|
|
160
|
+
|
|
161
|
+
## 검증
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
gofmt -w cmd internal
|
|
165
|
+
npm run test:npm
|
|
166
|
+
go test ./... -count=1
|
|
167
|
+
go build ./...
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
실제 사용자 vault를 대상으로 destructive test를 하지 않는다. `capture`/write 동작은 temp vault test나 명시적인 test vault에서 검증한다.
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"flag"
|
|
7
|
+
"fmt"
|
|
8
|
+
"log"
|
|
9
|
+
"os"
|
|
10
|
+
"os/signal"
|
|
11
|
+
"strings"
|
|
12
|
+
"syscall"
|
|
13
|
+
|
|
14
|
+
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
15
|
+
|
|
16
|
+
"github.com/m16khb/llm-wiki/internal/daemon"
|
|
17
|
+
"github.com/m16khb/llm-wiki/internal/mcpserver"
|
|
18
|
+
"github.com/m16khb/llm-wiki/internal/service"
|
|
19
|
+
"github.com/m16khb/llm-wiki/internal/store"
|
|
20
|
+
"github.com/m16khb/llm-wiki/internal/wiki"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
func main() {
|
|
24
|
+
log.SetOutput(os.Stderr)
|
|
25
|
+
if len(os.Args) < 2 {
|
|
26
|
+
usage()
|
|
27
|
+
os.Exit(2)
|
|
28
|
+
}
|
|
29
|
+
cmd := os.Args[1]
|
|
30
|
+
switch cmd {
|
|
31
|
+
case "mcp":
|
|
32
|
+
runMCP(os.Args[2:])
|
|
33
|
+
case "serve", "daemon":
|
|
34
|
+
runServe(os.Args[2:])
|
|
35
|
+
case "jobs", "queue":
|
|
36
|
+
runJobs(os.Args[2:])
|
|
37
|
+
case "info":
|
|
38
|
+
runInfo(os.Args[2:])
|
|
39
|
+
case "search":
|
|
40
|
+
runSearch(os.Args[2:])
|
|
41
|
+
case "read":
|
|
42
|
+
runRead(os.Args[2:])
|
|
43
|
+
case "lint":
|
|
44
|
+
runLint(os.Args[2:])
|
|
45
|
+
case "capture":
|
|
46
|
+
runCapture(os.Args[2:])
|
|
47
|
+
case "version", "--version", "-v":
|
|
48
|
+
fmt.Println(mcpserver.Version)
|
|
49
|
+
case "help", "--help", "-h":
|
|
50
|
+
usage()
|
|
51
|
+
default:
|
|
52
|
+
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
|
|
53
|
+
usage()
|
|
54
|
+
os.Exit(2)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func usage() {
|
|
59
|
+
fmt.Fprintf(os.Stderr, `llm-wiki %s
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
llm-wiki serve [--vault PATH] [--db PATH] [--addr 127.0.0.1:39233]
|
|
63
|
+
llm-wiki mcp [--vault PATH] [--db PATH]
|
|
64
|
+
llm-wiki info [--vault PATH] [--json]
|
|
65
|
+
llm-wiki search [--vault PATH] [--limit N] [--root FOLDER] QUERY
|
|
66
|
+
llm-wiki read [--vault PATH] [--max-bytes N] PAGE_OR_PATH
|
|
67
|
+
llm-wiki lint [--vault PATH] [--json]
|
|
68
|
+
llm-wiki capture [--vault PATH] [--db PATH] --title TITLE --body BODY [--type concept] [--tags a,b]
|
|
69
|
+
llm-wiki jobs [--db PATH] [--limit N]
|
|
70
|
+
|
|
71
|
+
Environment:
|
|
72
|
+
LLM_WIKI_ROOT or LLM_WIKI_VAULT can provide the default vault path.
|
|
73
|
+
LLM_WIKI_DB can provide the default SQLite WAL queue path.
|
|
74
|
+
|
|
75
|
+
`, mcpserver.Version)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func vaultFlag(fs *flag.FlagSet) *string {
|
|
79
|
+
return fs.String("vault", "", "Obsidian LLM Wiki vault root")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func dbFlag(fs *flag.FlagSet) *string {
|
|
83
|
+
return fs.String("db", "", "SQLite queue/cache database path")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func runMCP(args []string) {
|
|
87
|
+
fs := flag.NewFlagSet("mcp", flag.ExitOnError)
|
|
88
|
+
vaultRoot := vaultFlag(fs)
|
|
89
|
+
dbPath := dbFlag(fs)
|
|
90
|
+
_ = fs.Parse(args)
|
|
91
|
+
server, err := mcpserver.New(mcpserver.Config{VaultRoot: *vaultRoot, DBPath: *dbPath})
|
|
92
|
+
if err != nil {
|
|
93
|
+
log.Fatalf("llm-wiki mcp: %v", err)
|
|
94
|
+
}
|
|
95
|
+
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
|
96
|
+
log.Fatalf("llm-wiki mcp: %v", err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func runServe(args []string) {
|
|
101
|
+
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
|
102
|
+
vaultRoot := vaultFlag(fs)
|
|
103
|
+
dbPath := dbFlag(fs)
|
|
104
|
+
addr := fs.String("addr", "127.0.0.1:39233", "HTTP listen address for streamable MCP")
|
|
105
|
+
_ = fs.Parse(args)
|
|
106
|
+
srv, err := daemon.New(daemon.Config{VaultRoot: *vaultRoot, DBPath: *dbPath, Addr: *addr})
|
|
107
|
+
if err != nil {
|
|
108
|
+
log.Fatalf("llm-wiki serve: %v", err)
|
|
109
|
+
}
|
|
110
|
+
defer srv.Close()
|
|
111
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
112
|
+
defer stop()
|
|
113
|
+
if err := srv.Serve(ctx); err != nil && err != context.Canceled {
|
|
114
|
+
log.Fatalf("llm-wiki serve: %v", err)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func runInfo(args []string) {
|
|
119
|
+
fs := flag.NewFlagSet("info", flag.ExitOnError)
|
|
120
|
+
vaultRoot := vaultFlag(fs)
|
|
121
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
122
|
+
_ = fs.Parse(args)
|
|
123
|
+
v := mustVault(*vaultRoot)
|
|
124
|
+
info, err := v.Info()
|
|
125
|
+
must(err)
|
|
126
|
+
if *jsonOut {
|
|
127
|
+
printJSON(info)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
fmt.Printf("root: %s\nmarkdown: %d\nsources: %d\nconcepts: %d\nentities: %d\nsessions: %d\n", info.Root, info.MarkdownFiles, info.Sources, info.Concepts, info.Entities, info.Sessions)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func runSearch(args []string) {
|
|
134
|
+
fs := flag.NewFlagSet("search", flag.ExitOnError)
|
|
135
|
+
vaultRoot := vaultFlag(fs)
|
|
136
|
+
limit := fs.Int("limit", 10, "max results")
|
|
137
|
+
root := fs.String("root", "", "optional vault-relative folder filter")
|
|
138
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
139
|
+
_ = fs.Parse(args)
|
|
140
|
+
query := strings.Join(fs.Args(), " ")
|
|
141
|
+
v := mustVault(*vaultRoot)
|
|
142
|
+
var roots []string
|
|
143
|
+
if strings.TrimSpace(*root) != "" {
|
|
144
|
+
roots = append(roots, *root)
|
|
145
|
+
}
|
|
146
|
+
results, err := v.Search(wiki.SearchOptions{Query: query, Limit: *limit, Roots: roots})
|
|
147
|
+
must(err)
|
|
148
|
+
if *jsonOut {
|
|
149
|
+
printJSON(results)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
for _, r := range results {
|
|
153
|
+
fmt.Printf("[[%s]] %s score=%d\n %s\n", r.Slug, r.Path, r.Score, r.Snippet)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func runRead(args []string) {
|
|
158
|
+
fs := flag.NewFlagSet("read", flag.ExitOnError)
|
|
159
|
+
vaultRoot := vaultFlag(fs)
|
|
160
|
+
maxBytes := fs.Int("max-bytes", 0, "max bytes")
|
|
161
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
162
|
+
_ = fs.Parse(args)
|
|
163
|
+
if fs.NArg() != 1 {
|
|
164
|
+
fmt.Fprintln(os.Stderr, "read requires PAGE_OR_PATH")
|
|
165
|
+
os.Exit(2)
|
|
166
|
+
}
|
|
167
|
+
v := mustVault(*vaultRoot)
|
|
168
|
+
page, content, err := v.ReadPage(fs.Arg(0), *maxBytes)
|
|
169
|
+
must(err)
|
|
170
|
+
if *jsonOut {
|
|
171
|
+
printJSON(map[string]any{"page": page, "content": content})
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
fmt.Print(content)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func runLint(args []string) {
|
|
178
|
+
fs := flag.NewFlagSet("lint", flag.ExitOnError)
|
|
179
|
+
vaultRoot := vaultFlag(fs)
|
|
180
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
181
|
+
_ = fs.Parse(args)
|
|
182
|
+
v := mustVault(*vaultRoot)
|
|
183
|
+
result, err := v.Lint()
|
|
184
|
+
must(err)
|
|
185
|
+
if *jsonOut {
|
|
186
|
+
printJSON(result)
|
|
187
|
+
} else if result.OK {
|
|
188
|
+
fmt.Printf("PASS markdown=%d\n", result.MarkdownFiles)
|
|
189
|
+
} else {
|
|
190
|
+
fmt.Printf("FAIL markdown=%d missing_frontmatter=%d broken_wikilinks=%d duplicate_slugs=%d\n", result.MarkdownFiles, len(result.MissingFrontmatter), len(result.BrokenWikilinks), len(result.DuplicateSlugs))
|
|
191
|
+
}
|
|
192
|
+
if !result.OK {
|
|
193
|
+
os.Exit(1)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func runCapture(args []string) {
|
|
198
|
+
fs := flag.NewFlagSet("capture", flag.ExitOnError)
|
|
199
|
+
vaultRoot := vaultFlag(fs)
|
|
200
|
+
dbPath := dbFlag(fs)
|
|
201
|
+
title := fs.String("title", "", "page title")
|
|
202
|
+
body := fs.String("body", "", "markdown body")
|
|
203
|
+
bodyFile := fs.String("body-file", "", "read markdown body from file")
|
|
204
|
+
typ := fs.String("type", "concept", "page type")
|
|
205
|
+
status := fs.String("status", "draft", "page status")
|
|
206
|
+
domain := fs.String("domain", "dev-fundamentals", "domain")
|
|
207
|
+
tags := fs.String("tags", "", "comma-separated tags")
|
|
208
|
+
folder := fs.String("folder", "", "target folder")
|
|
209
|
+
slug := fs.String("slug", "", "page slug")
|
|
210
|
+
overwrite := fs.Bool("overwrite", false, "overwrite existing page")
|
|
211
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
212
|
+
_ = fs.Parse(args)
|
|
213
|
+
bodyText := *body
|
|
214
|
+
if *bodyFile != "" {
|
|
215
|
+
data, err := os.ReadFile(*bodyFile)
|
|
216
|
+
must(err)
|
|
217
|
+
bodyText = string(data)
|
|
218
|
+
}
|
|
219
|
+
svc, err := service.New(service.Config{VaultRoot: *vaultRoot, DBPath: *dbPath})
|
|
220
|
+
must(err)
|
|
221
|
+
defer svc.Close()
|
|
222
|
+
result, err := svc.Capture(wiki.CaptureInput{Title: *title, Body: bodyText, Type: *typ, Status: *status, Domain: *domain, Tags: splitComma(*tags), Folder: *folder, Slug: *slug, Overwrite: *overwrite})
|
|
223
|
+
must(err)
|
|
224
|
+
if *jsonOut {
|
|
225
|
+
printJSON(result)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
fmt.Printf("wrote %s\n", result.Path)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
func runJobs(args []string) {
|
|
232
|
+
fs := flag.NewFlagSet("jobs", flag.ExitOnError)
|
|
233
|
+
dbPath := dbFlag(fs)
|
|
234
|
+
limit := fs.Int("limit", 20, "max jobs")
|
|
235
|
+
_ = fs.Parse(args)
|
|
236
|
+
st, err := store.Open(*dbPath)
|
|
237
|
+
must(err)
|
|
238
|
+
defer st.Close()
|
|
239
|
+
jobs, err := st.RecentJobs(context.Background(), *limit)
|
|
240
|
+
must(err)
|
|
241
|
+
printJSON(jobs)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func mustVault(root string) *wiki.Vault {
|
|
245
|
+
v, err := wiki.New(root)
|
|
246
|
+
must(err)
|
|
247
|
+
return v
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func must(err error) {
|
|
251
|
+
if err != nil {
|
|
252
|
+
fmt.Fprintln(os.Stderr, "llm-wiki:", err)
|
|
253
|
+
os.Exit(1)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func printJSON(v any) {
|
|
258
|
+
enc := json.NewEncoder(os.Stdout)
|
|
259
|
+
enc.SetIndent("", " ")
|
|
260
|
+
must(enc.Encode(v))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func splitComma(s string) []string {
|
|
264
|
+
var out []string
|
|
265
|
+
for _, part := range strings.Split(s, ",") {
|
|
266
|
+
part = strings.TrimSpace(part)
|
|
267
|
+
if part != "" {
|
|
268
|
+
out = append(out, part)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
}
|
package/go.mod
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module github.com/m16khb/llm-wiki
|
|
2
|
+
|
|
3
|
+
go 1.25.5
|
|
4
|
+
|
|
5
|
+
require github.com/modelcontextprotocol/go-sdk v1.6.0
|
|
6
|
+
|
|
7
|
+
require (
|
|
8
|
+
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
9
|
+
github.com/google/jsonschema-go v0.4.3 // indirect
|
|
10
|
+
github.com/google/uuid v1.6.0 // indirect
|
|
11
|
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
12
|
+
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
13
|
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
14
|
+
github.com/segmentio/asm v1.1.3 // indirect
|
|
15
|
+
github.com/segmentio/encoding v0.5.4 // indirect
|
|
16
|
+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
|
17
|
+
golang.org/x/oauth2 v0.35.0 // indirect
|
|
18
|
+
golang.org/x/sys v0.42.0 // indirect
|
|
19
|
+
modernc.org/libc v1.72.3 // indirect
|
|
20
|
+
modernc.org/mathutil v1.7.1 // indirect
|
|
21
|
+
modernc.org/memory v1.11.0 // indirect
|
|
22
|
+
modernc.org/sqlite v1.50.1 // indirect
|
|
23
|
+
)
|
package/go.sum
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
2
|
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
3
|
+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
4
|
+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
5
|
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
6
|
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
7
|
+
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
|
8
|
+
github.com/google/jsonschema-go v0.4.3/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=
|
|
11
|
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
12
|
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
13
|
+
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
|
|
14
|
+
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
|
15
|
+
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
16
|
+
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
17
|
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
18
|
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
19
|
+
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
|
20
|
+
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
|
21
|
+
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
|
22
|
+
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
|
23
|
+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
|
24
|
+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
|
25
|
+
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
|
26
|
+
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
|
27
|
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
28
|
+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
29
|
+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
30
|
+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
|
31
|
+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
32
|
+
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
33
|
+
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
|
34
|
+
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
|
35
|
+
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
|
36
|
+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
37
|
+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
38
|
+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
39
|
+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
40
|
+
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
|
41
|
+
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package daemon
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"syscall"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Lock is an advisory cross-process singleton guard for a daemon instance.
|
|
11
|
+
type Lock struct {
|
|
12
|
+
path string
|
|
13
|
+
file *os.File
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// AcquireLock obtains a non-blocking exclusive advisory lock.
|
|
17
|
+
func AcquireLock(path string) (*Lock, error) {
|
|
18
|
+
if path == "" {
|
|
19
|
+
return nil, fmt.Errorf("lock path is required")
|
|
20
|
+
}
|
|
21
|
+
abs, err := filepath.Abs(path)
|
|
22
|
+
if err != nil {
|
|
23
|
+
return nil, err
|
|
24
|
+
}
|
|
25
|
+
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
26
|
+
return nil, err
|
|
27
|
+
}
|
|
28
|
+
file, err := os.OpenFile(abs, os.O_CREATE|os.O_RDWR, 0o644)
|
|
29
|
+
if err != nil {
|
|
30
|
+
return nil, err
|
|
31
|
+
}
|
|
32
|
+
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
33
|
+
file.Close()
|
|
34
|
+
return nil, fmt.Errorf("another llm-wiki daemon is already using %s: %w", abs, err)
|
|
35
|
+
}
|
|
36
|
+
_ = file.Truncate(0)
|
|
37
|
+
_, _ = fmt.Fprintf(file, "pid=%d\n", os.Getpid())
|
|
38
|
+
return &Lock{path: abs, file: file}, nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (l *Lock) Close() error {
|
|
42
|
+
if l == nil || l.file == nil {
|
|
43
|
+
return nil
|
|
44
|
+
}
|
|
45
|
+
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
|
46
|
+
err := l.file.Close()
|
|
47
|
+
_ = os.Remove(l.path)
|
|
48
|
+
l.file = nil
|
|
49
|
+
return err
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package daemon
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"path/filepath"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestAcquireLockPreventsSecondDaemon(t *testing.T) {
|
|
9
|
+
path := filepath.Join(t.TempDir(), "daemon.lock")
|
|
10
|
+
first, err := AcquireLock(path)
|
|
11
|
+
if err != nil {
|
|
12
|
+
t.Fatalf("AcquireLock first error = %v", err)
|
|
13
|
+
}
|
|
14
|
+
defer first.Close()
|
|
15
|
+
if second, err := AcquireLock(path); err == nil {
|
|
16
|
+
second.Close()
|
|
17
|
+
t.Fatalf("second AcquireLock unexpectedly succeeded")
|
|
18
|
+
}
|
|
19
|
+
}
|