@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.
@@ -0,0 +1,649 @@
1
+ package sessionctx
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "regexp"
9
+ "sort"
10
+ "strings"
11
+
12
+ "github.com/m16khb/llm-wiki/internal/wiki"
13
+ )
14
+
15
+ // Options controls project-aware wiki context lookup for lifecycle hooks.
16
+ type Options struct {
17
+ ProjectPath string
18
+ Query string
19
+ Limit int
20
+ }
21
+
22
+ // Project describes the current coding workspace using small deterministic cues.
23
+ type Project struct {
24
+ Path string `json:"path"`
25
+ Name string `json:"name"`
26
+ Terms []string `json:"terms"`
27
+ TechTerms []string `json:"tech_terms,omitempty"`
28
+ Sources []string `json:"sources,omitempty"`
29
+ }
30
+
31
+ // Context is the hook-friendly wiki context bundle.
32
+ type Context struct {
33
+ Project Project `json:"project"`
34
+ Query string `json:"query"`
35
+ Results []wiki.SearchResult `json:"results"`
36
+ TechResults []wiki.SearchResult `json:"tech_results,omitempty"`
37
+ }
38
+
39
+ // CaptureBlock is a selected note block emitted by an agent for durable capture.
40
+ type CaptureBlock struct {
41
+ Title string `json:"title"`
42
+ Tags []string `json:"tags,omitempty"`
43
+ Body string `json:"body"`
44
+ }
45
+
46
+ var tokenSplit = regexp.MustCompile(`[^A-Za-z0-9가-힣]+`)
47
+
48
+ // BuildContext derives project terms, combines them with an optional prompt
49
+ // query, and searches the configured LLM Wiki vault.
50
+ func BuildContext(v *wiki.Vault, opts Options) (Context, error) {
51
+ project, err := InspectProject(opts.ProjectPath)
52
+ if err != nil {
53
+ return Context{}, err
54
+ }
55
+ query := strings.TrimSpace(opts.Query)
56
+ if len(project.Terms) > 0 {
57
+ if query == "" {
58
+ query = strings.Join(project.Terms, " ")
59
+ } else {
60
+ query = query + " " + strings.Join(project.Terms, " ")
61
+ }
62
+ }
63
+ limit := opts.Limit
64
+ if limit <= 0 {
65
+ limit = 5
66
+ }
67
+ if limit > 20 {
68
+ limit = 20
69
+ }
70
+ results, err := v.Search(wiki.SearchOptions{Query: query, Limit: limit})
71
+ if err != nil {
72
+ return Context{}, err
73
+ }
74
+ var techResults []wiki.SearchResult
75
+ if len(project.TechTerms) > 0 {
76
+ techResults, err = buildTechResults(v, project, limit)
77
+ if err != nil {
78
+ return Context{}, err
79
+ }
80
+ }
81
+ return Context{Project: project, Query: query, Results: results, TechResults: techResults}, nil
82
+ }
83
+
84
+ // InspectProject collects stable, low-cost project identity terms without
85
+ // scanning source files broadly.
86
+ func InspectProject(projectPath string) (Project, error) {
87
+ if strings.TrimSpace(projectPath) == "" {
88
+ wd, err := os.Getwd()
89
+ if err != nil {
90
+ return Project{}, err
91
+ }
92
+ projectPath = wd
93
+ }
94
+ abs, err := filepath.Abs(projectPath)
95
+ if err != nil {
96
+ return Project{}, err
97
+ }
98
+ terms := newTermSet()
99
+ techTerms := newTermSet()
100
+ name := filepath.Base(abs)
101
+ terms.addTerm(strings.ToLower(name))
102
+ terms.addText(name)
103
+ sources := []string{"directory"}
104
+
105
+ if module, goMod := readGoModule(abs); module != "" {
106
+ sources = append(sources, "go.mod")
107
+ techTerms.addTerms("go", "golang")
108
+ terms.addText(module)
109
+ if base := pathBase(module); base != "" {
110
+ name = base
111
+ terms.addTerm(strings.ToLower(base))
112
+ terms.addText(base)
113
+ }
114
+ if hasAnyFold(goMod, "swaggo", "swagger", "openapi") {
115
+ techTerms.addTerms("swagger", "openapi", "swaggo")
116
+ }
117
+ if hasAnyFold(goMod, "modelcontextprotocol", "mcp") {
118
+ techTerms.addTerm("mcp")
119
+ }
120
+ if hasAnyFold(goMod, "sqlite") {
121
+ techTerms.addTerm("sqlite")
122
+ }
123
+ if hasAnyFold(goMod, "gin-gonic/gin") {
124
+ techTerms.addTerm("gin")
125
+ }
126
+ if hasAnyFold(goMod, "gorm.io/gorm") {
127
+ techTerms.addTerm("gorm")
128
+ }
129
+ if hasAnyFold(goMod, "redis") {
130
+ techTerms.addTerm("redis")
131
+ }
132
+ }
133
+ pkgName, packageJSON, packageHasBin := readPackage(abs)
134
+ if pkgName != "" {
135
+ sources = append(sources, "package.json")
136
+ techTerms.addTerms("node", "npm")
137
+ if packageHasBin {
138
+ techTerms.addTerm("npx")
139
+ }
140
+ terms.addTerm(strings.ToLower(pkgName))
141
+ terms.addText(pkgName)
142
+ if name == filepath.Base(abs) {
143
+ name = pkgName
144
+ }
145
+ }
146
+ if hasAnyFold(packageJSON, "swagger", "openapi", "swaggo") {
147
+ techTerms.addTerms("swagger", "openapi")
148
+ }
149
+ if hasKnownAPISpecFile(abs) {
150
+ techTerms.addTerms("swagger", "openapi")
151
+ }
152
+ if hasExistingFile(abs, ".mcp.json") || hasExistingFile(abs, ".codex/hooks.json") || hasExistingFile(abs, ".claude/settings.json") {
153
+ techTerms.addTerm("mcp")
154
+ }
155
+ heading, readme := readReadme(abs)
156
+ if heading != "" {
157
+ sources = append(sources, "README.md")
158
+ terms.addText(heading)
159
+ }
160
+ if hasAnyFold(readme, "model context protocol", "mcp") {
161
+ techTerms.addTerm("mcp")
162
+ }
163
+ if hasAnyFold(readme, "sqlite") {
164
+ techTerms.addTerm("sqlite")
165
+ }
166
+ if hasAnyFold(readme, "obsidian") {
167
+ techTerms.addTerm("obsidian")
168
+ }
169
+ return Project{Path: abs, Name: name, Terms: terms.values(), TechTerms: techTerms.values(), Sources: sources}, nil
170
+ }
171
+
172
+ func readGoModule(root string) (string, string) {
173
+ content := readTextFile(filepath.Join(root, "go.mod"))
174
+ for _, line := range strings.Split(content, "\n") {
175
+ line = strings.TrimSpace(line)
176
+ if strings.HasPrefix(line, "module ") {
177
+ return strings.TrimSpace(strings.TrimPrefix(line, "module ")), content
178
+ }
179
+ }
180
+ return "", content
181
+ }
182
+
183
+ func readPackage(root string) (string, string, bool) {
184
+ content := readTextFile(filepath.Join(root, "package.json"))
185
+ var pkg struct {
186
+ Name string `json:"name"`
187
+ Bin json.RawMessage `json:"bin"`
188
+ }
189
+ if err := json.Unmarshal([]byte(content), &pkg); err != nil {
190
+ return "", content, false
191
+ }
192
+ return strings.TrimSpace(pkg.Name), content, len(pkg.Bin) > 0 && string(pkg.Bin) != "null"
193
+ }
194
+
195
+ func readReadme(root string) (string, string) {
196
+ for _, name := range []string{"README.md", "readme.md"} {
197
+ data, err := os.ReadFile(filepath.Join(root, name))
198
+ if err != nil {
199
+ continue
200
+ }
201
+ content := string(data)
202
+ for _, line := range strings.Split(string(data), "\n") {
203
+ line = strings.TrimSpace(line)
204
+ if strings.HasPrefix(line, "# ") {
205
+ return strings.TrimSpace(strings.TrimPrefix(line, "# ")), content
206
+ }
207
+ }
208
+ return "", content
209
+ }
210
+ return "", ""
211
+ }
212
+
213
+ func readTextFile(path string) string {
214
+ data, err := os.ReadFile(path)
215
+ if err != nil {
216
+ return ""
217
+ }
218
+ return string(data)
219
+ }
220
+
221
+ func hasAnyFold(text string, needles ...string) bool {
222
+ text = strings.ToLower(text)
223
+ for _, needle := range needles {
224
+ if strings.Contains(text, strings.ToLower(needle)) {
225
+ return true
226
+ }
227
+ }
228
+ return false
229
+ }
230
+
231
+ func hasKnownAPISpecFile(root string) bool {
232
+ for _, rel := range []string{
233
+ "swagger.yaml",
234
+ "swagger.yml",
235
+ "swagger.json",
236
+ "openapi.yaml",
237
+ "openapi.yml",
238
+ "openapi.json",
239
+ "docs/swagger.yaml",
240
+ "docs/swagger.yml",
241
+ "docs/swagger.json",
242
+ "docs/openapi.yaml",
243
+ "docs/openapi.yml",
244
+ "docs/openapi.json",
245
+ "api/swagger.yaml",
246
+ "api/swagger.yml",
247
+ "api/swagger.json",
248
+ "api/openapi.yaml",
249
+ "api/openapi.yml",
250
+ "api/openapi.json",
251
+ "scripts/generate-swagger.sh",
252
+ } {
253
+ if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err == nil {
254
+ return true
255
+ }
256
+ }
257
+ return false
258
+ }
259
+
260
+ func hasExistingFile(root, rel string) bool {
261
+ _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel)))
262
+ return err == nil
263
+ }
264
+
265
+ func buildTechResults(v *wiki.Vault, project Project, limit int) ([]wiki.SearchResult, error) {
266
+ resultLimit := techResultLimit(project, limit)
267
+ candidateLimit := resultLimit * 3
268
+ if candidateLimit < 10 {
269
+ candidateLimit = 10
270
+ }
271
+ if candidateLimit > 50 {
272
+ candidateLimit = 50
273
+ }
274
+ var merged []wiki.SearchResult
275
+ seen := map[string]bool{}
276
+ addResults := func(results []wiki.SearchResult) {
277
+ for _, result := range results {
278
+ if result.Score < 5 {
279
+ continue
280
+ }
281
+ if !resultMatchesProject(project, result) && !resultIdentityMatchesAnyTerm(result, project.TechTerms) {
282
+ continue
283
+ }
284
+ if seen[result.Slug] {
285
+ continue
286
+ }
287
+ seen[result.Slug] = true
288
+ merged = append(merged, result)
289
+ }
290
+ }
291
+ for _, term := range project.TechTerms {
292
+ results, err := v.Search(wiki.SearchOptions{Query: term, Limit: 4})
293
+ if err != nil {
294
+ return nil, err
295
+ }
296
+ addResults(results)
297
+ }
298
+ results, err := v.Search(wiki.SearchOptions{Query: strings.Join(project.TechTerms, " "), Limit: candidateLimit})
299
+ if err != nil {
300
+ return nil, err
301
+ }
302
+ addResults(results)
303
+ return prioritizeTechResults(project, merged, resultLimit), nil
304
+ }
305
+
306
+ func techResultLimit(project Project, limit int) int {
307
+ resultLimit := limit * 2
308
+ if resultLimit < len(project.TechTerms) {
309
+ resultLimit = len(project.TechTerms)
310
+ }
311
+ if resultLimit < 5 {
312
+ resultLimit = 5
313
+ }
314
+ if resultLimit > 12 {
315
+ resultLimit = 12
316
+ }
317
+ return resultLimit
318
+ }
319
+
320
+ func prioritizeTechResults(project Project, results []wiki.SearchResult, limit int) []wiki.SearchResult {
321
+ var projectSpecific []wiki.SearchResult
322
+ var preferred []wiki.SearchResult
323
+ var projectReports []wiki.SearchResult
324
+ for _, result := range results {
325
+ if isUnrelatedRepositoryReference(project, result) {
326
+ continue
327
+ }
328
+ if resultMatchesProject(project, result) {
329
+ if strings.HasPrefix(result.Path, "00-meta/reports/") {
330
+ projectReports = append(projectReports, result)
331
+ continue
332
+ }
333
+ projectSpecific = append(projectSpecific, result)
334
+ continue
335
+ }
336
+ preferred = append(preferred, result)
337
+ }
338
+ out := append(projectSpecific, preferred...)
339
+ out = append(out, projectReports...)
340
+ if len(out) > limit {
341
+ out = out[:limit]
342
+ }
343
+ return out
344
+ }
345
+
346
+ func isUnrelatedRepositoryReference(project Project, result wiki.SearchResult) bool {
347
+ if !strings.Contains(result.Path, "20-wiki/concepts/repository-references/") {
348
+ return false
349
+ }
350
+ hay := strings.ToLower(result.Path + " " + result.Slug + " " + result.Title)
351
+ for _, needle := range projectReferenceNeedles(project) {
352
+ if strings.Contains(hay, needle) {
353
+ return false
354
+ }
355
+ }
356
+ return true
357
+ }
358
+
359
+ func resultMatchesProject(project Project, result wiki.SearchResult) bool {
360
+ hay := strings.ToLower(result.Path + " " + result.Slug + " " + result.Title + " " + strings.Join(result.Tags, " "))
361
+ for _, needle := range projectReferenceNeedles(project) {
362
+ if strings.Contains(hay, needle) {
363
+ return true
364
+ }
365
+ }
366
+ return false
367
+ }
368
+
369
+ func projectReferenceNeedles(project Project) []string {
370
+ terms := newTermSet()
371
+ terms.addTerm(strings.ToLower(wiki.Slugify(project.Name)))
372
+ base := strings.ToLower(filepath.Base(project.Path))
373
+ terms.addTerm(base)
374
+ for _, term := range project.Terms {
375
+ if strings.Contains(term, "-") || len([]rune(term)) >= 6 {
376
+ terms.addTerm(strings.ToLower(term))
377
+ }
378
+ }
379
+ return terms.values()
380
+ }
381
+
382
+ func pathBase(path string) string {
383
+ path = strings.Trim(path, "/")
384
+ if path == "" {
385
+ return ""
386
+ }
387
+ parts := strings.Split(path, "/")
388
+ return parts[len(parts)-1]
389
+ }
390
+
391
+ type termSet struct {
392
+ seen map[string]bool
393
+ out []string
394
+ }
395
+
396
+ func newTermSet() *termSet { return &termSet{seen: map[string]bool{}} }
397
+
398
+ func (s *termSet) addTerm(term string) {
399
+ term = strings.Trim(strings.TrimSpace(term), "-/_.")
400
+ if len([]rune(term)) < 2 || s.seen[term] {
401
+ return
402
+ }
403
+ s.seen[term] = true
404
+ s.out = append(s.out, term)
405
+ }
406
+
407
+ func (s *termSet) addTerms(terms ...string) {
408
+ for _, term := range terms {
409
+ s.addTerm(term)
410
+ }
411
+ }
412
+
413
+ func (s *termSet) addText(text string) {
414
+ for _, part := range tokenSplit.Split(strings.ToLower(text), -1) {
415
+ part = strings.TrimSpace(part)
416
+ if len([]rune(part)) < 2 || s.seen[part] {
417
+ continue
418
+ }
419
+ s.seen[part] = true
420
+ s.out = append(s.out, part)
421
+ }
422
+ }
423
+
424
+ func (s *termSet) values() []string {
425
+ out := make([]string, len(s.out))
426
+ copy(out, s.out)
427
+ return out
428
+ }
429
+
430
+ // FormatAdditionalContext renders context that both Claude Code and Codex can
431
+ // inject from SessionStart/UserPromptSubmit hooks.
432
+ func FormatAdditionalContext(ctx Context) string {
433
+ var b strings.Builder
434
+ b.WriteString("## llm-wiki session context\n\n")
435
+ fmt.Fprintf(&b, "Project: `%s` (`%s`)\n", ctx.Project.Name, ctx.Project.Path)
436
+ if len(ctx.Project.Terms) > 0 {
437
+ fmt.Fprintf(&b, "Project terms: %s\n", strings.Join(ctx.Project.Terms, ", "))
438
+ }
439
+ if len(ctx.Project.TechTerms) > 0 {
440
+ fmt.Fprintf(&b, "Detected tech terms: %s\n", strings.Join(ctx.Project.TechTerms, ", "))
441
+ }
442
+ if strings.TrimSpace(ctx.Query) != "" {
443
+ fmt.Fprintf(&b, "Wiki query: `%s`\n", ctx.Query)
444
+ }
445
+ b.WriteString("\n")
446
+
447
+ reads := suggestedReads(ctx)
448
+ if len(reads) == 0 {
449
+ b.WriteString("No matching llm-wiki pages found. Use `wiki_search` or `llm-wiki session-context --query ...` when more context is needed.\n")
450
+ } else {
451
+ b.WriteString("Suggested reads:\n")
452
+ writeReadList(&b, reads)
453
+ }
454
+ b.WriteString("\nContext notes:\n")
455
+ b.WriteString("- Hook context is search-only; read listed pages before relying on details.\n")
456
+ b.WriteString("- Use `wiki_read` or `$llm-wiki-query <topic>` when details matter.\n")
457
+ b.WriteString("- Capture only curated, non-secret decisions with `wiki_capture` or `llm-wiki session-capture`.\n")
458
+ return b.String()
459
+ }
460
+
461
+ func suggestedReads(ctx Context) []wiki.SearchResult {
462
+ const maxSuggestedReads = 8
463
+ merged := make([]wiki.SearchResult, 0, len(ctx.Results)+len(ctx.TechResults))
464
+ seen := map[string]bool{}
465
+ add := func(results []wiki.SearchResult, includeNoisy bool) {
466
+ for _, result := range results {
467
+ if result.Slug == "" || seen[result.Slug] {
468
+ continue
469
+ }
470
+ if !includeNoisy && shouldSkipSuggestedRead(ctx, result) {
471
+ continue
472
+ }
473
+ seen[result.Slug] = true
474
+ merged = append(merged, result)
475
+ if len(merged) >= maxSuggestedReads {
476
+ return
477
+ }
478
+ }
479
+ }
480
+
481
+ add(ctx.Results, false)
482
+ if len(merged) < maxSuggestedReads {
483
+ add(ctx.TechResults, false)
484
+ }
485
+ if len(merged) == 0 {
486
+ add(ctx.Results, true)
487
+ }
488
+ sortSuggestedReads(ctx.Project, merged)
489
+ if len(merged) > maxSuggestedReads {
490
+ return merged[:maxSuggestedReads]
491
+ }
492
+ return merged
493
+ }
494
+
495
+ func shouldSkipSuggestedRead(ctx Context, result wiki.SearchResult) bool {
496
+ if isLowSignalHookResult(result) {
497
+ return true
498
+ }
499
+ if isUnrelatedRepositoryReference(ctx.Project, result) {
500
+ return true
501
+ }
502
+ if resultMatchesProject(ctx.Project, result) {
503
+ return false
504
+ }
505
+ return !resultIdentityMatchesAnyTerm(result, suggestedReadTerms(ctx))
506
+ }
507
+
508
+ func sortSuggestedReads(project Project, results []wiki.SearchResult) {
509
+ sort.SliceStable(results, func(i, j int) bool {
510
+ left := suggestedReadPriority(project, results[i])
511
+ right := suggestedReadPriority(project, results[j])
512
+ if left == right {
513
+ return false
514
+ }
515
+ return left < right
516
+ })
517
+ }
518
+
519
+ func suggestedReadPriority(project Project, result wiki.SearchResult) int {
520
+ if resultMatchesProject(project, result) {
521
+ return 0
522
+ }
523
+ if strings.HasPrefix(result.Path, "20-wiki/concepts/") && strings.Contains(result.Slug, "conventions") {
524
+ return 1
525
+ }
526
+ if strings.HasPrefix(result.Path, "10-sources/") {
527
+ return 2
528
+ }
529
+ if strings.HasPrefix(result.Path, "20-wiki/concepts/") {
530
+ return 3
531
+ }
532
+ return 4
533
+ }
534
+
535
+ func isLowSignalHookResult(result wiki.SearchResult) bool {
536
+ switch strings.ToLower(strings.TrimSpace(result.Type)) {
537
+ case "index", "log", "schema", "report", "lint-report":
538
+ return true
539
+ }
540
+ switch strings.TrimSpace(result.Path) {
541
+ case "00-meta/index.md", "00-meta/log.md", "00-meta/AGENTS.md":
542
+ return true
543
+ }
544
+ return false
545
+ }
546
+
547
+ func suggestedReadTerms(ctx Context) []string {
548
+ terms := newTermSet()
549
+ terms.addTerms(ctx.Project.TechTerms...)
550
+ terms.addText(ctx.Query)
551
+ for _, field := range strings.Fields(strings.ToLower(ctx.Query)) {
552
+ terms.addTerm(strings.Trim(field, " \t\r\n`'\"()[]{}:;,!?"))
553
+ }
554
+ return terms.values()
555
+ }
556
+
557
+ func resultIdentityMatchesAnyTerm(result wiki.SearchResult, terms []string) bool {
558
+ if len(terms) == 0 {
559
+ return false
560
+ }
561
+ identity := strings.ToLower(result.Path + " " + result.Slug + " " + result.Title)
562
+ tokens := map[string]bool{}
563
+ for _, token := range tokenSplit.Split(identity, -1) {
564
+ if token != "" {
565
+ tokens[token] = true
566
+ }
567
+ }
568
+ for _, term := range terms {
569
+ term = strings.ToLower(strings.TrimSpace(term))
570
+ if term == "" {
571
+ continue
572
+ }
573
+ if strings.ContainsAny(term, "+#.") && strings.Contains(identity, term) {
574
+ return true
575
+ }
576
+ if tokens[term] {
577
+ return true
578
+ }
579
+ for _, token := range tokenSplit.Split(term, -1) {
580
+ if token != "" && tokens[token] {
581
+ return true
582
+ }
583
+ }
584
+ }
585
+ return false
586
+ }
587
+
588
+ func writeReadList(b *strings.Builder, results []wiki.SearchResult) {
589
+ for _, r := range results {
590
+ fmt.Fprintf(b, "- [[%s]] `%s`", r.Slug, r.Path)
591
+ if r.Title != "" && r.Title != r.Slug {
592
+ fmt.Fprintf(b, " — %s", r.Title)
593
+ }
594
+ b.WriteString("\n")
595
+ }
596
+ }
597
+
598
+ // ExtractCaptureBlock finds the last explicit llm-wiki-capture fenced block in
599
+ // assistant output. This keeps stop-hook persistence opt-in and curated.
600
+ func ExtractCaptureBlock(message string) (CaptureBlock, bool) {
601
+ marker := "```llm-wiki-capture"
602
+ start := strings.LastIndex(message, marker)
603
+ if start < 0 {
604
+ return CaptureBlock{}, false
605
+ }
606
+ bodyStart := start + len(marker)
607
+ for bodyStart < len(message) && (message[bodyStart] == '\r' || message[bodyStart] == '\n') {
608
+ bodyStart++
609
+ }
610
+ end := strings.Index(message[bodyStart:], "```")
611
+ if end < 0 {
612
+ return CaptureBlock{}, false
613
+ }
614
+ raw := strings.TrimSpace(message[bodyStart : bodyStart+end])
615
+ meta, body, ok := strings.Cut(raw, "---")
616
+ block := CaptureBlock{Body: strings.TrimSpace(raw)}
617
+ if ok {
618
+ block.Body = strings.TrimSpace(body)
619
+ for _, line := range strings.Split(meta, "\n") {
620
+ key, value, found := strings.Cut(line, ":")
621
+ if !found {
622
+ continue
623
+ }
624
+ key = strings.TrimSpace(strings.ToLower(key))
625
+ value = strings.TrimSpace(value)
626
+ switch key {
627
+ case "title":
628
+ block.Title = value
629
+ case "tags":
630
+ block.Tags = splitComma(value)
631
+ }
632
+ }
633
+ }
634
+ if block.Title == "" {
635
+ block.Title = "Session capture"
636
+ }
637
+ return block, strings.TrimSpace(block.Body) != ""
638
+ }
639
+
640
+ func splitComma(s string) []string {
641
+ var out []string
642
+ for _, part := range strings.Split(s, ",") {
643
+ part = strings.TrimSpace(part)
644
+ if part != "" {
645
+ out = append(out, part)
646
+ }
647
+ }
648
+ return out
649
+ }