daemora 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.
Files changed (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. package/src/tools/writeFile.js +26 -0
@@ -0,0 +1,288 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+ import eventBus from "../core/EventBus.js";
4
+ import { transcribeAudio } from "../tools/transcribeAudio.js";
5
+ import { writeFileSync, mkdirSync } from "node:fs";
6
+ import { join, extname, basename } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ /**
10
+ * Discord Channel — receives messages via Discord Bot API.
11
+ *
12
+ * Setup:
13
+ * 1. Go to https://discord.com/developers/applications
14
+ * 2. Create a new application → Bot → copy the token
15
+ * 3. Enable "Message Content Intent" under Bot → Privileged Gateway Intents
16
+ * 4. Invite bot to your server with permissions: Send Messages, Read Message History, Add Reactions
17
+ * 5. Set env: DISCORD_BOT_TOKEN
18
+ *
19
+ * Config:
20
+ * token — Bot token
21
+ * allowlist — Optional array of Discord user IDs (snowflakes) allowed to use the bot
22
+ * model — Optional model override
23
+ *
24
+ * The bot responds to:
25
+ * - Direct messages (DMs)
26
+ * - Messages that @mention the bot in any channel
27
+ */
28
+ export class DiscordChannel extends BaseChannel {
29
+ constructor(config) {
30
+ super("discord", config);
31
+ this.client = null;
32
+ this.botUserId = null;
33
+ }
34
+
35
+ async start() {
36
+ if (!this.config.token) {
37
+ console.log(`[Channel:Discord] Skipped — no DISCORD_BOT_TOKEN`);
38
+ return;
39
+ }
40
+
41
+ const { Client, GatewayIntentBits, Partials } = await import("discord.js");
42
+
43
+ this.client = new Client({
44
+ intents: [
45
+ GatewayIntentBits.Guilds,
46
+ GatewayIntentBits.GuildMessages,
47
+ GatewayIntentBits.MessageContent,
48
+ GatewayIntentBits.DirectMessages,
49
+ GatewayIntentBits.DirectMessageTyping,
50
+ GatewayIntentBits.GuildMessageReactions,
51
+ GatewayIntentBits.DirectMessageReactions,
52
+ ],
53
+ partials: [Partials.Channel, Partials.Message, Partials.Reaction],
54
+ });
55
+
56
+ this.client.once("ready", (c) => {
57
+ this.botUserId = c.user.id;
58
+ this.running = true;
59
+ console.log(`[Channel:Discord] Logged in as ${c.user.tag}`);
60
+ if (this.config.allowlist?.length) {
61
+ console.log(`[Channel:Discord] Allowlist active — ${this.config.allowlist.length} authorized user(s)`);
62
+ }
63
+ });
64
+
65
+ this.client.on("messageCreate", async (message) => {
66
+ // Ignore messages from bots (including self)
67
+ if (message.author.bot) return;
68
+
69
+ const isDM = message.channel.type === 1; // DM_CHANNEL = 1
70
+ const isMention = this.botUserId && message.mentions.has(this.botUserId);
71
+
72
+ // Only respond to DMs or direct @mentions
73
+ if (!isDM && !isMention) return;
74
+
75
+ const userId = message.author.id;
76
+
77
+ // Allowlist check
78
+ if (!this.isAllowed(userId)) {
79
+ console.log(`[Channel:Discord] Blocked (not in allowlist): ${userId}`);
80
+ await message.reply("You are not authorized to use this agent.").catch(() => {});
81
+ return;
82
+ }
83
+
84
+ // Strip the @mention from the message content
85
+ const text = message.content
86
+ .replace(/<@!?\d+>/g, "")
87
+ .trim();
88
+
89
+ const hasAttachments = message.attachments.size > 0;
90
+
91
+ if (!text && !hasAttachments) {
92
+ await message.reply("Send me a message and I'll get to work.").catch(() => {});
93
+ return;
94
+ }
95
+
96
+ const channelId = message.channelId;
97
+
98
+ console.log(`[Channel:Discord] Message from ${message.author.username} (${userId}): "${text.slice(0, 80)}"${hasAttachments ? ` + ${message.attachments.size} attachment(s)` : ""}`);
99
+
100
+ // Show typing indicator while processing
101
+ try { await message.channel.sendTyping(); } catch (_) {}
102
+
103
+ // React ⏳ to show we received it
104
+ await this.sendReaction({ message }, "⏳");
105
+
106
+ // Build task input: text + any attachments
107
+ const inputParts = text ? [text] : [];
108
+ for (const [, attachment] of message.attachments) {
109
+ const localPath = await this._downloadAttachment(attachment);
110
+ if (!localPath) continue;
111
+
112
+ const ct = attachment.contentType || "";
113
+ if (ct.startsWith("audio/")) {
114
+ console.log(`[Channel:Discord] Audio attachment — transcribing...`);
115
+ const transcript = await transcribeAudio(localPath);
116
+ inputParts.push(transcript.startsWith("Error:")
117
+ ? `[Audio file: ${localPath}]\n${transcript}`
118
+ : `[Audio transcript]: ${transcript}`);
119
+ } else if (ct.startsWith("image/")) {
120
+ inputParts.push(`[Photo received: ${localPath}]\nUser caption: ${attachment.description || text || "Describe and respond to this image."}`);
121
+ } else if (ct.startsWith("video/")) {
122
+ inputParts.push(`[Video received: ${localPath}]`);
123
+ } else {
124
+ inputParts.push(`[File received: ${localPath} (${attachment.name || "document"}, ${_fmtSize(attachment.size)})]`);
125
+ }
126
+ }
127
+
128
+ const input = inputParts.join("\n");
129
+
130
+ // Enqueue task
131
+ const task = taskQueue.enqueue({
132
+ input,
133
+ channel: "discord",
134
+ channelMeta: { userId, channelId, messageId: message.id, guildId: message.guildId, channel: "discord" },
135
+ sessionId: this.getSessionId(userId),
136
+ model: this.getModel(),
137
+ });
138
+
139
+ try {
140
+ const completedTask = await taskQueue.waitForCompletion(task.id);
141
+
142
+ // Absorbed into a concurrent session — response already sent via original task
143
+ if (this.isTaskMerged(completedTask)) {
144
+ await this._removeReaction(message, "⏳");
145
+ await this.sendReaction({ message }, "✅");
146
+ return;
147
+ }
148
+
149
+ const failed = completedTask.status === "failed";
150
+ const response = failed
151
+ ? `Sorry, I encountered an error: ${completedTask.error}`
152
+ : completedTask.result || "Done.";
153
+
154
+ // Remove ⏳ and add ✅ or ❌
155
+ await this._removeReaction(message, "⏳");
156
+ await this.sendReaction({ message }, failed ? "❌" : "✅");
157
+
158
+ // Discord message limit: 2000 chars
159
+ const chunks = splitMessage(response, 1990);
160
+ await message.reply(chunks[0]).catch(() => {});
161
+ for (let i = 1; i < chunks.length; i++) {
162
+ await message.channel.send(chunks[i]).catch(() => {});
163
+ }
164
+ } catch (error) {
165
+ console.error(`[Channel:Discord] Error:`, error.message);
166
+ await this._removeReaction(message, "⏳");
167
+ await this.sendReaction({ message }, "❌");
168
+ try { await message.reply("Sorry, something went wrong. Please try again."); } catch (_) {}
169
+ }
170
+ });
171
+
172
+ // Listen for approval requests
173
+ eventBus.on("approval:request", async (data) => {
174
+ if (data.channelMeta?.channel !== "discord") return;
175
+ const channel = await this.client.channels.fetch(data.channelMeta?.channelId).catch(() => null);
176
+ if (channel) await channel.send(data.message).catch(() => {});
177
+ });
178
+
179
+ try {
180
+ await this.client.login(this.config.token);
181
+ } catch (err) {
182
+ console.log(`[Channel:Discord] Login failed: ${err.message}`);
183
+ this.running = false;
184
+ }
185
+ }
186
+
187
+ async stop() {
188
+ if (this.client) {
189
+ await this.client.destroy();
190
+ this.running = false;
191
+ console.log(`[Channel:Discord] Stopped`);
192
+ }
193
+ }
194
+
195
+ async sendReply(channelMeta, text) {
196
+ if (!this.client) return;
197
+ try {
198
+ const channel = await this.client.channels.fetch(channelMeta.channelId);
199
+ const chunks = splitMessage(text, 1990);
200
+ for (const chunk of chunks) {
201
+ await channel.send(chunk);
202
+ }
203
+ } catch (err) {
204
+ console.log(`[Channel:Discord] sendReply error: ${err.message}`);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * React to a Discord message with an emoji.
210
+ * @param {{ message }} channelMeta - Must include the original Discord Message object
211
+ * @param {string} emoji
212
+ */
213
+ async sendReaction(channelMeta, emoji) {
214
+ const msg = channelMeta?.message;
215
+ if (!msg) return;
216
+ try {
217
+ await msg.react(emoji);
218
+ } catch (_) {}
219
+ }
220
+
221
+ /** Remove a specific reaction emoji the bot added. */
222
+ async _removeReaction(message, emoji) {
223
+ try {
224
+ const reaction = message.reactions.cache.get(emoji);
225
+ if (reaction) await reaction.users.remove(this.client.user.id);
226
+ } catch (_) {}
227
+ }
228
+
229
+ /**
230
+ * Send a local file to a Discord channel.
231
+ */
232
+ async sendFile(channelMeta, filePath, caption) {
233
+ if (!this.client) return;
234
+ try {
235
+ const channel = await this.client.channels.fetch(channelMeta.channelId);
236
+ await channel.send({
237
+ content: caption || undefined,
238
+ files: [{ attachment: filePath, name: basename(filePath) }],
239
+ });
240
+ } catch (err) {
241
+ console.log(`[Channel:Discord] sendFile error: ${err.message}`);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Download a Discord attachment to /tmp and return the local path.
247
+ */
248
+ async _downloadAttachment(attachment) {
249
+ try {
250
+ const ext = extname(attachment.name || attachment.url || "").split("?")[0] || "";
251
+ const tmpDir = join(tmpdir(), "daemora-discord");
252
+ mkdirSync(tmpDir, { recursive: true });
253
+ const filePath = join(tmpDir, `${attachment.id}${ext}`);
254
+
255
+ const res = await fetch(attachment.url, { signal: AbortSignal.timeout(30000) });
256
+ if (!res.ok) return null;
257
+
258
+ const buffer = await res.arrayBuffer();
259
+ writeFileSync(filePath, Buffer.from(buffer));
260
+ return filePath;
261
+ } catch (err) {
262
+ console.log(`[Channel:Discord] Attachment download error: ${err.message}`);
263
+ return null;
264
+ }
265
+ }
266
+ }
267
+
268
+ function _fmtSize(bytes) {
269
+ if (!bytes) return "unknown size";
270
+ if (bytes < 1024) return `${bytes} B`;
271
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
272
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
273
+ }
274
+
275
+ function splitMessage(text, maxLength) {
276
+ if (text.length <= maxLength) return [text];
277
+ const chunks = [];
278
+ let remaining = text;
279
+ while (remaining.length > 0) {
280
+ if (remaining.length <= maxLength) { chunks.push(remaining); break; }
281
+ let idx = remaining.lastIndexOf("\n", maxLength);
282
+ if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
283
+ if (idx === -1) idx = maxLength;
284
+ chunks.push(remaining.slice(0, idx));
285
+ remaining = remaining.slice(idx).trimStart();
286
+ }
287
+ return chunks;
288
+ }
@@ -0,0 +1,172 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+
4
+ /**
5
+ * Email Channel — polls IMAP for incoming emails, replies via SMTP.
6
+ *
7
+ * Setup:
8
+ * 1. Enable IMAP on your Gmail (or email provider)
9
+ * 2. Set env: EMAIL_USER, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST
10
+ * 3. For Gmail: use App Password (not regular password)
11
+ */
12
+ export class EmailChannel extends BaseChannel {
13
+ constructor(config) {
14
+ super("email", config);
15
+ this.transporter = null;
16
+ this.pollTimer = null;
17
+ this.processedIds = new Set();
18
+ }
19
+
20
+ async start() {
21
+ if (!this.config.user || !this.config.password) {
22
+ console.log(`[Channel:Email] Skipped — no EMAIL_USER/EMAIL_PASSWORD`);
23
+ return;
24
+ }
25
+
26
+ const nodemailer = await import("nodemailer");
27
+
28
+ // Set up SMTP transporter for sending replies
29
+ this.transporter = nodemailer.default.createTransport({
30
+ host: this.config.smtp.host,
31
+ port: this.config.smtp.port,
32
+ secure: this.config.smtp.port === 465,
33
+ auth: {
34
+ user: this.config.user,
35
+ pass: this.config.password,
36
+ },
37
+ });
38
+
39
+ // Start polling for new emails
40
+ this.running = true;
41
+ this.pollTimer = setInterval(() => this.pollEmails(), 60000); // every 60s
42
+ console.log(`[Channel:Email] Started (polling every 60s, user: ${this.config.user})`);
43
+
44
+ // Poll immediately on start
45
+ this.pollEmails();
46
+ }
47
+
48
+ async stop() {
49
+ this.running = false;
50
+ if (this.pollTimer) {
51
+ clearInterval(this.pollTimer);
52
+ this.pollTimer = null;
53
+ }
54
+ console.log(`[Channel:Email] Stopped`);
55
+ }
56
+
57
+ async pollEmails() {
58
+ try {
59
+ const Imap = (await import("imap")).default;
60
+
61
+ const imap = new Imap({
62
+ user: this.config.user,
63
+ password: this.config.password,
64
+ host: this.config.imap.host,
65
+ port: this.config.imap.port,
66
+ tls: true,
67
+ tlsOptions: { rejectUnauthorized: false },
68
+ });
69
+
70
+ await new Promise((resolve, reject) => {
71
+ imap.once("ready", () => {
72
+ imap.openBox("INBOX", false, (err) => {
73
+ if (err) { reject(err); return; }
74
+
75
+ // Search for unseen emails
76
+ imap.search(["UNSEEN"], (err, results) => {
77
+ if (err) { imap.end(); reject(err); return; }
78
+ if (!results || results.length === 0) { imap.end(); resolve(); return; }
79
+
80
+ console.log(`[Channel:Email] Found ${results.length} new email(s)`);
81
+
82
+ const fetch = imap.fetch(results, { bodies: "", markSeen: true });
83
+
84
+ fetch.on("message", (msg) => {
85
+ let body = "";
86
+ let headers = {};
87
+
88
+ msg.on("body", (stream) => {
89
+ stream.on("data", (chunk) => { body += chunk.toString(); });
90
+ });
91
+
92
+ msg.once("attributes", (attrs) => {
93
+ headers = attrs;
94
+ });
95
+
96
+ msg.once("end", () => {
97
+ this.processEmail(body, headers);
98
+ });
99
+ });
100
+
101
+ fetch.once("end", () => { imap.end(); resolve(); });
102
+ fetch.once("error", (err) => { imap.end(); reject(err); });
103
+ });
104
+ });
105
+ });
106
+
107
+ imap.once("error", reject);
108
+ imap.connect();
109
+ });
110
+ } catch (error) {
111
+ console.log(`[Channel:Email] Poll error: ${error.message}`);
112
+ }
113
+ }
114
+
115
+ async processEmail(rawEmail, attrs) {
116
+ try {
117
+ // Simple header parsing
118
+ const fromMatch = rawEmail.match(/^From:\s*(.+)$/mi);
119
+ const subjectMatch = rawEmail.match(/^Subject:\s*(.+)$/mi);
120
+ const from = fromMatch ? fromMatch[1].trim() : "unknown";
121
+ const subject = subjectMatch ? subjectMatch[1].trim() : "(no subject)";
122
+
123
+ // Extract email address from "Name <email>" format
124
+ const emailMatch = from.match(/<([^>]+)>/);
125
+ const senderEmail = emailMatch ? emailMatch[1] : from;
126
+
127
+ // Extract body (simplified — takes text after headers)
128
+ const bodyStart = rawEmail.indexOf("\r\n\r\n");
129
+ const emailBody = bodyStart > -1 ? rawEmail.slice(bodyStart + 4).trim() : rawEmail;
130
+
131
+ // Skip if already processed
132
+ const uid = `${senderEmail}-${subject}-${attrs?.uid || Date.now()}`;
133
+ if (this.processedIds.has(uid)) return;
134
+ this.processedIds.add(uid);
135
+
136
+ console.log(`[Channel:Email] Processing: from=${senderEmail} subject="${subject}"`);
137
+
138
+ const taskInput = `Email from: ${senderEmail}\nSubject: ${subject}\n\nBody:\n${emailBody.slice(0, 5000)}`;
139
+
140
+ const task = taskQueue.enqueue({
141
+ input: taskInput,
142
+ channel: "email",
143
+ channelMeta: { senderEmail, subject },
144
+ sessionId: this.getSessionId(senderEmail),
145
+ });
146
+
147
+ // Wait and reply
148
+ const completedTask = await taskQueue.waitForCompletion(task.id);
149
+ if (this.isTaskMerged(completedTask)) return; // absorbed into concurrent session
150
+ const response = completedTask.status === "failed"
151
+ ? `Sorry, I encountered an error processing your request: ${completedTask.error}`
152
+ : completedTask.result || "Done.";
153
+
154
+ await this.sendReply({ senderEmail, subject }, response);
155
+ } catch (error) {
156
+ console.log(`[Channel:Email] Process error: ${error.message}`);
157
+ }
158
+ }
159
+
160
+ async sendReply(channelMeta, text) {
161
+ if (!this.transporter) return;
162
+
163
+ await this.transporter.sendMail({
164
+ from: this.config.user,
165
+ to: channelMeta.senderEmail,
166
+ subject: `Re: ${channelMeta.subject}`,
167
+ text: text,
168
+ });
169
+
170
+ console.log(`[Channel:Email] Reply sent to ${channelMeta.senderEmail}`);
171
+ }
172
+ }