@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
package wiki
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestVaultInfoSearchReadAndLint(t *testing.T) {
|
|
12
|
+
root := t.TempDir()
|
|
13
|
+
writeTestPage(t, root, "20-wiki/concepts/llm-wiki-pattern.md", `---
|
|
14
|
+
title: LLM Wiki Pattern
|
|
15
|
+
type: concept
|
|
16
|
+
status: active
|
|
17
|
+
created: 2026-05-23
|
|
18
|
+
updated: 2026-05-23
|
|
19
|
+
tags: [llm, wiki]
|
|
20
|
+
domain: agent-memory
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# LLM Wiki Pattern
|
|
24
|
+
|
|
25
|
+
A Karpathy-inspired wiki pattern related to [[andrej-karpathy]].
|
|
26
|
+
`)
|
|
27
|
+
writeTestPage(t, root, "20-wiki/entities/andrej-karpathy.md", `---
|
|
28
|
+
title: Andrej Karpathy
|
|
29
|
+
type: entity
|
|
30
|
+
status: active
|
|
31
|
+
created: 2026-05-23
|
|
32
|
+
updated: 2026-05-23
|
|
33
|
+
tags: [llm]
|
|
34
|
+
domain: people
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
# Andrej Karpathy
|
|
38
|
+
`)
|
|
39
|
+
writeTestPage(t, root, "10-sources/karpathy-llm-wiki-gist.md", `---
|
|
40
|
+
title: Karpathy LLM Wiki Gist
|
|
41
|
+
type: source
|
|
42
|
+
status: active
|
|
43
|
+
created: 2026-05-23
|
|
44
|
+
updated: 2026-05-23
|
|
45
|
+
tags: [source]
|
|
46
|
+
domain: primary-source
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
source card
|
|
50
|
+
`)
|
|
51
|
+
writeTestPage(t, root, ".obsidian/ignored.md", `no frontmatter and should be ignored`)
|
|
52
|
+
|
|
53
|
+
v := newTestVault(t, root)
|
|
54
|
+
info, err := v.Info()
|
|
55
|
+
if err != nil {
|
|
56
|
+
t.Fatalf("Info() error = %v", err)
|
|
57
|
+
}
|
|
58
|
+
if info.MarkdownFiles != 3 || info.Sources != 1 || info.Concepts != 1 || info.Entities != 1 {
|
|
59
|
+
t.Fatalf("unexpected info: %+v", info)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
results, err := v.Search(SearchOptions{Query: "karpathy", Limit: 2})
|
|
63
|
+
if err != nil {
|
|
64
|
+
t.Fatalf("Search() error = %v", err)
|
|
65
|
+
}
|
|
66
|
+
if len(results) == 0 || results[0].Score == 0 {
|
|
67
|
+
t.Fatalf("expected search hit, got %+v", results)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
page, content, err := v.ReadPage("llm-wiki-pattern", 0)
|
|
71
|
+
if err != nil {
|
|
72
|
+
t.Fatalf("ReadPage() error = %v", err)
|
|
73
|
+
}
|
|
74
|
+
if page.Path != "20-wiki/concepts/llm-wiki-pattern.md" || !strings.Contains(content, "[[andrej-karpathy]]") {
|
|
75
|
+
t.Fatalf("unexpected read: page=%+v content=%q", page, content)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lint, err := v.Lint()
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatalf("Lint() error = %v", err)
|
|
81
|
+
}
|
|
82
|
+
if !lint.OK || len(lint.MissingFrontmatter) != 0 || len(lint.BrokenWikilinks) != 0 {
|
|
83
|
+
t.Fatalf("expected clean lint, got %+v", lint)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func TestLintReportsBrokenLinksAndDuplicateSlugWarning(t *testing.T) {
|
|
88
|
+
root := t.TempDir()
|
|
89
|
+
writeTestPage(t, root, "20-wiki/concepts/dup.md", testFrontmatter("Dup Concept", "concept")+"\n[[missing-page]]\n")
|
|
90
|
+
writeTestPage(t, root, "20-wiki/entities/dup.md", testFrontmatter("Dup Entity", "entity")+"\n")
|
|
91
|
+
|
|
92
|
+
lint, err := newTestVault(t, root).Lint()
|
|
93
|
+
if err != nil {
|
|
94
|
+
t.Fatalf("Lint() error = %v", err)
|
|
95
|
+
}
|
|
96
|
+
if lint.OK {
|
|
97
|
+
t.Fatalf("expected lint failure for broken link, got %+v", lint)
|
|
98
|
+
}
|
|
99
|
+
if len(lint.BrokenWikilinks) != 1 || !strings.Contains(lint.BrokenWikilinks[0], "missing-page") {
|
|
100
|
+
t.Fatalf("expected broken wikilink, got %+v", lint.BrokenWikilinks)
|
|
101
|
+
}
|
|
102
|
+
if len(lint.DuplicateSlugs) != 1 || len(lint.Warnings) != 1 {
|
|
103
|
+
t.Fatalf("expected duplicate slug warning, got %+v", lint)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func TestSafeJoinRejectsUnsafePaths(t *testing.T) {
|
|
108
|
+
v := newTestVault(t, t.TempDir())
|
|
109
|
+
for _, rel := range []string{"", "/tmp/file.md", "../escape.md", "20-wiki/../.obsidian/app.json", ".obsidian/app.json"} {
|
|
110
|
+
if _, err := v.SafeJoin(rel); err == nil {
|
|
111
|
+
t.Fatalf("SafeJoin(%q) expected error", rel)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if abs, err := v.SafeJoin("20-wiki/concepts/page.md"); err != nil || !strings.HasPrefix(abs, v.Root) {
|
|
115
|
+
t.Fatalf("SafeJoin allowed path = %q, %v", abs, err)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func TestCaptureWritesAllowedMarkdownAndRejectsProtectedFolders(t *testing.T) {
|
|
120
|
+
v := newTestVault(t, t.TempDir())
|
|
121
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
122
|
+
|
|
123
|
+
result, err := v.Capture(CaptureInput{
|
|
124
|
+
Title: "Captured Note",
|
|
125
|
+
Body: "Captured body.",
|
|
126
|
+
Type: "concept",
|
|
127
|
+
Tags: []string{"agent", "wiki"},
|
|
128
|
+
Folder: "20-wiki/concepts",
|
|
129
|
+
Slug: "captured-note",
|
|
130
|
+
})
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatalf("Capture() error = %v", err)
|
|
133
|
+
}
|
|
134
|
+
if result.Path != "20-wiki/concepts/captured-note.md" || !result.Created || result.Overwrote {
|
|
135
|
+
t.Fatalf("unexpected capture result: %+v", result)
|
|
136
|
+
}
|
|
137
|
+
data, err := os.ReadFile(filepath.Join(v.Root, filepath.FromSlash(result.Path)))
|
|
138
|
+
if err != nil {
|
|
139
|
+
t.Fatalf("read captured file: %v", err)
|
|
140
|
+
}
|
|
141
|
+
content := string(data)
|
|
142
|
+
for _, want := range []string{`title: "Captured Note"`, "created: 2026-05-23", "tags: [agent, wiki]", "## Changelog"} {
|
|
143
|
+
if !strings.Contains(content, want) {
|
|
144
|
+
t.Fatalf("captured content missing %q:\n%s", want, content)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if _, err := v.Capture(CaptureInput{Title: "Dup", Body: "body", Folder: "20-wiki/concepts", Slug: "captured-note"}); err == nil {
|
|
149
|
+
t.Fatalf("expected duplicate capture to fail without overwrite")
|
|
150
|
+
}
|
|
151
|
+
for _, folder := range []string{"10-sources", ".obsidian", "_archive", "../20-wiki/concepts"} {
|
|
152
|
+
if _, err := v.Capture(CaptureInput{Title: "Bad", Body: "body", Folder: folder, Slug: "bad"}); err == nil {
|
|
153
|
+
t.Fatalf("Capture folder %q expected error", folder)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func newTestVault(t *testing.T, root string) *Vault {
|
|
159
|
+
t.Helper()
|
|
160
|
+
v, err := New(root)
|
|
161
|
+
if err != nil {
|
|
162
|
+
t.Fatalf("New(%q) error = %v", root, err)
|
|
163
|
+
}
|
|
164
|
+
return v
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func writeTestPage(t *testing.T, root, rel, content string) {
|
|
168
|
+
t.Helper()
|
|
169
|
+
abs := filepath.Join(root, filepath.FromSlash(rel))
|
|
170
|
+
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
171
|
+
t.Fatalf("mkdir %s: %v", rel, err)
|
|
172
|
+
}
|
|
173
|
+
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
174
|
+
t.Fatalf("write %s: %v", rel, err)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func testFrontmatter(title, typ string) string {
|
|
179
|
+
return "---\n" +
|
|
180
|
+
"title: " + title + "\n" +
|
|
181
|
+
"type: " + typ + "\n" +
|
|
182
|
+
"status: active\n" +
|
|
183
|
+
"created: 2026-05-23\n" +
|
|
184
|
+
"updated: 2026-05-23\n" +
|
|
185
|
+
"tags: []\n" +
|
|
186
|
+
"domain: test\n" +
|
|
187
|
+
"---\n"
|
|
188
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const childProcess = require('node:child_process');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const SIGNAL_EXIT_CODES = {
|
|
8
|
+
SIGINT: 130,
|
|
9
|
+
SIGTERM: 143,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function executableName(platform = process.platform) {
|
|
13
|
+
return platform === 'win32' ? 'llm-wiki.exe' : 'llm-wiki';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isExecutable(file) {
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.statSync(file);
|
|
19
|
+
return stat.isFile();
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err && err.code === 'ENOENT') return false;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findBundledBinary(packageRoot, platform = process.platform) {
|
|
27
|
+
const candidate = path.join(packageRoot, 'bin', executableName(platform));
|
|
28
|
+
return isExecutable(candidate) ? candidate : '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readPackageVersion(packageRoot) {
|
|
32
|
+
try {
|
|
33
|
+
const data = fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8');
|
|
34
|
+
const pkg = JSON.parse(data);
|
|
35
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
|
|
36
|
+
} catch (_err) {
|
|
37
|
+
return '0.0.0';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hashFile(hash, root, file) {
|
|
42
|
+
let stat;
|
|
43
|
+
try {
|
|
44
|
+
stat = fs.statSync(file);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err && err.code === 'ENOENT') return;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (stat.isDirectory()) {
|
|
50
|
+
const entries = fs.readdirSync(file).sort();
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
hashFile(hash, root, path.join(file, entry));
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const isBuildInput = file.endsWith('.go') || ['go.mod', 'go.sum', 'package.json'].includes(path.basename(file));
|
|
57
|
+
if (!stat.isFile() || !isBuildInput) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
hash.update(path.relative(root, file));
|
|
61
|
+
hash.update('\0');
|
|
62
|
+
hash.update(String(stat.size));
|
|
63
|
+
hash.update('\0');
|
|
64
|
+
hash.update(String(Math.trunc(stat.mtimeMs)));
|
|
65
|
+
hash.update('\0');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sourceFingerprint(packageRoot) {
|
|
69
|
+
const hash = crypto.createHash('sha256');
|
|
70
|
+
for (const entry of ['go.mod', 'go.sum', 'package.json', 'cmd', 'internal']) {
|
|
71
|
+
hashFile(hash, packageRoot, path.join(packageRoot, entry));
|
|
72
|
+
}
|
|
73
|
+
return hash.digest('hex').slice(0, 16);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultCacheRoot(env = process.env) {
|
|
77
|
+
if (env.LLM_WIKI_NPM_CACHE_DIR) return env.LLM_WIKI_NPM_CACHE_DIR;
|
|
78
|
+
if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'llm-wiki', 'npm-wrapper');
|
|
79
|
+
const home = os.homedir();
|
|
80
|
+
if (home) return path.join(home, '.cache', 'llm-wiki', 'npm-wrapper');
|
|
81
|
+
return path.join(os.tmpdir(), 'llm-wiki', 'npm-wrapper');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cacheBinaryPath(packageRoot, env = process.env, platform = process.platform, arch = process.arch) {
|
|
85
|
+
const version = readPackageVersion(packageRoot);
|
|
86
|
+
const fingerprint = sourceFingerprint(packageRoot);
|
|
87
|
+
const filename = `llm-wiki-${version}-${platform}-${arch}-${fingerprint}${platform === 'win32' ? '.exe' : ''}`;
|
|
88
|
+
return path.join(defaultCacheRoot(env), filename);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildBinary(packageRoot, output, env = process.env) {
|
|
92
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
93
|
+
const result = childProcess.spawnSync('go', ['build', '-o', output, './cmd/llm-wiki'], {
|
|
94
|
+
cwd: packageRoot,
|
|
95
|
+
env,
|
|
96
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
97
|
+
});
|
|
98
|
+
if (result.error) {
|
|
99
|
+
throw new Error(`Go toolchain is required to build llm-wiki for npx: ${result.error.message}`);
|
|
100
|
+
}
|
|
101
|
+
if (result.status !== 0) {
|
|
102
|
+
throw new Error(`go build failed with exit code ${result.status}`);
|
|
103
|
+
}
|
|
104
|
+
if (!isExecutable(output)) {
|
|
105
|
+
throw new Error(`go build did not create expected binary: ${output}`);
|
|
106
|
+
}
|
|
107
|
+
return output;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ensureBinary(options = {}) {
|
|
111
|
+
const env = options.env || process.env;
|
|
112
|
+
const packageRoot = options.packageRoot || env.LLM_WIKI_NPM_PACKAGE_ROOT || path.resolve(__dirname, '..', '..');
|
|
113
|
+
const bundled = findBundledBinary(packageRoot);
|
|
114
|
+
if (bundled) return bundled;
|
|
115
|
+
|
|
116
|
+
const cached = cacheBinaryPath(packageRoot, env);
|
|
117
|
+
if (isExecutable(cached) && env.LLM_WIKI_NPM_REBUILD !== '1') return cached;
|
|
118
|
+
return buildBinary(packageRoot, cached, env);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function exitCodeForSignal(signal) {
|
|
122
|
+
return SIGNAL_EXIT_CODES[signal] || 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function run(argv = process.argv.slice(2), options = {}) {
|
|
126
|
+
const env = options.env || process.env;
|
|
127
|
+
let binary;
|
|
128
|
+
try {
|
|
129
|
+
binary = ensureBinary({ env, packageRoot: options.packageRoot });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error(`llm-wiki npm wrapper: ${err.message}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const child = childProcess.spawn(binary, argv, {
|
|
136
|
+
env,
|
|
137
|
+
stdio: 'inherit',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let childExited = false;
|
|
141
|
+
child.on('error', (err) => {
|
|
142
|
+
console.error(`llm-wiki npm wrapper: failed to run ${binary}: ${err.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
|
145
|
+
child.on('exit', (code, signal) => {
|
|
146
|
+
childExited = true;
|
|
147
|
+
if (signal) {
|
|
148
|
+
process.exit(exitCodeForSignal(signal));
|
|
149
|
+
}
|
|
150
|
+
process.exit(code === null ? 1 : code);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
for (const signal of Object.keys(SIGNAL_EXIT_CODES)) {
|
|
154
|
+
process.on(signal, () => {
|
|
155
|
+
if (!childExited) child.kill(signal);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
cacheBinaryPath,
|
|
162
|
+
ensureBinary,
|
|
163
|
+
executableName,
|
|
164
|
+
findBundledBinary,
|
|
165
|
+
run,
|
|
166
|
+
sourceFingerprint,
|
|
167
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@m16khb/llm-wiki",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "npx wrapper for the llm-wiki Go MCP server and CLI",
|
|
5
|
+
"bin": {
|
|
6
|
+
"llm-wiki": "npm/bin/llm-wiki.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"cmd/",
|
|
10
|
+
"internal/",
|
|
11
|
+
"npm/bin/",
|
|
12
|
+
"npm/lib/",
|
|
13
|
+
"go.mod",
|
|
14
|
+
"go.sum",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test:npm": "node --test npm/test/*.test.js",
|
|
19
|
+
"build:go": "go build -o bin/llm-wiki ./cmd/llm-wiki"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
}
|
|
28
|
+
}
|