@m16khb/llm-wiki 0.1.0 → 0.1.1
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 +196 -47
- package/cmd/llm-wiki/main.go +451 -11
- package/internal/mcpautostart/autostart.go +285 -0
- package/internal/mcpautostart/autostart_test.go +169 -0
- package/internal/mcpautostart/detach_unix.go +9 -0
- package/internal/mcpautostart/detach_windows.go +7 -0
- package/internal/mcpautostart/proxy.go +162 -0
- package/internal/sessionctx/sessionctx.go +649 -0
- package/internal/sessionctx/sessionctx_test.go +415 -0
- package/internal/wiki/cleanup.go +1189 -0
- package/internal/wiki/cleanup_plan.go +569 -0
- package/internal/wiki/cleanup_test.go +450 -0
- package/internal/wiki/init.go +77 -0
- package/internal/wiki/vault_test.go +41 -0
- package/package.json +1 -1
package/cmd/llm-wiki/main.go
CHANGED
|
@@ -3,19 +3,24 @@ package main
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"encoding/json"
|
|
6
|
+
"errors"
|
|
6
7
|
"flag"
|
|
7
8
|
"fmt"
|
|
9
|
+
"io"
|
|
8
10
|
"log"
|
|
9
11
|
"os"
|
|
10
12
|
"os/signal"
|
|
11
13
|
"strings"
|
|
12
14
|
"syscall"
|
|
15
|
+
"time"
|
|
13
16
|
|
|
14
17
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
15
18
|
|
|
16
19
|
"github.com/m16khb/llm-wiki/internal/daemon"
|
|
20
|
+
"github.com/m16khb/llm-wiki/internal/mcpautostart"
|
|
17
21
|
"github.com/m16khb/llm-wiki/internal/mcpserver"
|
|
18
22
|
"github.com/m16khb/llm-wiki/internal/service"
|
|
23
|
+
"github.com/m16khb/llm-wiki/internal/sessionctx"
|
|
19
24
|
"github.com/m16khb/llm-wiki/internal/store"
|
|
20
25
|
"github.com/m16khb/llm-wiki/internal/wiki"
|
|
21
26
|
)
|
|
@@ -28,8 +33,12 @@ func main() {
|
|
|
28
33
|
}
|
|
29
34
|
cmd := os.Args[1]
|
|
30
35
|
switch cmd {
|
|
36
|
+
case "init":
|
|
37
|
+
runInit(os.Args[2:])
|
|
31
38
|
case "mcp":
|
|
32
39
|
runMCP(os.Args[2:])
|
|
40
|
+
case "mcp-autostart":
|
|
41
|
+
runMCPAutostart(os.Args[2:])
|
|
33
42
|
case "serve", "daemon":
|
|
34
43
|
runServe(os.Args[2:])
|
|
35
44
|
case "jobs", "queue":
|
|
@@ -44,6 +53,14 @@ func main() {
|
|
|
44
53
|
runLint(os.Args[2:])
|
|
45
54
|
case "capture":
|
|
46
55
|
runCapture(os.Args[2:])
|
|
56
|
+
case "cleanup":
|
|
57
|
+
runCleanup(os.Args[2:])
|
|
58
|
+
case "session-context":
|
|
59
|
+
runSessionContext(os.Args[2:])
|
|
60
|
+
case "session-capture":
|
|
61
|
+
runSessionCapture(os.Args[2:])
|
|
62
|
+
case "hook":
|
|
63
|
+
runHook(os.Args[2:])
|
|
47
64
|
case "version", "--version", "-v":
|
|
48
65
|
fmt.Println(mcpserver.Version)
|
|
49
66
|
case "help", "--help", "-h":
|
|
@@ -59,14 +76,22 @@ func usage() {
|
|
|
59
76
|
fmt.Fprintf(os.Stderr, `llm-wiki %s
|
|
60
77
|
|
|
61
78
|
Usage:
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
llm-wiki init [--vault PATH] [--json]
|
|
80
|
+
llm-wiki serve [--vault PATH] [--db PATH] [--addr 127.0.0.1:39233]
|
|
81
|
+
llm-wiki mcp [--vault PATH] [--db PATH]
|
|
82
|
+
llm-wiki mcp-autostart [--vault PATH] [--db PATH] [--addr 127.0.0.1:39233]
|
|
64
83
|
llm-wiki info [--vault PATH] [--json]
|
|
65
84
|
llm-wiki search [--vault PATH] [--limit N] [--root FOLDER] QUERY
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
llm-wiki read [--vault PATH] [--max-bytes N] PAGE_OR_PATH
|
|
86
|
+
llm-wiki lint [--vault PATH] [--json]
|
|
87
|
+
llm-wiki capture [--vault PATH] [--db PATH] --title TITLE --body BODY [--type concept] [--tags a,b]
|
|
88
|
+
llm-wiki cleanup [--vault PATH] [--scope repository-references|duplicate-slugs] [--archive-root PATH] [--apply] [--json]
|
|
89
|
+
llm-wiki cleanup analyze [--vault PATH] [--scope repository-references|duplicate-slugs] [--archive-root PATH] [--json]
|
|
90
|
+
llm-wiki cleanup apply-plan [--vault PATH] --plan FILE [--apply] [--json]
|
|
91
|
+
llm-wiki session-context [--vault PATH] [--project PATH] [--query TEXT] [--limit N] [--json]
|
|
92
|
+
llm-wiki session-capture [--vault PATH] [--db PATH] [--project PATH] [--phase progress|end] --body BODY
|
|
93
|
+
llm-wiki hook [--vault PATH] [--db PATH] [--limit N]
|
|
94
|
+
llm-wiki jobs [--db PATH] [--limit N]
|
|
70
95
|
|
|
71
96
|
Environment:
|
|
72
97
|
LLM_WIKI_ROOT or LLM_WIKI_VAULT can provide the default vault path.
|
|
@@ -83,6 +108,29 @@ func dbFlag(fs *flag.FlagSet) *string {
|
|
|
83
108
|
return fs.String("db", "", "SQLite queue/cache database path")
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
func runInit(args []string) {
|
|
112
|
+
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
|
113
|
+
vaultRoot := vaultFlag(fs)
|
|
114
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
115
|
+
_ = fs.Parse(args)
|
|
116
|
+
result, err := wiki.InitVault(*vaultRoot)
|
|
117
|
+
must(err)
|
|
118
|
+
if *jsonOut {
|
|
119
|
+
printJSON(result)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
fmt.Printf("initialized %s\n", result.Root)
|
|
123
|
+
if result.CreatedRoot {
|
|
124
|
+
fmt.Println("created root")
|
|
125
|
+
}
|
|
126
|
+
for _, dir := range result.CreatedDirs {
|
|
127
|
+
fmt.Printf("created %s\n", dir)
|
|
128
|
+
}
|
|
129
|
+
if len(result.CreatedDirs) == 0 && !result.CreatedRoot {
|
|
130
|
+
fmt.Println("standard folders already exist")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
86
134
|
func runMCP(args []string) {
|
|
87
135
|
fs := flag.NewFlagSet("mcp", flag.ExitOnError)
|
|
88
136
|
vaultRoot := vaultFlag(fs)
|
|
@@ -97,12 +145,33 @@ func runMCP(args []string) {
|
|
|
97
145
|
}
|
|
98
146
|
}
|
|
99
147
|
|
|
148
|
+
func runMCPAutostart(args []string) {
|
|
149
|
+
fs := flag.NewFlagSet("mcp-autostart", flag.ExitOnError)
|
|
150
|
+
vaultRoot := vaultFlag(fs)
|
|
151
|
+
dbPath := dbFlag(fs)
|
|
152
|
+
addr := fs.String("addr", "127.0.0.1:39233", "HTTP listen address for the shared daemon")
|
|
153
|
+
startupTimeout := fs.Duration("startup-timeout", 15*time.Second, "maximum time to wait for daemon startup")
|
|
154
|
+
toolTimeout := fs.Duration("tool-timeout", 60*time.Second, "maximum time for proxied daemon tool calls")
|
|
155
|
+
initVault := fs.Bool("init-vault", true, "create standard vault folders before starting daemon when missing")
|
|
156
|
+
_ = fs.Parse(args)
|
|
157
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
158
|
+
defer stop()
|
|
159
|
+
if err := mcpautostart.Run(ctx, mcpautostart.Config{VaultRoot: *vaultRoot, DBPath: *dbPath, Addr: *addr, StartupTimeout: *startupTimeout, ToolTimeout: *toolTimeout, InitVault: *initVault}); err != nil {
|
|
160
|
+
log.Fatalf("llm-wiki mcp-autostart: %v", err)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
100
164
|
func runServe(args []string) {
|
|
101
165
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
|
102
166
|
vaultRoot := vaultFlag(fs)
|
|
103
167
|
dbPath := dbFlag(fs)
|
|
104
168
|
addr := fs.String("addr", "127.0.0.1:39233", "HTTP listen address for streamable MCP")
|
|
169
|
+
initVault := fs.Bool("init-vault", false, "create standard vault folders before starting daemon when missing")
|
|
105
170
|
_ = fs.Parse(args)
|
|
171
|
+
if *initVault {
|
|
172
|
+
_, err := wiki.InitVault(*vaultRoot)
|
|
173
|
+
must(err)
|
|
174
|
+
}
|
|
106
175
|
srv, err := daemon.New(daemon.Config{VaultRoot: *vaultRoot, DBPath: *dbPath, Addr: *addr})
|
|
107
176
|
if err != nil {
|
|
108
177
|
log.Fatalf("llm-wiki serve: %v", err)
|
|
@@ -210,16 +279,242 @@ func runCapture(args []string) {
|
|
|
210
279
|
overwrite := fs.Bool("overwrite", false, "overwrite existing page")
|
|
211
280
|
jsonOut := fs.Bool("json", false, "print JSON")
|
|
212
281
|
_ = fs.Parse(args)
|
|
213
|
-
bodyText := *body
|
|
214
|
-
|
|
215
|
-
|
|
282
|
+
bodyText, err := readBody(*body, *bodyFile)
|
|
283
|
+
must(err)
|
|
284
|
+
svc, err := service.New(service.Config{VaultRoot: *vaultRoot, DBPath: *dbPath})
|
|
285
|
+
must(err)
|
|
286
|
+
defer svc.Close()
|
|
287
|
+
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})
|
|
288
|
+
must(err)
|
|
289
|
+
if *jsonOut {
|
|
290
|
+
printJSON(result)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
fmt.Printf("wrote %s\n", result.Path)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func runCleanup(args []string) {
|
|
297
|
+
if len(args) > 0 {
|
|
298
|
+
switch args[0] {
|
|
299
|
+
case "analyze":
|
|
300
|
+
runCleanupAnalyze(args[1:])
|
|
301
|
+
return
|
|
302
|
+
case "apply-plan":
|
|
303
|
+
runCleanupApplyPlan(args[1:])
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
fs := flag.NewFlagSet("cleanup", flag.ExitOnError)
|
|
308
|
+
vaultRoot := vaultFlag(fs)
|
|
309
|
+
scope := fs.String("scope", "repository-references", "cleanup scope: repository-references or duplicate-slugs")
|
|
310
|
+
archiveRoot := fs.String("archive-root", "", "vault-relative archive root for moved fragments")
|
|
311
|
+
apply := fs.Bool("apply", false, "write changes; default is dry-run")
|
|
312
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
313
|
+
_ = fs.Parse(args)
|
|
314
|
+
v := mustVault(*vaultRoot)
|
|
315
|
+
switch *scope {
|
|
316
|
+
case "repository-references":
|
|
317
|
+
result, err := v.CleanupRepositoryReferences(wiki.RepositoryReferenceCleanupOptions{Apply: *apply, ArchiveRoot: *archiveRoot})
|
|
216
318
|
must(err)
|
|
217
|
-
|
|
319
|
+
if *jsonOut {
|
|
320
|
+
printJSON(result)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
printRepositoryReferenceCleanup(result)
|
|
324
|
+
case "duplicate-slugs":
|
|
325
|
+
result, err := v.CleanupDuplicateSlugs(wiki.DuplicateSlugCleanupOptions{Apply: *apply})
|
|
326
|
+
must(err)
|
|
327
|
+
if *jsonOut {
|
|
328
|
+
printJSON(result)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
printDuplicateSlugCleanup(result)
|
|
332
|
+
default:
|
|
333
|
+
must(fmt.Errorf("unsupported cleanup scope: %s", *scope))
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
func runCleanupAnalyze(args []string) {
|
|
338
|
+
fs := flag.NewFlagSet("cleanup analyze", flag.ExitOnError)
|
|
339
|
+
vaultRoot := vaultFlag(fs)
|
|
340
|
+
scope := fs.String("scope", "repository-references", "cleanup scope: repository-references or duplicate-slugs")
|
|
341
|
+
archiveRoot := fs.String("archive-root", "", "vault-relative archive root for moved fragments")
|
|
342
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
343
|
+
_ = fs.Parse(args)
|
|
344
|
+
v := mustVault(*vaultRoot)
|
|
345
|
+
analysis, err := v.AnalyzeCleanup(wiki.CleanupAnalyzeOptions{Scope: *scope, ArchiveRoot: *archiveRoot})
|
|
346
|
+
must(err)
|
|
347
|
+
if *jsonOut {
|
|
348
|
+
printJSON(analysis)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
printCleanupAnalysis(analysis)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
func runCleanupApplyPlan(args []string) {
|
|
355
|
+
fs := flag.NewFlagSet("cleanup apply-plan", flag.ExitOnError)
|
|
356
|
+
vaultRoot := vaultFlag(fs)
|
|
357
|
+
planPath := fs.String("plan", "", "JSON cleanup plan file")
|
|
358
|
+
apply := fs.Bool("apply", false, "write changes; default is dry-run")
|
|
359
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
360
|
+
_ = fs.Parse(args)
|
|
361
|
+
if strings.TrimSpace(*planPath) == "" {
|
|
362
|
+
must(errors.New("--plan is required"))
|
|
363
|
+
}
|
|
364
|
+
plan, err := readCleanupPlan(*planPath)
|
|
365
|
+
must(err)
|
|
366
|
+
v := mustVault(*vaultRoot)
|
|
367
|
+
result, err := v.ApplyCleanupPlan(wiki.CleanupPlanApplyOptions{Apply: *apply, Plan: plan})
|
|
368
|
+
must(err)
|
|
369
|
+
if *jsonOut {
|
|
370
|
+
printJSON(result)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
printCleanupPlanApply(result)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
func readCleanupPlan(path string) (wiki.CleanupPlan, error) {
|
|
377
|
+
data, err := os.ReadFile(path)
|
|
378
|
+
if err != nil {
|
|
379
|
+
return wiki.CleanupPlan{}, err
|
|
380
|
+
}
|
|
381
|
+
var plan wiki.CleanupPlan
|
|
382
|
+
if err := json.Unmarshal(data, &plan); err != nil {
|
|
383
|
+
return wiki.CleanupPlan{}, err
|
|
384
|
+
}
|
|
385
|
+
return plan, nil
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func printRepositoryReferenceCleanup(result wiki.RepositoryReferenceCleanupResult) {
|
|
389
|
+
if result.Applied {
|
|
390
|
+
fmt.Printf("applied %s cleanup: bundles=%d archive=%s\n", result.Scope, len(result.Bundles), result.ArchiveRoot)
|
|
391
|
+
} else {
|
|
392
|
+
fmt.Printf("dry-run %s cleanup: bundles=%d archive=%s\n", result.Scope, len(result.Bundles), result.ArchiveRoot)
|
|
393
|
+
fmt.Println("re-run with --apply to write changes")
|
|
394
|
+
}
|
|
395
|
+
for _, bundle := range result.Bundles {
|
|
396
|
+
fmt.Printf("- %s -> %s (%d fragments)\n", bundle.Repository, bundle.BundlePath, len(bundle.FragmentPaths))
|
|
397
|
+
}
|
|
398
|
+
if len(result.SkippedProtectedFiles) > 0 {
|
|
399
|
+
fmt.Printf("protected files skipped: %d\n", len(result.SkippedProtectedFiles))
|
|
400
|
+
}
|
|
401
|
+
if len(result.ReportPaths) > 0 {
|
|
402
|
+
fmt.Printf("reports: %s\n", strings.Join(result.ReportPaths, ", "))
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func printDuplicateSlugCleanup(result wiki.DuplicateSlugCleanupResult) {
|
|
407
|
+
if result.Applied {
|
|
408
|
+
fmt.Printf("applied %s cleanup: renames=%d\n", result.Scope, len(result.Renames))
|
|
409
|
+
} else {
|
|
410
|
+
fmt.Printf("dry-run %s cleanup: renames=%d\n", result.Scope, len(result.Renames))
|
|
411
|
+
fmt.Println("re-run with --apply to write changes")
|
|
412
|
+
}
|
|
413
|
+
for _, rename := range result.Renames {
|
|
414
|
+
fmt.Printf("- %s: %s -> %s (canonical %s)\n", rename.Slug, rename.FromPath, rename.ToPath, rename.CanonicalPath)
|
|
415
|
+
}
|
|
416
|
+
if len(result.Warnings) > 0 {
|
|
417
|
+
fmt.Printf("warnings: %d\n", len(result.Warnings))
|
|
418
|
+
}
|
|
419
|
+
if len(result.ReportPaths) > 0 {
|
|
420
|
+
fmt.Printf("reports: %s\n", strings.Join(result.ReportPaths, ", "))
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func printCleanupAnalysis(result wiki.CleanupAnalysis) {
|
|
425
|
+
fmt.Printf("analysis %s cleanup: candidates=%d\n", result.Scope, result.CandidateCount)
|
|
426
|
+
fmt.Println("write a JSON plan, validate with `cleanup apply-plan --plan FILE --json`, then add --apply to write")
|
|
427
|
+
for _, candidate := range result.Candidates {
|
|
428
|
+
fmt.Printf("- %s [%s]: %s\n", candidate.ID, candidate.Kind, candidate.Reason)
|
|
429
|
+
for _, file := range candidate.Files {
|
|
430
|
+
if file.TargetPath != "" {
|
|
431
|
+
fmt.Printf(" - %s: %s -> %s\n", file.Role, file.Path, file.TargetPath)
|
|
432
|
+
} else {
|
|
433
|
+
fmt.Printf(" - %s: %s\n", file.Role, file.Path)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if len(result.Warnings) > 0 {
|
|
438
|
+
fmt.Printf("warnings: %d\n", len(result.Warnings))
|
|
218
439
|
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
func printCleanupPlanApply(result wiki.CleanupPlanApplyResult) {
|
|
443
|
+
if result.Applied {
|
|
444
|
+
fmt.Printf("applied cleanup plan: actions=%d\n", len(result.Actions))
|
|
445
|
+
} else {
|
|
446
|
+
fmt.Printf("dry-run cleanup plan: actions=%d\n", len(result.Actions))
|
|
447
|
+
fmt.Println("re-run with --apply to write changes")
|
|
448
|
+
}
|
|
449
|
+
for _, action := range result.Actions {
|
|
450
|
+
switch action.Type {
|
|
451
|
+
case "rename_page":
|
|
452
|
+
fmt.Printf("- rename_page: %s -> %s\n", action.FromPath, action.ToPath)
|
|
453
|
+
default:
|
|
454
|
+
fmt.Printf("- %s %s: planned_changes=%d\n", action.Type, action.Scope, action.PlannedChanges)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if len(result.ReportPaths) > 0 {
|
|
458
|
+
fmt.Printf("reports: %s\n", strings.Join(result.ReportPaths, ", "))
|
|
459
|
+
}
|
|
460
|
+
if len(result.Warnings) > 0 {
|
|
461
|
+
fmt.Printf("warnings: %d\n", len(result.Warnings))
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
func runSessionContext(args []string) {
|
|
466
|
+
fs := flag.NewFlagSet("session-context", flag.ExitOnError)
|
|
467
|
+
vaultRoot := vaultFlag(fs)
|
|
468
|
+
projectPath := fs.String("project", "", "project root path, default current directory")
|
|
469
|
+
query := fs.String("query", "", "optional prompt or keywords to combine with project terms")
|
|
470
|
+
limit := fs.Int("limit", 5, "max wiki matches")
|
|
471
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
472
|
+
_ = fs.Parse(args)
|
|
473
|
+
_, err := wiki.InitVault(*vaultRoot)
|
|
474
|
+
must(err)
|
|
475
|
+
v := mustVault(*vaultRoot)
|
|
476
|
+
ctx, err := sessionctx.BuildContext(v, sessionctx.Options{ProjectPath: *projectPath, Query: *query, Limit: *limit})
|
|
477
|
+
must(err)
|
|
478
|
+
if *jsonOut {
|
|
479
|
+
printJSON(ctx)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
fmt.Print(sessionctx.FormatAdditionalContext(ctx))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
func runSessionCapture(args []string) {
|
|
486
|
+
fs := flag.NewFlagSet("session-capture", flag.ExitOnError)
|
|
487
|
+
vaultRoot := vaultFlag(fs)
|
|
488
|
+
dbPath := dbFlag(fs)
|
|
489
|
+
projectPath := fs.String("project", "", "project root path, default current directory")
|
|
490
|
+
phase := fs.String("phase", "progress", "session phase such as progress or end")
|
|
491
|
+
title := fs.String("title", "", "capture title")
|
|
492
|
+
body := fs.String("body", "", "curated markdown body")
|
|
493
|
+
bodyFile := fs.String("body-file", "", "read markdown body from file, or - for stdin")
|
|
494
|
+
tags := fs.String("tags", "", "comma-separated tags")
|
|
495
|
+
slug := fs.String("slug", "", "optional slug without .md")
|
|
496
|
+
overwrite := fs.Bool("overwrite", false, "overwrite existing page")
|
|
497
|
+
jsonOut := fs.Bool("json", false, "print JSON")
|
|
498
|
+
_ = fs.Parse(args)
|
|
499
|
+
bodyText, err := readBody(*body, *bodyFile)
|
|
500
|
+
must(err)
|
|
501
|
+
project, err := sessionctx.InspectProject(*projectPath)
|
|
502
|
+
must(err)
|
|
503
|
+
if *title == "" {
|
|
504
|
+
*title = "Session " + *phase + ": " + project.Name
|
|
505
|
+
}
|
|
506
|
+
allTags := append([]string{"session", strings.TrimSpace(*phase), wiki.Slugify(project.Name)}, splitComma(*tags)...)
|
|
507
|
+
wikiBody := fmt.Sprintf("## Project\n\n- Name: `%s`\n- Path: `%s`\n- Phase: `%s`\n\n## Capture\n\n%s", project.Name, project.Path, *phase, strings.TrimSpace(bodyText))
|
|
508
|
+
_, err = wiki.InitVault(*vaultRoot)
|
|
509
|
+
must(err)
|
|
219
510
|
svc, err := service.New(service.Config{VaultRoot: *vaultRoot, DBPath: *dbPath})
|
|
220
511
|
must(err)
|
|
221
512
|
defer svc.Close()
|
|
222
|
-
|
|
513
|
+
captureSlug := *slug
|
|
514
|
+
if captureSlug == "" {
|
|
515
|
+
captureSlug = wiki.Slugify(*title) + "-" + time.Now().Format("20060102-150405")
|
|
516
|
+
}
|
|
517
|
+
result, err := svc.Capture(wiki.CaptureInput{Title: *title, Body: wikiBody, Type: "session", Status: "active", Domain: "session", Tags: compactStrings(allTags), Slug: captureSlug, Overwrite: *overwrite})
|
|
223
518
|
must(err)
|
|
224
519
|
if *jsonOut {
|
|
225
520
|
printJSON(result)
|
|
@@ -228,6 +523,102 @@ func runCapture(args []string) {
|
|
|
228
523
|
fmt.Printf("wrote %s\n", result.Path)
|
|
229
524
|
}
|
|
230
525
|
|
|
526
|
+
type hookInput struct {
|
|
527
|
+
HookEventName string `json:"hook_event_name"`
|
|
528
|
+
CWD string `json:"cwd"`
|
|
529
|
+
Prompt string `json:"prompt"`
|
|
530
|
+
LastAssistantMessage string `json:"last_assistant_message"`
|
|
531
|
+
StopHookActive bool `json:"stop_hook_active"`
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
func runHook(args []string) {
|
|
535
|
+
fs := flag.NewFlagSet("hook", flag.ExitOnError)
|
|
536
|
+
vaultRoot := vaultFlag(fs)
|
|
537
|
+
dbPath := dbFlag(fs)
|
|
538
|
+
limit := fs.Int("limit", 5, "max wiki matches")
|
|
539
|
+
_ = fs.Parse(args)
|
|
540
|
+
data, err := io.ReadAll(os.Stdin)
|
|
541
|
+
if err != nil {
|
|
542
|
+
printHookWarning("read hook input: " + err.Error())
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
var in hookInput
|
|
546
|
+
if len(strings.TrimSpace(string(data))) > 0 {
|
|
547
|
+
if err := json.Unmarshal(data, &in); err != nil {
|
|
548
|
+
printHookWarning("parse hook input: " + err.Error())
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
event := strings.TrimSpace(in.HookEventName)
|
|
553
|
+
switch strings.ToLower(event) {
|
|
554
|
+
case "sessionstart", "session_start":
|
|
555
|
+
runContextHook(*vaultRoot, in.CWD, "", *limit, firstNonEmptyString(event, "SessionStart"), true)
|
|
556
|
+
case "userpromptsubmit", "user_prompt_submit":
|
|
557
|
+
runContextHook(*vaultRoot, in.CWD, in.Prompt, *limit, firstNonEmptyString(event, "UserPromptSubmit"), false)
|
|
558
|
+
case "stop":
|
|
559
|
+
runStopHook(*vaultRoot, *dbPath, in)
|
|
560
|
+
default:
|
|
561
|
+
printHookJSON(map[string]any{})
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
func runContextHook(vaultRoot, projectPath, query string, limit int, event string, includeEmpty bool) {
|
|
566
|
+
if _, err := wiki.InitVault(vaultRoot); err != nil {
|
|
567
|
+
printHookWarning("llm-wiki init failed: " + err.Error())
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
v, err := wiki.New(vaultRoot)
|
|
571
|
+
if err != nil {
|
|
572
|
+
printHookWarning("llm-wiki open failed: " + err.Error())
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
ctx, err := sessionctx.BuildContext(v, sessionctx.Options{ProjectPath: projectPath, Query: query, Limit: limit})
|
|
576
|
+
if err != nil {
|
|
577
|
+
printHookWarning("llm-wiki context failed: " + err.Error())
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
if !includeEmpty && len(ctx.Results) == 0 {
|
|
581
|
+
printHookJSON(map[string]any{})
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
printHookJSON(map[string]any{"hookSpecificOutput": map[string]any{"hookEventName": event, "additionalContext": sessionctx.FormatAdditionalContext(ctx)}})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
func runStopHook(vaultRoot, dbPath string, in hookInput) {
|
|
588
|
+
if in.StopHookActive {
|
|
589
|
+
printHookJSON(map[string]any{})
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
block, ok := sessionctx.ExtractCaptureBlock(in.LastAssistantMessage)
|
|
593
|
+
if !ok {
|
|
594
|
+
printHookJSON(map[string]any{})
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
project, err := sessionctx.InspectProject(in.CWD)
|
|
598
|
+
if err != nil {
|
|
599
|
+
printHookWarning("llm-wiki project inspect failed: " + err.Error())
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
if _, err := wiki.InitVault(vaultRoot); err != nil {
|
|
603
|
+
printHookWarning("llm-wiki init failed: " + err.Error())
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
svc, err := service.New(service.Config{VaultRoot: vaultRoot, DBPath: dbPath})
|
|
607
|
+
if err != nil {
|
|
608
|
+
printHookWarning("llm-wiki service failed: " + err.Error())
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
defer svc.Close()
|
|
612
|
+
slug := wiki.Slugify(block.Title) + "-" + time.Now().Format("20060102-150405")
|
|
613
|
+
body := fmt.Sprintf("## Project\n\n- Name: `%s`\n- Path: `%s`\n- Phase: `stop`\n\n## Capture\n\n%s", project.Name, project.Path, strings.TrimSpace(block.Body))
|
|
614
|
+
result, err := svc.Capture(wiki.CaptureInput{Title: block.Title, Body: body, Type: "session", Status: "active", Domain: "session", Tags: compactStrings(append([]string{"session", "stop", wiki.Slugify(project.Name)}, block.Tags...)), Slug: slug})
|
|
615
|
+
if err != nil {
|
|
616
|
+
printHookWarning("llm-wiki capture failed: " + err.Error())
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
printHookJSON(map[string]any{"systemMessage": "llm-wiki captured selected session note: " + result.Path})
|
|
620
|
+
}
|
|
621
|
+
|
|
231
622
|
func runJobs(args []string) {
|
|
232
623
|
fs := flag.NewFlagSet("jobs", flag.ExitOnError)
|
|
233
624
|
dbPath := dbFlag(fs)
|
|
@@ -260,6 +651,55 @@ func printJSON(v any) {
|
|
|
260
651
|
must(enc.Encode(v))
|
|
261
652
|
}
|
|
262
653
|
|
|
654
|
+
func printHookJSON(v any) {
|
|
655
|
+
enc := json.NewEncoder(os.Stdout)
|
|
656
|
+
_ = enc.Encode(v)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
func printHookWarning(message string) {
|
|
660
|
+
printHookJSON(map[string]any{"systemMessage": message})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
func readBody(body, bodyFile string) (string, error) {
|
|
664
|
+
if bodyFile == "" {
|
|
665
|
+
return body, nil
|
|
666
|
+
}
|
|
667
|
+
var data []byte
|
|
668
|
+
var err error
|
|
669
|
+
if bodyFile == "-" {
|
|
670
|
+
data, err = io.ReadAll(os.Stdin)
|
|
671
|
+
} else {
|
|
672
|
+
data, err = os.ReadFile(bodyFile)
|
|
673
|
+
}
|
|
674
|
+
if err != nil {
|
|
675
|
+
return "", err
|
|
676
|
+
}
|
|
677
|
+
return string(data), nil
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
func compactStrings(values []string) []string {
|
|
681
|
+
seen := map[string]bool{}
|
|
682
|
+
out := make([]string, 0, len(values))
|
|
683
|
+
for _, v := range values {
|
|
684
|
+
v = strings.TrimSpace(v)
|
|
685
|
+
if v == "" || seen[v] {
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
seen[v] = true
|
|
689
|
+
out = append(out, v)
|
|
690
|
+
}
|
|
691
|
+
return out
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
func firstNonEmptyString(values ...string) string {
|
|
695
|
+
for _, v := range values {
|
|
696
|
+
if strings.TrimSpace(v) != "" {
|
|
697
|
+
return v
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return ""
|
|
701
|
+
}
|
|
702
|
+
|
|
263
703
|
func splitComma(s string) []string {
|
|
264
704
|
var out []string
|
|
265
705
|
for _, part := range strings.Split(s, ",") {
|