@latentforce/latentgraph 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.md +320 -0
- package/build/cli/commands/add.js +350 -0
- package/build/cli/commands/config.js +142 -0
- package/build/cli/commands/init.js +285 -0
- package/build/cli/commands/start.js +65 -0
- package/build/cli/commands/status.js +76 -0
- package/build/cli/commands/stop.js +18 -0
- package/build/cli/commands/update-drg.js +194 -0
- package/build/daemon/daemon-manager.js +136 -0
- package/build/daemon/daemon.js +119 -0
- package/build/daemon/tools-executor.js +462 -0
- package/build/daemon/websocket-client.js +334 -0
- package/build/index.js +205 -0
- package/build/mcp-server.js +484 -0
- package/build/utils/api-client.js +147 -0
- package/build/utils/auth-resolver.js +184 -0
- package/build/utils/config.js +260 -0
- package/build/utils/machine-id.js +46 -0
- package/build/utils/prompts.js +114 -0
- package/build/utils/shiftignore.js +108 -0
- package/build/utils/tree-scanner.js +165 -0
- package/package.json +49 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools Executor for MCP
|
|
3
|
+
* Handles execution of local file tools matching extension's tools-executor.js
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs/promises';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { exec } from 'child_process';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
import { getProjectTree as getProjectTreeSync } from '../utils/tree-scanner.js';
|
|
10
|
+
const execAsync = promisify(exec);
|
|
11
|
+
export class ToolsExecutor {
|
|
12
|
+
wsClient;
|
|
13
|
+
workspaceRoot;
|
|
14
|
+
tools;
|
|
15
|
+
constructor(wsClient, workspaceRoot) {
|
|
16
|
+
this.wsClient = wsClient;
|
|
17
|
+
this.workspaceRoot = workspaceRoot;
|
|
18
|
+
this.tools = {
|
|
19
|
+
'get_tree_struct': this.getTreeStruct.bind(this),
|
|
20
|
+
'read_file': this.readFile.bind(this),
|
|
21
|
+
'get_file_info': this.getFileInfo.bind(this),
|
|
22
|
+
'get_repository_root': this.getRepositoryRoot.bind(this),
|
|
23
|
+
'get_project_tree': this.getProjectTree.bind(this),
|
|
24
|
+
'execute_command': this.executeCommand.bind(this),
|
|
25
|
+
'run_bash': this.runBash.bind(this),
|
|
26
|
+
'glob_search': this.globSearch.bind(this),
|
|
27
|
+
'grep_search': this.grepSearch.bind(this),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Execute a tool by name
|
|
32
|
+
*/
|
|
33
|
+
async executeTool(toolName, params) {
|
|
34
|
+
console.log(`[ToolsExecutor] Executing: ${toolName}`);
|
|
35
|
+
console.log(`[ToolsExecutor] Params:`, JSON.stringify(params, null, 2));
|
|
36
|
+
if (!this.tools[toolName]) {
|
|
37
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const result = await this.tools[toolName](params);
|
|
41
|
+
console.log(`[ToolsExecutor] ✓ ${toolName} completed`);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error(`[ToolsExecutor] ✗ ${toolName} failed:`, error);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get available tool names
|
|
51
|
+
*/
|
|
52
|
+
getAvailableTools() {
|
|
53
|
+
return Object.keys(this.tools);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Execute command (with auto-approval for MCP - no UI)
|
|
57
|
+
*/
|
|
58
|
+
async executeCommand(params) {
|
|
59
|
+
const { command, working_directory = '.' } = params;
|
|
60
|
+
if (!command) {
|
|
61
|
+
return {
|
|
62
|
+
status: 'error',
|
|
63
|
+
error: 'command is required'
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
console.log(`[ToolsExecutor] Executing command: ${command}`);
|
|
67
|
+
const fullWorkingDir = path.join(this.workspaceRoot, working_directory);
|
|
68
|
+
try {
|
|
69
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
70
|
+
cwd: fullWorkingDir,
|
|
71
|
+
timeout: 30000,
|
|
72
|
+
maxBuffer: 1024 * 1024
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
status: 'success',
|
|
76
|
+
command: command,
|
|
77
|
+
working_directory: working_directory,
|
|
78
|
+
stdout: stdout,
|
|
79
|
+
stderr: stderr,
|
|
80
|
+
exit_code: 0
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return {
|
|
85
|
+
status: 'error',
|
|
86
|
+
command: command,
|
|
87
|
+
working_directory: working_directory,
|
|
88
|
+
stdout: error.stdout || '',
|
|
89
|
+
stderr: error.stderr || '',
|
|
90
|
+
exit_code: error.code || 1,
|
|
91
|
+
error_message: error.message
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get tree structure of a directory
|
|
97
|
+
*/
|
|
98
|
+
async getTreeStruct(params) {
|
|
99
|
+
const { target_path = params.path || '.', depth = 3, exclude_patterns = ['.git', 'node_modules', '__pycache__', '.vscode', 'dist', 'build'] } = params;
|
|
100
|
+
const fullPath = path.join(this.workspaceRoot, target_path);
|
|
101
|
+
let file_count = 0;
|
|
102
|
+
let dir_count = 0;
|
|
103
|
+
let total_size = 0;
|
|
104
|
+
const scanDirectory = async (dirPath, currentDepth = 0, relativePath = '') => {
|
|
105
|
+
if (currentDepth >= depth) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const items = [];
|
|
109
|
+
try {
|
|
110
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (exclude_patterns.some((pattern) => entry.name.includes(pattern))) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const itemPath = path.join(dirPath, entry.name);
|
|
116
|
+
const itemRelativePath = path.join(relativePath, entry.name);
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
dir_count++;
|
|
119
|
+
const children = await scanDirectory(itemPath, currentDepth + 1, itemRelativePath);
|
|
120
|
+
items.push({
|
|
121
|
+
name: entry.name,
|
|
122
|
+
type: 'directory',
|
|
123
|
+
path: itemRelativePath,
|
|
124
|
+
children: children
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (entry.isFile()) {
|
|
128
|
+
file_count++;
|
|
129
|
+
try {
|
|
130
|
+
const stats = await fs.stat(itemPath);
|
|
131
|
+
total_size += stats.size;
|
|
132
|
+
items.push({
|
|
133
|
+
name: entry.name,
|
|
134
|
+
type: 'file',
|
|
135
|
+
path: itemRelativePath,
|
|
136
|
+
size: stats.size
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Skip files we can't read
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error(`Error scanning ${dirPath}:`, err.message);
|
|
147
|
+
}
|
|
148
|
+
return items;
|
|
149
|
+
};
|
|
150
|
+
const tree = await scanDirectory(fullPath, 0, target_path);
|
|
151
|
+
return {
|
|
152
|
+
status: 'success',
|
|
153
|
+
tree: tree,
|
|
154
|
+
file_count: file_count,
|
|
155
|
+
dir_count: dir_count,
|
|
156
|
+
total_size: `${(total_size / (1024 * 1024)).toFixed(2)} MB`,
|
|
157
|
+
scanned_path: target_path,
|
|
158
|
+
workspace_root: this.workspaceRoot
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read file contents
|
|
163
|
+
*/
|
|
164
|
+
async readFile(params) {
|
|
165
|
+
const { file_path } = params;
|
|
166
|
+
if (!file_path) {
|
|
167
|
+
return {
|
|
168
|
+
status: 'error',
|
|
169
|
+
error: 'file_path is required'
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
console.log(`[ToolsExecutor] Reading file: ${file_path}`);
|
|
173
|
+
try {
|
|
174
|
+
const fullPath = path.join(this.workspaceRoot, file_path);
|
|
175
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
176
|
+
const stats = await fs.stat(fullPath);
|
|
177
|
+
return {
|
|
178
|
+
status: 'success',
|
|
179
|
+
file_path: file_path,
|
|
180
|
+
content: content,
|
|
181
|
+
size: stats.size,
|
|
182
|
+
lines: content.split('\n').length
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
status: 'error',
|
|
188
|
+
file_path: file_path,
|
|
189
|
+
error: error.message
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get file info
|
|
195
|
+
*/
|
|
196
|
+
async getFileInfo(params) {
|
|
197
|
+
const { file_path } = params;
|
|
198
|
+
if (!file_path) {
|
|
199
|
+
return {
|
|
200
|
+
status: 'error',
|
|
201
|
+
error: 'file_path is required'
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const fullPath = path.join(this.workspaceRoot, file_path);
|
|
206
|
+
const stats = await fs.stat(fullPath);
|
|
207
|
+
return {
|
|
208
|
+
status: 'success',
|
|
209
|
+
file_path: file_path,
|
|
210
|
+
size: stats.size,
|
|
211
|
+
created: stats.birthtime,
|
|
212
|
+
modified: stats.mtime,
|
|
213
|
+
is_directory: stats.isDirectory()
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
status: 'error',
|
|
219
|
+
file_path: file_path,
|
|
220
|
+
error: error.message
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Get repository root directory
|
|
226
|
+
*/
|
|
227
|
+
async getRepositoryRoot(params) {
|
|
228
|
+
const { start_path = '.' } = params;
|
|
229
|
+
console.log(`[ToolsExecutor] Finding repository root from: ${start_path}`);
|
|
230
|
+
let currentPath = path.join(this.workspaceRoot, start_path);
|
|
231
|
+
try {
|
|
232
|
+
const stats = await fs.stat(currentPath);
|
|
233
|
+
if (stats.isFile()) {
|
|
234
|
+
currentPath = path.dirname(currentPath);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
currentPath = this.workspaceRoot;
|
|
239
|
+
}
|
|
240
|
+
let searchPath = currentPath;
|
|
241
|
+
const maxDepth = 20;
|
|
242
|
+
let depth = 0;
|
|
243
|
+
while (depth < maxDepth) {
|
|
244
|
+
try {
|
|
245
|
+
const gitPath = path.join(searchPath, '.git');
|
|
246
|
+
await fs.access(gitPath);
|
|
247
|
+
const entries = await fs.readdir(searchPath);
|
|
248
|
+
const has_package_json = entries.includes('package.json');
|
|
249
|
+
const has_requirements_txt = entries.includes('requirements.txt');
|
|
250
|
+
const has_cargo_toml = entries.includes('Cargo.toml');
|
|
251
|
+
const has_go_mod = entries.includes('go.mod');
|
|
252
|
+
let project_type = 'unknown';
|
|
253
|
+
if (has_package_json)
|
|
254
|
+
project_type = 'node';
|
|
255
|
+
else if (has_requirements_txt)
|
|
256
|
+
project_type = 'python';
|
|
257
|
+
else if (has_cargo_toml)
|
|
258
|
+
project_type = 'rust';
|
|
259
|
+
else if (has_go_mod)
|
|
260
|
+
project_type = 'go';
|
|
261
|
+
return {
|
|
262
|
+
status: 'success',
|
|
263
|
+
repository_root: searchPath,
|
|
264
|
+
relative_to_workspace: path.relative(this.workspaceRoot, searchPath),
|
|
265
|
+
has_git: true,
|
|
266
|
+
project_type: project_type,
|
|
267
|
+
files_in_root: entries.length
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
const parentPath = path.dirname(searchPath);
|
|
272
|
+
if (parentPath === searchPath) {
|
|
273
|
+
return {
|
|
274
|
+
status: 'success',
|
|
275
|
+
repository_root: null,
|
|
276
|
+
has_git: false,
|
|
277
|
+
message: 'No .git folder found in any parent directory'
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
searchPath = parentPath;
|
|
281
|
+
depth++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
status: 'success',
|
|
286
|
+
repository_root: null,
|
|
287
|
+
has_git: false,
|
|
288
|
+
message: `No .git folder found after searching ${maxDepth} levels up`
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Returns combined output matching tools.py's run_bash format.
|
|
293
|
+
*/
|
|
294
|
+
async runBash(params) {
|
|
295
|
+
const { command } = params;
|
|
296
|
+
if (!command) {
|
|
297
|
+
return { status: 'error', error: 'command is required' };
|
|
298
|
+
}
|
|
299
|
+
console.log(`[ToolsExecutor] run_bash: ${command}`);
|
|
300
|
+
try {
|
|
301
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
302
|
+
cwd: this.workspaceRoot,
|
|
303
|
+
timeout: 30000,
|
|
304
|
+
maxBuffer: 1024 * 1024,
|
|
305
|
+
});
|
|
306
|
+
let output = stdout;
|
|
307
|
+
if (stderr)
|
|
308
|
+
output += `\n[STDERR]\n${stderr}`;
|
|
309
|
+
return {
|
|
310
|
+
status: 'success',
|
|
311
|
+
output: output.trim() || '[No output]',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
let output = error.stdout || '';
|
|
316
|
+
if (error.stderr)
|
|
317
|
+
output += `\n[STDERR]\n${error.stderr}`;
|
|
318
|
+
output += `\n[EXIT CODE: ${error.code || 1}]`;
|
|
319
|
+
return {
|
|
320
|
+
status: 'error',
|
|
321
|
+
output: output.trim(),
|
|
322
|
+
error: error.message,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Find files matching a glob pattern
|
|
328
|
+
*/
|
|
329
|
+
async globSearch(params) {
|
|
330
|
+
const { pattern } = params;
|
|
331
|
+
if (!pattern) {
|
|
332
|
+
return { status: 'error', error: 'pattern is required' };
|
|
333
|
+
}
|
|
334
|
+
console.log(`[ToolsExecutor] Glob search: ${pattern}`);
|
|
335
|
+
let basePath = '.';
|
|
336
|
+
let namePattern = pattern;
|
|
337
|
+
const lastSlash = pattern.lastIndexOf('/');
|
|
338
|
+
if (lastSlash !== -1) {
|
|
339
|
+
basePath = pattern.substring(0, lastSlash).replace(/\/?\*\*$/, '') || '.';
|
|
340
|
+
namePattern = pattern.substring(lastSlash + 1);
|
|
341
|
+
}
|
|
342
|
+
const searchDir = path.join(this.workspaceRoot, basePath);
|
|
343
|
+
const excludes = [
|
|
344
|
+
'-not', '-path', '*/node_modules/*',
|
|
345
|
+
'-not', '-path', '*/.git/*',
|
|
346
|
+
'-not', '-path', '*/__pycache__/*',
|
|
347
|
+
'-not', '-path', '*/dist/*',
|
|
348
|
+
'-not', '-path', '*/build/*',
|
|
349
|
+
'-not', '-path', '*/.venv/*',
|
|
350
|
+
];
|
|
351
|
+
// Quote searchDir to handle workspace paths that contain spaces
|
|
352
|
+
const cmd = `find "${searchDir}" -name "${namePattern}" ${excludes.join(' ')}`;
|
|
353
|
+
try {
|
|
354
|
+
const { stdout } = await execAsync(cmd, { timeout: 30000 });
|
|
355
|
+
const matches = stdout
|
|
356
|
+
.trim()
|
|
357
|
+
.split('\n')
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.map(f => path.relative(this.workspaceRoot, f))
|
|
360
|
+
.slice(0, 200);
|
|
361
|
+
return {
|
|
362
|
+
status: 'success',
|
|
363
|
+
pattern,
|
|
364
|
+
matches,
|
|
365
|
+
count: matches.length,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
return { status: 'error', error: error.message };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Search file contents with a regex pattern
|
|
374
|
+
*/
|
|
375
|
+
async grepSearch(params) {
|
|
376
|
+
const { pattern, file_glob } = params;
|
|
377
|
+
if (!pattern) {
|
|
378
|
+
return { status: 'error', error: 'pattern is required' };
|
|
379
|
+
}
|
|
380
|
+
console.log(`[ToolsExecutor] Grep search: ${pattern}${file_glob ? ` (${file_glob})` : ''}`);
|
|
381
|
+
const baseArgs = [
|
|
382
|
+
'grep', '-rn', '-E',
|
|
383
|
+
'--exclude-dir=node_modules',
|
|
384
|
+
'--exclude-dir=.git',
|
|
385
|
+
'--exclude-dir=__pycache__',
|
|
386
|
+
'--exclude-dir=dist',
|
|
387
|
+
'--exclude-dir=build',
|
|
388
|
+
'--exclude-dir=.venv',
|
|
389
|
+
'--binary-files=without-match',
|
|
390
|
+
];
|
|
391
|
+
let searchPath = '.';
|
|
392
|
+
if (file_glob) {
|
|
393
|
+
if (file_glob.includes('/')) {
|
|
394
|
+
const match = file_glob.match(/^([^*]+?)\/(?:\*\*\/)?(.+)$/);
|
|
395
|
+
if (match) {
|
|
396
|
+
searchPath = match[1];
|
|
397
|
+
const namePat = match[2];
|
|
398
|
+
if (namePat && namePat !== '*')
|
|
399
|
+
baseArgs.push(`--include=${namePat}`);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
baseArgs.push(`--include=${file_glob}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
baseArgs.push(`--include=${file_glob}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const args = [...baseArgs, `'${pattern.replace(/'/g, `'\\''`)}'`, searchPath];
|
|
410
|
+
try {
|
|
411
|
+
const { stdout } = await execAsync(args.join(' '), {
|
|
412
|
+
cwd: this.workspaceRoot,
|
|
413
|
+
timeout: 30000,
|
|
414
|
+
maxBuffer: 1024 * 1024,
|
|
415
|
+
});
|
|
416
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
417
|
+
const truncated = lines.length > 500;
|
|
418
|
+
const output = truncated ? lines.slice(0, 500).join('\n') + `\n\n... [truncated — ${lines.length} total matches, showing first 500]` : stdout.trim();
|
|
419
|
+
return {
|
|
420
|
+
status: 'success',
|
|
421
|
+
pattern,
|
|
422
|
+
file_glob: file_glob || null,
|
|
423
|
+
output,
|
|
424
|
+
match_count: lines.length,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (error.code === 1 && !error.stderr) {
|
|
429
|
+
return { status: 'success', pattern, file_glob: file_glob || null, output: '', match_count: 0 };
|
|
430
|
+
}
|
|
431
|
+
return { status: 'error', error: error.message };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Get project tree — delegates to shared tree-scanner utility
|
|
436
|
+
*/
|
|
437
|
+
async getProjectTree(params) {
|
|
438
|
+
const { depth = 0, exclude_patterns } = params;
|
|
439
|
+
console.log(`[ToolsExecutor] Getting project tree from workspace root: ${this.workspaceRoot}`);
|
|
440
|
+
console.log(`[ToolsExecutor] Depth limit: ${depth === 0 ? 'unlimited' : depth}`);
|
|
441
|
+
const result = getProjectTreeSync(this.workspaceRoot, {
|
|
442
|
+
depth,
|
|
443
|
+
...(exclude_patterns && { exclude_patterns }),
|
|
444
|
+
});
|
|
445
|
+
console.log(`[ToolsExecutor] ✓ Scanned project tree:`);
|
|
446
|
+
console.log(`[ToolsExecutor] Files: ${result.file_count}`);
|
|
447
|
+
console.log(`[ToolsExecutor] Directories: ${result.dir_count}`);
|
|
448
|
+
console.log(`[ToolsExecutor] Total size: ${result.total_size_mb} MB`);
|
|
449
|
+
return {
|
|
450
|
+
status: 'success',
|
|
451
|
+
tree: result.tree,
|
|
452
|
+
file_count: result.file_count,
|
|
453
|
+
dir_count: result.dir_count,
|
|
454
|
+
total_size_bytes: result.total_size_bytes,
|
|
455
|
+
total_size_mb: result.total_size_mb,
|
|
456
|
+
scanned_from: result.scanned_from,
|
|
457
|
+
depth_limit: result.depth_limit,
|
|
458
|
+
actual_max_depth: result.actual_max_depth,
|
|
459
|
+
excluded_patterns: result.excluded_patterns,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|