@m16khb/llm-wiki 0.1.1 → 0.1.2
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 +5 -1
- package/cmd/llm-wiki/hook_test.go +108 -0
- package/cmd/llm-wiki/main.go +7 -1
- package/go.mod +1 -1
- package/npm/lib/runner.js +50 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -236,6 +236,10 @@ UserPromptSubmit -> llm-wiki hook -> prompt keyword + project/tech terms로 wik
|
|
|
236
236
|
Stop -> llm-wiki hook -> assistant가 명시한 llm-wiki-capture block만 30-sessions/에 저장
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
`SessionStart`/`UserPromptSubmit` hook은 `suppressOutput: true`로 transcript 출력을 숨긴다. 실제 context는
|
|
240
|
+
`hookSpecificOutput.additionalContext`로 주입되지만, Codex/Claude transcript에 긴 `hook context:` 문자열이 빽빽하게
|
|
241
|
+
보이지 않게 하기 위한 UX 안전장치다.
|
|
242
|
+
|
|
239
243
|
`go.mod`가 있으면 Go/Golang을 감지하고, MCP SDK·SQLite·Gin·GORM·Redis 같은 dependency 신호, `package.json`/`bin` 기반 Node/npm/npx wrapper 신호, `swaggo` dependency나 `swagger.yaml`/`openapi.yaml` 같은 API spec 파일을 tech context로 별도 검색한다. 검색 결과는 현재 project와 맞는 문서를 우선하고, 다른 repository 전용 reference는 hook tech context에서 제외한다.
|
|
240
244
|
|
|
241
245
|
공용 wrapper는 `scripts/hooks/llm-wiki-hook.sh`다. `LLM_WIKI_BIN`이 있으면 그 binary를 쓰고, 없으면 `llm-wiki` PATH, 마지막으로 이 repo를 `npx --package <repo>`로 실행한다.
|
|
@@ -293,7 +297,7 @@ tool_timeout_sec = 60
|
|
|
293
297
|
|
|
294
298
|
`llm-wiki mcp`는 daemon을 거치지 않고 stdio MCP server가 직접 vault/service를 연다. stdio-only 환경의 비상/호환 경로이며, Claude Code와 Codex가 같은 daemon을 공유해야 하는 기본 운영에는 `mcp-autostart`를 사용한다.
|
|
295
299
|
|
|
296
|
-
npx wrapper는 기존 `bin/llm-wiki`가
|
|
300
|
+
npx wrapper는 기존 `bin/llm-wiki`가 있고 Go build input보다 최신이면 그대로 실행한다. bundled binary가 stale이거나 없으면 Go toolchain으로 CLI를 cache에 빌드한 뒤 실행한다. `LLM_WIKI_NPM_REBUILD=1`은 bundled/cache binary를 모두 무시하고 재빌드한다. MCP stdio 안전을 위해 wrapper 자체는 stdout에 진단을 쓰지 않는다. `--prefix /tmp`는 이 repo처럼 현재 프로젝트의 `package.json` 이름이 `@m16khb/llm-wiki`와 같을 때 npx가 local package로 오인하는 것을 피하기 위한 안전장치다.
|
|
297
301
|
|
|
298
302
|
## 개발 문서
|
|
299
303
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestContextHookSuppressesTranscriptOutput(t *testing.T) {
|
|
12
|
+
vaultRoot := t.TempDir()
|
|
13
|
+
writeTestFile(t, vaultRoot, "20-wiki/concepts/llm-wiki-hooks.md", `---
|
|
14
|
+
title: LLM Wiki Hooks
|
|
15
|
+
type: concept
|
|
16
|
+
status: active
|
|
17
|
+
created: 2026-05-23
|
|
18
|
+
updated: 2026-05-23
|
|
19
|
+
tags: [llm-wiki, hooks]
|
|
20
|
+
domain: agent-memory
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# LLM Wiki Hooks
|
|
24
|
+
|
|
25
|
+
Hook context for llm-wiki.
|
|
26
|
+
`)
|
|
27
|
+
projectRoot := filepath.Join(t.TempDir(), "llm-wiki")
|
|
28
|
+
writeTestFile(t, projectRoot, "go.mod", "module github.com/m16khb/llm-wiki\n")
|
|
29
|
+
|
|
30
|
+
stdout := captureRunHook(t, []string{"--vault", vaultRoot, "--limit", "5"}, `{
|
|
31
|
+
"hook_event_name": "UserPromptSubmit",
|
|
32
|
+
"cwd": "`+projectRoot+`",
|
|
33
|
+
"prompt": "hook output"
|
|
34
|
+
}`)
|
|
35
|
+
|
|
36
|
+
var payload struct {
|
|
37
|
+
SuppressOutput bool `json:"suppressOutput"`
|
|
38
|
+
HookSpecificOutput struct {
|
|
39
|
+
HookEventName string `json:"hookEventName"`
|
|
40
|
+
AdditionalContext string `json:"additionalContext"`
|
|
41
|
+
} `json:"hookSpecificOutput"`
|
|
42
|
+
}
|
|
43
|
+
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
|
44
|
+
t.Fatalf("hook output is not valid JSON: %v\n%s", err, stdout)
|
|
45
|
+
}
|
|
46
|
+
if !payload.SuppressOutput {
|
|
47
|
+
t.Fatalf("expected context hook to suppress transcript output, got:\n%s", stdout)
|
|
48
|
+
}
|
|
49
|
+
if payload.HookSpecificOutput.HookEventName != "UserPromptSubmit" {
|
|
50
|
+
t.Fatalf("unexpected event name: %q", payload.HookSpecificOutput.HookEventName)
|
|
51
|
+
}
|
|
52
|
+
if payload.HookSpecificOutput.AdditionalContext == "" {
|
|
53
|
+
t.Fatalf("expected additionalContext to remain available for model injection")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func captureRunHook(t *testing.T, args []string, stdin string) string {
|
|
58
|
+
t.Helper()
|
|
59
|
+
|
|
60
|
+
oldStdin := os.Stdin
|
|
61
|
+
oldStdout := os.Stdout
|
|
62
|
+
defer func() {
|
|
63
|
+
os.Stdin = oldStdin
|
|
64
|
+
os.Stdout = oldStdout
|
|
65
|
+
}()
|
|
66
|
+
|
|
67
|
+
inRead, inWrite, err := os.Pipe()
|
|
68
|
+
if err != nil {
|
|
69
|
+
t.Fatalf("pipe stdin: %v", err)
|
|
70
|
+
}
|
|
71
|
+
if _, err := inWrite.WriteString(stdin); err != nil {
|
|
72
|
+
t.Fatalf("write stdin: %v", err)
|
|
73
|
+
}
|
|
74
|
+
if err := inWrite.Close(); err != nil {
|
|
75
|
+
t.Fatalf("close stdin writer: %v", err)
|
|
76
|
+
}
|
|
77
|
+
defer inRead.Close()
|
|
78
|
+
|
|
79
|
+
outRead, outWrite, err := os.Pipe()
|
|
80
|
+
if err != nil {
|
|
81
|
+
t.Fatalf("pipe stdout: %v", err)
|
|
82
|
+
}
|
|
83
|
+
defer outRead.Close()
|
|
84
|
+
|
|
85
|
+
os.Stdin = inRead
|
|
86
|
+
os.Stdout = outWrite
|
|
87
|
+
runHook(args)
|
|
88
|
+
if err := outWrite.Close(); err != nil {
|
|
89
|
+
t.Fatalf("close stdout writer: %v", err)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
data, err := io.ReadAll(outRead)
|
|
93
|
+
if err != nil {
|
|
94
|
+
t.Fatalf("read stdout: %v", err)
|
|
95
|
+
}
|
|
96
|
+
return string(data)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func writeTestFile(t *testing.T, root, rel, content string) {
|
|
100
|
+
t.Helper()
|
|
101
|
+
abs := filepath.Join(root, filepath.FromSlash(rel))
|
|
102
|
+
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
103
|
+
t.Fatalf("mkdir %s: %v", rel, err)
|
|
104
|
+
}
|
|
105
|
+
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
106
|
+
t.Fatalf("write %s: %v", rel, err)
|
|
107
|
+
}
|
|
108
|
+
}
|
package/cmd/llm-wiki/main.go
CHANGED
|
@@ -581,7 +581,13 @@ func runContextHook(vaultRoot, projectPath, query string, limit int, event strin
|
|
|
581
581
|
printHookJSON(map[string]any{})
|
|
582
582
|
return
|
|
583
583
|
}
|
|
584
|
-
printHookJSON(map[string]any{
|
|
584
|
+
printHookJSON(map[string]any{
|
|
585
|
+
"suppressOutput": true,
|
|
586
|
+
"hookSpecificOutput": map[string]any{
|
|
587
|
+
"hookEventName": event,
|
|
588
|
+
"additionalContext": sessionctx.FormatAdditionalContext(ctx),
|
|
589
|
+
},
|
|
590
|
+
})
|
|
585
591
|
}
|
|
586
592
|
|
|
587
593
|
func runStopHook(vaultRoot, dbPath string, in hookInput) {
|
package/go.mod
CHANGED
package/npm/lib/runner.js
CHANGED
|
@@ -9,6 +9,9 @@ const SIGNAL_EXIT_CODES = {
|
|
|
9
9
|
SIGTERM: 143,
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
const FINGERPRINT_INPUT_ENTRIES = ['go.mod', 'go.sum', 'package.json', 'cmd', 'internal'];
|
|
13
|
+
const BINARY_INPUT_ENTRIES = ['go.mod', 'go.sum', 'cmd', 'internal'];
|
|
14
|
+
|
|
12
15
|
function executableName(platform = process.platform) {
|
|
13
16
|
return platform === 'win32' ? 'llm-wiki.exe' : 'llm-wiki';
|
|
14
17
|
}
|
|
@@ -25,7 +28,9 @@ function isExecutable(file) {
|
|
|
25
28
|
|
|
26
29
|
function findBundledBinary(packageRoot, platform = process.platform) {
|
|
27
30
|
const candidate = path.join(packageRoot, 'bin', executableName(platform));
|
|
28
|
-
|
|
31
|
+
if (!isExecutable(candidate)) return '';
|
|
32
|
+
if (isBundledBinaryStale(packageRoot, candidate)) return '';
|
|
33
|
+
return candidate;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function readPackageVersion(packageRoot) {
|
|
@@ -38,7 +43,11 @@ function readPackageVersion(packageRoot) {
|
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
function
|
|
46
|
+
function isBuildInput(file) {
|
|
47
|
+
return file.endsWith('.go') || ['go.mod', 'go.sum', 'package.json'].includes(path.basename(file));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function walkBuildInputs(root, file, visitor) {
|
|
42
51
|
let stat;
|
|
43
52
|
try {
|
|
44
53
|
stat = fs.statSync(file);
|
|
@@ -49,14 +58,23 @@ function hashFile(hash, root, file) {
|
|
|
49
58
|
if (stat.isDirectory()) {
|
|
50
59
|
const entries = fs.readdirSync(file).sort();
|
|
51
60
|
for (const entry of entries) {
|
|
52
|
-
|
|
61
|
+
walkBuildInputs(root, path.join(file, entry), visitor);
|
|
53
62
|
}
|
|
54
63
|
return;
|
|
55
64
|
}
|
|
56
|
-
|
|
57
|
-
if (!stat.isFile() || !isBuildInput) {
|
|
65
|
+
if (!stat.isFile() || !isBuildInput(file)) {
|
|
58
66
|
return;
|
|
59
67
|
}
|
|
68
|
+
visitor(file, stat);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function visitBuildInputs(packageRoot, entries, visitor) {
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
walkBuildInputs(packageRoot, path.join(packageRoot, entry), visitor);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hashFile(hash, root, file, stat) {
|
|
60
78
|
hash.update(path.relative(root, file));
|
|
61
79
|
hash.update('\0');
|
|
62
80
|
hash.update(String(stat.size));
|
|
@@ -67,12 +85,30 @@ function hashFile(hash, root, file) {
|
|
|
67
85
|
|
|
68
86
|
function sourceFingerprint(packageRoot) {
|
|
69
87
|
const hash = crypto.createHash('sha256');
|
|
70
|
-
|
|
71
|
-
hashFile(hash, packageRoot, path.join(packageRoot, entry));
|
|
72
|
-
}
|
|
88
|
+
visitBuildInputs(packageRoot, FINGERPRINT_INPUT_ENTRIES, (file, stat) => hashFile(hash, packageRoot, file, stat));
|
|
73
89
|
return hash.digest('hex').slice(0, 16);
|
|
74
90
|
}
|
|
75
91
|
|
|
92
|
+
function latestBuildInputMTime(packageRoot) {
|
|
93
|
+
let latest = 0;
|
|
94
|
+
visitBuildInputs(packageRoot, BINARY_INPUT_ENTRIES, (_file, stat) => {
|
|
95
|
+
if (stat.mtimeMs > latest) latest = stat.mtimeMs;
|
|
96
|
+
});
|
|
97
|
+
return latest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isBundledBinaryStale(packageRoot, binary) {
|
|
101
|
+
let binaryStat;
|
|
102
|
+
try {
|
|
103
|
+
binaryStat = fs.statSync(binary);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err && err.code === 'ENOENT') return true;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
const latestInput = latestBuildInputMTime(packageRoot);
|
|
109
|
+
return latestInput > binaryStat.mtimeMs;
|
|
110
|
+
}
|
|
111
|
+
|
|
76
112
|
function defaultCacheRoot(env = process.env) {
|
|
77
113
|
if (env.LLM_WIKI_NPM_CACHE_DIR) return env.LLM_WIKI_NPM_CACHE_DIR;
|
|
78
114
|
if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'llm-wiki', 'npm-wrapper');
|
|
@@ -110,8 +146,10 @@ function buildBinary(packageRoot, output, env = process.env) {
|
|
|
110
146
|
function ensureBinary(options = {}) {
|
|
111
147
|
const env = options.env || process.env;
|
|
112
148
|
const packageRoot = options.packageRoot || env.LLM_WIKI_NPM_PACKAGE_ROOT || path.resolve(__dirname, '..', '..');
|
|
113
|
-
|
|
114
|
-
|
|
149
|
+
if (env.LLM_WIKI_NPM_REBUILD !== '1') {
|
|
150
|
+
const bundled = findBundledBinary(packageRoot);
|
|
151
|
+
if (bundled) return bundled;
|
|
152
|
+
}
|
|
115
153
|
|
|
116
154
|
const cached = cacheBinaryPath(packageRoot, env);
|
|
117
155
|
if (isExecutable(cached) && env.LLM_WIKI_NPM_REBUILD !== '1') return cached;
|
|
@@ -162,6 +200,8 @@ module.exports = {
|
|
|
162
200
|
ensureBinary,
|
|
163
201
|
executableName,
|
|
164
202
|
findBundledBinary,
|
|
203
|
+
isBundledBinaryStale,
|
|
204
|
+
latestBuildInputMTime,
|
|
165
205
|
run,
|
|
166
206
|
sourceFingerprint,
|
|
167
207
|
};
|