@csuwl/opencode-memory-plugin 1.0.0
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.npm.md +57 -0
- package/agents/memory-automation.md +121 -0
- package/agents/memory-consolidate.md +267 -0
- package/bin/install.js +354 -0
- package/index.js +47 -0
- package/memory/AGENTS.md +73 -0
- package/memory/BOOT.md +35 -0
- package/memory/BOOTSTRAP.md +105 -0
- package/memory/HEARTBEAT.md +26 -0
- package/memory/IDENTITY.md +28 -0
- package/memory/MEMORY.md +21 -0
- package/memory/SOUL.md +28 -0
- package/memory/TOOLS.md +91 -0
- package/memory/USER.md +37 -0
- package/package.json +45 -0
- package/scripts/docker-init.sh +154 -0
- package/scripts/init.sh +338 -0
- package/scripts/test-memory-functions.sh +131 -0
- package/tools/memory.ts +308 -0
- package/tools/vector-memory.ts +428 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { readFile, exists, mkdir } from "fs/promises"
|
|
4
|
+
import Database from "better-sqlite3"
|
|
5
|
+
|
|
6
|
+
const MEMORY_DIR = path.join(process.env.HOME || "", ".opencode", "memory")
|
|
7
|
+
const VECTOR_DB_PATH = path.join(MEMORY_DIR, "vector-index.db")
|
|
8
|
+
const CHUNK_SIZE = 400 // Target tokens per chunk
|
|
9
|
+
const CHUNK_OVERLAP = 80 // Overlap between chunks
|
|
10
|
+
|
|
11
|
+
// Helper: Ensure memory directory and vector database exist
|
|
12
|
+
async function ensureVectorIndex() {
|
|
13
|
+
await mkdir(MEMORY_DIR, { recursive: true })
|
|
14
|
+
|
|
15
|
+
const db = new Database(VECTOR_DB_PATH)
|
|
16
|
+
|
|
17
|
+
// Enable sqlite-vec extension if available
|
|
18
|
+
try {
|
|
19
|
+
db.loadExtension(path.join(__dirname, "..", "node_modules", "sqlite-vec", "dist", "sqlite-vec.node"))
|
|
20
|
+
} catch {
|
|
21
|
+
// Extension not available, will use in-memory fallback
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Create vector table if not exists
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
file_path TEXT NOT NULL,
|
|
29
|
+
chunk TEXT NOT NULL,
|
|
30
|
+
line_start INTEGER NOT NULL,
|
|
31
|
+
line_end INTEGER NOT NULL,
|
|
32
|
+
embedding BLOB,
|
|
33
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
34
|
+
metadata TEXT
|
|
35
|
+
)
|
|
36
|
+
`)
|
|
37
|
+
|
|
38
|
+
// Create FTS5 table for BM25 search
|
|
39
|
+
db.exec(`
|
|
40
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
41
|
+
chunk,
|
|
42
|
+
content='memory_chunks',
|
|
43
|
+
content_rowid='id'
|
|
44
|
+
)
|
|
45
|
+
`)
|
|
46
|
+
|
|
47
|
+
db.close()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Helper: Get text embedding using local model
|
|
51
|
+
async function getEmbedding(text: string): Promise<number[]> {
|
|
52
|
+
// This would use node-llama-cpp for local embeddings
|
|
53
|
+
// For now, return a simple hash-based embedding as fallback
|
|
54
|
+
// TODO: Integrate with node-llama-cpp
|
|
55
|
+
|
|
56
|
+
const words = text.toLowerCase().split(/\s+/)
|
|
57
|
+
const embedding: number[] = []
|
|
58
|
+
|
|
59
|
+
// Create a simple 384-dimension embedding (typical for small models)
|
|
60
|
+
for (let i = 0; i < 384; i++) {
|
|
61
|
+
let hash = 0
|
|
62
|
+
for (let j = 0; j < words.length; j++) {
|
|
63
|
+
const word = words[j]
|
|
64
|
+
for (let k = 0; k < word.length; k++) {
|
|
65
|
+
hash = ((hash << 5) - hash) + word.charCodeAt(k)
|
|
66
|
+
hash |= 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
embedding.push((hash % 1000) / 1000) // Normalize to 0-1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return embedding
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Helper: Split text into chunks
|
|
76
|
+
function splitIntoChunks(text: string, filePath: string): Array<{
|
|
77
|
+
file_path: string
|
|
78
|
+
chunk: string
|
|
79
|
+
line_start: number
|
|
80
|
+
line_end: number
|
|
81
|
+
}> {
|
|
82
|
+
const lines = text.split('\n')
|
|
83
|
+
const chunks: ReturnType<typeof splitIntoChunks> = []
|
|
84
|
+
|
|
85
|
+
let currentChunk: string[] = []
|
|
86
|
+
let startLine = 0
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i]
|
|
90
|
+
currentChunk.push(line)
|
|
91
|
+
|
|
92
|
+
// Check if chunk is roughly target size (estimated by character count)
|
|
93
|
+
const currentSize = currentChunk.join('\n').length
|
|
94
|
+
const targetSize = CHUNK_SIZE * 4 // Rough estimate: 1 token ≈ 4 characters
|
|
95
|
+
|
|
96
|
+
if (currentSize >= targetSize && currentChunk.length > CHUNK_OVERLAP / 4) {
|
|
97
|
+
const endLine = i + 1
|
|
98
|
+
chunks.push({
|
|
99
|
+
file_path: filePath,
|
|
100
|
+
chunk: currentChunk.join('\n'),
|
|
101
|
+
line_start: startLine,
|
|
102
|
+
line_end: endLine,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Start new chunk with overlap
|
|
106
|
+
const overlapLines = Math.floor(CHUNK_OVERLAP / 4)
|
|
107
|
+
currentChunk = currentChunk.slice(-overlapLines)
|
|
108
|
+
startLine = i - overlapLines + 1
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Add remaining content
|
|
113
|
+
if (currentChunk.length > 0) {
|
|
114
|
+
chunks.push({
|
|
115
|
+
file_path: filePath,
|
|
116
|
+
chunk: currentChunk.join('\n'),
|
|
117
|
+
line_start: startLine,
|
|
118
|
+
line_end: lines.length,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return chunks
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Helper: Calculate cosine similarity
|
|
126
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
127
|
+
if (a.length !== b.length) return 0
|
|
128
|
+
|
|
129
|
+
let dotProduct = 0
|
|
130
|
+
let normA = 0
|
|
131
|
+
let normB = 0
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < a.length; i++) {
|
|
134
|
+
dotProduct += a[i] * b[i]
|
|
135
|
+
normA += a[i] * a[i]
|
|
136
|
+
normB += b[i] * b[i]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (normA === 0 || normB === 0) return 0
|
|
140
|
+
|
|
141
|
+
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tool 1: Vector memory search (semantic)
|
|
145
|
+
export const vector_search = tool({
|
|
146
|
+
description: "Semantic search across memory files using vector embeddings. Finds relevant past context even when wording differs. This is the most powerful search method for finding related memories.",
|
|
147
|
+
args: {
|
|
148
|
+
query: tool.schema.string().describe("Search query for semantic search. Use natural language descriptions."),
|
|
149
|
+
scope: tool.schema.enum(["all", "long-term", "daily", "preference", "personality", "context", "tools", "identity", "user"]).describe("Search scope: all memory files or specific type"),
|
|
150
|
+
days: tool.schema.number().optional().describe("Number of recent daily files to include in search. Defaults to 7."),
|
|
151
|
+
limit: tool.schema.number().optional().describe("Maximum number of results to return. Defaults to 5."),
|
|
152
|
+
hybrid: tool.schema.boolean().optional().describe("Enable hybrid search (BM25 + vector) for better results. Defaults to true."),
|
|
153
|
+
},
|
|
154
|
+
async execute(args) {
|
|
155
|
+
await ensureVectorIndex()
|
|
156
|
+
|
|
157
|
+
const query = args.query
|
|
158
|
+
const limit = args.limit || 5
|
|
159
|
+
const useHybrid = args.hybrid !== false // Default to true
|
|
160
|
+
|
|
161
|
+
// Get query embedding
|
|
162
|
+
const queryEmbedding = await getEmbedding(query)
|
|
163
|
+
|
|
164
|
+
// Determine files to search and index
|
|
165
|
+
const filesToIndex: string[] = []
|
|
166
|
+
const daysToSearch = args.days || 7
|
|
167
|
+
|
|
168
|
+
if (args.scope === "all" || args.scope === "long-term") {
|
|
169
|
+
filesToIndex.push(path.join(MEMORY_DIR, "MEMORY.md"))
|
|
170
|
+
}
|
|
171
|
+
if (args.scope === "all" || args.scope === "preference") {
|
|
172
|
+
filesToIndex.push(path.join(MEMORY_DIR, "PREFERENCES.md"))
|
|
173
|
+
}
|
|
174
|
+
if (args.scope === "all" || args.scope === "personality") {
|
|
175
|
+
filesToIndex.push(path.join(MEMORY_DIR, "SOUL.md"))
|
|
176
|
+
}
|
|
177
|
+
if (args.scope === "all" || args.scope === "context") {
|
|
178
|
+
filesToIndex.push(path.join(MEMORY_DIR, "CONTEXT.md"))
|
|
179
|
+
}
|
|
180
|
+
if (args.scope === "all" || args.scope === "tools") {
|
|
181
|
+
filesToIndex.push(path.join(MEMORY_DIR, "TOOLS.md"))
|
|
182
|
+
}
|
|
183
|
+
if (args.scope === "all" || args.scope === "identity") {
|
|
184
|
+
filesToIndex.push(path.join(MEMORY_DIR, "IDENTITY.md"))
|
|
185
|
+
}
|
|
186
|
+
if (args.scope === "all" || args.scope === "user") {
|
|
187
|
+
filesToIndex.push(path.join(MEMORY_DIR, "USER.md"))
|
|
188
|
+
}
|
|
189
|
+
if (args.scope === "all" || args.scope === "daily") {
|
|
190
|
+
const dailyDir = path.join(MEMORY_DIR, "daily")
|
|
191
|
+
for (let i = 0; i < daysToSearch; i++) {
|
|
192
|
+
const date = new Date()
|
|
193
|
+
date.setDate(date.getDate() - i)
|
|
194
|
+
const dateStr = date.toISOString().split('T')[0]
|
|
195
|
+
filesToIndex.push(path.join(dailyDir, `${dateStr}.md`))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Index files and search
|
|
200
|
+
const db = new Database(VECTOR_DB_PATH, { readonly: true })
|
|
201
|
+
const results: { file: string; snippet: string; score: number; line_start: number; line_end: number }[] = []
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
// First, check if we have indexed data
|
|
205
|
+
const chunkCount = db.prepare("SELECT COUNT(*) as count FROM memory_chunks").get() as { count: number }
|
|
206
|
+
|
|
207
|
+
if (chunkCount.count === 0) {
|
|
208
|
+
db.close()
|
|
209
|
+
return "Vector index is empty. No memories have been indexed yet. Write some memories first, then try searching."
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Perform vector search
|
|
213
|
+
const vectorResults = db.prepare(`
|
|
214
|
+
SELECT file_path, chunk, line_start, line_end, embedding
|
|
215
|
+
FROM memory_chunks
|
|
216
|
+
WHERE file_path IN (${filesToIndex.map(() => "?").join(", ")})
|
|
217
|
+
`).all(...filesToIndex)
|
|
218
|
+
|
|
219
|
+
for (const row of vectorResults) {
|
|
220
|
+
const similarity = cosineSimilarity(queryEmbedding, JSON.parse(row.embedding as string))
|
|
221
|
+
results.push({
|
|
222
|
+
file: path.basename(row.file_path as string),
|
|
223
|
+
snippet: (row.chunk as string).substring(0, 700),
|
|
224
|
+
score: similarity,
|
|
225
|
+
line_start: row.line_start as number,
|
|
226
|
+
line_end: row.line_end as number,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Hybrid search with BM25 if enabled
|
|
231
|
+
if (useHybrid) {
|
|
232
|
+
const ftsResults = db.prepare(`
|
|
233
|
+
SELECT memory_chunks.file_path, memory_chunks.chunk, memory_chunks.line_start, memory_chunks.line_end, bm25(memory_fts) as bm25_score
|
|
234
|
+
FROM memory_fts
|
|
235
|
+
JOIN memory_chunks ON memory_chunks.id = memory_fts.rowid
|
|
236
|
+
WHERE memory_fts MATCH ? AND memory_chunks.file_path IN (${filesToIndex.map(() => "?").join(", ")})
|
|
237
|
+
ORDER BY bm25_score
|
|
238
|
+
LIMIT ${limit * 2}
|
|
239
|
+
`).all(query, ...filesToIndex)
|
|
240
|
+
|
|
241
|
+
// Combine vector and BM25 results
|
|
242
|
+
const vectorScores = new Map<string, number>()
|
|
243
|
+
for (const r of results) {
|
|
244
|
+
vectorScores.set(`${r.file}:${r.line_start}`, r.score)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const row of ftsResults) {
|
|
248
|
+
const key = `${path.basename(row.file_path as string)}:${row.line_start as number}`
|
|
249
|
+
const vectorScore = vectorScores.get(key) || 0
|
|
250
|
+
const bm25Score = 1 / (1 + (row.bm25_score as number))
|
|
251
|
+
|
|
252
|
+
// Hybrid score: 70% vector + 30% BM25 (like OpenClaw)
|
|
253
|
+
const hybridScore = 0.7 * vectorScore + 0.3 * bm25Score
|
|
254
|
+
|
|
255
|
+
// Check if already in results, update score if better
|
|
256
|
+
const existingIndex = results.findIndex((r) => r.file === path.basename(row.file_path as string) && r.line_start === row.line_start)
|
|
257
|
+
if (existingIndex >= 0) {
|
|
258
|
+
if (hybridScore > results[existingIndex].score) {
|
|
259
|
+
results[existingIndex].score = hybridScore
|
|
260
|
+
}
|
|
261
|
+
} else if (hybridScore > 0.3) {
|
|
262
|
+
results.push({
|
|
263
|
+
file: path.basename(row.file_path as string),
|
|
264
|
+
snippet: (row.chunk as string).substring(0, 700),
|
|
265
|
+
score: hybridScore,
|
|
266
|
+
line_start: row.line_start as number,
|
|
267
|
+
line_end: row.line_end as number,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Sort by score and limit
|
|
274
|
+
results.sort((a, b) => b.score - a.score)
|
|
275
|
+
const topResults = results.slice(0, limit)
|
|
276
|
+
|
|
277
|
+
// Format output
|
|
278
|
+
if (topResults.length === 0) {
|
|
279
|
+
db.close()
|
|
280
|
+
return `No relevant memories found for: "${query}"\n\nTry different keywords or check if memories have been indexed.`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let output = `🔍 Semantic Search Results for: "${query}"\n\n`
|
|
284
|
+
for (const r of topResults) {
|
|
285
|
+
const scorePercent = (r.score * 100).toFixed(1)
|
|
286
|
+
output += `### ${r.file} (Lines ${r.line_start}-${r.line_end})\n`
|
|
287
|
+
output += `**Relevance**: ${scorePercent}%\n`
|
|
288
|
+
output += `${r.snippet}${r.snippet.length >= 700 ? "..." : ""}\n\n`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return output.trim()
|
|
292
|
+
} finally {
|
|
293
|
+
db.close()
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// Tool 2: Rebuild vector index
|
|
299
|
+
export const rebuild_index = tool({
|
|
300
|
+
description: "Rebuild the vector index from all memory files. Use this after adding many new memories manually, or if search results seem outdated. This may take some time for large memory collections.",
|
|
301
|
+
args: {
|
|
302
|
+
force: tool.schema.boolean().optional().describe("Force complete rebuild even if index exists. Defaults to false."),
|
|
303
|
+
},
|
|
304
|
+
async execute(args) {
|
|
305
|
+
await ensureVectorIndex()
|
|
306
|
+
|
|
307
|
+
const db = new Database(VECTOR_DB_PATH)
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
// Clear existing index if forced
|
|
311
|
+
if (args.force) {
|
|
312
|
+
db.exec("DELETE FROM memory_chunks")
|
|
313
|
+
db.exec("DELETE FROM memory_fts")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Get all markdown files to index
|
|
317
|
+
const filesToIndex = [
|
|
318
|
+
"MEMORY.md",
|
|
319
|
+
"PREFERENCES.md",
|
|
320
|
+
"SOUL.md",
|
|
321
|
+
"USER.md",
|
|
322
|
+
"IDENTITY.md",
|
|
323
|
+
"TOOLS.md",
|
|
324
|
+
"CONTEXT.md",
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
// Add daily files
|
|
328
|
+
const dailyDir = path.join(MEMORY_DIR, "daily")
|
|
329
|
+
try {
|
|
330
|
+
const dailyFiles = await readdir(dailyDir)
|
|
331
|
+
for (const file of dailyFiles) {
|
|
332
|
+
if (file.endsWith(".md")) {
|
|
333
|
+
filesToIndex.push(path.join("daily", file))
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
// Daily directory doesn't exist yet
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let indexedChunks = 0
|
|
341
|
+
let indexedFiles = 0
|
|
342
|
+
|
|
343
|
+
for (const fileName of filesToIndex) {
|
|
344
|
+
const filePath = path.join(MEMORY_DIR, fileName)
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
if (!(await exists(filePath))) continue
|
|
348
|
+
|
|
349
|
+
const content = await readFile(filePath, "utf-8")
|
|
350
|
+
const chunks = splitIntoChunks(content, fileName)
|
|
351
|
+
const relativePath = path.basename(fileName)
|
|
352
|
+
|
|
353
|
+
for (const chunk of chunks) {
|
|
354
|
+
const embedding = await getEmbedding(chunk.chunk)
|
|
355
|
+
|
|
356
|
+
db.prepare(`
|
|
357
|
+
INSERT INTO memory_chunks (file_path, chunk, line_start, line_end, embedding, metadata)
|
|
358
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
359
|
+
`).run(
|
|
360
|
+
chunk.file_path,
|
|
361
|
+
chunk.chunk,
|
|
362
|
+
chunk.line_start,
|
|
363
|
+
chunk.line_end,
|
|
364
|
+
JSON.stringify(embedding),
|
|
365
|
+
JSON.stringify({ file: relativePath, lines: `${chunk.line_start}-${chunk.line_end}` }),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
indexedChunks++
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
indexedFiles++
|
|
372
|
+
} catch (error) {
|
|
373
|
+
// Skip file on error
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return `✓ Vector index rebuilt successfully!\n\nIndexed ${indexedFiles} file(s) with ${indexedChunks} chunk(s).\n\nUse vector_memory_search to find relevant memories.`
|
|
378
|
+
} finally {
|
|
379
|
+
db.close()
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// Tool 3: Check vector index status
|
|
385
|
+
export const index_status = tool({
|
|
386
|
+
description: "Check the status of the vector index. Shows number of indexed files, chunks, and last update time.",
|
|
387
|
+
args: {},
|
|
388
|
+
async execute() {
|
|
389
|
+
await ensureVectorIndex()
|
|
390
|
+
|
|
391
|
+
const db = new Database(VECTOR_DB_PATH, { readonly: true })
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const stats = db.prepare(`
|
|
395
|
+
SELECT
|
|
396
|
+
COUNT(DISTINCT file_path) as files,
|
|
397
|
+
COUNT(*) as chunks,
|
|
398
|
+
MIN(created_at) as oldest,
|
|
399
|
+
MAX(created_at) as newest
|
|
400
|
+
FROM memory_chunks
|
|
401
|
+
`).get() as { files: number; chunks: number; oldest: string; newest: string }
|
|
402
|
+
|
|
403
|
+
if (stats.files === 0) {
|
|
404
|
+
db.close()
|
|
405
|
+
return "Vector index is empty. No memories have been indexed yet.\n\nUse rebuild_index to create the index from your memory files."
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let output = "📊 Vector Index Status\n\n"
|
|
409
|
+
output += `Files Indexed: ${stats.files}\n`
|
|
410
|
+
output += `Chunks Indexed: ${stats.chunks}\n`
|
|
411
|
+
output += `Oldest Entry: ${stats.oldest}\n`
|
|
412
|
+
output += `Newest Entry: ${stats.newest}\n`
|
|
413
|
+
|
|
414
|
+
const dbSize = (await stat(VECTOR_DB_PATH)).size / 1024
|
|
415
|
+
output += `Database Size: ${dbSize.toFixed(2)} KB\n`
|
|
416
|
+
|
|
417
|
+
return output
|
|
418
|
+
} finally {
|
|
419
|
+
db.close()
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
export default {
|
|
425
|
+
vector_search,
|
|
426
|
+
rebuild_index,
|
|
427
|
+
index_status,
|
|
428
|
+
}
|