@tspappsen/elamax 1.2.3

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.
@@ -0,0 +1,486 @@
1
+ import { z } from "zod";
2
+ import { approveAll, defineTool } from "@github/copilot-sdk";
3
+ import { getDb, addMemory, searchMemories, removeMemory } from "../store/db.js";
4
+ import { readdirSync, readFileSync } from "fs";
5
+ import { join, sep, resolve } from "path";
6
+ import { homedir } from "os";
7
+ import { listSkills, createSkill, removeSkill } from "./skills.js";
8
+ import { config, persistModel } from "../config.js";
9
+ import { SESSIONS_DIR } from "../paths.js";
10
+ import { getCurrentSourceChannel, getCurrentSourceChannelId, getCurrentSourceThreadId, invalidateSession } from "./orchestrator.js";
11
+ import { getRouterConfig, updateRouterConfig } from "./router.js";
12
+ import { formatDiagnosedFailure } from "../diagnosis.js";
13
+ function formatWorkerError(workerName, startedAt, _timeoutMs, err) {
14
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
15
+ const msg = err instanceof Error ? err.message : String(err);
16
+ return formatDiagnosedFailure(workerName, elapsed, msg);
17
+ }
18
+ const BLOCKED_WORKER_DIRS = [
19
+ ".ssh", ".gnupg", ".aws", ".azure", ".config/gcloud",
20
+ ".kube", ".docker", ".npmrc", ".pypirc",
21
+ ];
22
+ const MAX_CONCURRENT_WORKERS = 5;
23
+ export function createTools(deps) {
24
+ return [
25
+ defineTool("create_worker_session", {
26
+ description: "Create a new Copilot CLI worker session in a specific directory. " +
27
+ "Use for coding tasks, debugging, file operations. " +
28
+ "Returns confirmation with session name.",
29
+ parameters: z.object({
30
+ name: z.string().describe("Short descriptive name for the session, e.g. 'auth-fix'"),
31
+ working_dir: z.string().describe("Absolute path to the directory to work in"),
32
+ initial_prompt: z.string().optional().describe("Optional initial prompt to send to the worker"),
33
+ }),
34
+ handler: async (args) => {
35
+ if (deps.workers.has(args.name)) {
36
+ return `Worker '${args.name}' already exists. Use send_to_worker to interact with it.`;
37
+ }
38
+ const home = homedir();
39
+ const resolvedDir = resolve(args.working_dir);
40
+ for (const blocked of BLOCKED_WORKER_DIRS) {
41
+ const blockedPath = join(home, blocked);
42
+ if (resolvedDir === blockedPath || resolvedDir.startsWith(blockedPath + sep)) {
43
+ return `Refused: '${args.working_dir}' is a sensitive directory. Workers cannot operate in ${blocked}.`;
44
+ }
45
+ }
46
+ if (deps.workers.size >= MAX_CONCURRENT_WORKERS) {
47
+ const names = Array.from(deps.workers.keys()).join(", ");
48
+ return `Worker limit reached (${MAX_CONCURRENT_WORKERS}). Active: ${names}. Kill a session first.`;
49
+ }
50
+ const session = await deps.client.createSession({
51
+ model: config.copilotModel,
52
+ configDir: SESSIONS_DIR,
53
+ workingDirectory: args.working_dir,
54
+ onPermissionRequest: approveAll,
55
+ });
56
+ const worker = {
57
+ name: args.name,
58
+ session,
59
+ workingDir: args.working_dir,
60
+ status: "idle",
61
+ originChannel: getCurrentSourceChannel(),
62
+ originChannelId: getCurrentSourceChannelId(),
63
+ originThreadId: getCurrentSourceThreadId(),
64
+ };
65
+ deps.workers.set(args.name, worker);
66
+ // Persist to SQLite
67
+ const db = getDb();
68
+ db.prepare(`INSERT OR REPLACE INTO worker_sessions (name, copilot_session_id, working_dir, status)
69
+ VALUES (?, ?, ?, 'idle')`).run(args.name, session.sessionId, args.working_dir);
70
+ if (args.initial_prompt) {
71
+ worker.status = "running";
72
+ worker.startedAt = Date.now();
73
+ db.prepare(`UPDATE worker_sessions SET status = 'running', updated_at = CURRENT_TIMESTAMP WHERE name = ?`).run(args.name);
74
+ const timeoutMs = config.workerTimeoutMs;
75
+ // Non-blocking: dispatch work and return immediately
76
+ session.sendAndWait({
77
+ prompt: `Working directory: ${args.working_dir}\n\n${args.initial_prompt}`,
78
+ }, timeoutMs).then((result) => {
79
+ worker.lastOutput = result?.data?.content || "No response";
80
+ deps.onWorkerComplete(args.name, worker.lastOutput);
81
+ }).catch((err) => {
82
+ const errMsg = formatWorkerError(args.name, worker.startedAt, timeoutMs, err);
83
+ worker.lastOutput = errMsg;
84
+ deps.onWorkerComplete(args.name, errMsg, true);
85
+ }).finally(() => {
86
+ // Auto-destroy background workers after completion to free memory (~400MB per worker)
87
+ session.destroy().catch(() => { });
88
+ deps.workers.delete(args.name);
89
+ getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
90
+ });
91
+ return `Worker '${args.name}' created in ${args.working_dir}. Task dispatched — I'll notify you when it's done.`;
92
+ }
93
+ return `Worker '${args.name}' created in ${args.working_dir}. Use send_to_worker to send it prompts.`;
94
+ },
95
+ }),
96
+ defineTool("send_to_worker", {
97
+ description: "Send a prompt to an existing worker session and wait for its response. " +
98
+ "Use for follow-up instructions or questions about ongoing work.",
99
+ parameters: z.object({
100
+ name: z.string().describe("Name of the worker session"),
101
+ prompt: z.string().describe("The prompt to send"),
102
+ }),
103
+ handler: async (args) => {
104
+ const worker = deps.workers.get(args.name);
105
+ if (!worker) {
106
+ return `No worker named '${args.name}'. Use list_sessions to see available workers.`;
107
+ }
108
+ if (worker.status === "running") {
109
+ return `Worker '${args.name}' is currently busy. Wait for it to finish or kill it.`;
110
+ }
111
+ worker.status = "running";
112
+ worker.startedAt = Date.now();
113
+ const db = getDb();
114
+ db.prepare(`UPDATE worker_sessions SET status = 'running', updated_at = CURRENT_TIMESTAMP WHERE name = ?`).run(args.name);
115
+ const timeoutMs = config.workerTimeoutMs;
116
+ // Non-blocking: dispatch work and return immediately
117
+ worker.session.sendAndWait({ prompt: args.prompt }, timeoutMs).then((result) => {
118
+ worker.lastOutput = result?.data?.content || "No response";
119
+ deps.onWorkerComplete(args.name, worker.lastOutput);
120
+ }).catch((err) => {
121
+ const errMsg = formatWorkerError(args.name, worker.startedAt, timeoutMs, err);
122
+ worker.lastOutput = errMsg;
123
+ deps.onWorkerComplete(args.name, errMsg, true);
124
+ }).finally(() => {
125
+ // Auto-destroy after each send_to_worker dispatch to free memory
126
+ worker.session.destroy().catch(() => { });
127
+ deps.workers.delete(args.name);
128
+ getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
129
+ });
130
+ return `Task dispatched to worker '${args.name}'. I'll notify you when it's done.`;
131
+ },
132
+ }),
133
+ defineTool("list_sessions", {
134
+ description: "List all active worker sessions with their name, status, and working directory.",
135
+ parameters: z.object({}),
136
+ handler: async () => {
137
+ if (deps.workers.size === 0) {
138
+ return "No active worker sessions.";
139
+ }
140
+ const lines = Array.from(deps.workers.values()).map((w) => `• ${w.name} (${w.workingDir}) — ${w.status}`);
141
+ return `Active sessions:\n${lines.join("\n")}`;
142
+ },
143
+ }),
144
+ defineTool("check_session_status", {
145
+ description: "Get detailed status of a specific worker session, including its last output.",
146
+ parameters: z.object({
147
+ name: z.string().describe("Name of the worker session"),
148
+ }),
149
+ handler: async (args) => {
150
+ const worker = deps.workers.get(args.name);
151
+ if (!worker) {
152
+ return `No worker named '${args.name}'.`;
153
+ }
154
+ const output = worker.lastOutput
155
+ ? `\n\nLast output:\n${worker.lastOutput.slice(0, 2000)}`
156
+ : "";
157
+ return `Worker '${args.name}'\nDirectory: ${worker.workingDir}\nStatus: ${worker.status}${output}`;
158
+ },
159
+ }),
160
+ defineTool("kill_session", {
161
+ description: "Terminate a worker session and free its resources.",
162
+ parameters: z.object({
163
+ name: z.string().describe("Name of the worker session to kill"),
164
+ }),
165
+ handler: async (args) => {
166
+ const worker = deps.workers.get(args.name);
167
+ if (!worker) {
168
+ return `No worker named '${args.name}'.`;
169
+ }
170
+ try {
171
+ await worker.session.destroy();
172
+ }
173
+ catch {
174
+ // Session may already be gone
175
+ }
176
+ deps.workers.delete(args.name);
177
+ const db = getDb();
178
+ db.prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
179
+ return `Worker '${args.name}' terminated.`;
180
+ },
181
+ }),
182
+ defineTool("list_machine_sessions", {
183
+ description: "List ALL Copilot CLI sessions on this machine — including sessions started from VS Code, " +
184
+ "the terminal, or other tools. Shows session ID, summary, working directory. " +
185
+ "Use this when the user asks about existing sessions running on the machine. " +
186
+ "By default shows the 20 most recently active sessions.",
187
+ parameters: z.object({
188
+ cwd_filter: z.string().optional().describe("Optional: only show sessions whose working directory contains this string"),
189
+ limit: z.number().int().min(1).max(100).optional().describe("Max sessions to return (default 20)"),
190
+ }),
191
+ handler: async (args) => {
192
+ const sessionStateDir = join(homedir(), ".copilot", "session-state");
193
+ const limit = args.limit || 20;
194
+ let entries = [];
195
+ try {
196
+ const dirs = readdirSync(sessionStateDir);
197
+ for (const dir of dirs) {
198
+ const yamlPath = join(sessionStateDir, dir, "workspace.yaml");
199
+ try {
200
+ const content = readFileSync(yamlPath, "utf-8");
201
+ const parsed = parseSimpleYaml(content);
202
+ if (args.cwd_filter && !parsed.cwd?.includes(args.cwd_filter))
203
+ continue;
204
+ entries.push({
205
+ id: parsed.id || dir,
206
+ cwd: parsed.cwd || "unknown",
207
+ summary: parsed.summary || "",
208
+ updatedAt: parsed.updated_at ? new Date(parsed.updated_at) : new Date(0),
209
+ });
210
+ }
211
+ catch {
212
+ // Skip dirs without valid workspace.yaml
213
+ }
214
+ }
215
+ }
216
+ catch (err) {
217
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
218
+ return "No Copilot sessions found on this machine (session state directory does not exist yet).";
219
+ }
220
+ return "Could not read session state directory.";
221
+ }
222
+ // Sort by most recently updated
223
+ entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
224
+ entries = entries.slice(0, limit);
225
+ if (entries.length === 0) {
226
+ return "No Copilot sessions found on this machine.";
227
+ }
228
+ const lines = entries.map((s) => {
229
+ const age = formatAge(s.updatedAt);
230
+ const summary = s.summary ? ` — ${s.summary}` : "";
231
+ return `• ID: ${s.id}\n ${s.cwd} (${age})${summary}`;
232
+ });
233
+ return `Found ${entries.length} session(s) (most recent first):\n${lines.join("\n")}`;
234
+ },
235
+ }),
236
+ defineTool("attach_machine_session", {
237
+ description: "Attach to an existing Copilot CLI session on this machine (e.g. one started from VS Code or terminal). " +
238
+ "Resumes the session and adds it as a managed worker so you can send prompts to it.",
239
+ parameters: z.object({
240
+ session_id: z.string().describe("The session ID to attach to (from list_machine_sessions)"),
241
+ name: z.string().describe("A short name to reference this session by, e.g. 'vscode-main'"),
242
+ }),
243
+ handler: async (args) => {
244
+ if (deps.workers.has(args.name)) {
245
+ return `A worker named '${args.name}' already exists. Choose a different name.`;
246
+ }
247
+ try {
248
+ const session = await deps.client.resumeSession(args.session_id, {
249
+ model: config.copilotModel,
250
+ onPermissionRequest: approveAll,
251
+ });
252
+ const worker = {
253
+ name: args.name,
254
+ session,
255
+ workingDir: "(attached)",
256
+ status: "idle",
257
+ originChannel: getCurrentSourceChannel(),
258
+ originChannelId: getCurrentSourceChannelId(),
259
+ originThreadId: getCurrentSourceThreadId(),
260
+ };
261
+ deps.workers.set(args.name, worker);
262
+ const db = getDb();
263
+ db.prepare(`INSERT OR REPLACE INTO worker_sessions (name, copilot_session_id, working_dir, status)
264
+ VALUES (?, ?, '(attached)', 'idle')`).run(args.name, args.session_id);
265
+ return `Attached to session ${args.session_id.slice(0, 8)}… as worker '${args.name}'. You can now send_to_worker to interact with it.`;
266
+ }
267
+ catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ return `Failed to attach to session: ${msg}`;
270
+ }
271
+ },
272
+ }),
273
+ defineTool("list_skills", {
274
+ description: "List all available skills that Max knows. Skills are instruction documents that teach Max " +
275
+ "how to use external tools and services (e.g. Gmail, browser automation, YouTube transcripts). " +
276
+ "Shows skill name, description, and whether it's a local or global skill.",
277
+ parameters: z.object({}),
278
+ handler: async () => {
279
+ const skills = listSkills();
280
+ if (skills.length === 0) {
281
+ return "No skills installed yet. Use learn_skill to teach me something new.";
282
+ }
283
+ const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
284
+ return `Available skills (${skills.length}):\n${lines.join("\n")}`;
285
+ },
286
+ }),
287
+ defineTool("learn_skill", {
288
+ description: "Teach Max a new skill by creating a SKILL.md instruction file. Use this when the user asks Max " +
289
+ "to do something it doesn't know how to do yet (e.g. 'check my email', 'search the web'). " +
290
+ "First, use a worker session to research what CLI tools are available on the system (run 'which', " +
291
+ "'--help', etc.), then create the skill with the instructions you've learned. " +
292
+ "The skill becomes available on the next message (no restart needed).",
293
+ parameters: z.object({
294
+ slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe("Short kebab-case identifier for the skill, e.g. 'gmail', 'web-search'"),
295
+ name: z.string().refine(s => !s.includes('\n'), "must be single-line").describe("Human-readable name for the skill, e.g. 'Gmail', 'Web Search'"),
296
+ description: z.string().refine(s => !s.includes('\n'), "must be single-line").describe("One-line description of when to use this skill"),
297
+ instructions: z.string().describe("Markdown instructions for how to use the skill. Include: what CLI tool to use, " +
298
+ "common commands with examples, authentication steps if needed, tips and gotchas. " +
299
+ "This becomes the SKILL.md content body."),
300
+ }),
301
+ handler: async (args) => {
302
+ return createSkill(args.slug, args.name, args.description, args.instructions);
303
+ },
304
+ }),
305
+ defineTool("uninstall_skill", {
306
+ description: "Remove a skill from Max's local skills directory (~/.max/skills/). " +
307
+ "The skill will no longer be available on the next message. " +
308
+ "Only works for local skills — bundled and global skills cannot be removed this way.",
309
+ parameters: z.object({
310
+ slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe("The kebab-case slug of the skill to remove, e.g. 'gmail', 'web-search'"),
311
+ }),
312
+ handler: async (args) => {
313
+ const result = removeSkill(args.slug);
314
+ return result.message;
315
+ },
316
+ }),
317
+ defineTool("list_models", {
318
+ description: "List all available Copilot models. Shows model id, name, and billing tier. " +
319
+ "Marks the currently active model. Use when the user asks what models are available " +
320
+ "or wants to know which model is in use.",
321
+ parameters: z.object({}),
322
+ handler: async () => {
323
+ try {
324
+ const models = await deps.client.listModels();
325
+ if (models.length === 0) {
326
+ return "No models available.";
327
+ }
328
+ const current = config.copilotModel;
329
+ const lines = models.map((m) => {
330
+ const active = m.id === current ? " ← active" : "";
331
+ const billing = m.billing ? ` (${m.billing.multiplier}x)` : "";
332
+ return `• ${m.id}${billing}${active}`;
333
+ });
334
+ return `Available models (${models.length}):\n${lines.join("\n")}\n\nCurrent: ${current}`;
335
+ }
336
+ catch (err) {
337
+ const msg = err instanceof Error ? err.message : String(err);
338
+ return `Failed to list models: ${msg}`;
339
+ }
340
+ },
341
+ }),
342
+ defineTool("switch_model", {
343
+ description: "Switch the Copilot model Max uses for conversations. Takes effect on the next message. " +
344
+ "The change is persisted across restarts. Use when the user asks to change or switch models.",
345
+ parameters: z.object({
346
+ model_id: z.string().describe("The model id to switch to (from list_models)"),
347
+ }),
348
+ handler: async (args) => {
349
+ try {
350
+ const models = await deps.client.listModels();
351
+ const match = models.find((m) => m.id === args.model_id);
352
+ if (!match) {
353
+ const suggestions = models
354
+ .filter((m) => m.id.includes(args.model_id) || m.id.toLowerCase().includes(args.model_id.toLowerCase()))
355
+ .map((m) => m.id);
356
+ const hint = suggestions.length > 0
357
+ ? ` Did you mean: ${suggestions.join(", ")}?`
358
+ : " Use list_models to see available options.";
359
+ return `Model '${args.model_id}' not found.${hint}`;
360
+ }
361
+ const previous = config.copilotModel;
362
+ config.copilotModel = args.model_id;
363
+ persistModel(args.model_id);
364
+ invalidateSession();
365
+ // Disable router when manually switching — user has explicit preference
366
+ if (getRouterConfig().enabled) {
367
+ updateRouterConfig({ enabled: false });
368
+ return `Switched model from '${previous}' to '${args.model_id}'. Auto-routing disabled (use /auto or toggle_auto to re-enable). Takes effect on next message.`;
369
+ }
370
+ return `Switched model from '${previous}' to '${args.model_id}'. Takes effect on next message.`;
371
+ }
372
+ catch (err) {
373
+ const msg = err instanceof Error ? err.message : String(err);
374
+ return `Failed to switch model: ${msg}`;
375
+ }
376
+ },
377
+ }),
378
+ defineTool("toggle_auto", {
379
+ description: "Enable or disable automatic model routing (auto mode). When enabled, Max automatically picks " +
380
+ "the best model (fast/standard/premium) for each message to save cost and optimize speed. " +
381
+ "Use when the user asks to turn auto-routing on or off.",
382
+ parameters: z.object({
383
+ enabled: z.boolean().describe("true to enable auto-routing, false to disable"),
384
+ }),
385
+ handler: async (args) => {
386
+ const updated = updateRouterConfig({ enabled: args.enabled });
387
+ if (args.enabled) {
388
+ const tiers = updated.tierModels;
389
+ return `Auto-routing enabled. Tier models:\n• fast: ${tiers.fast}\n• standard: ${tiers.standard}\n• premium: ${tiers.premium}\n\nMax will automatically pick the best model for each message.`;
390
+ }
391
+ return `Auto-routing disabled. Using fixed model: ${config.copilotModel}`;
392
+ },
393
+ }),
394
+ defineTool("remember", {
395
+ description: "Save something to Max's long-term memory. Use when the user says 'remember that...', " +
396
+ "states a preference, shares a fact about themselves, or mentions something important " +
397
+ "that should be remembered across conversations. Also use proactively when you detect " +
398
+ "important information worth persisting.",
399
+ parameters: z.object({
400
+ category: z.enum(["preference", "fact", "project", "person", "routine"])
401
+ .describe("Category: preference (likes/dislikes/settings), fact (general knowledge), project (codebase/repo info), person (people info), routine (schedules/habits)"),
402
+ content: z.string().describe("The thing to remember — a concise, self-contained statement"),
403
+ source: z.enum(["user", "auto"]).optional().describe("'user' if explicitly asked to remember, 'auto' if Max detected it (default: 'user')"),
404
+ }),
405
+ handler: async (args) => {
406
+ const id = addMemory(args.category, args.content, args.source || "user");
407
+ return `Remembered (#${id}, ${args.category}): "${args.content}"`;
408
+ },
409
+ }),
410
+ defineTool("recall", {
411
+ description: "Search Max's long-term memory for stored facts, preferences, or information. " +
412
+ "Use when you need to look up something the user told you before, or when the user " +
413
+ "asks 'do you remember...?' or 'what do you know about...?'",
414
+ parameters: z.object({
415
+ keyword: z.string().optional().describe("Search term to match against memory content"),
416
+ category: z.enum(["preference", "fact", "project", "person", "routine"]).optional()
417
+ .describe("Optional: filter by category"),
418
+ }),
419
+ handler: async (args) => {
420
+ const results = searchMemories(args.keyword, args.category);
421
+ if (results.length === 0) {
422
+ return "No matching memories found.";
423
+ }
424
+ const lines = results.map((m) => `• #${m.id} [${m.category}] ${m.content} (${m.source}, ${m.created_at})`);
425
+ return `Found ${results.length} memory/memories:\n${lines.join("\n")}`;
426
+ },
427
+ }),
428
+ defineTool("forget", {
429
+ description: "Remove a specific memory from Max's long-term storage. Use when the user asks " +
430
+ "to forget something, or when a memory is outdated/incorrect. Requires the memory ID " +
431
+ "(use recall to find it first).",
432
+ parameters: z.object({
433
+ memory_id: z.number().int().describe("The memory ID to remove (from recall results)"),
434
+ }),
435
+ handler: async (args) => {
436
+ const removed = removeMemory(args.memory_id);
437
+ return removed
438
+ ? `Memory #${args.memory_id} forgotten.`
439
+ : `Memory #${args.memory_id} not found — it may have already been removed.`;
440
+ },
441
+ }),
442
+ defineTool("restart_max", {
443
+ description: "Restart the Max daemon process. Use when the user asks Max to restart himself, " +
444
+ "or when a restart is needed to pick up configuration changes. " +
445
+ "Spawns a new process and exits the current one.",
446
+ parameters: z.object({
447
+ reason: z.string().optional().describe("Optional reason for the restart"),
448
+ }),
449
+ handler: async (args) => {
450
+ const reason = args.reason ? ` (${args.reason})` : "";
451
+ // Dynamic import to avoid circular dependency
452
+ const { restartDaemon } = await import("../daemon.js");
453
+ // Schedule restart after returning the response
454
+ setTimeout(() => {
455
+ restartDaemon().catch((err) => {
456
+ console.error("[max] Restart failed:", err);
457
+ });
458
+ }, 1000);
459
+ return `Restarting Max${reason}. I'll be back in a few seconds.`;
460
+ },
461
+ }),
462
+ ];
463
+ }
464
+ function formatAge(date) {
465
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
466
+ if (seconds < 60)
467
+ return "just now";
468
+ if (seconds < 3600)
469
+ return `${Math.floor(seconds / 60)}m ago`;
470
+ if (seconds < 86400)
471
+ return `${Math.floor(seconds / 3600)}h ago`;
472
+ return `${Math.floor(seconds / 86400)}d ago`;
473
+ }
474
+ function parseSimpleYaml(content) {
475
+ const result = {};
476
+ for (const line of content.split("\n")) {
477
+ const idx = line.indexOf(": ");
478
+ if (idx > 0) {
479
+ const key = line.slice(0, idx).trim();
480
+ const value = line.slice(idx + 2).trim();
481
+ result[key] = value;
482
+ }
483
+ }
484
+ return result;
485
+ }
486
+ //# sourceMappingURL=tools.js.map