@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.
@@ -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
- llm-wiki serve [--vault PATH] [--db PATH] [--addr 127.0.0.1:39233]
63
- llm-wiki mcp [--vault PATH] [--db PATH]
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
- 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]
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
- if *bodyFile != "" {
215
- data, err := os.ReadFile(*bodyFile)
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
- bodyText = string(data)
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
- 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})
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, ",") {