linguclaw 0.4.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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/agent-system.d.ts +196 -0
  4. package/dist/agent-system.d.ts.map +1 -0
  5. package/dist/agent-system.js +738 -0
  6. package/dist/agent-system.js.map +1 -0
  7. package/dist/alphabeta.d.ts +54 -0
  8. package/dist/alphabeta.d.ts.map +1 -0
  9. package/dist/alphabeta.js +193 -0
  10. package/dist/alphabeta.js.map +1 -0
  11. package/dist/browser.d.ts +62 -0
  12. package/dist/browser.d.ts.map +1 -0
  13. package/dist/browser.js +224 -0
  14. package/dist/browser.js.map +1 -0
  15. package/dist/cli.d.ts +7 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +565 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/code-parser.d.ts +39 -0
  20. package/dist/code-parser.d.ts.map +1 -0
  21. package/dist/code-parser.js +385 -0
  22. package/dist/code-parser.js.map +1 -0
  23. package/dist/config.d.ts +66 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +232 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/core/engine.d.ts +359 -0
  28. package/dist/core/engine.d.ts.map +1 -0
  29. package/dist/core/engine.js +127 -0
  30. package/dist/core/engine.js.map +1 -0
  31. package/dist/daemon.d.ts +29 -0
  32. package/dist/daemon.d.ts.map +1 -0
  33. package/dist/daemon.js +212 -0
  34. package/dist/daemon.js.map +1 -0
  35. package/dist/email-receiver.d.ts +63 -0
  36. package/dist/email-receiver.d.ts.map +1 -0
  37. package/dist/email-receiver.js +553 -0
  38. package/dist/email-receiver.js.map +1 -0
  39. package/dist/git-integration.d.ts +180 -0
  40. package/dist/git-integration.d.ts.map +1 -0
  41. package/dist/git-integration.js +850 -0
  42. package/dist/git-integration.js.map +1 -0
  43. package/dist/inbox.d.ts +84 -0
  44. package/dist/inbox.d.ts.map +1 -0
  45. package/dist/inbox.js +198 -0
  46. package/dist/inbox.js.map +1 -0
  47. package/dist/index.d.ts +6 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +41 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/languages/cpp.d.ts +51 -0
  52. package/dist/languages/cpp.d.ts.map +1 -0
  53. package/dist/languages/cpp.js +930 -0
  54. package/dist/languages/cpp.js.map +1 -0
  55. package/dist/languages/csharp.d.ts +79 -0
  56. package/dist/languages/csharp.d.ts.map +1 -0
  57. package/dist/languages/csharp.js +1776 -0
  58. package/dist/languages/csharp.js.map +1 -0
  59. package/dist/languages/go.d.ts +50 -0
  60. package/dist/languages/go.d.ts.map +1 -0
  61. package/dist/languages/go.js +882 -0
  62. package/dist/languages/go.js.map +1 -0
  63. package/dist/languages/java.d.ts +47 -0
  64. package/dist/languages/java.d.ts.map +1 -0
  65. package/dist/languages/java.js +649 -0
  66. package/dist/languages/java.js.map +1 -0
  67. package/dist/languages/python.d.ts +47 -0
  68. package/dist/languages/python.d.ts.map +1 -0
  69. package/dist/languages/python.js +655 -0
  70. package/dist/languages/python.js.map +1 -0
  71. package/dist/languages/rust.d.ts +61 -0
  72. package/dist/languages/rust.d.ts.map +1 -0
  73. package/dist/languages/rust.js +1064 -0
  74. package/dist/languages/rust.js.map +1 -0
  75. package/dist/logger.d.ts +20 -0
  76. package/dist/logger.d.ts.map +1 -0
  77. package/dist/logger.js +133 -0
  78. package/dist/logger.js.map +1 -0
  79. package/dist/longterm-memory.d.ts +47 -0
  80. package/dist/longterm-memory.d.ts.map +1 -0
  81. package/dist/longterm-memory.js +300 -0
  82. package/dist/longterm-memory.js.map +1 -0
  83. package/dist/memory.d.ts +42 -0
  84. package/dist/memory.d.ts.map +1 -0
  85. package/dist/memory.js +274 -0
  86. package/dist/memory.js.map +1 -0
  87. package/dist/messaging.d.ts +103 -0
  88. package/dist/messaging.d.ts.map +1 -0
  89. package/dist/messaging.js +645 -0
  90. package/dist/messaging.js.map +1 -0
  91. package/dist/multi-provider.d.ts +69 -0
  92. package/dist/multi-provider.d.ts.map +1 -0
  93. package/dist/multi-provider.js +484 -0
  94. package/dist/multi-provider.js.map +1 -0
  95. package/dist/orchestrator.d.ts +65 -0
  96. package/dist/orchestrator.d.ts.map +1 -0
  97. package/dist/orchestrator.js +441 -0
  98. package/dist/orchestrator.js.map +1 -0
  99. package/dist/plugins.d.ts +52 -0
  100. package/dist/plugins.d.ts.map +1 -0
  101. package/dist/plugins.js +215 -0
  102. package/dist/plugins.js.map +1 -0
  103. package/dist/prism-orchestrator.d.ts +26 -0
  104. package/dist/prism-orchestrator.d.ts.map +1 -0
  105. package/dist/prism-orchestrator.js +191 -0
  106. package/dist/prism-orchestrator.js.map +1 -0
  107. package/dist/prism.d.ts +46 -0
  108. package/dist/prism.d.ts.map +1 -0
  109. package/dist/prism.js +188 -0
  110. package/dist/prism.js.map +1 -0
  111. package/dist/privacy.d.ts +23 -0
  112. package/dist/privacy.d.ts.map +1 -0
  113. package/dist/privacy.js +220 -0
  114. package/dist/privacy.js.map +1 -0
  115. package/dist/proactive.d.ts +30 -0
  116. package/dist/proactive.d.ts.map +1 -0
  117. package/dist/proactive.js +260 -0
  118. package/dist/proactive.js.map +1 -0
  119. package/dist/refactoring-engine.d.ts +100 -0
  120. package/dist/refactoring-engine.d.ts.map +1 -0
  121. package/dist/refactoring-engine.js +717 -0
  122. package/dist/refactoring-engine.js.map +1 -0
  123. package/dist/resilience.d.ts +43 -0
  124. package/dist/resilience.d.ts.map +1 -0
  125. package/dist/resilience.js +200 -0
  126. package/dist/resilience.js.map +1 -0
  127. package/dist/safety.d.ts +40 -0
  128. package/dist/safety.d.ts.map +1 -0
  129. package/dist/safety.js +133 -0
  130. package/dist/safety.js.map +1 -0
  131. package/dist/sandbox.d.ts +33 -0
  132. package/dist/sandbox.d.ts.map +1 -0
  133. package/dist/sandbox.js +173 -0
  134. package/dist/sandbox.js.map +1 -0
  135. package/dist/scheduler.d.ts +72 -0
  136. package/dist/scheduler.d.ts.map +1 -0
  137. package/dist/scheduler.js +374 -0
  138. package/dist/scheduler.js.map +1 -0
  139. package/dist/semantic-memory.d.ts +70 -0
  140. package/dist/semantic-memory.d.ts.map +1 -0
  141. package/dist/semantic-memory.js +430 -0
  142. package/dist/semantic-memory.js.map +1 -0
  143. package/dist/skills.d.ts +97 -0
  144. package/dist/skills.d.ts.map +1 -0
  145. package/dist/skills.js +575 -0
  146. package/dist/skills.js.map +1 -0
  147. package/dist/static/dashboard.html +853 -0
  148. package/dist/static/hub.html +772 -0
  149. package/dist/static/index.html +818 -0
  150. package/dist/static/logo.svg +24 -0
  151. package/dist/static/workflow-editor.html +913 -0
  152. package/dist/tools.d.ts +67 -0
  153. package/dist/tools.d.ts.map +1 -0
  154. package/dist/tools.js +303 -0
  155. package/dist/tools.js.map +1 -0
  156. package/dist/types.d.ts +295 -0
  157. package/dist/types.d.ts.map +1 -0
  158. package/dist/types.js +90 -0
  159. package/dist/types.js.map +1 -0
  160. package/dist/web.d.ts +76 -0
  161. package/dist/web.d.ts.map +1 -0
  162. package/dist/web.js +2139 -0
  163. package/dist/web.js.map +1 -0
  164. package/dist/workflow-engine.d.ts +114 -0
  165. package/dist/workflow-engine.d.ts.map +1 -0
  166. package/dist/workflow-engine.js +855 -0
  167. package/dist/workflow-engine.js.map +1 -0
  168. package/package.json +77 -0
package/dist/web.js ADDED
@@ -0,0 +1,2139 @@
1
+ "use strict";
2
+ /**
3
+ * Web UI server using Express
4
+ * TypeScript equivalent of Python web.py
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.WebUIManager = void 0;
11
+ exports.runWebUI = runWebUI;
12
+ const express_1 = __importDefault(require("express"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const http_1 = require("http");
15
+ const ws_1 = require("ws");
16
+ const orchestrator_1 = require("./orchestrator");
17
+ const memory_1 = require("./memory");
18
+ const longterm_memory_1 = require("./longterm-memory");
19
+ const tools_1 = require("./tools");
20
+ const multi_provider_1 = require("./multi-provider");
21
+ const browser_1 = require("./browser");
22
+ const scheduler_1 = require("./scheduler");
23
+ const config_1 = require("./config");
24
+ const logger_1 = require("./logger");
25
+ const inbox_1 = require("./inbox");
26
+ const email_receiver_1 = require("./email-receiver");
27
+ const messaging_1 = require("./messaging");
28
+ const semantic_memory_1 = require("./semantic-memory");
29
+ const workflow_engine_1 = require("./workflow-engine");
30
+ const logger = (0, logger_1.getLogger)();
31
+ // Load config
32
+ (0, config_1.loadEnvConfig)();
33
+ class WebUIManager {
34
+ projectRoot;
35
+ host;
36
+ port;
37
+ app;
38
+ server;
39
+ wss;
40
+ orchestrator;
41
+ connections;
42
+ memory;
43
+ scheduler;
44
+ browser;
45
+ chatHistory;
46
+ providerManager;
47
+ inbox;
48
+ emailReceiver;
49
+ messagingHub;
50
+ semanticMemory;
51
+ constructor(projectRoot, host = '0.0.0.0', port = 8080) {
52
+ this.projectRoot = projectRoot;
53
+ this.host = host;
54
+ this.port = port;
55
+ this.app = (0, express_1.default)();
56
+ this.server = (0, http_1.createServer)(this.app);
57
+ this.wss = new ws_1.WebSocketServer({ server: this.server });
58
+ this.orchestrator = null;
59
+ this.connections = new Set();
60
+ this.memory = new longterm_memory_1.LongTermMemory();
61
+ this.scheduler = new scheduler_1.TaskScheduler();
62
+ this.browser = new browser_1.BrowserAutomation();
63
+ this.chatHistory = [];
64
+ this.providerManager = new multi_provider_1.ProviderManager();
65
+ this.inbox = new inbox_1.InboxManager(this.memory);
66
+ this.emailReceiver = new email_receiver_1.EmailReceiver(this.inbox);
67
+ this.messagingHub = new messaging_1.MessagingHub();
68
+ this.semanticMemory = (0, semantic_memory_1.getSemanticMemory)();
69
+ }
70
+ /**
71
+ * Initialize and start the web server
72
+ */
73
+ async start() {
74
+ // Setup middleware
75
+ this.app.use(express_1.default.json());
76
+ this.app.use(express_1.default.static(path_1.default.join(__dirname, 'static')));
77
+ // Setup routes
78
+ this.setupRoutes();
79
+ this.setupWebSocket();
80
+ // Start email receivers if configured
81
+ this.startEmailReceivers();
82
+ // Start server
83
+ return new Promise((resolve, reject) => {
84
+ this.server.listen(this.port, this.host, () => {
85
+ logger.info(`Web UI server started at http://${this.host}:${this.port}`);
86
+ resolve();
87
+ });
88
+ this.server.on('error', reject);
89
+ });
90
+ }
91
+ /**
92
+ * Setup HTTP routes
93
+ */
94
+ setupRoutes() {
95
+ // Health check
96
+ this.app.get('/api/health', (_req, res) => {
97
+ res.json({ status: 'ok', version: '0.3.0' });
98
+ });
99
+ // Get current state
100
+ this.app.get('/api/state', (req, res) => {
101
+ if (!this.orchestrator) {
102
+ res.json({ running: false });
103
+ return;
104
+ }
105
+ res.json({
106
+ running: true,
107
+ state: this.orchestrator.state,
108
+ });
109
+ });
110
+ // Start new task
111
+ this.app.post('/api/task', async (req, res) => {
112
+ const body = req.body;
113
+ if (!body.task) {
114
+ res.status(400).json({ error: 'Task required' });
115
+ return;
116
+ }
117
+ try {
118
+ // Initialize provider
119
+ const manager = new multi_provider_1.ProviderManager();
120
+ const provider = manager.createFromEnv();
121
+ if (!provider) {
122
+ res.status(500).json({ error: 'No LLM provider available' });
123
+ return;
124
+ }
125
+ // Initialize tools
126
+ const shell = new tools_1.ShellTool(this.projectRoot);
127
+ await shell.init();
128
+ const fs = new tools_1.FileSystemTool(this.projectRoot);
129
+ const memory = new memory_1.RAGMemory(this.projectRoot);
130
+ await memory.init();
131
+ // Create orchestrator
132
+ this.orchestrator = new orchestrator_1.Orchestrator(provider, shell, fs, body.max_steps || 15);
133
+ // Run task asynchronously
134
+ this.runTask(body.task);
135
+ res.json({ task_id: Date.now().toString(), status: 'started' });
136
+ }
137
+ catch (error) {
138
+ res.status(500).json({ error: error.message });
139
+ }
140
+ });
141
+ // Settings endpoints
142
+ this.app.get('/api/settings', (_req, res) => {
143
+ const config = (0, config_1.getConfig)();
144
+ const cfg = config.get();
145
+ const safeConfig = { ...cfg, llm: { ...cfg.llm, apiKey: cfg.llm.apiKey ? '***' : '' } };
146
+ res.json(safeConfig);
147
+ });
148
+ this.app.post('/api/settings', (req, res) => {
149
+ const config = (0, config_1.getConfig)();
150
+ const settings = req.body;
151
+ try {
152
+ if (settings.llm) {
153
+ if (!settings.llm.apiKey || settings.llm.apiKey === '***' || settings.llm.apiKey.trim() === '') {
154
+ delete settings.llm.apiKey;
155
+ }
156
+ config.updateLLM(settings.llm);
157
+ }
158
+ if (settings.system)
159
+ config.updateSystem(settings.system);
160
+ if (settings.webui)
161
+ config.updateWebUI(settings.webui);
162
+ if (settings.user)
163
+ config.updateUser(settings.user);
164
+ res.json({ success: true });
165
+ }
166
+ catch (error) {
167
+ res.status(400).json({ error: error.message });
168
+ }
169
+ });
170
+ // ============ CHAT API ============
171
+ // Helper: execute a messaging action from parsed JSON
172
+ const execMessagingAction = async (action) => {
173
+ const platform = (action.platform || '').toLowerCase();
174
+ try {
175
+ if (platform === 'email') {
176
+ const ints = loadIntegrations();
177
+ const cfg = ints['email'] || {};
178
+ const host = cfg.host || process.env.EMAIL_HOST || 'smtp.gmail.com';
179
+ const port = parseInt(cfg.port || process.env.EMAIL_PORT || '587', 10);
180
+ const username = cfg.username || process.env.EMAIL_USERNAME;
181
+ const password = cfg.password || process.env.EMAIL_PASSWORD;
182
+ if (!username || !password)
183
+ return { success: false, message: 'Email not configured. Go to Skills → Email → Configure.' };
184
+ const nodemailer = require('nodemailer');
185
+ const transporter = nodemailer.createTransport({ host, port, secure: port === 465, auth: { user: username, pass: password } });
186
+ await transporter.sendMail({ from: `LinguClaw <${username}>`, to: action.to, subject: action.subject || 'Message from LinguClaw', text: action.body || '', html: action.body ? `<div style="font-family:sans-serif">${(action.body || '').replace(/\n/g, '<br>')}</div>` : '' });
187
+ return { success: true, message: `✅ Email sent to ${action.to}` };
188
+ }
189
+ else if (platform === 'telegram') {
190
+ const ints = loadIntegrations();
191
+ const cfg = ints['telegram'] || {};
192
+ const token = cfg.botToken || process.env.TELEGRAM_BOT_TOKEN;
193
+ const chatId = action.chatId || cfg.chatId || process.env.TELEGRAM_CHAT_ID;
194
+ if (!token)
195
+ return { success: false, message: 'Telegram not configured.' };
196
+ if (!chatId)
197
+ return { success: false, message: 'Telegram Chat ID not set.' };
198
+ const https = require('https');
199
+ const pd = JSON.stringify({ chat_id: chatId, text: action.message || action.body, parse_mode: 'HTML' });
200
+ const result = await new Promise((resolve, reject) => { const r = https.request({ hostname: 'api.telegram.org', path: `/bot${token}/sendMessage`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(pd) } }, (resp) => { let d = ''; resp.on('data', (c) => d += c); resp.on('end', () => { try {
201
+ resolve(JSON.parse(d));
202
+ }
203
+ catch {
204
+ resolve({ ok: false });
205
+ } }); }); r.on('error', reject); r.write(pd); r.end(); });
206
+ return result.ok ? { success: true, message: `✅ Telegram message sent` } : { success: false, message: 'Telegram error: ' + (result.description || 'unknown') };
207
+ }
208
+ else if (platform === 'discord') {
209
+ const ints = loadIntegrations();
210
+ const cfg = ints['discord'] || {};
211
+ const token = cfg.botToken || process.env.DISCORD_BOT_TOKEN;
212
+ if (!token)
213
+ return { success: false, message: 'Discord not configured.' };
214
+ if (!action.channelId)
215
+ return { success: false, message: 'Discord channelId required.' };
216
+ const https = require('https');
217
+ const pd = JSON.stringify({ content: action.message || action.body });
218
+ const result = await new Promise((resolve, reject) => { const r = https.request({ hostname: 'discord.com', path: `/api/v10/channels/${action.channelId}/messages`, method: 'POST', headers: { 'Authorization': `Bot ${token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(pd) } }, (resp) => { let d = ''; resp.on('data', (c) => d += c); resp.on('end', () => { try {
219
+ resolve(JSON.parse(d));
220
+ }
221
+ catch {
222
+ resolve({});
223
+ } }); }); r.on('error', reject); r.write(pd); r.end(); });
224
+ return result.id ? { success: true, message: `✅ Discord message sent` } : { success: false, message: 'Discord error: ' + (result.message || 'unknown') };
225
+ }
226
+ else if (platform === 'slack') {
227
+ const ints = loadIntegrations();
228
+ const cfg = ints['slack'] || {};
229
+ const token = cfg.botToken || process.env.SLACK_BOT_TOKEN;
230
+ const channel = action.channel || cfg.channel || '#general';
231
+ if (!token)
232
+ return { success: false, message: 'Slack not configured.' };
233
+ const https = require('https');
234
+ const pd = JSON.stringify({ channel, text: action.message || action.body });
235
+ const result = await new Promise((resolve, reject) => { const r = https.request({ hostname: 'slack.com', path: '/api/chat.postMessage', method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(pd) } }, (resp) => { let d = ''; resp.on('data', (c) => d += c); resp.on('end', () => { try {
236
+ resolve(JSON.parse(d));
237
+ }
238
+ catch {
239
+ resolve({ ok: false });
240
+ } }); }); r.on('error', reject); r.write(pd); r.end(); });
241
+ return result.ok ? { success: true, message: `✅ Slack message sent to ${channel}` } : { success: false, message: 'Slack error: ' + (result.error || 'unknown') };
242
+ }
243
+ else if (platform === 'whatsapp') {
244
+ const ints = loadIntegrations();
245
+ const cfg = ints['whatsapp'] || {};
246
+ const sid = cfg.accountSid || process.env.TWILIO_ACCOUNT_SID;
247
+ const authToken = cfg.authToken || process.env.TWILIO_AUTH_TOKEN;
248
+ const from = cfg.phoneNumber || process.env.TWILIO_PHONE_NUMBER;
249
+ if (!sid || !authToken || !from)
250
+ return { success: false, message: 'WhatsApp not configured.' };
251
+ if (!action.to)
252
+ return { success: false, message: 'WhatsApp "to" phone number required.' };
253
+ const https = require('https');
254
+ const pd = `To=whatsapp:${action.to}&From=whatsapp:${from}&Body=${encodeURIComponent(action.message || action.body || '')}`;
255
+ const auth = Buffer.from(`${sid}:${authToken}`).toString('base64');
256
+ const result = await new Promise((resolve, reject) => { const r = https.request({ hostname: 'api.twilio.com', path: `/2010-04-01/Accounts/${sid}/Messages.json`, method: 'POST', headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(pd) } }, (resp) => { let d = ''; resp.on('data', (c) => d += c); resp.on('end', () => { try {
257
+ resolve(JSON.parse(d));
258
+ }
259
+ catch {
260
+ resolve({});
261
+ } }); }); r.on('error', reject); r.write(pd); r.end(); });
262
+ return result.sid ? { success: true, message: `✅ WhatsApp message sent to ${action.to}` } : { success: false, message: 'WhatsApp error: ' + (result.message || 'unknown') };
263
+ }
264
+ return { success: false, message: 'Unknown platform: ' + platform };
265
+ }
266
+ catch (e) {
267
+ return { success: false, message: e.message };
268
+ }
269
+ };
270
+ this.app.post('/api/chat', async (req, res) => {
271
+ const { message } = req.body;
272
+ if (!message) {
273
+ res.status(400).json({ error: 'message required' });
274
+ return;
275
+ }
276
+ try {
277
+ const provider = this.providerManager.createFromEnv();
278
+ if (!provider) {
279
+ res.status(500).json({ error: 'No LLM provider configured. Go to Settings to add your API key.' });
280
+ return;
281
+ }
282
+ this.chatHistory.push({ role: 'user', content: message, timestamp: new Date().toISOString() });
283
+ // Build context from both memory systems
284
+ const recentMemories = this.memory.search(message, undefined, 5);
285
+ const memCtx = recentMemories.length > 0 ? '\nRelevant memories: ' + recentMemories.map((m) => m.value).join('; ') : '';
286
+ // Semantic memory: find related past conversations and knowledge
287
+ const semanticResults = this.semanticMemory.search(message, 5, undefined, 0.1);
288
+ const semanticCtx = semanticResults.length > 0
289
+ ? '\n\nRelated knowledge from memory:\n' + semanticResults.map((r, i) => `${i + 1}. [${r.category}] ${r.content.substring(0, 200)}`).join('\n')
290
+ : '';
291
+ // Inbox context - show unread messages to AI
292
+ const unreadMessages = this.inbox.getUnread();
293
+ let inboxCtx = '';
294
+ if (unreadMessages.length > 0) {
295
+ inboxCtx = `\n\n📬 INBOX: You have ${unreadMessages.length} unread message(s).\nRecent unread:\n` +
296
+ unreadMessages.slice(0, 3).map((m, i) => `${i + 1}. [${m.platform}] From: ${m.from} | Subject: ${m.subject || '(no subject)'} | Body: ${(m.body || '').substring(0, 100)}...`).join('\n');
297
+ }
298
+ else {
299
+ inboxCtx = '\n\n📬 INBOX: No unread messages.';
300
+ }
301
+ // Check which integrations are configured
302
+ const ints = loadIntegrations();
303
+ const available = [];
304
+ if (ints['email'] && ints['email'].username)
305
+ available.push('email');
306
+ if (ints['telegram'] && ints['telegram'].botToken)
307
+ available.push('telegram');
308
+ if (ints['discord'] && ints['discord'].botToken)
309
+ available.push('discord');
310
+ if (ints['slack'] && ints['slack'].botToken)
311
+ available.push('slack');
312
+ if (ints['whatsapp'] && ints['whatsapp'].accountSid)
313
+ available.push('whatsapp');
314
+ const toolsInfo = available.length > 0 ? `\n\nYou have these messaging integrations configured: ${available.join(', ')}.\n\nMESSAGING RULES — follow these strictly:\n1. When the user asks to send a message, FIRST check what information is missing (to, subject, body for email; message for telegram, etc).\n2. If the user provides all details including body/content, use what they provided.\n3. If the user says things like "send an email to John about the meeting", generate a suitable email body content intelligently.\n4. Once you have ALL required info, show a PREVIEW of what will be sent. Format it clearly like:\n 📧 Email Preview:\n To: user@example.com\n Subject: Hello\n Body: Your message text here\n\n Then ask: "Should I send this?"\n5. When the user CONFIRMS (says yes, send it, go, gönder, tamam, etc), THEN respond with ONLY the JSON action object:\n {"action":"send","platform":"email","to":"user@example.com","subject":"Hello","body":"Message text"}\n {"action":"send","platform":"telegram","message":"Hello!"}\n {"action":"send","platform":"discord","channelId":"123","message":"Hello!"}\n {"action":"send","platform":"slack","channel":"#general","message":"Hello!"}\n {"action":"send","platform":"whatsapp","to":"+1234567890","message":"Hello!"}\n6. The JSON must be the ONLY content in your response — no extra text.\n7. For everything else (questions, conversation), respond normally in natural language.` : '\n\nNo messaging integrations are configured yet. If the user asks to send messages, tell them to configure integrations in the Skills panel first.';
315
+ const sysPrompt = 'You are LinguClaw 🦀, a powerful personal AI assistant. You can send emails, Telegram/Discord/Slack/WhatsApp messages, browse the web, manage files, schedule jobs, and more. Be helpful and conversational. When a user wants to send a message, always ask for missing details, show a preview, and wait for confirmation before sending.' + memCtx + semanticCtx + inboxCtx + toolsInfo;
316
+ const messages = [
317
+ { role: 'system', content: sysPrompt },
318
+ ...this.chatHistory.slice(-20).map(m => ({ role: m.role, content: m.content })),
319
+ ];
320
+ const response = await provider.complete(messages, 0.7, 2048);
321
+ if (response.error) {
322
+ res.json({ reply: 'Error: ' + response.error });
323
+ return;
324
+ }
325
+ let reply = response.content || 'No response';
326
+ // Check if the LLM returned a JSON action
327
+ let actionExecuted = false;
328
+ try {
329
+ const trimmed = reply.trim();
330
+ if (trimmed.startsWith('{') && trimmed.includes('"action"')) {
331
+ const action = JSON.parse(trimmed);
332
+ if (action.action === 'send' && action.platform) {
333
+ const result = await execMessagingAction(action);
334
+ // Build a detailed summary of what was sent
335
+ let details = '';
336
+ if (action.platform === 'email') {
337
+ details = `\n\n📧 Email Details:\nTo: ${action.to}\nSubject: ${action.subject || 'Message from LinguClaw'}\nBody: ${action.body || '(empty)'}`;
338
+ }
339
+ else if (action.platform === 'telegram') {
340
+ details = `\n\n📨 Telegram Message:\n${action.message || action.body}`;
341
+ }
342
+ else if (action.platform === 'discord') {
343
+ details = `\n\n💬 Discord Message (Channel: ${action.channelId}):\n${action.message || action.body}`;
344
+ }
345
+ else if (action.platform === 'slack') {
346
+ details = `\n\n💬 Slack Message (${action.channel || '#general'}):\n${action.message || action.body}`;
347
+ }
348
+ else if (action.platform === 'whatsapp') {
349
+ details = `\n\n📱 WhatsApp Message (To: ${action.to}):\n${action.message || action.body}`;
350
+ }
351
+ reply = result.message + details;
352
+ actionExecuted = true;
353
+ }
354
+ }
355
+ }
356
+ catch (parseErr) {
357
+ logger.debug(`Chat reply JSON parse attempt: ${parseErr.message}`);
358
+ }
359
+ this.chatHistory.push({ role: 'assistant', content: reply, timestamp: new Date().toISOString() });
360
+ // Auto-save important info to memory
361
+ if (message.toLowerCase().includes('remember') || message.toLowerCase().includes('hatırla') || message.toLowerCase().includes('hatirla')) {
362
+ this.memory.store('chat_' + Date.now(), message, 'chat');
363
+ }
364
+ // Save conversation turn to semantic memory for cross-session recall
365
+ const turnId = `chat_${Date.now()}`;
366
+ this.semanticMemory.store(turnId, `User: ${message}\nAssistant: ${reply.substring(0, 500)}`, 'conversation', {
367
+ timestamp: new Date().toISOString(),
368
+ actionExecuted,
369
+ });
370
+ res.json({ reply, model: response.model, actionExecuted });
371
+ }
372
+ catch (error) {
373
+ res.json({ reply: 'Error: ' + error.message });
374
+ }
375
+ });
376
+ // ============ STREAMING CHAT (SSE) ============
377
+ this.app.post('/api/chat/stream', async (req, res) => {
378
+ const { message } = req.body;
379
+ if (!message) {
380
+ res.status(400).json({ error: 'message required' });
381
+ return;
382
+ }
383
+ try {
384
+ const provider = this.providerManager.createFromEnv();
385
+ if (!provider) {
386
+ res.status(500).json({ error: 'No LLM provider configured.' });
387
+ return;
388
+ }
389
+ this.chatHistory.push({ role: 'user', content: message, timestamp: new Date().toISOString() });
390
+ // Build context (same as non-streaming)
391
+ const recentMemories = this.memory.search(message, undefined, 5);
392
+ const memCtx = recentMemories.length > 0 ? '\nRelevant memories: ' + recentMemories.map((m) => m.value).join('; ') : '';
393
+ const semanticResults = this.semanticMemory.search(message, 5, undefined, 0.1);
394
+ const semanticCtx = semanticResults.length > 0
395
+ ? '\n\nRelated knowledge from memory:\n' + semanticResults.map((r, i) => `${i + 1}. [${r.category}] ${r.content.substring(0, 200)}`).join('\n')
396
+ : '';
397
+ const unreadMessages = this.inbox.getUnread();
398
+ const inboxCtx = unreadMessages.length > 0
399
+ ? `\n\n📬 INBOX: ${unreadMessages.length} unread.`
400
+ : '\n\n📬 INBOX: No unread messages.';
401
+ const sysPrompt = 'You are LinguClaw 🦀, a powerful personal AI assistant. Be helpful and conversational.' + memCtx + semanticCtx + inboxCtx;
402
+ const messages = [
403
+ { role: 'system', content: sysPrompt },
404
+ ...this.chatHistory.slice(-20).map(m => ({ role: m.role, content: m.content })),
405
+ ];
406
+ // Set SSE headers
407
+ res.setHeader('Content-Type', 'text/event-stream');
408
+ res.setHeader('Cache-Control', 'no-cache');
409
+ res.setHeader('Connection', 'keep-alive');
410
+ res.setHeader('X-Accel-Buffering', 'no');
411
+ res.flushHeaders();
412
+ let fullReply = '';
413
+ try {
414
+ for await (const chunk of provider.stream(messages, 0.7, 2048)) {
415
+ fullReply += chunk;
416
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
417
+ }
418
+ }
419
+ catch (streamErr) {
420
+ // If streaming fails, fall back to complete
421
+ logger.warn(`Streaming failed, falling back: ${streamErr.message}`);
422
+ const response = await provider.complete(messages, 0.7, 2048);
423
+ fullReply = response.content || '';
424
+ res.write(`data: ${JSON.stringify({ chunk: fullReply })}\n\n`);
425
+ }
426
+ // Send done event
427
+ res.write(`data: ${JSON.stringify({ done: true, model: provider.model })}\n\n`);
428
+ res.end();
429
+ // Save to history and semantic memory
430
+ this.chatHistory.push({ role: 'assistant', content: fullReply, timestamp: new Date().toISOString() });
431
+ this.semanticMemory.store(`chat_${Date.now()}`, `User: ${message}\nAssistant: ${fullReply.substring(0, 500)}`, 'conversation', {
432
+ timestamp: new Date().toISOString(),
433
+ });
434
+ if (message.toLowerCase().includes('remember') || message.toLowerCase().includes('hatırla') || message.toLowerCase().includes('hatirla')) {
435
+ this.memory.store('chat_' + Date.now(), message, 'chat');
436
+ }
437
+ }
438
+ catch (error) {
439
+ if (!res.headersSent) {
440
+ res.status(500).json({ error: error.message });
441
+ }
442
+ else {
443
+ res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
444
+ res.end();
445
+ }
446
+ }
447
+ });
448
+ this.app.get('/api/chat/history', (_req, res) => {
449
+ res.json(this.chatHistory.slice(-100));
450
+ });
451
+ this.app.delete('/api/chat/history', (_req, res) => {
452
+ this.chatHistory = [];
453
+ res.json({ success: true });
454
+ });
455
+ // ============ MEMORY API ============
456
+ this.app.get('/api/memory', (_req, res) => {
457
+ try {
458
+ const stats = this.memory.getStats();
459
+ const all = this.memory.search('', undefined, 100);
460
+ res.json({ entries: all, stats });
461
+ }
462
+ catch (e) {
463
+ res.json([]);
464
+ }
465
+ });
466
+ this.app.post('/api/memory', (req, res) => {
467
+ const { key, value, category, tags } = req.body;
468
+ if (!key || !value) {
469
+ res.status(400).json({ error: 'key and value required' });
470
+ return;
471
+ }
472
+ try {
473
+ this.memory.store(key, value, category || 'general', tags || []);
474
+ res.json({ success: true });
475
+ }
476
+ catch (e) {
477
+ res.status(500).json({ error: e.message });
478
+ }
479
+ });
480
+ this.app.delete('/api/memory/:key', (req, res) => {
481
+ try {
482
+ this.memory.delete(req.params.key);
483
+ res.json({ success: true });
484
+ }
485
+ catch (e) {
486
+ res.status(500).json({ error: e.message });
487
+ }
488
+ });
489
+ // ============ SCHEDULER API ============
490
+ this.app.get('/api/scheduler/jobs', (_req, res) => {
491
+ res.json(this.scheduler.getJobs());
492
+ });
493
+ this.app.post('/api/scheduler/jobs', (req, res) => {
494
+ const { name, type, schedule, command, tags } = req.body;
495
+ if (!name || !type || !schedule || !command) {
496
+ res.status(400).json({ error: 'name, type, schedule, command required' });
497
+ return;
498
+ }
499
+ const job = this.scheduler.addJob({ name, type, schedule, command, enabled: true, tags: tags || [] });
500
+ res.json(job);
501
+ });
502
+ this.app.delete('/api/scheduler/jobs/:id', (req, res) => {
503
+ const ok = this.scheduler.removeJob(req.params.id);
504
+ res.json({ success: ok });
505
+ });
506
+ this.app.post('/api/scheduler/jobs/:id/toggle', (req, res) => {
507
+ const job = this.scheduler.toggleJob(req.params.id);
508
+ res.json(job || { error: 'Job not found' });
509
+ });
510
+ this.app.get('/api/scheduler/results', (_req, res) => {
511
+ res.json(this.scheduler.getResults());
512
+ });
513
+ // ============ BROWSER API ============
514
+ this.app.post('/api/browser/browse', async (req, res) => {
515
+ const { url } = req.body;
516
+ if (!url) {
517
+ res.status(400).json({ error: 'url required' });
518
+ return;
519
+ }
520
+ if (!this.browser.isAvailable)
521
+ await this.browser.init();
522
+ const result = await this.browser.browse(url);
523
+ res.json(result);
524
+ });
525
+ this.app.post('/api/browser/screenshot', async (req, res) => {
526
+ const { url } = req.body;
527
+ if (!this.browser.isAvailable)
528
+ await this.browser.init();
529
+ const result = await this.browser.screenshot(url);
530
+ res.json(result);
531
+ });
532
+ this.app.post('/api/browser/extract', async (req, res) => {
533
+ const { selector } = req.body;
534
+ if (!selector) {
535
+ res.status(400).json({ error: 'selector required' });
536
+ return;
537
+ }
538
+ const result = await this.browser.extract(selector);
539
+ res.json(result);
540
+ });
541
+ // AI-powered browser helpers
542
+ const getBrowserPageContent = async () => {
543
+ if (!this.browser.isAvailable)
544
+ return null;
545
+ try {
546
+ const result = await this.browser.evaluate('(() => { const b = document.body; if (!b) return ""; const c = b.cloneNode(true); c.querySelectorAll("script,style,noscript,svg,img").forEach(e => e.remove()); return c.innerText.substring(0, 8000); })()');
547
+ const title = await this.browser.evaluate('document.title');
548
+ const url = await this.browser.evaluate('window.location.href');
549
+ return { content: result.data || '', title: title.data || '', url: url.data || '' };
550
+ }
551
+ catch (err) {
552
+ logger.warn(`getBrowserPageContent error: ${err.message}`);
553
+ return null;
554
+ }
555
+ };
556
+ this.app.post('/api/browser/summarize', async (req, res) => {
557
+ try {
558
+ const provider = this.providerManager.createFromEnv();
559
+ if (!provider) {
560
+ res.status(500).json({ error: 'No LLM provider configured' });
561
+ return;
562
+ }
563
+ const page = await getBrowserPageContent();
564
+ if (!page || !page.content) {
565
+ res.status(400).json({ error: 'No page loaded. Browse a URL first.' });
566
+ return;
567
+ }
568
+ const lang = req.body.language || 'English';
569
+ const messages = [
570
+ { role: 'system', content: 'You are a helpful assistant that summarizes web pages clearly and concisely.' },
571
+ { role: 'user', content: `Summarize the following web page in ${lang}. Include key points, main topic, and any important details.\n\nPage title: ${page.title}\nURL: ${page.url}\n\nContent:\n${page.content}` }
572
+ ];
573
+ const response = await provider.complete(messages, 0.3, 1500);
574
+ res.json({ success: true, summary: response.content || 'No summary generated', title: page.title, url: page.url, model: response.model });
575
+ }
576
+ catch (e) {
577
+ res.status(500).json({ error: e.message });
578
+ }
579
+ });
580
+ this.app.post('/api/browser/ask', async (req, res) => {
581
+ const { question } = req.body;
582
+ if (!question) {
583
+ res.status(400).json({ error: 'question required' });
584
+ return;
585
+ }
586
+ try {
587
+ const provider = this.providerManager.createFromEnv();
588
+ if (!provider) {
589
+ res.status(500).json({ error: 'No LLM provider configured' });
590
+ return;
591
+ }
592
+ const page = await getBrowserPageContent();
593
+ if (!page || !page.content) {
594
+ res.status(400).json({ error: 'No page loaded. Browse a URL first.' });
595
+ return;
596
+ }
597
+ const messages = [
598
+ { role: 'system', content: 'You are a helpful assistant. Answer questions about the given web page content accurately and concisely. If the answer is not in the content, say so.' },
599
+ { role: 'user', content: `Page: ${page.title} (${page.url})\n\nContent:\n${page.content}\n\n---\nQuestion: ${question}` }
600
+ ];
601
+ const response = await provider.complete(messages, 0.3, 1500);
602
+ res.json({ success: true, answer: response.content || 'No answer generated', question, title: page.title, model: response.model });
603
+ }
604
+ catch (e) {
605
+ res.status(500).json({ error: e.message });
606
+ }
607
+ });
608
+ this.app.post('/api/browser/smart-extract', async (req, res) => {
609
+ const { prompt } = req.body;
610
+ if (!prompt) {
611
+ res.status(400).json({ error: 'prompt required (e.g. "extract all prices", "find email addresses")' });
612
+ return;
613
+ }
614
+ try {
615
+ const provider = this.providerManager.createFromEnv();
616
+ if (!provider) {
617
+ res.status(500).json({ error: 'No LLM provider configured' });
618
+ return;
619
+ }
620
+ const page = await getBrowserPageContent();
621
+ if (!page || !page.content) {
622
+ res.status(400).json({ error: 'No page loaded. Browse a URL first.' });
623
+ return;
624
+ }
625
+ const messages = [
626
+ { role: 'system', content: 'You are a data extraction assistant. Extract the requested data from the web page content. Return the data in a clean, structured format. Use JSON when appropriate.' },
627
+ { role: 'user', content: `Extract from this page: ${prompt}\n\nPage: ${page.title} (${page.url})\n\nContent:\n${page.content}` }
628
+ ];
629
+ const response = await provider.complete(messages, 0.2, 2000);
630
+ res.json({ success: true, extracted: response.content || 'Nothing extracted', prompt, title: page.title, model: response.model });
631
+ }
632
+ catch (e) {
633
+ res.status(500).json({ error: e.message });
634
+ }
635
+ });
636
+ this.app.post('/api/browser/search', async (req, res) => {
637
+ const { query } = req.body;
638
+ if (!query) {
639
+ res.status(400).json({ error: 'query required' });
640
+ return;
641
+ }
642
+ try {
643
+ if (!this.browser.isAvailable)
644
+ await this.browser.init();
645
+ const searchUrl = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query);
646
+ const result = await this.browser.browse(searchUrl);
647
+ if (!result.success) {
648
+ res.json(result);
649
+ return;
650
+ }
651
+ const provider = this.providerManager.createFromEnv();
652
+ if (provider && result.content) {
653
+ const messages = [
654
+ { role: 'system', content: 'You are a helpful search assistant. Based on the search results below, provide a clear, informative answer to the user\'s query. Cite relevant sources when possible.' },
655
+ { role: 'user', content: `Search query: ${query}\n\nSearch results:\n${result.content}` }
656
+ ];
657
+ const response = await provider.complete(messages, 0.4, 1500);
658
+ res.json({ success: true, query, answer: response.content, rawContent: result.content, links: result.links, model: response.model });
659
+ }
660
+ else {
661
+ res.json({ success: true, query, rawContent: result.content, links: result.links });
662
+ }
663
+ }
664
+ catch (e) {
665
+ res.status(500).json({ error: e.message });
666
+ }
667
+ });
668
+ // ============ SYSTEM STATUS API ============
669
+ this.app.get('/api/system/status', (_req, res) => {
670
+ const config = (0, config_1.getConfig)();
671
+ const cfg = config.get();
672
+ res.json({
673
+ version: cfg.version || '0.3.0',
674
+ provider: cfg.llm.provider,
675
+ model: cfg.llm.model,
676
+ hasApiKey: !!(cfg.llm.apiKey && cfg.llm.apiKey.length > 0),
677
+ scheduler: { jobs: this.scheduler.getJobs().length, running: true },
678
+ browser: { available: this.browser.isAvailable },
679
+ memory: { entries: this.memory.getStats().total_entries },
680
+ chat: { messages: this.chatHistory.length },
681
+ connections: this.connections.size,
682
+ uptime: process.uptime(),
683
+ });
684
+ });
685
+ // ============ SKILLS API ============
686
+ const integrationsPath = path_1.default.join(require('os').homedir(), '.linguclaw', 'integrations.json');
687
+ const loadIntegrations = () => {
688
+ try {
689
+ return JSON.parse(require('fs').readFileSync(integrationsPath, 'utf8'));
690
+ }
691
+ catch (e) {
692
+ logger.debug(`loadIntegrations: ${e.message}`);
693
+ return {};
694
+ }
695
+ };
696
+ const saveIntegrations = (data) => {
697
+ const dir = path_1.default.dirname(integrationsPath);
698
+ if (!require('fs').existsSync(dir))
699
+ require('fs').mkdirSync(dir, { recursive: true });
700
+ require('fs').writeFileSync(integrationsPath, JSON.stringify(data, null, 2));
701
+ };
702
+ const isIntEnabled = (name, envKey) => {
703
+ const ints = loadIntegrations();
704
+ return !!(process.env[envKey] || (ints[name] && Object.values(ints[name]).some(v => v && v.length > 0)));
705
+ };
706
+ this.app.get('/api/skills', (_req, res) => {
707
+ res.json([
708
+ // Built-in skills
709
+ { name: 'shell', description: 'Execute shell commands', type: 'builtin', enabled: true, category: 'system' },
710
+ { name: 'filesystem', description: 'Read/write files', type: 'builtin', enabled: true, category: 'system' },
711
+ { name: 'browser', description: 'Browse websites & extract data', type: 'builtin', enabled: this.browser.isAvailable, category: 'system' },
712
+ { name: 'scheduler', description: 'Schedule background tasks', type: 'builtin', enabled: true, category: 'system' },
713
+ { name: 'memory', description: 'Persistent memory storage', type: 'builtin', enabled: true, category: 'system' },
714
+ { name: 'inbox', description: 'Track and reply to messages', type: 'builtin', enabled: true, category: 'system' },
715
+ // Messaging - Mainstream
716
+ { name: 'email', description: 'Send/receive emails via IMAP', type: 'integration', enabled: isIntEnabled('email', 'EMAIL_USERNAME'), category: 'messaging',
717
+ configFields: [
718
+ { key: 'host', label: 'IMAP Host', placeholder: 'imap.gmail.com' },
719
+ { key: 'port', label: 'IMAP Port', placeholder: '993' },
720
+ { key: 'username', label: 'Email', placeholder: 'you@gmail.com' },
721
+ { key: 'password', label: 'App Password', placeholder: '••••••••', secret: true },
722
+ { key: 'folders', label: 'Folders to Monitor', placeholder: 'INBOX, Sent, Drafts, Trash' },
723
+ { key: 'useIdle', label: 'Real-time IDLE', placeholder: 'true' },
724
+ { key: 'pollInterval', label: 'Poll Interval (minutes)', placeholder: '1' },
725
+ ] },
726
+ { name: 'telegram', description: 'Telegram bot integration', type: 'integration', enabled: isIntEnabled('telegram', 'TELEGRAM_BOT_TOKEN'), category: 'messaging',
727
+ configFields: [
728
+ { key: 'botToken', label: 'Bot Token', placeholder: '123456:ABC-DEF...', secret: true },
729
+ { key: 'chatId', label: 'Chat ID (optional)', placeholder: '123456789' },
730
+ ] },
731
+ { name: 'discord', description: 'Discord bot integration', type: 'integration', enabled: isIntEnabled('discord', 'DISCORD_BOT_TOKEN'), category: 'messaging',
732
+ configFields: [
733
+ { key: 'botToken', label: 'Bot Token', placeholder: 'MTIz...', secret: true },
734
+ { key: 'guildId', label: 'Server ID (optional)', placeholder: '123456789' },
735
+ ] },
736
+ { name: 'slack', description: 'Slack bot integration', type: 'integration', enabled: isIntEnabled('slack', 'SLACK_BOT_TOKEN'), category: 'messaging',
737
+ configFields: [
738
+ { key: 'botToken', label: 'Bot Token', placeholder: 'xoxb-...', secret: true },
739
+ { key: 'channel', label: 'Channel', placeholder: '#general' },
740
+ ] },
741
+ { name: 'whatsapp', description: 'WhatsApp via Twilio', type: 'integration', enabled: isIntEnabled('whatsapp', 'TWILIO_ACCOUNT_SID'), category: 'messaging',
742
+ configFields: [
743
+ { key: 'accountSid', label: 'Account SID', placeholder: 'AC...', secret: true },
744
+ { key: 'authToken', label: 'Auth Token', placeholder: '••••••••', secret: true },
745
+ { key: 'phoneNumber', label: 'Twilio Phone', placeholder: '+1234567890' },
746
+ ] },
747
+ // Messaging - Privacy & Enterprise
748
+ { name: 'signal', description: 'Signal via signal-cli', type: 'integration', enabled: isIntEnabled('signal', 'SIGNAL_CLI_PATH'), category: 'messaging',
749
+ configFields: [
750
+ { key: 'cliPath', label: 'signal-cli Path', placeholder: '/usr/bin/signal-cli' },
751
+ { key: 'phoneNumber', label: 'Your Phone Number', placeholder: '+1234567890' },
752
+ ] },
753
+ { name: 'imessage', description: 'iMessage via BlueBubbles', type: 'integration', enabled: isIntEnabled('imessage', 'BLUEBUBBLES_URL'), category: 'messaging',
754
+ configFields: [
755
+ { key: 'url', label: 'BlueBubbles URL', placeholder: 'http://localhost:1234' },
756
+ { key: 'password', label: 'Password', placeholder: '••••••••', secret: true },
757
+ ] },
758
+ { name: 'teams', description: 'Microsoft Teams integration', type: 'integration', enabled: isIntEnabled('teams', 'MS_TEAMS_WEBHOOK'), category: 'messaging',
759
+ configFields: [
760
+ { key: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://outlook.office.com/webhook/...', secret: true },
761
+ { key: 'tenantId', label: 'Tenant ID (optional)', placeholder: '...' },
762
+ ] },
763
+ { name: 'matrix', description: 'Matrix protocol integration', type: 'integration', enabled: isIntEnabled('matrix', 'MATRIX_HOMESERVER'), category: 'messaging',
764
+ configFields: [
765
+ { key: 'homeserver', label: 'Homeserver URL', placeholder: 'https://matrix.org' },
766
+ { key: 'userId', label: 'User ID', placeholder: '@user:matrix.org' },
767
+ { key: 'accessToken', label: 'Access Token', placeholder: '••••••••', secret: true },
768
+ ] },
769
+ { name: 'nostr', description: 'Nostr NIP-04 messages', type: 'integration', enabled: isIntEnabled('nostr', 'NOSTR_PRIVATE_KEY'), category: 'messaging',
770
+ configFields: [
771
+ { key: 'privateKey', label: 'Private Key (nsec)', placeholder: 'nsec1...', secret: true },
772
+ { key: 'relay', label: 'Relay URL', placeholder: 'wss://relay.damus.io' },
773
+ ] },
774
+ { name: 'zalo', description: 'Zalo bot integration', type: 'integration', enabled: isIntEnabled('zalo', 'ZALO_APP_ID'), category: 'messaging',
775
+ configFields: [
776
+ { key: 'appId', label: 'App ID', placeholder: '123456' },
777
+ { key: 'appSecret', label: 'App Secret', placeholder: '••••••••', secret: true },
778
+ { key: 'accessToken', label: 'Access Token', placeholder: '••••••••', secret: true },
779
+ ] },
780
+ // AI Models - Cloud
781
+ { name: 'openai', description: 'OpenAI GPT-4, o1, GPT-5', type: 'integration', enabled: isIntEnabled('openai', 'OPENAI_API_KEY'), category: 'ai',
782
+ configFields: [
783
+ { key: 'apiKey', label: 'API Key', placeholder: 'sk-...', secret: true },
784
+ { key: 'model', label: 'Model', placeholder: 'gpt-4o' },
785
+ ] },
786
+ { name: 'anthropic', description: 'Anthropic Claude 3.5/4', type: 'integration', enabled: isIntEnabled('anthropic', 'ANTHROPIC_API_KEY'), category: 'ai',
787
+ configFields: [
788
+ { key: 'apiKey', label: 'API Key', placeholder: 'sk-ant-...', secret: true },
789
+ { key: 'model', label: 'Model', placeholder: 'claude-3-5-sonnet' },
790
+ ] },
791
+ { name: 'google', description: 'Google Gemini 1.5/2.5', type: 'integration', enabled: isIntEnabled('google', 'GOOGLE_API_KEY'), category: 'ai',
792
+ configFields: [
793
+ { key: 'apiKey', label: 'API Key', placeholder: 'AIza...', secret: true },
794
+ { key: 'model', label: 'Model', placeholder: 'gemini-1.5-pro' },
795
+ ] },
796
+ { name: 'xai', description: 'xAI Grok', type: 'integration', enabled: isIntEnabled('xai', 'XAI_API_KEY'), category: 'ai',
797
+ configFields: [
798
+ { key: 'apiKey', label: 'API Key', placeholder: 'xai-...', secret: true },
799
+ { key: 'model', label: 'Model', placeholder: 'grok-2' },
800
+ ] },
801
+ { name: 'mistral', description: 'Mistral AI models', type: 'integration', enabled: isIntEnabled('mistral', 'MISTRAL_API_KEY'), category: 'ai',
802
+ configFields: [
803
+ { key: 'apiKey', label: 'API Key', placeholder: '••••••••', secret: true },
804
+ { key: 'model', label: 'Model', placeholder: 'mistral-large' },
805
+ ] },
806
+ { name: 'deepseek', description: 'DeepSeek V3 & R1', type: 'integration', enabled: isIntEnabled('deepseek', 'DEEPSEEK_API_KEY'), category: 'ai',
807
+ configFields: [
808
+ { key: 'apiKey', label: 'API Key', placeholder: 'sk-...', secret: true },
809
+ { key: 'model', label: 'Model', placeholder: 'deepseek-chat' },
810
+ ] },
811
+ { name: 'perplexity', description: 'Perplexity Search-augmented', type: 'integration', enabled: isIntEnabled('perplexity', 'PERPLEXITY_API_KEY'), category: 'ai',
812
+ configFields: [
813
+ { key: 'apiKey', label: 'API Key', placeholder: 'pplx-...', secret: true },
814
+ { key: 'model', label: 'Model', placeholder: 'sonar' },
815
+ ] },
816
+ // AI Local
817
+ { name: 'ollama', description: 'Ollama local models', type: 'integration', enabled: isIntEnabled('ollama', 'OLLAMA_URL'), category: 'ai',
818
+ configFields: [
819
+ { key: 'url', label: 'Ollama URL', placeholder: 'http://localhost:11434' },
820
+ { key: 'model', label: 'Model', placeholder: 'llama3.1' },
821
+ ] },
822
+ { name: 'lmstudio', description: 'LM Studio local models', type: 'integration', enabled: isIntEnabled('lmstudio', 'LMSTUDIO_URL'), category: 'ai',
823
+ configFields: [
824
+ { key: 'url', label: 'LM Studio URL', placeholder: 'http://localhost:1234' },
825
+ { key: 'model', label: 'Model', placeholder: 'local-model' },
826
+ ] },
827
+ // Productivity & Workspace
828
+ { name: 'notion', description: 'Notion pages & databases', type: 'integration', enabled: isIntEnabled('notion', 'NOTION_TOKEN'), category: 'productivity',
829
+ configFields: [
830
+ { key: 'token', label: 'Integration Token', placeholder: 'secret_...', secret: true },
831
+ { key: 'databaseId', label: 'Database ID (optional)', placeholder: '...' },
832
+ ] },
833
+ { name: 'trello', description: 'Trello boards & cards', type: 'integration', enabled: isIntEnabled('trello', 'TRELLO_API_KEY'), category: 'productivity',
834
+ configFields: [
835
+ { key: 'apiKey', label: 'API Key', placeholder: '••••••••', secret: true },
836
+ { key: 'token', label: 'Token', placeholder: '••••••••', secret: true },
837
+ { key: 'boardId', label: 'Board ID', placeholder: '...' },
838
+ ] },
839
+ { name: 'github', description: 'GitHub repos, issues, PRs', type: 'integration', enabled: isIntEnabled('github', 'GITHUB_TOKEN'), category: 'productivity',
840
+ configFields: [
841
+ { key: 'token', label: 'Personal Access Token', placeholder: 'ghp_...', secret: true },
842
+ { key: 'owner', label: 'Default Owner', placeholder: 'username' },
843
+ { key: 'repo', label: 'Default Repo', placeholder: 'repo-name' },
844
+ ] },
845
+ { name: 'obsidian', description: 'Obsidian vault access', type: 'integration', enabled: isIntEnabled('obsidian', 'OBSIDIAN_VAULT_PATH'), category: 'productivity',
846
+ configFields: [
847
+ { key: 'vaultPath', label: 'Vault Path', placeholder: '/home/user/Obsidian' },
848
+ ] },
849
+ { name: 'apple-notes', description: 'Apple Notes (macOS)', type: 'integration', enabled: isIntEnabled('apple-notes', 'APPLE_NOTES_ENABLED'), category: 'productivity',
850
+ configFields: [
851
+ { key: 'enabled', label: 'Enabled', placeholder: 'true' },
852
+ ] },
853
+ // Smart Home & Media
854
+ { name: 'home-assistant', description: 'Home Assistant hub', type: 'integration', enabled: isIntEnabled('home-assistant', 'HASS_URL'), category: 'smart-home',
855
+ configFields: [
856
+ { key: 'url', label: 'Home Assistant URL', placeholder: 'http://homeassistant:8123' },
857
+ { key: 'token', label: 'Long-Lived Access Token', placeholder: '••••••••', secret: true },
858
+ ] },
859
+ { name: 'hue', description: 'Philips Hue lighting', type: 'integration', enabled: isIntEnabled('hue', 'HUE_BRIDGE_IP'), category: 'smart-home',
860
+ configFields: [
861
+ { key: 'bridgeIp', label: 'Bridge IP', placeholder: '192.168.1.100' },
862
+ { key: 'username', label: 'API Key', placeholder: '••••••••', secret: true },
863
+ ] },
864
+ { name: 'spotify', description: 'Spotify control', type: 'integration', enabled: isIntEnabled('spotify', 'SPOTIFY_CLIENT_ID'), category: 'smart-home',
865
+ configFields: [
866
+ { key: 'clientId', label: 'Client ID', placeholder: '...' },
867
+ { key: 'clientSecret', label: 'Client Secret', placeholder: '••••••••', secret: true },
868
+ { key: 'refreshToken', label: 'Refresh Token', placeholder: '••••••••', secret: true },
869
+ ] },
870
+ { name: 'sonos', description: 'Sonos multi-room audio', type: 'integration', enabled: isIntEnabled('sonos', 'SONOS_IP'), category: 'smart-home',
871
+ configFields: [
872
+ { key: 'ip', label: 'Speaker IP', placeholder: '192.168.1.101' },
873
+ ] },
874
+ // Utilities
875
+ { name: 'weather', description: 'Weather forecasts', type: 'integration', enabled: isIntEnabled('weather', 'OPENWEATHER_API_KEY'), category: 'utility',
876
+ configFields: [
877
+ { key: 'apiKey', label: 'OpenWeather API Key', placeholder: '••••••••', secret: true },
878
+ { key: 'city', label: 'Default City', placeholder: 'London' },
879
+ ] },
880
+ { name: 'webhooks', description: 'HTTP webhook triggers', type: 'integration', enabled: isIntEnabled('webhooks', 'WEBHOOK_SECRET'), category: 'utility',
881
+ configFields: [
882
+ { key: 'secret', label: 'Webhook Secret', placeholder: '••••••••', secret: true },
883
+ { key: 'port', label: 'Port', placeholder: '8081' },
884
+ ] },
885
+ { name: 'image-gen', description: 'AI Image Generation', type: 'integration', enabled: isIntEnabled('image-gen', 'IMAGE_API_KEY'), category: 'utility',
886
+ configFields: [
887
+ { key: 'provider', label: 'Provider', placeholder: 'openai, stability, replicate' },
888
+ { key: 'apiKey', label: 'API Key', placeholder: '••••••••', secret: true },
889
+ ] },
890
+ { name: '1password', description: '1Password secrets', type: 'integration', enabled: isIntEnabled('1password', 'OP_SERVICE_ACCOUNT_TOKEN'), category: 'utility',
891
+ configFields: [
892
+ { key: 'serviceAccountToken', label: 'Service Account Token', placeholder: 'ops_...', secret: true },
893
+ { key: 'vault', label: 'Vault Name', placeholder: 'Private' },
894
+ ] },
895
+ { name: 'gmail', description: 'Gmail Pub/Sub triggers', type: 'integration', enabled: isIntEnabled('gmail', 'GMAIL_REFRESH_TOKEN'), category: 'utility',
896
+ configFields: [
897
+ { key: 'clientId', label: 'Client ID', placeholder: '...' },
898
+ { key: 'clientSecret', label: 'Client Secret', placeholder: '••••••••', secret: true },
899
+ { key: 'refreshToken', label: 'Refresh Token', placeholder: '••••••••', secret: true },
900
+ ] },
901
+ ]);
902
+ });
903
+ this.app.get('/api/skills/config/:name', (req, res) => {
904
+ const ints = loadIntegrations();
905
+ const cfg = ints[req.params.name] || {};
906
+ // Mask secret values
907
+ const masked = {};
908
+ for (const [k, v] of Object.entries(cfg)) {
909
+ masked[k] = v && v.length > 4 ? v.substring(0, 3) + '•'.repeat(v.length - 3) : v ? '••••' : '';
910
+ }
911
+ res.json({ name: req.params.name, config: masked, hasConfig: Object.keys(cfg).length > 0 });
912
+ });
913
+ this.app.post('/api/skills/config/:name', (req, res) => {
914
+ try {
915
+ const ints = loadIntegrations();
916
+ const existing = ints[req.params.name] || {};
917
+ const incoming = req.body.config || {};
918
+ // Only overwrite non-empty values (preserve existing if field left empty)
919
+ for (const [k, v] of Object.entries(incoming)) {
920
+ if (v && v.length > 0 && !v.includes('•'))
921
+ existing[k] = v;
922
+ }
923
+ ints[req.params.name] = existing;
924
+ saveIntegrations(ints);
925
+ res.json({ success: true });
926
+ }
927
+ catch (e) {
928
+ res.status(500).json({ error: e.message });
929
+ }
930
+ });
931
+ this.app.delete('/api/skills/config/:name', (req, res) => {
932
+ try {
933
+ const ints = loadIntegrations();
934
+ delete ints[req.params.name];
935
+ saveIntegrations(ints);
936
+ res.json({ success: true });
937
+ }
938
+ catch (e) {
939
+ res.status(500).json({ error: e.message });
940
+ }
941
+ });
942
+ // ============ DIRECT EMAIL API ============
943
+ this.app.post('/api/email/send', async (req, res) => {
944
+ try {
945
+ const { to, subject, body } = req.body;
946
+ if (!to || !subject) {
947
+ res.status(400).json({ error: 'to and subject are required' });
948
+ return;
949
+ }
950
+ const ints = loadIntegrations();
951
+ const cfg = ints['email'] || {};
952
+ const host = cfg.host || process.env.EMAIL_HOST || 'smtp.gmail.com';
953
+ const port = parseInt(cfg.port || process.env.EMAIL_PORT || '587', 10);
954
+ const username = cfg.username || process.env.EMAIL_USERNAME;
955
+ const password = cfg.password || process.env.EMAIL_PASSWORD;
956
+ if (!username || !password) {
957
+ res.status(400).json({ error: 'Email not configured. Go to Skills → Email → Configure first.' });
958
+ return;
959
+ }
960
+ const nodemailer = require('nodemailer');
961
+ const transporter = nodemailer.createTransport({ host, port, secure: port === 465, auth: { user: username, pass: password } });
962
+ await transporter.sendMail({
963
+ from: `LinguClaw <${username}>`,
964
+ to,
965
+ subject,
966
+ text: body || '',
967
+ html: body ? `<div style="font-family:sans-serif;line-height:1.6">${body.replace(/\n/g, '<br>')}</div>` : ''
968
+ });
969
+ res.json({ success: true, message: `Email sent to ${to}` });
970
+ }
971
+ catch (e) {
972
+ const msg = e.message || '';
973
+ let hint = '';
974
+ if (msg.includes('EAUTH') || msg.includes('Invalid login'))
975
+ hint = ' — Check credentials. For Gmail use App Password.';
976
+ else if (msg.includes('ECONNREFUSED'))
977
+ hint = ' — Cannot connect to SMTP server.';
978
+ res.status(500).json({ error: 'Send failed: ' + msg + hint });
979
+ }
980
+ });
981
+ // ============ TELEGRAM API ============
982
+ this.app.post('/api/telegram/send', async (req, res) => {
983
+ try {
984
+ const { chatId, message } = req.body;
985
+ if (!message) {
986
+ res.status(400).json({ error: 'message required' });
987
+ return;
988
+ }
989
+ const ints = loadIntegrations();
990
+ const cfg = ints['telegram'] || {};
991
+ const token = cfg.botToken || process.env.TELEGRAM_BOT_TOKEN;
992
+ const target = chatId || cfg.chatId || process.env.TELEGRAM_CHAT_ID;
993
+ if (!token) {
994
+ res.status(400).json({ error: 'Telegram not configured. Go to Skills → Telegram → Configure.' });
995
+ return;
996
+ }
997
+ if (!target) {
998
+ res.status(400).json({ error: 'Chat ID required. Set it in Skills → Telegram → Configure or provide chatId.' });
999
+ return;
1000
+ }
1001
+ const https = require('https');
1002
+ const postData = JSON.stringify({ chat_id: target, text: message, parse_mode: 'HTML' });
1003
+ const result = await new Promise((resolve, reject) => {
1004
+ const r = https.request({ hostname: 'api.telegram.org', path: `/bot${token}/sendMessage`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } }, (resp) => {
1005
+ let d = '';
1006
+ resp.on('data', (c) => d += c);
1007
+ resp.on('end', () => { try {
1008
+ resolve(JSON.parse(d));
1009
+ }
1010
+ catch {
1011
+ resolve({ ok: false, description: d });
1012
+ } });
1013
+ });
1014
+ r.on('error', reject);
1015
+ r.write(postData);
1016
+ r.end();
1017
+ });
1018
+ if (result.ok)
1019
+ res.json({ success: true, message: `Telegram message sent to ${target}` });
1020
+ else
1021
+ res.status(500).json({ error: 'Telegram error: ' + (result.description || JSON.stringify(result)) });
1022
+ }
1023
+ catch (e) {
1024
+ res.status(500).json({ error: e.message });
1025
+ }
1026
+ });
1027
+ // ============ DISCORD API ============
1028
+ this.app.post('/api/discord/send', async (req, res) => {
1029
+ try {
1030
+ const { channelId, message } = req.body;
1031
+ if (!message) {
1032
+ res.status(400).json({ error: 'message required' });
1033
+ return;
1034
+ }
1035
+ const ints = loadIntegrations();
1036
+ const cfg = ints['discord'] || {};
1037
+ const token = cfg.botToken || process.env.DISCORD_BOT_TOKEN;
1038
+ if (!token) {
1039
+ res.status(400).json({ error: 'Discord not configured. Go to Skills → Discord → Configure.' });
1040
+ return;
1041
+ }
1042
+ if (!channelId) {
1043
+ res.status(400).json({ error: 'channelId required.' });
1044
+ return;
1045
+ }
1046
+ const https = require('https');
1047
+ const postData = JSON.stringify({ content: message });
1048
+ const result = await new Promise((resolve, reject) => {
1049
+ const r = https.request({ hostname: 'discord.com', path: `/api/v10/channels/${channelId}/messages`, method: 'POST', headers: { 'Authorization': `Bot ${token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } }, (resp) => {
1050
+ let d = '';
1051
+ resp.on('data', (c) => d += c);
1052
+ resp.on('end', () => { try {
1053
+ resolve(JSON.parse(d));
1054
+ }
1055
+ catch {
1056
+ resolve({ error: d });
1057
+ } });
1058
+ });
1059
+ r.on('error', reject);
1060
+ r.write(postData);
1061
+ r.end();
1062
+ });
1063
+ if (result.id)
1064
+ res.json({ success: true, message: `Discord message sent to channel ${channelId}` });
1065
+ else
1066
+ res.status(500).json({ error: 'Discord error: ' + (result.message || JSON.stringify(result)) });
1067
+ }
1068
+ catch (e) {
1069
+ res.status(500).json({ error: e.message });
1070
+ }
1071
+ });
1072
+ // ============ SLACK API ============
1073
+ this.app.post('/api/slack/send', async (req, res) => {
1074
+ try {
1075
+ const { channel, message } = req.body;
1076
+ if (!message) {
1077
+ res.status(400).json({ error: 'message required' });
1078
+ return;
1079
+ }
1080
+ const ints = loadIntegrations();
1081
+ const cfg = ints['slack'] || {};
1082
+ const token = cfg.botToken || process.env.SLACK_BOT_TOKEN;
1083
+ const target = channel || cfg.channel || '#general';
1084
+ if (!token) {
1085
+ res.status(400).json({ error: 'Slack not configured. Go to Skills → Slack → Configure.' });
1086
+ return;
1087
+ }
1088
+ const https = require('https');
1089
+ const postData = JSON.stringify({ channel: target, text: message });
1090
+ const result = await new Promise((resolve, reject) => {
1091
+ const r = https.request({ hostname: 'slack.com', path: '/api/chat.postMessage', method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } }, (resp) => {
1092
+ let d = '';
1093
+ resp.on('data', (c) => d += c);
1094
+ resp.on('end', () => { try {
1095
+ resolve(JSON.parse(d));
1096
+ }
1097
+ catch {
1098
+ resolve({ ok: false, error: d });
1099
+ } });
1100
+ });
1101
+ r.on('error', reject);
1102
+ r.write(postData);
1103
+ r.end();
1104
+ });
1105
+ if (result.ok)
1106
+ res.json({ success: true, message: `Slack message sent to ${target}` });
1107
+ else
1108
+ res.status(500).json({ error: 'Slack error: ' + (result.error || JSON.stringify(result)) });
1109
+ }
1110
+ catch (e) {
1111
+ res.status(500).json({ error: e.message });
1112
+ }
1113
+ });
1114
+ // ============ WHATSAPP API (Twilio) ============
1115
+ this.app.post('/api/whatsapp/send', async (req, res) => {
1116
+ try {
1117
+ const { to, message } = req.body;
1118
+ if (!to || !message) {
1119
+ res.status(400).json({ error: 'to and message required' });
1120
+ return;
1121
+ }
1122
+ const ints = loadIntegrations();
1123
+ const cfg = ints['whatsapp'] || {};
1124
+ const sid = cfg.accountSid || process.env.TWILIO_ACCOUNT_SID;
1125
+ const authToken = cfg.authToken || process.env.TWILIO_AUTH_TOKEN;
1126
+ const from = cfg.phoneNumber || process.env.TWILIO_PHONE_NUMBER;
1127
+ if (!sid || !authToken || !from) {
1128
+ res.status(400).json({ error: 'WhatsApp not configured. Go to Skills → WhatsApp → Configure.' });
1129
+ return;
1130
+ }
1131
+ const https = require('https');
1132
+ const postData = `To=whatsapp:${to}&From=whatsapp:${from}&Body=${encodeURIComponent(message)}`;
1133
+ const auth = Buffer.from(`${sid}:${authToken}`).toString('base64');
1134
+ const result = await new Promise((resolve, reject) => {
1135
+ const r = https.request({ hostname: 'api.twilio.com', path: `/2010-04-01/Accounts/${sid}/Messages.json`, method: 'POST', headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }, (resp) => {
1136
+ let d = '';
1137
+ resp.on('data', (c) => d += c);
1138
+ resp.on('end', () => { try {
1139
+ resolve(JSON.parse(d));
1140
+ }
1141
+ catch {
1142
+ resolve({ error: d });
1143
+ } });
1144
+ });
1145
+ r.on('error', reject);
1146
+ r.write(postData);
1147
+ r.end();
1148
+ });
1149
+ if (result.sid)
1150
+ res.json({ success: true, message: `WhatsApp message sent to ${to}` });
1151
+ else
1152
+ res.status(500).json({ error: 'WhatsApp error: ' + (result.message || JSON.stringify(result)) });
1153
+ }
1154
+ catch (e) {
1155
+ res.status(500).json({ error: e.message });
1156
+ }
1157
+ });
1158
+ // ============ INBOX API ============
1159
+ // Use the class inbox instance (this.inbox) that was created in constructor
1160
+ // Get unread messages
1161
+ this.app.get('/api/inbox/unread', (_req, res) => {
1162
+ res.json(this.inbox.getUnread());
1163
+ });
1164
+ // Get all messages (paginated)
1165
+ this.app.get('/api/inbox/messages', (req, res) => {
1166
+ const limit = parseInt(req.query.limit) || 50;
1167
+ const offset = parseInt(req.query.offset) || 0;
1168
+ res.json({
1169
+ messages: this.inbox.getMessages(limit, offset),
1170
+ unread: this.inbox.getUnreadCount()
1171
+ });
1172
+ });
1173
+ // Get message threads
1174
+ this.app.get('/api/inbox/threads', (_req, res) => {
1175
+ res.json(this.inbox.getThreads());
1176
+ });
1177
+ // Get single thread
1178
+ this.app.get('/api/inbox/threads/:id', (req, res) => {
1179
+ const thread = this.inbox.getThread(req.params.id);
1180
+ if (!thread) {
1181
+ res.status(404).json({ error: 'Thread not found' });
1182
+ return;
1183
+ }
1184
+ res.json(thread);
1185
+ });
1186
+ // Mark message as read
1187
+ this.app.post('/api/inbox/messages/:id/read', (req, res) => {
1188
+ this.inbox.markAsRead(req.params.id);
1189
+ res.json({ success: true });
1190
+ });
1191
+ // Mark thread as read
1192
+ this.app.post('/api/inbox/threads/:id/read', (req, res) => {
1193
+ this.inbox.markThreadAsRead(req.params.id);
1194
+ res.json({ success: true });
1195
+ });
1196
+ // Get unread counts
1197
+ this.app.get('/api/inbox/counts', (_req, res) => {
1198
+ res.json({
1199
+ total: this.inbox.getUnreadCount(),
1200
+ byPlatform: this.inbox.getUnreadByPlatform()
1201
+ });
1202
+ });
1203
+ // Receive incoming message (webhook endpoint)
1204
+ this.app.post('/api/inbox/receive', async (req, res) => {
1205
+ try {
1206
+ const { platform, from, to, subject, body, threadId, inReplyTo } = req.body;
1207
+ if (!platform || !from || !to || !body) {
1208
+ res.status(400).json({ error: 'platform, from, to, body required' });
1209
+ return;
1210
+ }
1211
+ // Store message
1212
+ const msg = this.inbox.addMessage({
1213
+ platform,
1214
+ from,
1215
+ to,
1216
+ subject,
1217
+ body,
1218
+ threadId,
1219
+ inReplyTo
1220
+ });
1221
+ if (!msg) {
1222
+ res.status(409).json({ error: 'Duplicate message' });
1223
+ return;
1224
+ }
1225
+ // AI Analysis of incoming message
1226
+ try {
1227
+ const provider = this.providerManager.createFromEnv();
1228
+ if (provider) {
1229
+ const analysisMessages = [
1230
+ { role: 'system', content: 'You are an assistant that analyzes incoming messages and provides a brief summary and suggested reply. Respond in JSON format: { "summary": "...", "suggestedReply": "..." }' },
1231
+ { role: 'user', content: `Analyze this ${platform} message from ${from}${subject ? ` about "${subject}"` : ''}:
1232
+
1233
+ ${body}
1234
+
1235
+ Provide a brief summary and suggested reply.` }
1236
+ ];
1237
+ const response = await provider.complete(analysisMessages, 0.3, 500);
1238
+ if (response.content) {
1239
+ try {
1240
+ const analysis = JSON.parse(response.content);
1241
+ this.inbox.setAnalysis(msg.id, analysis.summary, analysis.suggestedReply);
1242
+ }
1243
+ catch (parseErr) {
1244
+ logger.debug(`Inbox analysis JSON parse failed: ${parseErr.message}`);
1245
+ this.inbox.setAnalysis(msg.id, response.content.substring(0, 200), undefined);
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+ catch (e) {
1251
+ // AI analysis failed, but message is still stored
1252
+ logger.warn('AI analysis failed for incoming message:', e);
1253
+ }
1254
+ // Broadcast to WebSocket clients
1255
+ this.broadcast({
1256
+ type: 'inbox_new',
1257
+ payload: { message: msg, unreadCount: this.inbox.getUnreadCount() }
1258
+ });
1259
+ res.json({ success: true, id: msg.id });
1260
+ }
1261
+ catch (e) {
1262
+ res.status(500).json({ error: e.message });
1263
+ }
1264
+ });
1265
+ // Poll for new messages (for platforms that support polling)
1266
+ this.app.post('/api/inbox/poll/:platform', async (req, res) => {
1267
+ const platform = req.params.platform;
1268
+ try {
1269
+ // This would integrate with platform-specific APIs to poll for new messages
1270
+ // For now, return success - actual implementation would vary by platform
1271
+ res.json({ success: true, polled: platform, newMessages: 0 });
1272
+ }
1273
+ catch (e) {
1274
+ res.status(500).json({ error: e.message });
1275
+ }
1276
+ });
1277
+ // Reply to a message (chat command integration)
1278
+ this.app.post('/api/inbox/reply', async (req, res) => {
1279
+ try {
1280
+ const { threadId, message, platform, to } = req.body;
1281
+ if (!threadId || !message) {
1282
+ res.status(400).json({ error: 'threadId and message required' });
1283
+ return;
1284
+ }
1285
+ // Get thread to find recipient
1286
+ const thread = this.inbox.getThread(threadId);
1287
+ if (!thread) {
1288
+ res.status(404).json({ error: 'Thread not found' });
1289
+ return;
1290
+ }
1291
+ // Send reply via appropriate platform
1292
+ const targetPlatform = platform || thread.platform;
1293
+ const targetTo = to || thread.participants.find((p) => p !== 'me') || thread.participants[0];
1294
+ // Execute messaging action
1295
+ const action = {
1296
+ action: 'send',
1297
+ platform: targetPlatform,
1298
+ to: targetTo,
1299
+ message,
1300
+ channelId: req.body.channelId,
1301
+ channel: req.body.channel
1302
+ };
1303
+ const result = await execMessagingAction(action);
1304
+ // Add sent message to thread
1305
+ this.inbox.addMessage({
1306
+ platform: targetPlatform,
1307
+ from: 'me',
1308
+ to: targetTo,
1309
+ body: message,
1310
+ threadId
1311
+ });
1312
+ res.json(result);
1313
+ }
1314
+ catch (e) {
1315
+ res.status(500).json({ error: e.message });
1316
+ }
1317
+ });
1318
+ // Mark all messages as read
1319
+ this.app.post('/api/inbox/mark-all-read', (_req, res) => {
1320
+ const messages = this.inbox.getMessages(1000);
1321
+ messages.forEach(m => {
1322
+ if (!m.read)
1323
+ this.inbox.markAsRead(m.id);
1324
+ });
1325
+ res.json({ success: true, marked: messages.filter(m => !m.read).length });
1326
+ });
1327
+ // Delete message
1328
+ this.app.delete('/api/inbox/messages/:id', (req, res) => {
1329
+ // Note: InboxManager doesn't have delete method, we'll add a simple version
1330
+ this.inbox['messages'] = this.inbox['messages'].filter((m) => m.id !== req.params.id);
1331
+ this.inbox['saveToMemory']();
1332
+ res.json({ success: true });
1333
+ });
1334
+ // Manual check for new emails
1335
+ this.app.post('/api/inbox/check', async (_req, res) => {
1336
+ try {
1337
+ // Trigger IMAP poll if configured
1338
+ const integrationsPath = path_1.default.join(require('os').homedir(), '.linguclaw', 'integrations.json');
1339
+ let ints = {};
1340
+ try {
1341
+ ints = JSON.parse(require('fs').readFileSync(integrationsPath, 'utf8'));
1342
+ }
1343
+ catch (e) {
1344
+ logger.debug(`No integrations file: ${e.message}`);
1345
+ }
1346
+ const emailCfg = ints['email'];
1347
+ if (!emailCfg || !emailCfg.host) {
1348
+ res.status(400).json({ error: 'Email not configured' });
1349
+ return;
1350
+ }
1351
+ // Trigger manual poll via email receiver
1352
+ const folders = emailCfg.folders ? emailCfg.folders.split(',').map((f) => f.trim()) : ['INBOX'];
1353
+ // Force a poll by stopping and restarting
1354
+ this.emailReceiver.stop();
1355
+ setTimeout(() => {
1356
+ this.emailReceiver.startIMAP({
1357
+ host: emailCfg.host,
1358
+ port: parseInt(emailCfg.port || '993', 10),
1359
+ username: emailCfg.username,
1360
+ password: emailCfg.password,
1361
+ tls: true,
1362
+ pollInterval: 1,
1363
+ folders: folders,
1364
+ useIdle: false // Use polling for manual check
1365
+ });
1366
+ }, 100);
1367
+ res.json({ success: true, checked: folders.length, folders });
1368
+ }
1369
+ catch (e) {
1370
+ res.status(500).json({ error: e.message });
1371
+ }
1372
+ });
1373
+ // Serve main HTML - use static dashboard or fallback to inline
1374
+ // ============ WORKFLOW API ============
1375
+ const workflowEngine = (0, workflow_engine_1.getWorkflowEngine)();
1376
+ this.app.get('/api/workflows/node-definitions', (_req, res) => {
1377
+ res.json(workflowEngine.getNodeDefinitions());
1378
+ });
1379
+ this.app.get('/api/workflows', (_req, res) => {
1380
+ res.json(workflowEngine.listWorkflows());
1381
+ });
1382
+ this.app.post('/api/workflows', (req, res) => {
1383
+ const { name, description } = req.body;
1384
+ if (!name) {
1385
+ res.status(400).json({ error: 'name required' });
1386
+ return;
1387
+ }
1388
+ const wf = workflowEngine.createWorkflow(name, description || '');
1389
+ res.json(wf);
1390
+ });
1391
+ this.app.get('/api/workflows/:id', (req, res) => {
1392
+ const wf = workflowEngine.getWorkflow(req.params.id);
1393
+ if (!wf) {
1394
+ res.status(404).json({ error: 'Workflow not found' });
1395
+ return;
1396
+ }
1397
+ res.json(wf);
1398
+ });
1399
+ this.app.put('/api/workflows/:id', (req, res) => {
1400
+ const wf = workflowEngine.updateWorkflow(req.params.id, req.body);
1401
+ if (!wf) {
1402
+ res.status(404).json({ error: 'Workflow not found' });
1403
+ return;
1404
+ }
1405
+ res.json(wf);
1406
+ });
1407
+ this.app.delete('/api/workflows/:id', (req, res) => {
1408
+ const ok = workflowEngine.deleteWorkflow(req.params.id);
1409
+ res.json({ success: ok });
1410
+ });
1411
+ this.app.post('/api/workflows/:id/duplicate', (req, res) => {
1412
+ const wf = workflowEngine.duplicateWorkflow(req.params.id);
1413
+ if (!wf) {
1414
+ res.status(404).json({ error: 'Workflow not found' });
1415
+ return;
1416
+ }
1417
+ res.json(wf);
1418
+ });
1419
+ this.app.post('/api/workflows/:id/execute', async (req, res) => {
1420
+ try {
1421
+ const provider = this.providerManager.createFromEnv();
1422
+ const result = await workflowEngine.executeWorkflow(req.params.id, req.body.triggerData, {
1423
+ provider,
1424
+ memory: this.memory,
1425
+ });
1426
+ res.json(result);
1427
+ }
1428
+ catch (e) {
1429
+ res.status(500).json({ error: e.message });
1430
+ }
1431
+ });
1432
+ this.app.post('/api/workflows/:id/toggle', (req, res) => {
1433
+ const wf = workflowEngine.getWorkflow(req.params.id);
1434
+ if (!wf) {
1435
+ res.status(404).json({ error: 'Workflow not found' });
1436
+ return;
1437
+ }
1438
+ const updated = workflowEngine.updateWorkflow(req.params.id, { active: !wf.active });
1439
+ res.json(updated);
1440
+ });
1441
+ this.app.get('/', (_req, res) => {
1442
+ const landingPath = path_1.default.join(__dirname, 'static', 'index.html');
1443
+ if (require('fs').existsSync(landingPath)) {
1444
+ res.sendFile(landingPath);
1445
+ }
1446
+ else {
1447
+ res.send(this.generateHTML());
1448
+ }
1449
+ });
1450
+ this.app.get('/dashboard', (_req, res) => {
1451
+ res.sendFile(path_1.default.join(__dirname, 'static', 'dashboard.html'));
1452
+ });
1453
+ this.app.get('/hub', (_req, res) => {
1454
+ res.sendFile(path_1.default.join(__dirname, 'static', 'hub.html'));
1455
+ });
1456
+ }
1457
+ /**
1458
+ * Setup WebSocket for real-time updates
1459
+ */
1460
+ setupWebSocket() {
1461
+ this.wss.on('connection', (ws) => {
1462
+ logger.info('WebSocket client connected');
1463
+ this.connections.add(ws);
1464
+ ws.on('close', () => {
1465
+ this.connections.delete(ws);
1466
+ logger.info('WebSocket client disconnected');
1467
+ });
1468
+ ws.on('message', (data) => {
1469
+ try {
1470
+ const message = JSON.parse(data);
1471
+ this.handleWebSocketMessage(ws, message);
1472
+ }
1473
+ catch (error) {
1474
+ logger.error('Invalid WebSocket message');
1475
+ }
1476
+ });
1477
+ });
1478
+ }
1479
+ /**
1480
+ * Handle WebSocket messages
1481
+ */
1482
+ handleWebSocketMessage(ws, message) {
1483
+ if (message.type === 'ping') {
1484
+ ws.send(JSON.stringify({ type: 'pong' }));
1485
+ }
1486
+ }
1487
+ /**
1488
+ * Run task and broadcast updates
1489
+ */
1490
+ async runTask(task) {
1491
+ if (!this.orchestrator)
1492
+ return;
1493
+ try {
1494
+ this.broadcast({
1495
+ type: 'state_update',
1496
+ payload: { task, running: true },
1497
+ });
1498
+ const result = await this.orchestrator.run(task);
1499
+ this.broadcast({
1500
+ type: 'complete',
1501
+ payload: { result },
1502
+ });
1503
+ }
1504
+ catch (error) {
1505
+ this.broadcast({
1506
+ type: 'error',
1507
+ payload: { error: error.message },
1508
+ });
1509
+ }
1510
+ }
1511
+ /**
1512
+ * Broadcast message to all connected clients
1513
+ */
1514
+ broadcast(message) {
1515
+ const data = JSON.stringify(message);
1516
+ for (const ws of this.connections) {
1517
+ if (ws.readyState === ws_1.WebSocket.OPEN) {
1518
+ ws.send(data);
1519
+ }
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Start email and messaging receivers - all messages go to inbox
1524
+ */
1525
+ startEmailReceivers() {
1526
+ const integrationsPath = path_1.default.join(require('os').homedir(), '.linguclaw', 'integrations.json');
1527
+ let ints = {};
1528
+ try {
1529
+ ints = JSON.parse(require('fs').readFileSync(integrationsPath, 'utf8'));
1530
+ }
1531
+ catch (e) {
1532
+ logger.debug(`No integrations file yet: ${e.message}`);
1533
+ }
1534
+ // Check for IMAP email configuration
1535
+ const emailCfg = ints['email'];
1536
+ if (emailCfg && emailCfg.host && emailCfg.username && emailCfg.password) {
1537
+ logger.info('Starting IMAP email receiver for ' + emailCfg.username);
1538
+ // Parse folders (comma-separated list)
1539
+ const folders = emailCfg.folders ? emailCfg.folders.split(',').map((f) => f.trim()) : ['INBOX'];
1540
+ this.emailReceiver.startIMAP({
1541
+ host: emailCfg.host,
1542
+ port: parseInt(emailCfg.port || '993', 10),
1543
+ username: emailCfg.username,
1544
+ password: emailCfg.password,
1545
+ tls: true,
1546
+ pollInterval: parseFloat(emailCfg.pollInterval || '1'),
1547
+ folders: folders,
1548
+ useIdle: emailCfg.useIdle !== 'false' // Default true for real-time
1549
+ });
1550
+ }
1551
+ // Check for Gmail API configuration
1552
+ const gmailCfg = ints['gmail'];
1553
+ if (gmailCfg && gmailCfg.clientId && gmailCfg.clientSecret && gmailCfg.refreshToken) {
1554
+ logger.info('Starting Gmail API receiver');
1555
+ this.emailReceiver.startGmail({
1556
+ clientId: gmailCfg.clientId,
1557
+ clientSecret: gmailCfg.clientSecret,
1558
+ refreshToken: gmailCfg.refreshToken
1559
+ });
1560
+ }
1561
+ // ========== TELEGRAM ==========
1562
+ const telegramCfg = ints['telegram'];
1563
+ if (telegramCfg && telegramCfg.botToken) {
1564
+ logger.info('Starting Telegram bot');
1565
+ const telegramBot = new messaging_1.TelegramBot(telegramCfg.botToken);
1566
+ if (telegramCfg.chatId) {
1567
+ telegramBot.allowChats([parseInt(telegramCfg.chatId)]);
1568
+ }
1569
+ // Route incoming messages to inbox
1570
+ telegramBot.onMessage((msg) => {
1571
+ const inboxMsg = this.inbox.addMessage({
1572
+ platform: 'telegram',
1573
+ from: msg.from || 'unknown',
1574
+ to: 'me',
1575
+ subject: msg.subject || `Telegram from ${msg.from}`,
1576
+ body: msg.body || msg.message || '',
1577
+ threadId: msg.chatId || msg.from
1578
+ });
1579
+ // Broadcast to WebSocket clients
1580
+ this.broadcast({
1581
+ type: 'inbox_new',
1582
+ payload: { message: inboxMsg, unreadCount: this.inbox.getUnreadCount() }
1583
+ });
1584
+ return null;
1585
+ });
1586
+ this.messagingHub.addPlatform(telegramBot);
1587
+ telegramBot.start();
1588
+ }
1589
+ // ========== DISCORD ==========
1590
+ const discordCfg = ints['discord'];
1591
+ if (discordCfg && discordCfg.botToken) {
1592
+ logger.info('Starting Discord bot');
1593
+ const discordBot = new messaging_1.DiscordBot(discordCfg.botToken);
1594
+ discordBot.onMessage((msg) => {
1595
+ const inboxMsg = this.inbox.addMessage({
1596
+ platform: 'discord',
1597
+ from: msg.from || 'unknown',
1598
+ to: 'me',
1599
+ subject: msg.subject || `Discord from ${msg.from}`,
1600
+ body: msg.body || msg.message || '',
1601
+ threadId: msg.channelId || msg.from
1602
+ });
1603
+ this.broadcast({
1604
+ type: 'inbox_new',
1605
+ payload: { message: inboxMsg, unreadCount: this.inbox.getUnreadCount() }
1606
+ });
1607
+ return null;
1608
+ });
1609
+ this.messagingHub.addPlatform(discordBot);
1610
+ discordBot.start();
1611
+ }
1612
+ // ========== SLACK ==========
1613
+ const slackCfg = ints['slack'];
1614
+ if (slackCfg && slackCfg.botToken) {
1615
+ logger.info('Starting Slack bot');
1616
+ const slackBot = new messaging_1.SlackBot(slackCfg.botToken);
1617
+ slackBot.onMessage((msg) => {
1618
+ const inboxMsg = this.inbox.addMessage({
1619
+ platform: 'slack',
1620
+ from: msg.from || 'unknown',
1621
+ to: 'me',
1622
+ subject: msg.subject || `Slack from ${msg.from}`,
1623
+ body: msg.body || msg.message || '',
1624
+ threadId: msg.channel || msg.from
1625
+ });
1626
+ this.broadcast({
1627
+ type: 'inbox_new',
1628
+ payload: { message: inboxMsg, unreadCount: this.inbox.getUnreadCount() }
1629
+ });
1630
+ return null;
1631
+ });
1632
+ this.messagingHub.addPlatform(slackBot);
1633
+ slackBot.start();
1634
+ }
1635
+ // ========== WHATSAPP ==========
1636
+ const whatsappCfg = ints['whatsapp'];
1637
+ if (whatsappCfg && whatsappCfg.accountSid && whatsappCfg.authToken) {
1638
+ logger.info('Starting WhatsApp (Twilio)');
1639
+ const whatsappBot = new messaging_1.WhatsAppBot('twilio');
1640
+ whatsappBot.onMessage((msg) => {
1641
+ const inboxMsg = this.inbox.addMessage({
1642
+ platform: 'whatsapp',
1643
+ from: msg.from || 'unknown',
1644
+ to: 'me',
1645
+ subject: msg.subject || `WhatsApp from ${msg.from}`,
1646
+ body: msg.body || msg.message || '',
1647
+ threadId: msg.from
1648
+ });
1649
+ this.broadcast({
1650
+ type: 'inbox_new',
1651
+ payload: { message: inboxMsg, unreadCount: this.inbox.getUnreadCount() }
1652
+ });
1653
+ return null;
1654
+ });
1655
+ this.messagingHub.addPlatform(whatsappBot);
1656
+ whatsappBot.start();
1657
+ }
1658
+ logger.info('All messaging receivers started - messages will appear in inbox');
1659
+ }
1660
+ /**
1661
+ * Generate main HTML page
1662
+ */
1663
+ generateHTML() {
1664
+ return `<!DOCTYPE html>
1665
+ <html lang="en">
1666
+ <head>
1667
+ <meta charset="UTF-8">
1668
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1669
+ <title>LinguClaw Dashboard</title>
1670
+ <style>
1671
+ :root {
1672
+ --bg-primary: #0f0f1a;
1673
+ --bg-secondary: #1a1a2e;
1674
+ --bg-card: #16213e;
1675
+ --bg-input: #0d0d1a;
1676
+ --border: #2a2a4a;
1677
+ --accent: #7c3aed;
1678
+ --accent-hover: #6d28d9;
1679
+ --accent-glow: rgba(124,58,237,0.3);
1680
+ --green: #10b981;
1681
+ --red: #ef4444;
1682
+ --yellow: #f59e0b;
1683
+ --blue: #3b82f6;
1684
+ --text: #e2e8f0;
1685
+ --text-dim: #94a3b8;
1686
+ --text-muted: #64748b;
1687
+ --radius: 12px;
1688
+ }
1689
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1690
+ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; }
1691
+
1692
+ /* Top Bar */
1693
+ .topbar { display: flex; align-items: center; justify-content: space-between; padding: 0 1.5rem; height: 56px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); }
1694
+ .topbar-left { display: flex; align-items: center; gap: 0.75rem; }
1695
+ .logo { width: 32px; height: 32px; background: var(--accent); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
1696
+ .topbar-title { font-size: 1.1rem; font-weight: 700; color: var(--text); }
1697
+ .topbar-title span { color: var(--accent); }
1698
+ .topbar-right { display: flex; align-items: center; gap: 1rem; }
1699
+ .status-badge { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; font-size: 0.75rem; color: var(--text-dim); }
1700
+ .status-dot { width: 8px; height: 8px; border-radius: 50%; }
1701
+ .status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
1702
+ .status-dot.offline { background: var(--red); }
1703
+
1704
+ /* Layout */
1705
+ .layout { display: flex; height: calc(100vh - 56px); }
1706
+
1707
+ /* Sidebar */
1708
+ .sidebar { width: 260px; background: var(--bg-secondary); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
1709
+ .nav { padding: 1rem 0.75rem; flex-shrink: 0; }
1710
+ .nav-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.75rem; border-radius: 8px; cursor: pointer; font-size: 0.875rem; color: var(--text-dim); transition: all 0.15s; margin-bottom: 2px; }
1711
+ .nav-item:hover { background: var(--bg-card); color: var(--text); }
1712
+ .nav-item.active { background: var(--accent); color: white; }
1713
+ .nav-icon { font-size: 1rem; width: 20px; text-align: center; }
1714
+ .sidebar-section { padding: 0.5rem 1rem; margin-top: 0.5rem; }
1715
+ .sidebar-section-title { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 0.5rem; font-weight: 600; }
1716
+ .step-list { list-style: none; padding: 0 0.75rem; flex: 1; overflow-y: auto; }
1717
+ .step-item { padding: 0.5rem 0.6rem; margin-bottom: 3px; border-radius: 6px; font-size: 0.8rem; color: var(--text-dim); background: var(--bg-card); border-left: 3px solid var(--border); display: flex; align-items: center; gap: 0.4rem; }
1718
+ .step-item.completed { border-left-color: var(--green); color: var(--green); }
1719
+ .step-item.failed { border-left-color: var(--red); color: var(--red); }
1720
+ .step-item.running { border-left-color: var(--yellow); color: var(--yellow); animation: pulse 1.5s infinite; }
1721
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.6; } }
1722
+ .step-empty { color: var(--text-muted); font-size: 0.8rem; padding: 0.5rem 0.6rem; }
1723
+
1724
+ /* Main Content */
1725
+ .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
1726
+
1727
+ /* Task View */
1728
+ .task-view { flex: 1; display: flex; flex-direction: column; padding: 1.25rem; gap: 1rem; }
1729
+ .task-header { display: flex; gap: 0.5rem; }
1730
+ .task-input { flex: 1; padding: 0.8rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.95rem; outline: none; transition: border 0.2s; }
1731
+ .task-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
1732
+ .task-btn { padding: 0.8rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; font-size: 0.9rem; transition: all 0.2s; display: flex; align-items: center; gap: 0.4rem; }
1733
+ .task-btn:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 12px var(--accent-glow); }
1734
+ .task-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
1735
+
1736
+ /* Output Area */
1737
+ .output-area { flex: 1; display: flex; flex-direction: column; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
1738
+ .output-tabs { display: flex; border-bottom: 1px solid var(--border); background: var(--bg-card); }
1739
+ .output-tab { padding: 0.6rem 1rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; }
1740
+ .output-tab:hover { color: var(--text-dim); }
1741
+ .output-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
1742
+ .output-content { flex: 1; overflow-y: auto; padding: 1rem; font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; font-size: 0.825rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; color: var(--text-dim); }
1743
+ .output-content .msg-task { color: var(--blue); }
1744
+ .output-content .msg-ok { color: var(--green); }
1745
+ .output-content .msg-err { color: var(--red); }
1746
+ .output-content .msg-step { color: var(--yellow); }
1747
+ .output-content .msg-info { color: var(--text-dim); }
1748
+ .task-running { display: none; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; background: rgba(124,58,237,0.1); border: 1px solid var(--accent); border-radius: var(--radius); font-size: 0.85rem; color: var(--accent); }
1749
+ .task-running.show { display: flex; }
1750
+ .spinner { width: 16px; height: 16px; border: 2px solid var(--accent-glow); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
1751
+ @keyframes spin { to { transform: rotate(360deg); } }
1752
+
1753
+ /* Settings View */
1754
+ .settings-view { display: none; flex: 1; padding: 1.25rem; overflow-y: auto; }
1755
+ .settings-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1rem; max-width: 700px; }
1756
+ .settings-card-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: var(--text); display: flex; align-items: center; gap: 0.5rem; }
1757
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
1758
+ .form-group { margin-bottom: 0.75rem; }
1759
+ .form-label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.3rem; font-weight: 500; }
1760
+ .form-input, .form-select { width: 100%; padding: 0.6rem 0.75rem; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 0.875rem; outline: none; transition: border 0.2s; }
1761
+ .form-input:focus, .form-select:focus { border-color: var(--accent); }
1762
+ .form-hint { font-size: 0.7rem; color: var(--text-muted); margin-top: 0.2rem; }
1763
+ .form-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
1764
+ .btn { padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem; font-weight: 500; cursor: pointer; border: none; transition: all 0.15s; }
1765
+ .btn-accent { background: var(--accent); color: white; }
1766
+ .btn-accent:hover { background: var(--accent-hover); }
1767
+ .btn-ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
1768
+ .btn-ghost:hover { background: var(--bg-card); color: var(--text); }
1769
+ .toast { display: none; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.825rem; margin-top: 0.75rem; }
1770
+ .toast.show { display: block; }
1771
+ .toast.ok { background: rgba(16,185,129,0.15); color: var(--green); border: 1px solid rgba(16,185,129,0.3); }
1772
+ .toast.err { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.3); }
1773
+
1774
+ /* History View */
1775
+ .history-view { display: none; flex: 1; padding: 1.25rem; overflow-y: auto; }
1776
+ .history-empty { color: var(--text-muted); text-align: center; padding: 3rem; font-size: 0.9rem; }
1777
+ .history-item { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; margin-bottom: 0.75rem; cursor: pointer; transition: border 0.15s; }
1778
+ .history-item:hover { border-color: var(--accent); }
1779
+ .history-item-task { font-weight: 500; margin-bottom: 0.3rem; }
1780
+ .history-item-meta { font-size: 0.75rem; color: var(--text-muted); display: flex; gap: 1rem; }
1781
+ </style>
1782
+ </head>
1783
+ <body>
1784
+
1785
+ <div class="topbar">
1786
+ <div class="topbar-left">
1787
+ <div class="logo">C</div>
1788
+ <div class="topbar-title">Lingu<span>Claw</span></div>
1789
+ </div>
1790
+ <div class="topbar-right">
1791
+ <div class="status-badge" id="modelBadge">openai/gpt-3.5-turbo</div>
1792
+ <div class="status-badge"><div class="status-dot online" id="wsDot"></div><span id="wsStatus">Connected</span></div>
1793
+ </div>
1794
+ </div>
1795
+
1796
+ <div class="layout">
1797
+ <div class="sidebar">
1798
+ <div class="nav">
1799
+ <div class="nav-item active" onclick="showView('task', this)"><span class="nav-icon">&#9889;</span> Tasks</div>
1800
+ <div class="nav-item" onclick="showView('history', this)"><span class="nav-icon">&#128203;</span> History</div>
1801
+ <div class="nav-item" onclick="showView('settings', this)"><span class="nav-icon">&#9881;</span> Settings</div>
1802
+ </div>
1803
+ <div class="sidebar-section">
1804
+ <div class="sidebar-section-title">Execution Steps</div>
1805
+ </div>
1806
+ <ul class="step-list" id="steps">
1807
+ <li class="step-empty">No active task</li>
1808
+ </ul>
1809
+ </div>
1810
+
1811
+ <div class="main">
1812
+ <!-- TASK VIEW -->
1813
+ <div class="task-view" id="taskView">
1814
+ <div class="task-header">
1815
+ <input class="task-input" id="taskInput" placeholder="Describe your task... (e.g. List all TypeScript files)" onkeydown="if(event.key==='Enter')startTask()" />
1816
+ <button class="task-btn" id="taskBtn" onclick="startTask()">&#9654; Run</button>
1817
+ </div>
1818
+ <div class="task-running" id="taskRunning"><div class="spinner"></div> Running task...</div>
1819
+ <div class="output-area">
1820
+ <div class="output-tabs">
1821
+ <div class="output-tab active" onclick="switchTab('output', this)">Output</div>
1822
+ <div class="output-tab" onclick="switchTab('raw', this)">Raw</div>
1823
+ </div>
1824
+ <div class="output-content" id="outputArea">
1825
+ <span class="msg-info">Welcome to LinguClaw Dashboard.
1826
+ Type a task above and click Run to get started.
1827
+
1828
+ Examples:
1829
+ - List all TypeScript files in this project
1830
+ - Create a hello.ts file
1831
+ - Analyze the project structure</span></div>
1832
+ <div class="output-content" id="rawArea" style="display:none;"></div>
1833
+ </div>
1834
+ </div>
1835
+
1836
+ <!-- HISTORY VIEW -->
1837
+ <div class="history-view" id="historyView">
1838
+ <h2 style="margin-bottom:1rem;font-size:1.1rem;">Task History</h2>
1839
+ <div id="historyList"><div class="history-empty">No tasks run yet this session.</div></div>
1840
+ </div>
1841
+
1842
+ <!-- SETTINGS VIEW -->
1843
+ <div class="settings-view" id="settingsView">
1844
+ <div class="settings-card">
1845
+ <div class="settings-card-title">&#129302; LLM Provider</div>
1846
+ <div class="form-row">
1847
+ <div class="form-group">
1848
+ <label class="form-label">Provider</label>
1849
+ <select class="form-select" id="settingProvider">
1850
+ <option value="openrouter">OpenRouter</option>
1851
+ <option value="openai">OpenAI</option>
1852
+ <option value="anthropic">Anthropic</option>
1853
+ <option value="ollama">Ollama (Local)</option>
1854
+ <option value="lmstudio">LM Studio (Local)</option>
1855
+ </select>
1856
+ </div>
1857
+ <div class="form-group">
1858
+ <label class="form-label">Model</label>
1859
+ <input class="form-input" id="settingModel" placeholder="openai/gpt-3.5-turbo" />
1860
+ </div>
1861
+ </div>
1862
+ <div class="form-group">
1863
+ <label class="form-label">API Key</label>
1864
+ <input class="form-input" type="password" id="settingApiKey" placeholder="Enter new API key..." />
1865
+ <div class="form-hint">Leave empty to keep current key.</div>
1866
+ </div>
1867
+ <div class="form-row">
1868
+ <div class="form-group">
1869
+ <label class="form-label">Max Tokens</label>
1870
+ <input class="form-input" type="number" id="settingMaxTokens" min="100" max="8000" />
1871
+ </div>
1872
+ <div class="form-group">
1873
+ <label class="form-label">Temperature</label>
1874
+ <input class="form-input" type="number" id="settingTemperature" min="0" max="2" step="0.1" />
1875
+ </div>
1876
+ </div>
1877
+ </div>
1878
+ <div class="settings-card">
1879
+ <div class="settings-card-title">&#128736; System</div>
1880
+ <div class="form-row">
1881
+ <div class="form-group">
1882
+ <label class="form-label">Max Steps</label>
1883
+ <input class="form-input" type="number" id="settingMaxSteps" min="1" max="50" />
1884
+ </div>
1885
+ <div class="form-group">
1886
+ <label class="form-label">Log Level</label>
1887
+ <select class="form-select" id="settingLogLevel">
1888
+ <option value="debug">Debug</option>
1889
+ <option value="info">Info</option>
1890
+ <option value="warn">Warning</option>
1891
+ <option value="error">Error</option>
1892
+ </select>
1893
+ </div>
1894
+ </div>
1895
+ <div class="form-group">
1896
+ <label class="form-label">Safety Mode</label>
1897
+ <select class="form-select" id="settingSafetyMode">
1898
+ <option value="strict">Strict</option>
1899
+ <option value="balanced">Balanced</option>
1900
+ <option value="permissive">Permissive</option>
1901
+ </select>
1902
+ </div>
1903
+ <div class="form-actions">
1904
+ <button class="btn btn-accent" onclick="saveSettings()">Save Settings</button>
1905
+ <button class="btn btn-ghost" onclick="resetSettings()">Reset Defaults</button>
1906
+ </div>
1907
+ <div class="toast" id="settingsToast"></div>
1908
+ </div>
1909
+ </div>
1910
+ </div>
1911
+ </div>
1912
+
1913
+ <script>
1914
+ var outputEl = document.getElementById('outputArea');
1915
+ var rawEl = document.getElementById('rawArea');
1916
+ var stepsEl = document.getElementById('steps');
1917
+ var taskBtn = document.getElementById('taskBtn');
1918
+ var runningEl = document.getElementById('taskRunning');
1919
+ var ws = null;
1920
+ var taskHistory = [];
1921
+ var isRunning = false;
1922
+ var rawLog = '';
1923
+
1924
+ // --- Views ---
1925
+ function showView(view, el) {
1926
+ var items = document.querySelectorAll('.nav-item');
1927
+ for (var i = 0; i < items.length; i++) items[i].classList.remove('active');
1928
+ el.classList.add('active');
1929
+ document.getElementById('taskView').style.display = view === 'task' ? 'flex' : 'none';
1930
+ document.getElementById('settingsView').style.display = view === 'settings' ? 'block' : 'none';
1931
+ document.getElementById('historyView').style.display = view === 'history' ? 'block' : 'none';
1932
+ if (view === 'settings') loadSettings();
1933
+ if (view === 'history') renderHistory();
1934
+ }
1935
+
1936
+ // --- Output Tabs ---
1937
+ function switchTab(tab, el) {
1938
+ var tabs = document.querySelectorAll('.output-tab');
1939
+ for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('active');
1940
+ el.classList.add('active');
1941
+ document.getElementById('outputArea').style.display = tab === 'output' ? 'block' : 'none';
1942
+ document.getElementById('rawArea').style.display = tab === 'raw' ? 'block' : 'none';
1943
+ }
1944
+
1945
+ // --- Output helpers ---
1946
+ function appendOutput(html) { outputEl.innerHTML += html; outputEl.scrollTop = outputEl.scrollHeight; }
1947
+ function setOutput(html) { outputEl.innerHTML = html; }
1948
+ function appendRaw(text) { rawEl.textContent += text; rawLog += text; }
1949
+
1950
+ // --- Task ---
1951
+ function startTask() {
1952
+ var task = document.getElementById('taskInput').value.trim();
1953
+ if (!task || isRunning) return;
1954
+ isRunning = true;
1955
+ taskBtn.disabled = true;
1956
+ runningEl.classList.add('show');
1957
+ setOutput('<span class="msg-task">Task: ' + esc(task) + '</span>\\n');
1958
+ rawEl.textContent = '';
1959
+ rawLog = '';
1960
+ stepsEl.innerHTML = '<li class="step-item running">Planning...</li>';
1961
+
1962
+ fetch('/api/task', {
1963
+ method: 'POST',
1964
+ headers: { 'Content-Type': 'application/json' },
1965
+ body: JSON.stringify({ task: task, max_steps: 15 })
1966
+ }).then(function(r) { return r.json(); }).then(function(result) {
1967
+ if (result.error) {
1968
+ appendOutput('<span class="msg-err">Error: ' + esc(result.error) + '</span>\\n');
1969
+ taskDone(task, 'error');
1970
+ } else {
1971
+ appendOutput('<span class="msg-info">Task queued (ID: ' + result.task_id + ')</span>\\n');
1972
+ }
1973
+ }).catch(function(e) {
1974
+ appendOutput('<span class="msg-err">Network error: ' + esc(e.message) + '</span>\\n');
1975
+ taskDone(task, 'error');
1976
+ });
1977
+ }
1978
+
1979
+ function taskDone(task, status) {
1980
+ isRunning = false;
1981
+ taskBtn.disabled = false;
1982
+ runningEl.classList.remove('show');
1983
+ taskHistory.unshift({ task: task, time: new Date().toLocaleTimeString(), status: status, log: rawLog });
1984
+ }
1985
+
1986
+ function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
1987
+
1988
+ // --- History ---
1989
+ function renderHistory() {
1990
+ var el = document.getElementById('historyList');
1991
+ if (taskHistory.length === 0) { el.innerHTML = '<div class="history-empty">No tasks run yet this session.</div>'; return; }
1992
+ var html = '';
1993
+ for (var i = 0; i < taskHistory.length; i++) {
1994
+ var h = taskHistory[i];
1995
+ var icon = h.status === 'done' ? '<span style="color:var(--green)">&#10003;</span>' : '<span style="color:var(--red)">&#10007;</span>';
1996
+ html += '<div class="history-item" onclick="showHistoryDetail(' + i + ')">';
1997
+ html += '<div class="history-item-task">' + icon + ' ' + esc(h.task) + '</div>';
1998
+ html += '<div class="history-item-meta"><span>' + h.time + '</span><span>' + h.status + '</span></div>';
1999
+ html += '</div>';
2000
+ }
2001
+ el.innerHTML = html;
2002
+ }
2003
+ function showHistoryDetail(idx) {
2004
+ var h = taskHistory[idx];
2005
+ showView('task', document.querySelector('.nav-item'));
2006
+ setOutput('<span class="msg-task">[History] ' + esc(h.task) + '</span>\\n' + esc(h.log));
2007
+ }
2008
+
2009
+ // --- Settings ---
2010
+ function loadSettings() {
2011
+ fetch('/api/settings').then(function(r) { return r.json(); }).then(function(s) {
2012
+ document.getElementById('settingProvider').value = s.llm.provider || '';
2013
+ document.getElementById('settingModel').value = s.llm.model || '';
2014
+ document.getElementById('settingMaxTokens').value = s.llm.maxTokens || 1000;
2015
+ document.getElementById('settingTemperature').value = s.llm.temperature || 0.7;
2016
+ document.getElementById('settingMaxSteps').value = s.system.maxSteps || 15;
2017
+ document.getElementById('settingLogLevel').value = s.system.logLevel || 'info';
2018
+ document.getElementById('settingSafetyMode').value = s.system.safetyMode || 'balanced';
2019
+ document.getElementById('modelBadge').textContent = (s.llm.provider || '') + '/' + (s.llm.model || '');
2020
+ }).catch(function(e) { console.error('Settings load error:', e); });
2021
+ }
2022
+ function saveSettings() {
2023
+ var toast = document.getElementById('settingsToast');
2024
+ toast.className = 'toast';
2025
+ var data = {
2026
+ llm: {
2027
+ provider: document.getElementById('settingProvider').value,
2028
+ model: document.getElementById('settingModel').value,
2029
+ maxTokens: parseInt(document.getElementById('settingMaxTokens').value),
2030
+ temperature: parseFloat(document.getElementById('settingTemperature').value)
2031
+ },
2032
+ system: {
2033
+ maxSteps: parseInt(document.getElementById('settingMaxSteps').value),
2034
+ logLevel: document.getElementById('settingLogLevel').value,f
2035
+ safetyMode: document.getElementById('settingSafetyMode').value
2036
+ }
2037
+ };
2038
+ var apiKey = document.getElementById('settingApiKey').value;
2039
+ if (apiKey) data.llm.apiKey = apiKey;
2040
+ fetch('/api/settings', {
2041
+ method: 'POST',
2042
+ headers: { 'Content-Type': 'application/json' },
2043
+ body: JSON.stringify(data)
2044
+ }).then(function(r) {
2045
+ if (r.ok) {
2046
+ toast.textContent = 'Settings saved successfully';
2047
+ toast.className = 'toast show ok';
2048
+ document.getElementById('settingApiKey').value = '';
2049
+ document.getElementById('modelBadge').textContent = data.llm.provider + '/' + data.llm.model;
2050
+ } else {
2051
+ toast.textContent = 'Failed to save settings';
2052
+ toast.className = 'toast show err';
2053
+ }
2054
+ }).catch(function(e) {
2055
+ toast.textContent = 'Error: ' + e.message;
2056
+ toast.className = 'toast show err';
2057
+ });
2058
+ }
2059
+ function resetSettings() {
2060
+ if (!confirm('Reset all settings to defaults?')) return;
2061
+ fetch('/api/settings', {
2062
+ method: 'POST',
2063
+ headers: { 'Content-Type': 'application/json' },
2064
+ body: JSON.stringify({ llm: { provider: 'openrouter', model: 'openai/gpt-3.5-turbo', maxTokens: 1000, temperature: 0.7 }, system: { maxSteps: 15, logLevel: 'info', safetyMode: 'balanced' } })
2065
+ }).then(function() { loadSettings(); var t = document.getElementById('settingsToast'); t.textContent = 'Reset to defaults'; t.className = 'toast show ok'; });
2066
+ }
2067
+
2068
+ // --- WebSocket ---
2069
+ try {
2070
+ ws = new WebSocket('ws://' + window.location.host);
2071
+ ws.onopen = function() {
2072
+ document.getElementById('wsDot').className = 'status-dot online';
2073
+ document.getElementById('wsStatus').textContent = 'Connected';
2074
+ };
2075
+ ws.onclose = function() {
2076
+ document.getElementById('wsDot').className = 'status-dot offline';
2077
+ document.getElementById('wsStatus').textContent = 'Disconnected';
2078
+ };
2079
+ ws.onerror = function() {
2080
+ document.getElementById('wsDot').className = 'status-dot offline';
2081
+ document.getElementById('wsStatus').textContent = 'Error';
2082
+ };
2083
+ ws.onmessage = function(ev) {
2084
+ var msg = JSON.parse(ev.data);
2085
+ if (msg.type === 'state_update') {
2086
+ appendOutput('<span class="msg-step">Running: ' + esc(msg.payload.task) + '</span>\\n');
2087
+ appendRaw('Task: ' + msg.payload.task + '\\n');
2088
+ } else if (msg.type === 'step_update') {
2089
+ var s = msg.payload;
2090
+ appendOutput('<span class="msg-step">[' + esc(s.id) + '] ' + esc(s.description) + '</span>\\n');
2091
+ appendRaw('[Step] ' + s.id + ': ' + s.description + '\\n');
2092
+ updateSteps(msg.payload.steps || []);
2093
+ } else if (msg.type === 'log') {
2094
+ appendOutput('<span class="msg-info">' + esc(msg.payload) + '</span>\\n');
2095
+ appendRaw(msg.payload + '\\n');
2096
+ } else if (msg.type === 'complete') {
2097
+ var res = msg.payload.result || '';
2098
+ appendOutput('\\n<span class="msg-ok">--- Task Complete ---</span>\\n' + esc(res) + '\\n');
2099
+ appendRaw('\\nComplete:\\n' + res + '\\n');
2100
+ taskDone(document.getElementById('taskInput').value, 'done');
2101
+ stepsEl.innerHTML = '<li class="step-item completed">All steps completed</li>';
2102
+ } else if (msg.type === 'error') {
2103
+ appendOutput('<span class="msg-err">Error: ' + esc(msg.payload.error) + '</span>\\n');
2104
+ appendRaw('Error: ' + msg.payload.error + '\\n');
2105
+ taskDone(document.getElementById('taskInput').value, 'error');
2106
+ }
2107
+ };
2108
+ setInterval(function() {
2109
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
2110
+ }, 30000);
2111
+ } catch(e) { console.log('WS init failed:', e); }
2112
+
2113
+ function updateSteps(stepList) {
2114
+ if (!stepList || stepList.length === 0) return;
2115
+ var html = '';
2116
+ for (var i = 0; i < stepList.length; i++) {
2117
+ var s = stepList[i];
2118
+ var cls = s.status === 'completed' ? 'completed' : s.status === 'failed' ? 'failed' : s.status === 'in_progress' ? 'running' : '';
2119
+ html += '<li class="step-item ' + cls + '">' + esc(s.id) + ': ' + esc(s.description || '').substring(0, 40) + '</li>';
2120
+ }
2121
+ stepsEl.innerHTML = html;
2122
+ }
2123
+
2124
+ // Load model badge on start
2125
+ loadSettings();
2126
+ </script>
2127
+ </body>
2128
+ </html>`;
2129
+ }
2130
+ }
2131
+ exports.WebUIManager = WebUIManager;
2132
+ /**
2133
+ * Run web UI server
2134
+ */
2135
+ async function runWebUI(projectRoot, host, port) {
2136
+ const manager = new WebUIManager(projectRoot, host, port);
2137
+ await manager.start();
2138
+ }
2139
+ //# sourceMappingURL=web.js.map