@m16khb/llm-wiki 0.1.0 → 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 +200 -47
- package/cmd/llm-wiki/hook_test.go +108 -0
- package/cmd/llm-wiki/main.go +457 -11
- package/go.mod +1 -1
- 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/npm/lib/runner.js +50 -10
- package/package.json +1 -1
|
@@ -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
|
+
}
|