@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
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
package sessionctx
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/m16khb/llm-wiki/internal/wiki"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestBuildContextDerivesProjectTermsAndSearchesWiki(t *testing.T) {
|
|
13
|
+
vaultRoot := t.TempDir()
|
|
14
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/llm-wiki-hooks.md", `---
|
|
15
|
+
title: LLM Wiki Hooks
|
|
16
|
+
type: concept
|
|
17
|
+
status: active
|
|
18
|
+
created: 2026-05-23
|
|
19
|
+
updated: 2026-05-23
|
|
20
|
+
tags: [llm-wiki, hooks]
|
|
21
|
+
domain: agent-memory
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# LLM Wiki Hooks
|
|
25
|
+
|
|
26
|
+
Codex and Claude Code use portable hook commands for llm-wiki context.
|
|
27
|
+
`)
|
|
28
|
+
projectRoot := filepath.Join(t.TempDir(), "llm-wiki")
|
|
29
|
+
writeFile(t, projectRoot, "go.mod", "module github.com/m16khb/llm-wiki\n")
|
|
30
|
+
writeFile(t, projectRoot, "README.md", "# LLM Wiki\n")
|
|
31
|
+
|
|
32
|
+
v, err := wiki.New(vaultRoot)
|
|
33
|
+
if err != nil {
|
|
34
|
+
t.Fatalf("wiki.New() error = %v", err)
|
|
35
|
+
}
|
|
36
|
+
result, err := BuildContext(v, Options{ProjectPath: projectRoot, Limit: 5})
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatalf("BuildContext() error = %v", err)
|
|
39
|
+
}
|
|
40
|
+
if result.Project.Name != "llm-wiki" || !contains(result.Project.Terms, "llm-wiki") {
|
|
41
|
+
t.Fatalf("unexpected project info: %+v", result.Project)
|
|
42
|
+
}
|
|
43
|
+
if len(result.Results) != 1 || result.Results[0].Slug != "llm-wiki-hooks" {
|
|
44
|
+
t.Fatalf("expected hook search hit, got %+v", result.Results)
|
|
45
|
+
}
|
|
46
|
+
text := FormatAdditionalContext(result)
|
|
47
|
+
if !strings.Contains(text, "llm-wiki session context") || !strings.Contains(text, "[[llm-wiki-hooks]]") {
|
|
48
|
+
t.Fatalf("unexpected context text:\n%s", text)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestBuildContextAddsTechResultsForGoAndSwaggerProject(t *testing.T) {
|
|
53
|
+
vaultRoot := t.TempDir()
|
|
54
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/llm-wiki-hooks.md", `---
|
|
55
|
+
title: LLM Wiki Hooks
|
|
56
|
+
type: concept
|
|
57
|
+
status: active
|
|
58
|
+
created: 2026-05-23
|
|
59
|
+
updated: 2026-05-23
|
|
60
|
+
tags: [llm-wiki, hooks]
|
|
61
|
+
domain: agent-memory
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
# LLM Wiki Hooks
|
|
65
|
+
|
|
66
|
+
Project-specific context for llm-wiki hook behavior.
|
|
67
|
+
`)
|
|
68
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/go-conventions.md", `---
|
|
69
|
+
title: Go Conventions
|
|
70
|
+
type: concept
|
|
71
|
+
status: active
|
|
72
|
+
created: 2026-05-23
|
|
73
|
+
updated: 2026-05-23
|
|
74
|
+
tags: [go, golang]
|
|
75
|
+
domain: programming
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
# Go Conventions
|
|
79
|
+
|
|
80
|
+
Go and Golang style rules for agents working in Go modules.
|
|
81
|
+
`)
|
|
82
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/swagger-rules.md", `---
|
|
83
|
+
title: Swagger Rules
|
|
84
|
+
type: concept
|
|
85
|
+
status: active
|
|
86
|
+
created: 2026-05-23
|
|
87
|
+
updated: 2026-05-23
|
|
88
|
+
tags: [swagger, openapi, swaggo]
|
|
89
|
+
domain: api
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# Swagger Rules
|
|
93
|
+
|
|
94
|
+
Swagger and OpenAPI documentation rules for API projects.
|
|
95
|
+
`)
|
|
96
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/mcp-conventions.md", `---
|
|
97
|
+
title: MCP Conventions
|
|
98
|
+
type: concept
|
|
99
|
+
status: active
|
|
100
|
+
created: 2026-05-23
|
|
101
|
+
updated: 2026-05-23
|
|
102
|
+
tags: [mcp]
|
|
103
|
+
domain: protocol
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
# MCP Conventions
|
|
107
|
+
|
|
108
|
+
Model Context Protocol rules for local tools.
|
|
109
|
+
`)
|
|
110
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/sqlite-conventions.md", `---
|
|
111
|
+
title: SQLite Conventions
|
|
112
|
+
type: concept
|
|
113
|
+
status: active
|
|
114
|
+
created: 2026-05-23
|
|
115
|
+
updated: 2026-05-23
|
|
116
|
+
tags: [sqlite]
|
|
117
|
+
domain: database
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
# SQLite Conventions
|
|
121
|
+
|
|
122
|
+
SQLite WAL queue rules.
|
|
123
|
+
`)
|
|
124
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/llm-wiki-tech-map.md", `---
|
|
125
|
+
title: LLM Wiki Tech Map
|
|
126
|
+
type: concept
|
|
127
|
+
status: active
|
|
128
|
+
created: 2026-05-23
|
|
129
|
+
updated: 2026-05-23
|
|
130
|
+
tags: [llm-wiki, go, golang, mcp, sqlite, swagger, openapi, swaggo]
|
|
131
|
+
domain: agent-memory
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
# LLM Wiki Tech Map
|
|
135
|
+
|
|
136
|
+
Project-specific map for llm-wiki Go, MCP, SQLite, and Swagger retrieval.
|
|
137
|
+
`)
|
|
138
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/gin-conventions.md", `---
|
|
139
|
+
title: Gin Conventions
|
|
140
|
+
type: concept
|
|
141
|
+
status: active
|
|
142
|
+
created: 2026-05-23
|
|
143
|
+
updated: 2026-05-23
|
|
144
|
+
tags: [go, gin]
|
|
145
|
+
domain: web
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
# Gin Conventions
|
|
149
|
+
|
|
150
|
+
Go Gin framework rules. This should only appear when Gin is detected.
|
|
151
|
+
`)
|
|
152
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/repository-references/other-api/other-api-go-noise.md", `---
|
|
153
|
+
title: Other API Go Noise
|
|
154
|
+
type: concept
|
|
155
|
+
status: active
|
|
156
|
+
created: 2026-05-23
|
|
157
|
+
updated: 2026-05-23
|
|
158
|
+
tags: [go]
|
|
159
|
+
domain: programming
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
# Other API Go Noise
|
|
163
|
+
|
|
164
|
+
go go go go go go go go go go go go go go go go go go go go
|
|
165
|
+
golang golang golang golang golang golang golang golang golang golang
|
|
166
|
+
`)
|
|
167
|
+
projectRoot := filepath.Join(t.TempDir(), "llm-wiki")
|
|
168
|
+
writeFile(t, projectRoot, "go.mod", `module github.com/m16khb/llm-wiki
|
|
169
|
+
|
|
170
|
+
require (
|
|
171
|
+
github.com/modelcontextprotocol/go-sdk v1.6.0
|
|
172
|
+
github.com/swaggo/gin-swagger v1.6.0
|
|
173
|
+
modernc.org/sqlite v1.50.1
|
|
174
|
+
)
|
|
175
|
+
`)
|
|
176
|
+
writeFile(t, projectRoot, "README.md", "# LLM Wiki\n")
|
|
177
|
+
|
|
178
|
+
v, err := wiki.New(vaultRoot)
|
|
179
|
+
if err != nil {
|
|
180
|
+
t.Fatalf("wiki.New() error = %v", err)
|
|
181
|
+
}
|
|
182
|
+
result, err := BuildContext(v, Options{ProjectPath: projectRoot, Limit: 5})
|
|
183
|
+
if err != nil {
|
|
184
|
+
t.Fatalf("BuildContext() error = %v", err)
|
|
185
|
+
}
|
|
186
|
+
text := FormatAdditionalContext(result)
|
|
187
|
+
for _, want := range []string{"Detected tech terms:", "go", "golang", "mcp", "sqlite", "swagger", "openapi", "swaggo", "Suggested reads:", "[[go-conventions]]", "[[swagger-rules]]", "[[mcp-conventions]]", "[[sqlite-conventions]]"} {
|
|
188
|
+
if !strings.Contains(text, want) {
|
|
189
|
+
t.Fatalf("expected formatted context to contain %q:\n%s", want, text)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if len(result.TechResults) == 0 || result.TechResults[0].Slug == "other-api-go-noise" {
|
|
193
|
+
t.Fatalf("expected unrelated repository-reference tech hit to be demoted, got %+v", result.TechResults)
|
|
194
|
+
}
|
|
195
|
+
if result.TechResults[0].Slug != "llm-wiki-tech-map" {
|
|
196
|
+
t.Fatalf("expected project-specific tech map first, got %+v", result.TechResults)
|
|
197
|
+
}
|
|
198
|
+
for _, hit := range result.TechResults {
|
|
199
|
+
if hit.Slug == "gin-conventions" {
|
|
200
|
+
t.Fatalf("did not expect Gin-specific page without Gin dependency, got %+v", result.TechResults)
|
|
201
|
+
}
|
|
202
|
+
if hit.Slug == "other-api-go-noise" {
|
|
203
|
+
t.Fatalf("did not expect unrelated repository-reference page, got %+v", result.TechResults)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func TestBuildContextAddsPackageWrapperTechResults(t *testing.T) {
|
|
209
|
+
vaultRoot := t.TempDir()
|
|
210
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/npm-wrapper.md", `---
|
|
211
|
+
title: npm Wrapper
|
|
212
|
+
type: concept
|
|
213
|
+
status: active
|
|
214
|
+
created: 2026-05-23
|
|
215
|
+
updated: 2026-05-23
|
|
216
|
+
tags: [node, npm, npx]
|
|
217
|
+
domain: packaging
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
# npm Wrapper
|
|
221
|
+
|
|
222
|
+
Node, npm, and npx wrapper conventions.
|
|
223
|
+
`)
|
|
224
|
+
projectRoot := filepath.Join(t.TempDir(), "wrapped-cli")
|
|
225
|
+
writeFile(t, projectRoot, "package.json", `{
|
|
226
|
+
"name": "@example/wrapped-cli",
|
|
227
|
+
"bin": {
|
|
228
|
+
"wrapped-cli": "npm/bin/wrapped-cli.js"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
`)
|
|
232
|
+
|
|
233
|
+
v, err := wiki.New(vaultRoot)
|
|
234
|
+
if err != nil {
|
|
235
|
+
t.Fatalf("wiki.New() error = %v", err)
|
|
236
|
+
}
|
|
237
|
+
result, err := BuildContext(v, Options{ProjectPath: projectRoot, Limit: 5})
|
|
238
|
+
if err != nil {
|
|
239
|
+
t.Fatalf("BuildContext() error = %v", err)
|
|
240
|
+
}
|
|
241
|
+
text := FormatAdditionalContext(result)
|
|
242
|
+
for _, want := range []string{"Detected tech terms:", "node", "npm", "npx", "Suggested reads:", "[[npm-wrapper]]"} {
|
|
243
|
+
if !strings.Contains(text, want) {
|
|
244
|
+
t.Fatalf("expected formatted context to contain %q:\n%s", want, text)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func TestBuildContextAddsSwaggerTechResultsForOpenAPIFile(t *testing.T) {
|
|
250
|
+
vaultRoot := t.TempDir()
|
|
251
|
+
writeFile(t, vaultRoot, "20-wiki/concepts/swagger-rules.md", `---
|
|
252
|
+
title: Swagger Rules
|
|
253
|
+
type: concept
|
|
254
|
+
status: active
|
|
255
|
+
created: 2026-05-23
|
|
256
|
+
updated: 2026-05-23
|
|
257
|
+
tags: [swagger, openapi]
|
|
258
|
+
domain: api
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
# Swagger Rules
|
|
262
|
+
|
|
263
|
+
OpenAPI and Swagger documentation conventions.
|
|
264
|
+
`)
|
|
265
|
+
projectRoot := filepath.Join(t.TempDir(), "api-project")
|
|
266
|
+
writeFile(t, projectRoot, "openapi.yaml", "openapi: 3.0.0\ninfo:\n title: API\n")
|
|
267
|
+
|
|
268
|
+
v, err := wiki.New(vaultRoot)
|
|
269
|
+
if err != nil {
|
|
270
|
+
t.Fatalf("wiki.New() error = %v", err)
|
|
271
|
+
}
|
|
272
|
+
result, err := BuildContext(v, Options{ProjectPath: projectRoot, Limit: 5})
|
|
273
|
+
if err != nil {
|
|
274
|
+
t.Fatalf("BuildContext() error = %v", err)
|
|
275
|
+
}
|
|
276
|
+
text := FormatAdditionalContext(result)
|
|
277
|
+
for _, want := range []string{"Detected tech terms:", "swagger", "openapi", "Suggested reads:", "[[swagger-rules]]"} {
|
|
278
|
+
if !strings.Contains(text, want) {
|
|
279
|
+
t.Fatalf("expected formatted context to contain %q:\n%s", want, text)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func TestFormatAdditionalContextRendersConciseReadList(t *testing.T) {
|
|
285
|
+
ctx := Context{
|
|
286
|
+
Project: Project{
|
|
287
|
+
Path: "/workspace/llm-wiki",
|
|
288
|
+
Name: "@m16khb/llm-wiki",
|
|
289
|
+
Terms: []string{"llm-wiki", "wiki"},
|
|
290
|
+
TechTerms: []string{"go", "mcp"},
|
|
291
|
+
},
|
|
292
|
+
Query: "go hook context",
|
|
293
|
+
Results: []wiki.SearchResult{
|
|
294
|
+
{
|
|
295
|
+
Slug: "index",
|
|
296
|
+
Path: "00-meta/index.md",
|
|
297
|
+
Title: "Wiki Index",
|
|
298
|
+
Type: "index",
|
|
299
|
+
Score: 99,
|
|
300
|
+
Snippet: "# Wiki Index\n\nHuge catalog noise should not appear in hook output.",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
Slug: "llm-wiki-hooks",
|
|
304
|
+
Path: "20-wiki/concepts/llm-wiki-hooks.md",
|
|
305
|
+
Title: "LLM Wiki Hooks",
|
|
306
|
+
Type: "concept",
|
|
307
|
+
Score: 80,
|
|
308
|
+
Snippet: "# LLM Wiki Hooks\n\nLong raw snippets make hook output noisy.",
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
TechResults: []wiki.SearchResult{
|
|
312
|
+
{
|
|
313
|
+
Slug: "go-conventions",
|
|
314
|
+
Path: "20-wiki/concepts/go-conventions.md",
|
|
315
|
+
Title: "Go Conventions",
|
|
316
|
+
Type: "concept",
|
|
317
|
+
Score: 30,
|
|
318
|
+
Snippet: "# Go Conventions\n\nDetailed page body should be read explicitly, not injected.",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
Slug: "api-swagger-openapi-conventions",
|
|
322
|
+
Path: "20-wiki/concepts/api-swagger-openapi-conventions.md",
|
|
323
|
+
Title: "Swagger / OpenAPI Conventions",
|
|
324
|
+
Type: "concept",
|
|
325
|
+
Tags: []string{"go", "swagger", "openapi"},
|
|
326
|
+
Score: 28,
|
|
327
|
+
Snippet: "# Swagger / OpenAPI Conventions\n\nOnly include when requested or detected.",
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
text := FormatAdditionalContext(ctx)
|
|
333
|
+
for _, want := range []string{
|
|
334
|
+
"Suggested reads:",
|
|
335
|
+
"- [[llm-wiki-hooks]] `20-wiki/concepts/llm-wiki-hooks.md` — LLM Wiki Hooks",
|
|
336
|
+
"- [[go-conventions]] `20-wiki/concepts/go-conventions.md` — Go Conventions",
|
|
337
|
+
"Hook context is search-only; read listed pages before relying on details.",
|
|
338
|
+
} {
|
|
339
|
+
if !strings.Contains(text, want) {
|
|
340
|
+
t.Fatalf("expected concise context to contain %q:\n%s", want, text)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for _, notWant := range []string{
|
|
344
|
+
"Relevant wiki pages:",
|
|
345
|
+
"Relevant tech wiki pages:",
|
|
346
|
+
"[[index]]",
|
|
347
|
+
"api-swagger-openapi-conventions",
|
|
348
|
+
"Huge catalog noise",
|
|
349
|
+
"Long raw snippets",
|
|
350
|
+
"Detailed page body",
|
|
351
|
+
"Only include when requested",
|
|
352
|
+
} {
|
|
353
|
+
if strings.Contains(text, notWant) {
|
|
354
|
+
t.Fatalf("did not expect noisy hook output %q:\n%s", notWant, text)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func TestFormatAdditionalContextUsesQueryTermsWithoutTechAllowlist(t *testing.T) {
|
|
360
|
+
ctx := Context{
|
|
361
|
+
Project: Project{
|
|
362
|
+
Path: "/workspace/polyglot",
|
|
363
|
+
Name: "polyglot",
|
|
364
|
+
Terms: []string{"polyglot"},
|
|
365
|
+
},
|
|
366
|
+
Query: "k8s rust c++",
|
|
367
|
+
Results: []wiki.SearchResult{
|
|
368
|
+
{Slug: "k8s-patterns", Path: "20-wiki/concepts/k8s-patterns.md", Title: "K8s Patterns", Type: "concept", Score: 30},
|
|
369
|
+
{Slug: "rust-async-stack", Path: "20-wiki/concepts/rust-async-stack.md", Title: "Rust Async Stack", Type: "concept", Score: 30},
|
|
370
|
+
{Slug: "cpp-guide", Path: "20-wiki/concepts/cpp-guide.md", Title: "C++ Guide", Type: "concept", Score: 30},
|
|
371
|
+
{Slug: "unrelated-api", Path: "20-wiki/concepts/unrelated-api.md", Title: "Unrelated API", Type: "concept", Tags: []string{"k8s"}, Score: 30},
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
text := FormatAdditionalContext(ctx)
|
|
376
|
+
for _, want := range []string{"[[k8s-patterns]]", "[[rust-async-stack]]", "[[cpp-guide]]"} {
|
|
377
|
+
if !strings.Contains(text, want) {
|
|
378
|
+
t.Fatalf("expected query-matched page %q without a hard-coded tech allowlist:\n%s", want, text)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if strings.Contains(text, "[[unrelated-api]]") {
|
|
382
|
+
t.Fatalf("did not expect a page to match only through tags:\n%s", text)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func TestExtractCaptureBlock(t *testing.T) {
|
|
387
|
+
msg := "done\n\n```llm-wiki-capture\ntitle: Hook decision\ntags: hooks, codex\n---\nPersist this decision.\n```\n"
|
|
388
|
+
block, ok := ExtractCaptureBlock(msg)
|
|
389
|
+
if !ok {
|
|
390
|
+
t.Fatalf("expected capture block")
|
|
391
|
+
}
|
|
392
|
+
if block.Title != "Hook decision" || block.Body != "Persist this decision." || len(block.Tags) != 2 {
|
|
393
|
+
t.Fatalf("unexpected block: %+v", block)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func contains(values []string, want string) bool {
|
|
398
|
+
for _, v := range values {
|
|
399
|
+
if v == want {
|
|
400
|
+
return true
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return false
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func writeFile(t *testing.T, root, rel, content string) {
|
|
407
|
+
t.Helper()
|
|
408
|
+
abs := filepath.Join(root, filepath.FromSlash(rel))
|
|
409
|
+
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
410
|
+
t.Fatalf("mkdir %s: %v", rel, err)
|
|
411
|
+
}
|
|
412
|
+
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
413
|
+
t.Fatalf("write %s: %v", rel, err)
|
|
414
|
+
}
|
|
415
|
+
}
|