baby-daemon 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/.env.example +3 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/baby-daemon.js +189 -0
- package/bin/memory-watch.js +98 -0
- package/bin/memory.js +399 -0
- package/mcp-server.js +553 -0
- package/package.json +63 -0
- package/src/config.js +18 -0
- package/src/idempotency.js +159 -0
- package/src/memoryStore.js +95 -0
- package/src/summarizer.js +151 -0
- package/src/vectorStore.js +410 -0
- package/src/watcher.js +263 -0
package/bin/memory.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bin/memory.js
|
|
5
|
+
* ─────────────
|
|
6
|
+
* CLI tool to search, dump, and archive memories.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* memory search "authentication issue" --type bug --since "2 days ago"
|
|
10
|
+
* memory "authentication issue" --type bug
|
|
11
|
+
* memory dump --since "yesterday"
|
|
12
|
+
* memory archive --age 15
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import * as chrono from 'chrono-node';
|
|
19
|
+
import { searchMemories, archiveMemories } from '../src/vectorStore.js';
|
|
20
|
+
import { readAllMemories } from '../src/memoryStore.js';
|
|
21
|
+
|
|
22
|
+
// Resolve __dirname equivalent in ES Modules
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
25
|
+
const DEFAULT_LOGS_DIR = path.join(PROJECT_ROOT, 'logs');
|
|
26
|
+
|
|
27
|
+
// CLI Styling Tokens
|
|
28
|
+
const RESET = '\x1b[0m';
|
|
29
|
+
const BOLD = '\x1b[1m';
|
|
30
|
+
const DIM = '\x1b[2m';
|
|
31
|
+
const CYAN = '\x1b[36m';
|
|
32
|
+
const GREEN = '\x1b[32m';
|
|
33
|
+
const YELLOW = '\x1b[33m';
|
|
34
|
+
const RED = '\x1b[31m';
|
|
35
|
+
const BLUE = '\x1b[34m';
|
|
36
|
+
const MAGENTA = '\x1b[35m';
|
|
37
|
+
|
|
38
|
+
// Parse CLI args
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
|
|
41
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
42
|
+
printHelp();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────
|
|
47
|
+
// ROUTE SUBCOMMAND
|
|
48
|
+
// ─────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const command = args[0].toLowerCase();
|
|
51
|
+
|
|
52
|
+
if (command === 'dump') {
|
|
53
|
+
await handleDump();
|
|
54
|
+
} else if (command === 'archive') {
|
|
55
|
+
await handleArchive();
|
|
56
|
+
} else if (command === 'read') {
|
|
57
|
+
await handleRead();
|
|
58
|
+
} else {
|
|
59
|
+
// If first argument is 'search', execute search with subsequent args.
|
|
60
|
+
// Otherwise, treat the entire set of args as search (implicit search).
|
|
61
|
+
await handleSearch();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────
|
|
65
|
+
// SEARCH HANDLER
|
|
66
|
+
// ─────────────────────────────────────────────────────────
|
|
67
|
+
async function handleSearch() {
|
|
68
|
+
let queryStartIndex = 0;
|
|
69
|
+
if (args[0].toLowerCase() === 'search') {
|
|
70
|
+
queryStartIndex = 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Find the query (everything before the first option flag)
|
|
74
|
+
const queryParts = [];
|
|
75
|
+
let i = queryStartIndex;
|
|
76
|
+
while (i < args.length && !args[i].startsWith('-')) {
|
|
77
|
+
queryParts.push(args[i]);
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const queryText = queryParts.join(' ').trim();
|
|
82
|
+
|
|
83
|
+
if (!queryText) {
|
|
84
|
+
console.error(`\n ${RED}✗ Error:${RESET} No search query specified.\n`);
|
|
85
|
+
printHelp();
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract flag values
|
|
90
|
+
const sinceVal = getFlagValue('--since', args);
|
|
91
|
+
const typeVal = getFlagValue('--type', args);
|
|
92
|
+
const fileVal = getFlagValue('--file', args);
|
|
93
|
+
const limitVal = getFlagValue('--limit', args) || '5';
|
|
94
|
+
|
|
95
|
+
const limit = parseInt(limitVal, 10);
|
|
96
|
+
|
|
97
|
+
// Parse natural language date using chrono-node
|
|
98
|
+
let sinceDate = null;
|
|
99
|
+
if (sinceVal) {
|
|
100
|
+
const parsed = chrono.parseDate(sinceVal);
|
|
101
|
+
if (!parsed) {
|
|
102
|
+
console.warn(` ${YELLOW}⚠️ Warning:${RESET} Could not parse date description "${sinceVal}". Ignoring date filter.`);
|
|
103
|
+
} else {
|
|
104
|
+
sinceDate = parsed.toISOString();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(`\n ${CYAN}🔍 Querying memories...${RESET}`);
|
|
109
|
+
if (sinceVal && sinceDate) console.log(` ${DIM}• Since: ${sinceVal} (${new Date(sinceDate).toLocaleDateString()})${RESET}`);
|
|
110
|
+
if (typeVal) console.log(` ${DIM}• Type: ${typeVal}${RESET}`);
|
|
111
|
+
if (fileVal) console.log(` ${DIM}• File: ${fileVal}${RESET}`);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const searchResult = await searchMemories(queryText, {
|
|
115
|
+
since: sinceDate,
|
|
116
|
+
type: typeVal,
|
|
117
|
+
file: fileVal,
|
|
118
|
+
limit,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const { method, results } = searchResult;
|
|
122
|
+
|
|
123
|
+
if (results.length === 0) {
|
|
124
|
+
console.log(`\n ${YELLOW}No matching memories found.${RESET}\n`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const headerIcon = method === 'semantic' ? '🧠' : '🔍';
|
|
129
|
+
const methodNameText = method === 'semantic' ? 'Semantic Vector Search' : 'Keyword Fallback Search';
|
|
130
|
+
|
|
131
|
+
console.log(`\n ${headerIcon} ${BOLD}${CYAN}[${methodNameText}]${RESET} Found ${results.length} matches:`);
|
|
132
|
+
|
|
133
|
+
results.forEach((item, index) => {
|
|
134
|
+
const displayScore = item.score ? ` (similarity: ${(item.score * 100).toFixed(0)}%)` : '';
|
|
135
|
+
const formattedDate = new Date(item.timestamp).toLocaleString();
|
|
136
|
+
const relativeSource = item.chat_file;
|
|
137
|
+
|
|
138
|
+
console.log(`\n ${CYAN}${BOLD}${index + 1}. [${item.type.toUpperCase()}]${RESET}${displayScore}`);
|
|
139
|
+
console.log(` ${DIM}Date: ${formattedDate} | Source: ${relativeSource}${RESET}`);
|
|
140
|
+
console.log(` ${BOLD}Content:${RESET} ${item.content}`);
|
|
141
|
+
if (item.related_files && item.related_files.length > 0) {
|
|
142
|
+
console.log(` ${DIM}Related files:${RESET} ${item.related_files.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
if (item.original_text) {
|
|
145
|
+
console.log(` ${DIM}Evidence:${RESET}`);
|
|
146
|
+
const formattedContext = item.original_text
|
|
147
|
+
.split('\n')
|
|
148
|
+
.map(line => ` | ${line}`)
|
|
149
|
+
.join('\n');
|
|
150
|
+
console.log(`${DIM}${formattedContext}${RESET}`);
|
|
151
|
+
}
|
|
152
|
+
console.log(` ${DIM}─────────────────────────────────────────${RESET}`);
|
|
153
|
+
});
|
|
154
|
+
console.log('');
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`\n ${RED}✗ Search failed:${RESET}`, error.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─────────────────────────────────────────────────────────
|
|
161
|
+
// DUMP HANDLER
|
|
162
|
+
// ─────────────────────────────────────────────────────────
|
|
163
|
+
async function handleDump() {
|
|
164
|
+
const sinceVal = getFlagValue('--since', args);
|
|
165
|
+
const watchDirVal = getFlagValue('--watch-dir', args);
|
|
166
|
+
|
|
167
|
+
const logsDir = watchDirVal ? path.resolve(watchDirVal) : DEFAULT_LOGS_DIR;
|
|
168
|
+
|
|
169
|
+
if (!fs.existsSync(logsDir)) {
|
|
170
|
+
console.error(`\n ${RED}✗ Error:${RESET} Logs directory not found at: ${logsDir}\n`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let cutoffDate = null;
|
|
175
|
+
if (sinceVal) {
|
|
176
|
+
cutoffDate = chrono.parseDate(sinceVal);
|
|
177
|
+
if (!cutoffDate) {
|
|
178
|
+
console.error(`\n ${RED}✗ Error:${RESET} Could not parse date "${sinceVal}".\n`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(`\n ${MAGENTA}📁 Dumping raw logs from:${RESET} ${logsDir}`);
|
|
184
|
+
if (cutoffDate) {
|
|
185
|
+
console.log(` ${DIM}Filtering: modified since ${cutoffDate.toLocaleString()}${RESET}`);
|
|
186
|
+
}
|
|
187
|
+
console.log(` ${DIM}─────────────────────────────────────────${RESET}`);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const files = fs.readdirSync(logsDir);
|
|
191
|
+
let dumpedCount = 0;
|
|
192
|
+
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
const fullPath = path.join(logsDir, file);
|
|
195
|
+
const stat = fs.statSync(fullPath);
|
|
196
|
+
|
|
197
|
+
if (!stat.isFile()) continue;
|
|
198
|
+
|
|
199
|
+
if (cutoffDate && stat.mtime < cutoffDate) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(`\n ${BOLD}${CYAN}📄 Log File: ${file}${RESET}`);
|
|
204
|
+
console.log(` ${DIM}Modified: ${stat.mtime.toLocaleString()} | Size: ${stat.size} bytes${RESET}`);
|
|
205
|
+
console.log(` ${DIM}─────────────────────────────────────────${RESET}`);
|
|
206
|
+
|
|
207
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
208
|
+
console.log(content.trim() ? content : ' (empty file)');
|
|
209
|
+
console.log(`\n ${DIM}─────────────────────────────────────────${RESET}`);
|
|
210
|
+
dumpedCount++;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (dumpedCount === 0) {
|
|
214
|
+
console.log(`\n ${YELLOW}No files matched dump criteria.${RESET}\n`);
|
|
215
|
+
} else {
|
|
216
|
+
console.log(`\n ${GREEN}✓ Dumped ${dumpedCount} raw log files.${RESET}\n`);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error(`\n ${RED}✗ Dump failed:${RESET}`, error.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─────────────────────────────────────────────────────────
|
|
224
|
+
// ARCHIVE HANDLER
|
|
225
|
+
// ─────────────────────────────────────────────────────────
|
|
226
|
+
async function handleArchive() {
|
|
227
|
+
const ageVal = getFlagValue('--age', args) || '30';
|
|
228
|
+
const ageDays = parseInt(ageVal, 10);
|
|
229
|
+
|
|
230
|
+
if (isNaN(ageDays) || ageDays < 0) {
|
|
231
|
+
console.error(`\n ${RED}✗ Error:${RESET} Invalid age value "${ageVal}".\n`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log(`\n ${YELLOW}📦 Running LanceDB archival...${RESET}`);
|
|
236
|
+
console.log(` ${DIM}Archiving memories older than ${ageDays} days...${RESET}\n`);
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const stats = await archiveMemories({ ageDays });
|
|
240
|
+
console.log(` ${GREEN}✓ Success:${RESET} ${stats.msg}\n`);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error(` ${RED}✗ Archival failed:${RESET}`, error.message);
|
|
243
|
+
console.log(` ${DIM}(If '@lancedb/lancedb' native bindings are missing on this OS, archival is not supported.)${RESET}\n`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─────────────────────────────────────────────────────────
|
|
248
|
+
// READ HANDLER
|
|
249
|
+
// ─────────────────────────────────────────────────────────
|
|
250
|
+
async function handleRead() {
|
|
251
|
+
console.log(`\n ${BOLD}${CYAN}🧠 Loading Project Knowledge Briefing...${RESET}`);
|
|
252
|
+
try {
|
|
253
|
+
const allMemories = readAllMemories();
|
|
254
|
+
const activeMemories = allMemories.filter(m => m.status === 'active');
|
|
255
|
+
|
|
256
|
+
if (activeMemories.length === 0) {
|
|
257
|
+
console.log(`\n ${YELLOW}No active memories found in this project.${RESET}\n`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
console.log(` ${GREEN}Loaded ${activeMemories.length} active memories.${RESET}`);
|
|
262
|
+
console.log(` ${DIM}──────────────────────────────────────────────────${RESET}`);
|
|
263
|
+
|
|
264
|
+
// Grouping
|
|
265
|
+
const groups = {
|
|
266
|
+
decision: [],
|
|
267
|
+
architecture_note: [],
|
|
268
|
+
bug: [],
|
|
269
|
+
resolved_bug: [],
|
|
270
|
+
file_change: [],
|
|
271
|
+
proposed_idea: [],
|
|
272
|
+
open_question: [],
|
|
273
|
+
other: []
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
activeMemories.forEach(mem => {
|
|
277
|
+
const type = mem.type;
|
|
278
|
+
if (groups[type]) {
|
|
279
|
+
groups[type].push(mem);
|
|
280
|
+
} else {
|
|
281
|
+
groups[type] = groups[type] || [];
|
|
282
|
+
groups[type].push(mem);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const displayOrder = [
|
|
287
|
+
{ key: 'decision', title: 'Decisions', emoji: '📢', color: GREEN },
|
|
288
|
+
{ key: 'architecture_note', title: 'Architecture Notes', emoji: '🏗️', color: BLUE },
|
|
289
|
+
{ key: 'bug', title: 'Active Bugs', emoji: '🐛', color: RED },
|
|
290
|
+
{ key: 'resolved_bug', title: 'Resolved Bugs', emoji: '✅', color: GREEN },
|
|
291
|
+
{ key: 'file_change', title: 'File Changes', emoji: '📝', color: CYAN },
|
|
292
|
+
{ key: 'proposed_idea', title: 'Proposed Ideas', emoji: '💡', color: YELLOW },
|
|
293
|
+
{ key: 'open_question', title: 'Open Questions', emoji: '❓', color: MAGENTA }
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
// Find any other groups that are not in displayOrder
|
|
297
|
+
const otherKeys = Object.keys(groups).filter(k => !displayOrder.find(o => o.key === k) && k !== 'other');
|
|
298
|
+
otherKeys.forEach(k => {
|
|
299
|
+
if (groups[k] && groups[k].length > 0) {
|
|
300
|
+
groups.other.push(...groups[k]);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
displayOrder.forEach(({ key, title, emoji, color }) => {
|
|
305
|
+
const items = groups[key] || [];
|
|
306
|
+
if (items.length === 0) return;
|
|
307
|
+
|
|
308
|
+
console.log(`\n ${color}${BOLD}${emoji} ${title.toUpperCase()} (${items.length})${RESET}`);
|
|
309
|
+
console.log(` ${DIM}─────────────────────────────────────────${RESET}`);
|
|
310
|
+
|
|
311
|
+
items.forEach((item, index) => {
|
|
312
|
+
const formattedDate = new Date(item.timestamp).toLocaleDateString();
|
|
313
|
+
const chatFile = item.source?.chat_file || 'unknown source';
|
|
314
|
+
|
|
315
|
+
console.log(` ${color}${BOLD}${index + 1}.${RESET} ${BOLD}${item.content}${RESET}`);
|
|
316
|
+
console.log(` ${DIM}• Source: ${chatFile} (${formattedDate})${RESET}`);
|
|
317
|
+
if (item.related_files && item.related_files.length > 0) {
|
|
318
|
+
console.log(` ${DIM}• Files: ${item.related_files.join(', ')}${RESET}`);
|
|
319
|
+
}
|
|
320
|
+
if (item.tags && item.tags.length > 0) {
|
|
321
|
+
console.log(` ${DIM}• Tags: ${item.tags.join(', ')}${RESET}`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Handle other/unknown types
|
|
327
|
+
if (groups.other.length > 0) {
|
|
328
|
+
console.log(`\n ${MAGENTA}${BOLD}🔮 Other Memories (${groups.other.length})${RESET}`);
|
|
329
|
+
console.log(` ${DIM}─────────────────────────────────────────${RESET}`);
|
|
330
|
+
groups.other.forEach((item, index) => {
|
|
331
|
+
const formattedDate = new Date(item.timestamp).toLocaleDateString();
|
|
332
|
+
const chatFile = item.source?.chat_file || 'unknown source';
|
|
333
|
+
console.log(` ${MAGENTA}${BOLD}${index + 1}.${RESET} [${item.type.toUpperCase()}] ${BOLD}${item.content}${RESET}`);
|
|
334
|
+
console.log(` ${DIM}• Source: ${chatFile} (${formattedDate})${RESET}`);
|
|
335
|
+
if (item.related_files && item.related_files.length > 0) {
|
|
336
|
+
console.log(` ${DIM}• Files: ${item.related_files.join(', ')}${RESET}`);
|
|
337
|
+
}
|
|
338
|
+
if (item.tags && item.tags.length > 0) {
|
|
339
|
+
console.log(` ${DIM}• Tags: ${item.tags.join(', ')}${RESET}`);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log(`\n ${DIM}──────────────────────────────────────────────────${RESET}`);
|
|
345
|
+
console.log(` ${GREEN}✓ Done. Use these active memories for project context!${RESET}\n`);
|
|
346
|
+
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error(`\n ${RED}✗ Read failed:${RESET}`, error.message);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─────────────────────────────────────────────────────────
|
|
353
|
+
// HELPERS
|
|
354
|
+
// ─────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function getFlagValue(flag, argList) {
|
|
357
|
+
const index = argList.indexOf(flag);
|
|
358
|
+
if (index !== -1 && index + 1 < argList.length) {
|
|
359
|
+
return argList[index + 1];
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function printHelp() {
|
|
365
|
+
console.log(`
|
|
366
|
+
${BOLD}Baby Daemon — memory CLI (Phase 3)${RESET}
|
|
367
|
+
|
|
368
|
+
${BOLD}USAGE:${RESET}
|
|
369
|
+
memory [search] "<query>" [options]
|
|
370
|
+
memory read
|
|
371
|
+
memory dump [options]
|
|
372
|
+
memory archive [options]
|
|
373
|
+
|
|
374
|
+
${BOLD}SUBCOMMANDS:${RESET}
|
|
375
|
+
${CYAN}search${RESET} Query stored memories (vector search with keyword fallback)
|
|
376
|
+
${CYAN}read${RESET} Output all active project memories grouped by category
|
|
377
|
+
${CYAN}dump${RESET} Output raw log file contents directly (bypassing vector search)
|
|
378
|
+
${CYAN}archive${RESET} Move old memories to an archive table to optimize searches
|
|
379
|
+
|
|
380
|
+
${BOLD}SEARCH OPTIONS:${RESET}
|
|
381
|
+
--since "<date>" Filter memories created since natural language date (e.g. "yesterday", "2 days ago")
|
|
382
|
+
--type <type> Filter by category (decision, proposed_idea, bug, resolved_bug, architecture_note)
|
|
383
|
+
--file <filename> Filter by related file path or source chat filename
|
|
384
|
+
--limit <number> Max results to return (default: 5)
|
|
385
|
+
|
|
386
|
+
${BOLD}DUMP OPTIONS:${RESET}
|
|
387
|
+
--since "<date>" Dump only logs modified since natural language date
|
|
388
|
+
--watch-dir <path> Specify folder to read logs from (default: project logs/ folder)
|
|
389
|
+
|
|
390
|
+
${BOLD}ARCHIVE OPTIONS:${RESET}
|
|
391
|
+
--age <days> Days threshold to move memories to archive (default: 30)
|
|
392
|
+
|
|
393
|
+
${BOLD}EXAMPLES:${RESET}
|
|
394
|
+
memory "caching database strategy"
|
|
395
|
+
memory search "auth bug" --type resolved_bug --since "3 days ago"
|
|
396
|
+
memory dump --since "2 hours ago"
|
|
397
|
+
memory archive --age 14
|
|
398
|
+
`);
|
|
399
|
+
}
|