@madh-io/alfred-ai 0.2.0 → 0.3.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 (2) hide show
  1. package/bundle/index.js +1055 -53
  2. package/package.json +14 -12
package/bundle/index.js CHANGED
@@ -11,7 +11,7 @@ var __export = (target, all) => {
11
11
 
12
12
  // ../config/dist/schema.js
13
13
  import { z } from "zod";
14
- var TelegramConfigSchema, DiscordConfigSchema, WhatsAppConfigSchema, MatrixConfigSchema, SignalConfigSchema, StorageConfigSchema, LoggerConfigSchema, SecurityConfigSchema, LLMProviderConfigSchema, AlfredConfigSchema;
14
+ var TelegramConfigSchema, DiscordConfigSchema, WhatsAppConfigSchema, MatrixConfigSchema, SignalConfigSchema, StorageConfigSchema, LoggerConfigSchema, SecurityConfigSchema, LLMProviderConfigSchema, SearchConfigSchema, EmailConfigSchema, AlfredConfigSchema;
15
15
  var init_schema = __esm({
16
16
  "../config/dist/schema.js"() {
17
17
  "use strict";
@@ -59,6 +59,27 @@ var init_schema = __esm({
59
59
  temperature: z.number().optional(),
60
60
  maxTokens: z.number().optional()
61
61
  });
62
+ SearchConfigSchema = z.object({
63
+ provider: z.enum(["brave", "searxng", "tavily", "duckduckgo"]),
64
+ apiKey: z.string().optional(),
65
+ baseUrl: z.string().optional()
66
+ });
67
+ EmailConfigSchema = z.object({
68
+ imap: z.object({
69
+ host: z.string(),
70
+ port: z.number(),
71
+ secure: z.boolean()
72
+ }),
73
+ smtp: z.object({
74
+ host: z.string(),
75
+ port: z.number(),
76
+ secure: z.boolean()
77
+ }),
78
+ auth: z.object({
79
+ user: z.string(),
80
+ pass: z.string()
81
+ })
82
+ });
62
83
  AlfredConfigSchema = z.object({
63
84
  name: z.string(),
64
85
  telegram: TelegramConfigSchema,
@@ -69,7 +90,9 @@ var init_schema = __esm({
69
90
  llm: LLMProviderConfigSchema,
70
91
  storage: StorageConfigSchema,
71
92
  logger: LoggerConfigSchema,
72
- security: SecurityConfigSchema
93
+ security: SecurityConfigSchema,
94
+ search: SearchConfigSchema.optional(),
95
+ email: EmailConfigSchema.optional()
73
96
  });
74
97
  }
75
98
  });
@@ -184,7 +207,12 @@ var init_loader = __esm({
184
207
  ALFRED_LLM_BASE_URL: ["llm", "baseUrl"],
185
208
  ALFRED_STORAGE_PATH: ["storage", "path"],
186
209
  ALFRED_LOG_LEVEL: ["logger", "level"],
187
- ALFRED_OWNER_USER_ID: ["security", "ownerUserId"]
210
+ ALFRED_OWNER_USER_ID: ["security", "ownerUserId"],
211
+ ALFRED_SEARCH_PROVIDER: ["search", "provider"],
212
+ ALFRED_SEARCH_API_KEY: ["search", "apiKey"],
213
+ ALFRED_SEARCH_BASE_URL: ["search", "baseUrl"],
214
+ ALFRED_EMAIL_USER: ["email", "auth", "user"],
215
+ ALFRED_EMAIL_PASS: ["email", "auth", "pass"]
188
216
  };
189
217
  ConfigLoader = class {
190
218
  loadConfig(configPath) {
@@ -255,6 +283,131 @@ var init_dist2 = __esm({
255
283
  }
256
284
  });
257
285
 
286
+ // ../storage/dist/migrations/migrator.js
287
+ var Migrator;
288
+ var init_migrator = __esm({
289
+ "../storage/dist/migrations/migrator.js"() {
290
+ "use strict";
291
+ Migrator = class {
292
+ db;
293
+ constructor(db) {
294
+ this.db = db;
295
+ this.ensureMigrationsTable();
296
+ }
297
+ ensureMigrationsTable() {
298
+ this.db.exec(`
299
+ CREATE TABLE IF NOT EXISTS _migrations (
300
+ version INTEGER PRIMARY KEY,
301
+ description TEXT,
302
+ applied_at TEXT NOT NULL
303
+ )
304
+ `);
305
+ }
306
+ /** Get current schema version */
307
+ getCurrentVersion() {
308
+ const row = this.db.prepare("SELECT MAX(version) as version FROM _migrations").get();
309
+ return row?.version ?? 0;
310
+ }
311
+ /** Run all pending migrations */
312
+ migrate(migrations) {
313
+ const sorted = [...migrations].sort((a, b) => a.version - b.version);
314
+ const currentVersion = this.getCurrentVersion();
315
+ for (const migration of sorted) {
316
+ if (migration.version <= currentVersion) {
317
+ continue;
318
+ }
319
+ const run = this.db.transaction(() => {
320
+ migration.up(this.db);
321
+ this.db.prepare("INSERT INTO _migrations (version, description, applied_at) VALUES (?, ?, ?)").run(migration.version, migration.description, (/* @__PURE__ */ new Date()).toISOString());
322
+ });
323
+ run();
324
+ }
325
+ }
326
+ /** Get list of applied migrations */
327
+ getAppliedMigrations() {
328
+ const rows = this.db.prepare("SELECT version, applied_at FROM _migrations ORDER BY version ASC").all();
329
+ return rows.map((row) => ({
330
+ version: row.version,
331
+ appliedAt: row.applied_at
332
+ }));
333
+ }
334
+ };
335
+ }
336
+ });
337
+
338
+ // ../storage/dist/migrations/index.js
339
+ var MIGRATIONS;
340
+ var init_migrations = __esm({
341
+ "../storage/dist/migrations/index.js"() {
342
+ "use strict";
343
+ init_migrator();
344
+ MIGRATIONS = [
345
+ {
346
+ version: 1,
347
+ description: "Initial schema \u2014 conversations, messages, users, audit_log",
348
+ up(_db) {
349
+ }
350
+ },
351
+ {
352
+ version: 2,
353
+ description: "Add plugin_skills table for tracking loaded external plugins",
354
+ up(db) {
355
+ db.exec(`
356
+ CREATE TABLE IF NOT EXISTS plugin_skills (
357
+ name TEXT PRIMARY KEY,
358
+ file_path TEXT NOT NULL,
359
+ version TEXT NOT NULL,
360
+ loaded_at TEXT NOT NULL DEFAULT (datetime('now')),
361
+ enabled INTEGER NOT NULL DEFAULT 1
362
+ )
363
+ `);
364
+ }
365
+ },
366
+ {
367
+ version: 3,
368
+ description: "Add memories and reminders tables",
369
+ up(db) {
370
+ db.exec(`
371
+ CREATE TABLE IF NOT EXISTS memories (
372
+ id TEXT PRIMARY KEY,
373
+ user_id TEXT NOT NULL,
374
+ key TEXT NOT NULL,
375
+ value TEXT NOT NULL,
376
+ category TEXT NOT NULL DEFAULT 'general',
377
+ created_at TEXT NOT NULL,
378
+ updated_at TEXT NOT NULL,
379
+ UNIQUE(user_id, key)
380
+ );
381
+
382
+ CREATE INDEX IF NOT EXISTS idx_memories_user
383
+ ON memories(user_id, updated_at DESC);
384
+
385
+ CREATE INDEX IF NOT EXISTS idx_memories_user_category
386
+ ON memories(user_id, category);
387
+
388
+ CREATE TABLE IF NOT EXISTS reminders (
389
+ id TEXT PRIMARY KEY,
390
+ user_id TEXT NOT NULL,
391
+ platform TEXT NOT NULL,
392
+ chat_id TEXT NOT NULL,
393
+ message TEXT NOT NULL,
394
+ trigger_at TEXT NOT NULL,
395
+ created_at TEXT NOT NULL,
396
+ fired INTEGER NOT NULL DEFAULT 0
397
+ );
398
+
399
+ CREATE INDEX IF NOT EXISTS idx_reminders_due
400
+ ON reminders(fired, trigger_at);
401
+
402
+ CREATE INDEX IF NOT EXISTS idx_reminders_user
403
+ ON reminders(user_id, fired);
404
+ `);
405
+ }
406
+ }
407
+ ];
408
+ }
409
+ });
410
+
258
411
  // ../storage/dist/database.js
259
412
  import BetterSqlite3 from "better-sqlite3";
260
413
  import fs2 from "node:fs";
@@ -263,6 +416,8 @@ var Database;
263
416
  var init_database = __esm({
264
417
  "../storage/dist/database.js"() {
265
418
  "use strict";
419
+ init_migrator();
420
+ init_migrations();
266
421
  Database = class {
267
422
  db;
268
423
  constructor(dbPath) {
@@ -271,6 +426,7 @@ var init_database = __esm({
271
426
  this.db = new BetterSqlite3(dbPath);
272
427
  this.db.pragma("journal_mode = WAL");
273
428
  this.initTables();
429
+ this.runMigrations();
274
430
  }
275
431
  initTables() {
276
432
  this.db.exec(`
@@ -325,6 +481,10 @@ var init_database = __esm({
325
481
  ON users(platform, platform_user_id);
326
482
  `);
327
483
  }
484
+ runMigrations() {
485
+ const migrator = new Migrator(this.db);
486
+ migrator.migrate(MIGRATIONS);
487
+ }
328
488
  getDb() {
329
489
  return this.db;
330
490
  }
@@ -630,21 +790,6 @@ var init_memory_repository = __esm({
630
790
  }
631
791
  });
632
792
 
633
- // ../storage/dist/migrations/migrator.js
634
- var init_migrator = __esm({
635
- "../storage/dist/migrations/migrator.js"() {
636
- "use strict";
637
- }
638
- });
639
-
640
- // ../storage/dist/migrations/index.js
641
- var init_migrations = __esm({
642
- "../storage/dist/migrations/index.js"() {
643
- "use strict";
644
- init_migrator();
645
- }
646
- });
647
-
648
793
  // ../storage/dist/repositories/reminder-repository.js
649
794
  import { randomUUID as randomUUID2 } from "node:crypto";
650
795
  var ReminderRepository;
@@ -721,15 +866,56 @@ var init_dist3 = __esm({
721
866
  });
722
867
 
723
868
  // ../llm/dist/provider.js
724
- var LLMProvider;
869
+ function lookupContextWindow(model) {
870
+ if (KNOWN_CONTEXT_WINDOWS[model])
871
+ return KNOWN_CONTEXT_WINDOWS[model];
872
+ for (const [key, value] of Object.entries(KNOWN_CONTEXT_WINDOWS)) {
873
+ if (model.startsWith(key))
874
+ return value;
875
+ }
876
+ return void 0;
877
+ }
878
+ var KNOWN_CONTEXT_WINDOWS, DEFAULT_CONTEXT_WINDOW, LLMProvider;
725
879
  var init_provider = __esm({
726
880
  "../llm/dist/provider.js"() {
727
881
  "use strict";
882
+ KNOWN_CONTEXT_WINDOWS = {
883
+ // Anthropic
884
+ "claude-opus-4-20250514": { maxInputTokens: 2e5, maxOutputTokens: 32e3 },
885
+ "claude-sonnet-4-20250514": { maxInputTokens: 2e5, maxOutputTokens: 16e3 },
886
+ "claude-haiku-3-5-20241022": { maxInputTokens: 2e5, maxOutputTokens: 8192 },
887
+ // OpenAI
888
+ "gpt-4o": { maxInputTokens: 128e3, maxOutputTokens: 16384 },
889
+ "gpt-4o-mini": { maxInputTokens: 128e3, maxOutputTokens: 16384 },
890
+ "gpt-4-turbo": { maxInputTokens: 128e3, maxOutputTokens: 4096 },
891
+ "gpt-4": { maxInputTokens: 8192, maxOutputTokens: 4096 },
892
+ "gpt-3.5-turbo": { maxInputTokens: 16384, maxOutputTokens: 4096 },
893
+ "o1": { maxInputTokens: 2e5, maxOutputTokens: 1e5 },
894
+ "o1-mini": { maxInputTokens: 128e3, maxOutputTokens: 65536 },
895
+ "o3-mini": { maxInputTokens: 2e5, maxOutputTokens: 1e5 },
896
+ // Common Ollama models
897
+ "llama3.2": { maxInputTokens: 128e3, maxOutputTokens: 4096 },
898
+ "llama3.1": { maxInputTokens: 128e3, maxOutputTokens: 4096 },
899
+ "llama3": { maxInputTokens: 8192, maxOutputTokens: 4096 },
900
+ "mistral": { maxInputTokens: 32e3, maxOutputTokens: 4096 },
901
+ "mistral-small": { maxInputTokens: 32e3, maxOutputTokens: 4096 },
902
+ "mixtral": { maxInputTokens: 32e3, maxOutputTokens: 4096 },
903
+ "gemma2": { maxInputTokens: 8192, maxOutputTokens: 4096 },
904
+ "qwen2.5": { maxInputTokens: 128e3, maxOutputTokens: 4096 },
905
+ "phi3": { maxInputTokens: 128e3, maxOutputTokens: 4096 },
906
+ "deepseek-r1": { maxInputTokens: 128e3, maxOutputTokens: 8192 },
907
+ "command-r": { maxInputTokens: 128e3, maxOutputTokens: 4096 }
908
+ };
909
+ DEFAULT_CONTEXT_WINDOW = { maxInputTokens: 8192, maxOutputTokens: 4096 };
728
910
  LLMProvider = class {
729
911
  config;
912
+ contextWindow = DEFAULT_CONTEXT_WINDOW;
730
913
  constructor(config) {
731
914
  this.config = config;
732
915
  }
916
+ getContextWindow() {
917
+ return this.contextWindow;
918
+ }
733
919
  };
734
920
  }
735
921
  });
@@ -748,6 +934,9 @@ var init_anthropic = __esm({
748
934
  }
749
935
  async initialize() {
750
936
  this.client = new Anthropic({ apiKey: this.config.apiKey });
937
+ const cw = lookupContextWindow(this.config.model);
938
+ if (cw)
939
+ this.contextWindow = cw;
751
940
  }
752
941
  async complete(request) {
753
942
  const messages = this.mapMessages(request.messages);
@@ -886,6 +1075,9 @@ var init_openai = __esm({
886
1075
  apiKey: this.config.apiKey,
887
1076
  baseURL: this.config.baseUrl
888
1077
  });
1078
+ const cw = lookupContextWindow(this.config.model);
1079
+ if (cw)
1080
+ this.contextWindow = cw;
889
1081
  }
890
1082
  async complete(request) {
891
1083
  const messages = this.mapMessages(request.messages, request.system);
@@ -1132,6 +1324,34 @@ var init_ollama = __esm({
1132
1324
  const raw = this.config.baseUrl ?? "http://localhost:11434";
1133
1325
  this.baseUrl = raw.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
1134
1326
  this.apiKey = this.config.apiKey ?? "";
1327
+ const cw = lookupContextWindow(this.config.model);
1328
+ if (cw) {
1329
+ this.contextWindow = cw;
1330
+ } else {
1331
+ await this.fetchModelContextWindow();
1332
+ }
1333
+ }
1334
+ async fetchModelContextWindow() {
1335
+ try {
1336
+ const res = await fetch(`${this.baseUrl}/api/show`, {
1337
+ method: "POST",
1338
+ headers: this.getHeaders(),
1339
+ body: JSON.stringify({ name: this.config.model })
1340
+ });
1341
+ if (!res.ok)
1342
+ return;
1343
+ const data = await res.json();
1344
+ const info = data.model_info ?? {};
1345
+ const ctxKey = Object.keys(info).find((k) => k.includes("context_length") || k === "num_ctx");
1346
+ const ctxLen = ctxKey ? Number(info[ctxKey]) : 0;
1347
+ if (ctxLen > 0) {
1348
+ this.contextWindow = {
1349
+ maxInputTokens: ctxLen,
1350
+ maxOutputTokens: Math.min(ctxLen, 4096)
1351
+ };
1352
+ }
1353
+ } catch {
1354
+ }
1135
1355
  }
1136
1356
  getHeaders() {
1137
1357
  const headers = { "Content-Type": "application/json" };
@@ -1414,6 +1634,29 @@ var init_provider_factory = __esm({
1414
1634
  });
1415
1635
 
1416
1636
  // ../llm/dist/prompt-builder.js
1637
+ function estimateTokens(text) {
1638
+ return Math.ceil(text.length / 3.5);
1639
+ }
1640
+ function estimateMessageTokens(msg) {
1641
+ if (typeof msg.content === "string") {
1642
+ return estimateTokens(msg.content) + 4;
1643
+ }
1644
+ let tokens = 4;
1645
+ for (const block of msg.content) {
1646
+ switch (block.type) {
1647
+ case "text":
1648
+ tokens += estimateTokens(block.text);
1649
+ break;
1650
+ case "tool_use":
1651
+ tokens += estimateTokens(block.name) + estimateTokens(JSON.stringify(block.input));
1652
+ break;
1653
+ case "tool_result":
1654
+ tokens += estimateTokens(block.content);
1655
+ break;
1656
+ }
1657
+ }
1658
+ return tokens;
1659
+ }
1417
1660
  var PromptBuilder;
1418
1661
  var init_prompt_builder = __esm({
1419
1662
  "../llm/dist/prompt-builder.js"() {
@@ -2072,31 +2315,212 @@ var init_web_search = __esm({
2072
2315
  "use strict";
2073
2316
  init_skill();
2074
2317
  WebSearchSkill = class extends Skill {
2318
+ config;
2075
2319
  metadata = {
2076
2320
  name: "web_search",
2077
- description: "Search the web (placeholder \u2014 returns mock results)",
2321
+ description: "Search the web for current information",
2078
2322
  riskLevel: "read",
2079
- version: "0.1.0",
2323
+ version: "1.1.0",
2080
2324
  inputSchema: {
2081
2325
  type: "object",
2082
2326
  properties: {
2083
2327
  query: {
2084
2328
  type: "string",
2085
- description: "Search query"
2329
+ description: "The search query"
2330
+ },
2331
+ count: {
2332
+ type: "number",
2333
+ description: "Number of results to return (default: 5, max: 10)"
2086
2334
  }
2087
2335
  },
2088
2336
  required: ["query"]
2089
2337
  }
2090
2338
  };
2339
+ constructor(config) {
2340
+ super();
2341
+ this.config = config;
2342
+ }
2091
2343
  async execute(input2, _context) {
2092
2344
  const query = input2.query;
2093
- return {
2094
- success: true,
2095
- data: {
2096
- note: "Web search is not yet connected to a search API"
2097
- },
2098
- display: `Web search for "${query}" is not yet implemented. This skill will be connected to a search API in a future update.`
2099
- };
2345
+ const count = Math.min(Math.max(1, input2.count || 5), 10);
2346
+ if (!query || typeof query !== "string") {
2347
+ return { success: false, error: 'Invalid input: "query" must be a non-empty string' };
2348
+ }
2349
+ if (!this.config) {
2350
+ return {
2351
+ success: false,
2352
+ error: "Web search is not configured. Run `alfred setup` to configure a search provider."
2353
+ };
2354
+ }
2355
+ const needsKey = this.config.provider === "brave" || this.config.provider === "tavily";
2356
+ if (needsKey && !this.config.apiKey) {
2357
+ return {
2358
+ success: false,
2359
+ error: `Web search requires an API key for ${this.config.provider}. Run \`alfred setup\` to configure it.`
2360
+ };
2361
+ }
2362
+ try {
2363
+ let results;
2364
+ switch (this.config.provider) {
2365
+ case "brave":
2366
+ results = await this.searchBrave(query, count);
2367
+ break;
2368
+ case "searxng":
2369
+ results = await this.searchSearXNG(query, count);
2370
+ break;
2371
+ case "tavily":
2372
+ results = await this.searchTavily(query, count);
2373
+ break;
2374
+ case "duckduckgo":
2375
+ results = await this.searchDuckDuckGo(query, count);
2376
+ break;
2377
+ default:
2378
+ return { success: false, error: `Unknown search provider: ${this.config.provider}` };
2379
+ }
2380
+ if (results.length === 0) {
2381
+ return {
2382
+ success: true,
2383
+ data: { results: [] },
2384
+ display: `No results found for "${query}".`
2385
+ };
2386
+ }
2387
+ const display = results.map((r, i) => `${i + 1}. **${r.title}**
2388
+ ${r.url}
2389
+ ${r.snippet}`).join("\n\n");
2390
+ return {
2391
+ success: true,
2392
+ data: { query, results },
2393
+ display: `Search results for "${query}":
2394
+
2395
+ ${display}`
2396
+ };
2397
+ } catch (err) {
2398
+ const msg = err instanceof Error ? err.message : String(err);
2399
+ return { success: false, error: `Search failed: ${msg}` };
2400
+ }
2401
+ }
2402
+ // ── Brave Search ──────────────────────────────────────────────
2403
+ async searchBrave(query, count) {
2404
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
2405
+ url.searchParams.set("q", query);
2406
+ url.searchParams.set("count", String(count));
2407
+ const response = await fetch(url.toString(), {
2408
+ headers: {
2409
+ "Accept": "application/json",
2410
+ "Accept-Encoding": "gzip",
2411
+ "X-Subscription-Token": this.config.apiKey
2412
+ }
2413
+ });
2414
+ if (!response.ok) {
2415
+ throw new Error(`Brave Search API returned ${response.status}: ${response.statusText}`);
2416
+ }
2417
+ const data = await response.json();
2418
+ return (data.web?.results ?? []).slice(0, count).map((r) => ({
2419
+ title: r.title,
2420
+ url: r.url,
2421
+ snippet: r.description
2422
+ }));
2423
+ }
2424
+ // ── SearXNG ───────────────────────────────────────────────────
2425
+ async searchSearXNG(query, count) {
2426
+ const base = (this.config.baseUrl ?? "http://localhost:8080").replace(/\/+$/, "");
2427
+ const url = new URL(`${base}/search`);
2428
+ url.searchParams.set("q", query);
2429
+ url.searchParams.set("format", "json");
2430
+ url.searchParams.set("pageno", "1");
2431
+ const response = await fetch(url.toString(), {
2432
+ headers: { "Accept": "application/json" }
2433
+ });
2434
+ if (!response.ok) {
2435
+ throw new Error(`SearXNG returned ${response.status}: ${response.statusText}`);
2436
+ }
2437
+ const data = await response.json();
2438
+ return (data.results ?? []).slice(0, count).map((r) => ({
2439
+ title: r.title,
2440
+ url: r.url,
2441
+ snippet: r.content
2442
+ }));
2443
+ }
2444
+ // ── Tavily ────────────────────────────────────────────────────
2445
+ async searchTavily(query, count) {
2446
+ const response = await fetch("https://api.tavily.com/search", {
2447
+ method: "POST",
2448
+ headers: { "Content-Type": "application/json" },
2449
+ body: JSON.stringify({
2450
+ api_key: this.config.apiKey,
2451
+ query,
2452
+ max_results: count,
2453
+ include_answer: false
2454
+ })
2455
+ });
2456
+ if (!response.ok) {
2457
+ throw new Error(`Tavily API returned ${response.status}: ${response.statusText}`);
2458
+ }
2459
+ const data = await response.json();
2460
+ return (data.results ?? []).slice(0, count).map((r) => ({
2461
+ title: r.title,
2462
+ url: r.url,
2463
+ snippet: r.content
2464
+ }));
2465
+ }
2466
+ // ── DuckDuckGo (HTML scraping, no API key) ────────────────────
2467
+ async searchDuckDuckGo(query, count) {
2468
+ const url = new URL("https://html.duckduckgo.com/html/");
2469
+ url.searchParams.set("q", query);
2470
+ const response = await fetch(url.toString(), {
2471
+ headers: {
2472
+ "User-Agent": "Mozilla/5.0 (compatible; Alfred/1.0)"
2473
+ }
2474
+ });
2475
+ if (!response.ok) {
2476
+ throw new Error(`DuckDuckGo returned ${response.status}: ${response.statusText}`);
2477
+ }
2478
+ const html = await response.text();
2479
+ return this.parseDuckDuckGoHtml(html, count);
2480
+ }
2481
+ parseDuckDuckGoHtml(html, count) {
2482
+ const results = [];
2483
+ const linkRegex = /<a[^>]+class="result__a"[^>]+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
2484
+ const snippetRegex = /<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
2485
+ const links = [];
2486
+ let match;
2487
+ while ((match = linkRegex.exec(html)) !== null) {
2488
+ const rawUrl = match[1];
2489
+ const title = this.stripHtml(match[2]).trim();
2490
+ const actualUrl = this.extractDdgUrl(rawUrl);
2491
+ if (title && actualUrl) {
2492
+ links.push({ url: actualUrl, title });
2493
+ }
2494
+ }
2495
+ const snippets = [];
2496
+ while ((match = snippetRegex.exec(html)) !== null) {
2497
+ snippets.push(this.stripHtml(match[1]).trim());
2498
+ }
2499
+ for (let i = 0; i < Math.min(links.length, count); i++) {
2500
+ results.push({
2501
+ title: links[i].title,
2502
+ url: links[i].url,
2503
+ snippet: snippets[i] ?? ""
2504
+ });
2505
+ }
2506
+ return results;
2507
+ }
2508
+ extractDdgUrl(rawUrl) {
2509
+ try {
2510
+ if (rawUrl.includes("uddg=")) {
2511
+ const parsed = new URL(rawUrl, "https://duckduckgo.com");
2512
+ const uddg = parsed.searchParams.get("uddg");
2513
+ if (uddg)
2514
+ return decodeURIComponent(uddg);
2515
+ }
2516
+ } catch {
2517
+ }
2518
+ if (rawUrl.startsWith("http"))
2519
+ return rawUrl;
2520
+ return "";
2521
+ }
2522
+ stripHtml(html) {
2523
+ return html.replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ");
2100
2524
  }
2101
2525
  };
2102
2526
  }
@@ -3026,6 +3450,328 @@ Additional context: ${additionalContext}`;
3026
3450
  }
3027
3451
  });
3028
3452
 
3453
+ // ../skills/dist/built-in/email.js
3454
+ var EmailSkill;
3455
+ var init_email = __esm({
3456
+ "../skills/dist/built-in/email.js"() {
3457
+ "use strict";
3458
+ init_skill();
3459
+ EmailSkill = class extends Skill {
3460
+ config;
3461
+ metadata = {
3462
+ name: "email",
3463
+ description: "Read, search, and send emails via IMAP/SMTP",
3464
+ riskLevel: "write",
3465
+ version: "1.0.0",
3466
+ inputSchema: {
3467
+ type: "object",
3468
+ properties: {
3469
+ action: {
3470
+ type: "string",
3471
+ enum: ["inbox", "read", "search", "send"],
3472
+ description: "The email action to perform"
3473
+ },
3474
+ count: {
3475
+ type: "number",
3476
+ description: "Number of emails to fetch (for inbox, default: 10)"
3477
+ },
3478
+ messageId: {
3479
+ type: "string",
3480
+ description: "Message sequence number to read (for read action)"
3481
+ },
3482
+ query: {
3483
+ type: "string",
3484
+ description: "Search query (for search action)"
3485
+ },
3486
+ to: {
3487
+ type: "string",
3488
+ description: "Recipient email address (for send action)"
3489
+ },
3490
+ subject: {
3491
+ type: "string",
3492
+ description: "Email subject (for send action)"
3493
+ },
3494
+ body: {
3495
+ type: "string",
3496
+ description: "Email body text (for send action)"
3497
+ }
3498
+ },
3499
+ required: ["action"]
3500
+ }
3501
+ };
3502
+ constructor(config) {
3503
+ super();
3504
+ this.config = config;
3505
+ }
3506
+ async execute(input2, _context) {
3507
+ if (!this.config) {
3508
+ return {
3509
+ success: false,
3510
+ error: "Email is not configured. Run `alfred setup` to configure email access."
3511
+ };
3512
+ }
3513
+ const action = input2.action;
3514
+ try {
3515
+ switch (action) {
3516
+ case "inbox":
3517
+ return await this.fetchInbox(input2.count);
3518
+ case "read":
3519
+ return await this.readMessage(input2.messageId);
3520
+ case "search":
3521
+ return await this.searchMessages(input2.query, input2.count);
3522
+ case "send":
3523
+ return await this.sendMessage(input2.to, input2.subject, input2.body);
3524
+ default:
3525
+ return { success: false, error: `Unknown action: ${action}. Use: inbox, read, search, send` };
3526
+ }
3527
+ } catch (err) {
3528
+ const msg = err instanceof Error ? err.message : String(err);
3529
+ return { success: false, error: `Email error: ${msg}` };
3530
+ }
3531
+ }
3532
+ // ── IMAP: Fetch inbox ──────────────────────────────────────────
3533
+ async fetchInbox(count) {
3534
+ const limit = Math.min(Math.max(1, count ?? 10), 50);
3535
+ const { ImapFlow } = await import("imapflow");
3536
+ const client = new ImapFlow({
3537
+ host: this.config.imap.host,
3538
+ port: this.config.imap.port,
3539
+ secure: this.config.imap.secure,
3540
+ auth: this.config.auth,
3541
+ logger: false
3542
+ });
3543
+ try {
3544
+ await client.connect();
3545
+ const lock = await client.getMailboxLock("INBOX");
3546
+ try {
3547
+ const messages = [];
3548
+ const mb = client.mailbox;
3549
+ const totalMessages = mb && typeof mb === "object" ? mb.exists ?? 0 : 0;
3550
+ if (totalMessages === 0) {
3551
+ return { success: true, data: { messages: [] }, display: "Inbox is empty." };
3552
+ }
3553
+ const startSeq = Math.max(1, totalMessages - limit + 1);
3554
+ const range = `${startSeq}:*`;
3555
+ for await (const msg of client.fetch(range, {
3556
+ envelope: true,
3557
+ flags: true
3558
+ })) {
3559
+ const from = msg.envelope?.from?.[0];
3560
+ const fromStr = from ? from.name ? `${from.name} <${from.address}>` : from.address ?? "unknown" : "unknown";
3561
+ messages.push({
3562
+ seq: msg.seq,
3563
+ from: fromStr,
3564
+ subject: msg.envelope?.subject ?? "(no subject)",
3565
+ date: msg.envelope?.date?.toISOString() ?? "",
3566
+ seen: msg.flags?.has("\\Seen") ?? false
3567
+ });
3568
+ }
3569
+ messages.reverse();
3570
+ const display = messages.length === 0 ? "No messages found." : messages.map((m, i) => {
3571
+ const unread = m.seen ? "" : " [UNREAD]";
3572
+ return `${i + 1}. [#${m.seq}]${unread} ${m.subject}
3573
+ From: ${m.from}
3574
+ Date: ${m.date}`;
3575
+ }).join("\n\n");
3576
+ const unreadCount = messages.filter((m) => !m.seen).length;
3577
+ return {
3578
+ success: true,
3579
+ data: { messages, totalMessages, unreadCount },
3580
+ display: `Inbox (${totalMessages} total, ${unreadCount} unread):
3581
+
3582
+ ${display}`
3583
+ };
3584
+ } finally {
3585
+ lock.release();
3586
+ }
3587
+ } finally {
3588
+ await client.logout();
3589
+ }
3590
+ }
3591
+ // ── IMAP: Read single message ──────────────────────────────────
3592
+ async readMessage(messageId) {
3593
+ if (!messageId) {
3594
+ return { success: false, error: "messageId is required. Use the sequence number from inbox." };
3595
+ }
3596
+ const seq = parseInt(messageId, 10);
3597
+ if (isNaN(seq) || seq < 1) {
3598
+ return { success: false, error: "messageId must be a positive number (sequence number)." };
3599
+ }
3600
+ const { ImapFlow } = await import("imapflow");
3601
+ const client = new ImapFlow({
3602
+ host: this.config.imap.host,
3603
+ port: this.config.imap.port,
3604
+ secure: this.config.imap.secure,
3605
+ auth: this.config.auth,
3606
+ logger: false
3607
+ });
3608
+ try {
3609
+ await client.connect();
3610
+ const lock = await client.getMailboxLock("INBOX");
3611
+ try {
3612
+ const msg = await client.fetchOne(String(seq), {
3613
+ envelope: true,
3614
+ source: true
3615
+ });
3616
+ if (!msg) {
3617
+ return { success: false, error: `Message #${seq} not found.` };
3618
+ }
3619
+ const from = msg.envelope?.from?.[0];
3620
+ const fromStr = from ? from.name ? `${from.name} <${from.address}>` : from.address ?? "unknown" : "unknown";
3621
+ const to = msg.envelope?.to?.map((t) => t.name ? `${t.name} <${t.address}>` : t.address ?? "").join(", ") ?? "";
3622
+ const rawSource = msg.source?.toString() ?? "";
3623
+ const body = this.extractTextBody(rawSource);
3624
+ return {
3625
+ success: true,
3626
+ data: {
3627
+ seq,
3628
+ from: fromStr,
3629
+ to,
3630
+ subject: msg.envelope?.subject ?? "(no subject)",
3631
+ date: msg.envelope?.date?.toISOString() ?? "",
3632
+ body
3633
+ },
3634
+ display: [
3635
+ `From: ${fromStr}`,
3636
+ `To: ${to}`,
3637
+ `Subject: ${msg.envelope?.subject ?? "(no subject)"}`,
3638
+ `Date: ${msg.envelope?.date?.toISOString() ?? ""}`,
3639
+ "",
3640
+ body.slice(0, 3e3) + (body.length > 3e3 ? "\n\n... (truncated)" : "")
3641
+ ].join("\n")
3642
+ };
3643
+ } finally {
3644
+ lock.release();
3645
+ }
3646
+ } finally {
3647
+ await client.logout();
3648
+ }
3649
+ }
3650
+ // ── IMAP: Search messages ──────────────────────────────────────
3651
+ async searchMessages(query, count) {
3652
+ if (!query) {
3653
+ return { success: false, error: "query is required for search." };
3654
+ }
3655
+ const limit = Math.min(Math.max(1, count ?? 10), 50);
3656
+ const { ImapFlow } = await import("imapflow");
3657
+ const client = new ImapFlow({
3658
+ host: this.config.imap.host,
3659
+ port: this.config.imap.port,
3660
+ secure: this.config.imap.secure,
3661
+ auth: this.config.auth,
3662
+ logger: false
3663
+ });
3664
+ try {
3665
+ await client.connect();
3666
+ const lock = await client.getMailboxLock("INBOX");
3667
+ try {
3668
+ const rawResult = await client.search({
3669
+ or: [
3670
+ { subject: query },
3671
+ { from: query },
3672
+ { body: query }
3673
+ ]
3674
+ });
3675
+ const searchResult = Array.isArray(rawResult) ? rawResult : [];
3676
+ if (searchResult.length === 0) {
3677
+ return { success: true, data: { results: [] }, display: `No emails found for "${query}".` };
3678
+ }
3679
+ const seqNums = searchResult.slice(-limit);
3680
+ const messages = [];
3681
+ for await (const msg of client.fetch(seqNums, { envelope: true })) {
3682
+ const from = msg.envelope?.from?.[0];
3683
+ const fromStr = from ? from.name ? `${from.name} <${from.address}>` : from.address ?? "unknown" : "unknown";
3684
+ messages.push({
3685
+ seq: msg.seq,
3686
+ from: fromStr,
3687
+ subject: msg.envelope?.subject ?? "(no subject)",
3688
+ date: msg.envelope?.date?.toISOString() ?? ""
3689
+ });
3690
+ }
3691
+ messages.reverse();
3692
+ const display = messages.map((m, i) => `${i + 1}. [#${m.seq}] ${m.subject}
3693
+ From: ${m.from}
3694
+ Date: ${m.date}`).join("\n\n");
3695
+ return {
3696
+ success: true,
3697
+ data: { query, results: messages, totalMatches: seqNums.length },
3698
+ display: `Search results for "${query}" (${seqNums.length} matches):
3699
+
3700
+ ${display}`
3701
+ };
3702
+ } finally {
3703
+ lock.release();
3704
+ }
3705
+ } finally {
3706
+ await client.logout();
3707
+ }
3708
+ }
3709
+ // ── SMTP: Send message ─────────────────────────────────────────
3710
+ async sendMessage(to, subject, body) {
3711
+ if (!to)
3712
+ return { success: false, error: '"to" (recipient email) is required.' };
3713
+ if (!subject)
3714
+ return { success: false, error: '"subject" is required.' };
3715
+ if (!body)
3716
+ return { success: false, error: '"body" is required.' };
3717
+ const nodemailer = await import("nodemailer");
3718
+ const transport = nodemailer.createTransport({
3719
+ host: this.config.smtp.host,
3720
+ port: this.config.smtp.port,
3721
+ secure: this.config.smtp.secure,
3722
+ auth: this.config.auth
3723
+ });
3724
+ const info = await transport.sendMail({
3725
+ from: this.config.auth.user,
3726
+ to,
3727
+ subject,
3728
+ text: body
3729
+ });
3730
+ return {
3731
+ success: true,
3732
+ data: { messageId: info.messageId, to, subject },
3733
+ display: `Email sent to ${to}
3734
+ Subject: ${subject}
3735
+ Message ID: ${info.messageId}`
3736
+ };
3737
+ }
3738
+ // ── Helper: extract text body from raw email source ────────────
3739
+ extractTextBody(rawSource) {
3740
+ const parts = rawSource.split(/\r?\n\r?\n/);
3741
+ if (parts.length < 2)
3742
+ return rawSource;
3743
+ const headers = parts[0].toLowerCase();
3744
+ if (!headers.includes("multipart")) {
3745
+ return this.decodeBody(parts.slice(1).join("\n\n"));
3746
+ }
3747
+ const boundaryMatch = headers.match(/boundary="?([^"\s;]+)"?/i) ?? rawSource.match(/boundary="?([^"\s;]+)"?/i);
3748
+ if (!boundaryMatch) {
3749
+ return parts.slice(1).join("\n\n").slice(0, 5e3);
3750
+ }
3751
+ const boundary = boundaryMatch[1];
3752
+ const sections = rawSource.split(`--${boundary}`);
3753
+ for (const section of sections) {
3754
+ const sectionLower = section.toLowerCase();
3755
+ if (sectionLower.includes("content-type: text/plain") || sectionLower.includes("content-type:text/plain")) {
3756
+ const bodyStart = section.indexOf("\n\n");
3757
+ if (bodyStart >= 0) {
3758
+ return this.decodeBody(section.slice(bodyStart + 2));
3759
+ }
3760
+ const bodyStartCr = section.indexOf("\r\n\r\n");
3761
+ if (bodyStartCr >= 0) {
3762
+ return this.decodeBody(section.slice(bodyStartCr + 4));
3763
+ }
3764
+ }
3765
+ }
3766
+ return this.decodeBody(parts.slice(1).join("\n\n").slice(0, 5e3));
3767
+ }
3768
+ decodeBody(body) {
3769
+ return body.replace(/=\r?\n/g, "").replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))).trim();
3770
+ }
3771
+ };
3772
+ }
3773
+ });
3774
+
3029
3775
  // ../skills/dist/index.js
3030
3776
  var init_dist6 = __esm({
3031
3777
  "../skills/dist/index.js"() {
@@ -3045,6 +3791,7 @@ var init_dist6 = __esm({
3045
3791
  init_shell();
3046
3792
  init_memory();
3047
3793
  init_delegate();
3794
+ init_email();
3048
3795
  }
3049
3796
  });
3050
3797
 
@@ -3077,12 +3824,13 @@ var init_conversation_manager = __esm({
3077
3824
  });
3078
3825
 
3079
3826
  // ../core/dist/message-pipeline.js
3080
- var MAX_TOOL_ITERATIONS, MessagePipeline;
3827
+ var MAX_TOOL_ITERATIONS, TOKEN_BUDGET_RATIO, MessagePipeline;
3081
3828
  var init_message_pipeline = __esm({
3082
3829
  "../core/dist/message-pipeline.js"() {
3083
3830
  "use strict";
3084
3831
  init_dist4();
3085
3832
  MAX_TOOL_ITERATIONS = 10;
3833
+ TOKEN_BUDGET_RATIO = 0.85;
3086
3834
  MessagePipeline = class {
3087
3835
  llm;
3088
3836
  conversationManager;
@@ -3110,7 +3858,7 @@ var init_message_pipeline = __esm({
3110
3858
  try {
3111
3859
  const user = this.users.findOrCreate(message.platform, message.userId, message.userName, message.displayName);
3112
3860
  const conversation = this.conversationManager.getOrCreateConversation(message.platform, message.chatId, user.id);
3113
- const history = this.conversationManager.getHistory(conversation.id);
3861
+ const history = this.conversationManager.getHistory(conversation.id, 50);
3114
3862
  this.conversationManager.addMessage(conversation.id, "user", message.text);
3115
3863
  let memories;
3116
3864
  if (this.memoryRepo) {
@@ -3120,8 +3868,9 @@ var init_message_pipeline = __esm({
3120
3868
  }
3121
3869
  }
3122
3870
  const system = this.promptBuilder.buildSystemPrompt(memories);
3123
- const messages = this.promptBuilder.buildMessages(history);
3124
- messages.push({ role: "user", content: message.text });
3871
+ const allMessages = this.promptBuilder.buildMessages(history);
3872
+ allMessages.push({ role: "user", content: message.text });
3873
+ const messages = this.trimToContextWindow(system, allMessages);
3125
3874
  const tools = this.skillRegistry ? this.promptBuilder.buildTools(this.skillRegistry.getAll().map((s) => s.metadata)) : void 0;
3126
3875
  let response;
3127
3876
  let iteration = 0;
@@ -3219,6 +3968,43 @@ var init_message_pipeline = __esm({
3219
3968
  return { content: `Skill execution failed: ${msg}`, isError: true };
3220
3969
  }
3221
3970
  }
3971
+ /**
3972
+ * Trim messages to fit within the LLM's context window.
3973
+ * Keeps the system prompt, the latest user message, and as many
3974
+ * recent history messages as possible. Drops oldest messages first.
3975
+ * Injects a summary note when messages are trimmed.
3976
+ */
3977
+ trimToContextWindow(system, messages) {
3978
+ const contextWindow = this.llm.getContextWindow();
3979
+ const maxInputTokens = Math.floor(contextWindow.maxInputTokens * TOKEN_BUDGET_RATIO);
3980
+ const systemTokens = estimateTokens(system);
3981
+ const latestMsg = messages[messages.length - 1];
3982
+ const latestTokens = estimateMessageTokens(latestMsg);
3983
+ const reservedTokens = systemTokens + latestTokens + 200;
3984
+ let availableTokens = maxInputTokens - reservedTokens;
3985
+ if (availableTokens <= 0) {
3986
+ this.logger.warn({ maxInputTokens, systemTokens, latestTokens }, "Context window very tight, sending only latest message");
3987
+ return [latestMsg];
3988
+ }
3989
+ const keptMessages = [];
3990
+ for (let i = messages.length - 2; i >= 0; i--) {
3991
+ const msgTokens = estimateMessageTokens(messages[i]);
3992
+ if (msgTokens > availableTokens)
3993
+ break;
3994
+ availableTokens -= msgTokens;
3995
+ keptMessages.unshift(messages[i]);
3996
+ }
3997
+ const trimmedCount = messages.length - 1 - keptMessages.length;
3998
+ if (trimmedCount > 0) {
3999
+ this.logger.info({ trimmedCount, totalMessages: messages.length, maxInputTokens }, "Trimmed conversation history to fit context window");
4000
+ keptMessages.unshift({
4001
+ role: "user",
4002
+ content: `[System note: ${trimmedCount} older message(s) were omitted to fit the context window. The conversation continues from the most recent messages.]`
4003
+ });
4004
+ }
4005
+ keptMessages.push(latestMsg);
4006
+ return keptMessages;
4007
+ }
3222
4008
  };
3223
4009
  }
3224
4010
  });
@@ -3810,7 +4596,11 @@ var init_alfred = __esm({
3810
4596
  const skillRegistry = new SkillRegistry();
3811
4597
  skillRegistry.register(new CalculatorSkill());
3812
4598
  skillRegistry.register(new SystemInfoSkill());
3813
- skillRegistry.register(new WebSearchSkill());
4599
+ skillRegistry.register(new WebSearchSkill(this.config.search ? {
4600
+ provider: this.config.search.provider,
4601
+ apiKey: this.config.search.apiKey,
4602
+ baseUrl: this.config.search.baseUrl
4603
+ } : void 0));
3814
4604
  skillRegistry.register(new ReminderSkill(reminderRepo));
3815
4605
  skillRegistry.register(new NoteSkill());
3816
4606
  skillRegistry.register(new SummarizeSkill());
@@ -3819,6 +4609,11 @@ var init_alfred = __esm({
3819
4609
  skillRegistry.register(new ShellSkill());
3820
4610
  skillRegistry.register(new MemorySkill(memoryRepo));
3821
4611
  skillRegistry.register(new DelegateSkill(llmProvider));
4612
+ skillRegistry.register(new EmailSkill(this.config.email ? {
4613
+ imap: this.config.email.imap,
4614
+ smtp: this.config.email.smtp,
4615
+ auth: this.config.email.auth
4616
+ } : void 0));
3822
4617
  this.logger.info({ skills: skillRegistry.getAll().map((s) => s.metadata.name) }, "Skills registered");
3823
4618
  const skillSandbox = new SkillSandbox(this.logger.child({ component: "sandbox" }));
3824
4619
  const conversationManager = new ConversationManager(conversationRepo);
@@ -4025,6 +4820,8 @@ function loadExistingConfig(projectRoot) {
4025
4820
  const config = {};
4026
4821
  const env = {};
4027
4822
  let shellEnabled = false;
4823
+ let writeInGroups = false;
4824
+ let rateLimit = 30;
4028
4825
  const configPath = path4.join(projectRoot, "config", "default.yml");
4029
4826
  if (fs4.existsSync(configPath)) {
4030
4827
  try {
@@ -4057,11 +4854,19 @@ function loadExistingConfig(projectRoot) {
4057
4854
  const rulesContent = yaml2.load(fs4.readFileSync(rulesPath, "utf-8"));
4058
4855
  if (rulesContent?.rules) {
4059
4856
  shellEnabled = rulesContent.rules.some((r) => r.id === "allow-owner-admin" && r.effect === "allow");
4857
+ const writeDmRule = rulesContent.rules.find((r) => r.id === "allow-write-for-dm" || r.id === "allow-write-all");
4858
+ if (writeDmRule?.id === "allow-write-all") {
4859
+ writeInGroups = true;
4860
+ }
4861
+ const rlRule = rulesContent.rules.find((r) => r.id === "rate-limit-write");
4862
+ if (rlRule?.rateLimit?.maxInvocations) {
4863
+ rateLimit = rlRule.rateLimit.maxInvocations;
4864
+ }
4060
4865
  }
4061
4866
  } catch {
4062
4867
  }
4063
4868
  }
4064
- return { config, env, shellEnabled };
4869
+ return { config, env, shellEnabled, writeInGroups, rateLimit };
4065
4870
  }
4066
4871
  async function setupCommand() {
4067
4872
  const rl = createInterface({ input, output });
@@ -4114,6 +4919,58 @@ ${bold("Which LLM provider would you like to use?")}`);
4114
4919
  const existingModel = existing.config.llm?.model ?? provider.defaultModel;
4115
4920
  console.log("");
4116
4921
  const model = await askWithDefault(rl, "Which model?", existingModel);
4922
+ const searchProviders = ["brave", "tavily", "duckduckgo", "searxng"];
4923
+ const existingSearchProvider = existing.config.search?.provider ?? existing.env["ALFRED_SEARCH_PROVIDER"] ?? "";
4924
+ const existingSearchIdx = searchProviders.indexOf(existingSearchProvider);
4925
+ const defaultSearchChoice = existingSearchIdx >= 0 ? existingSearchIdx + 1 : 0;
4926
+ console.log(`
4927
+ ${bold("Web Search provider (for searching the internet):")}`);
4928
+ const searchLabels = [
4929
+ "Brave Search \u2014 recommended, free tier (2,000/month)",
4930
+ "Tavily \u2014 built for AI agents, free tier (1,000/month)",
4931
+ "DuckDuckGo \u2014 free, no API key needed",
4932
+ "SearXNG \u2014 self-hosted, no API key needed"
4933
+ ];
4934
+ const mark = (i) => existingSearchIdx === i ? ` ${dim("(current)")}` : "";
4935
+ console.log(` ${cyan("0)")} None (disable web search)${existingSearchIdx === -1 && existingSearchProvider === "" ? ` ${dim("(current)")}` : ""}`);
4936
+ for (let i = 0; i < searchLabels.length; i++) {
4937
+ console.log(` ${cyan(String(i + 1) + ")")} ${searchLabels[i]}${mark(i)}`);
4938
+ }
4939
+ const searchChoice = await askNumber(rl, "> ", 0, searchProviders.length, defaultSearchChoice);
4940
+ let searchProvider;
4941
+ let searchApiKey = "";
4942
+ let searchBaseUrl = "";
4943
+ if (searchChoice >= 1 && searchChoice <= searchProviders.length) {
4944
+ searchProvider = searchProviders[searchChoice - 1];
4945
+ }
4946
+ if (searchProvider === "brave") {
4947
+ const existingKey = existing.env["ALFRED_SEARCH_API_KEY"] ?? "";
4948
+ if (existingKey) {
4949
+ searchApiKey = await askWithDefault(rl, " Brave Search API key", existingKey);
4950
+ } else {
4951
+ console.log(` ${dim("Get your free API key at: https://brave.com/search/api/")}`);
4952
+ searchApiKey = await askRequired(rl, " Brave Search API key");
4953
+ }
4954
+ console.log(` ${green(">")} Brave Search: ${dim(maskKey(searchApiKey))}`);
4955
+ } else if (searchProvider === "tavily") {
4956
+ const existingKey = existing.env["ALFRED_SEARCH_API_KEY"] ?? "";
4957
+ if (existingKey) {
4958
+ searchApiKey = await askWithDefault(rl, " Tavily API key", existingKey);
4959
+ } else {
4960
+ console.log(` ${dim("Get your free API key at: https://tavily.com/")}`);
4961
+ searchApiKey = await askRequired(rl, " Tavily API key");
4962
+ }
4963
+ console.log(` ${green(">")} Tavily: ${dim(maskKey(searchApiKey))}`);
4964
+ } else if (searchProvider === "duckduckgo") {
4965
+ console.log(` ${green(">")} DuckDuckGo: ${dim("no API key needed")}`);
4966
+ } else if (searchProvider === "searxng") {
4967
+ const existingSearxUrl = existing.config.search?.baseUrl ?? existing.env["ALFRED_SEARCH_BASE_URL"] ?? "http://localhost:8080";
4968
+ searchBaseUrl = await askWithDefault(rl, " SearXNG URL", existingSearxUrl);
4969
+ searchBaseUrl = searchBaseUrl.replace(/\/+$/, "");
4970
+ console.log(` ${green(">")} SearXNG: ${dim(searchBaseUrl)}`);
4971
+ } else {
4972
+ console.log(` ${dim("Web search disabled \u2014 you can configure it later.")}`);
4973
+ }
4117
4974
  const currentlyEnabled = [];
4118
4975
  for (let i = 0; i < PLATFORMS.length; i++) {
4119
4976
  const p = PLATFORMS[i];
@@ -4186,8 +5043,73 @@ ${bold(platform.label + " configuration:")}`);
4186
5043
  }
4187
5044
  platformCredentials[platform.configKey] = creds;
4188
5045
  }
5046
+ const existingEmailUser = existing.config.email?.auth?.user ?? existing.env["ALFRED_EMAIL_USER"] ?? "";
5047
+ const hasEmail = !!existingEmailUser;
5048
+ const emailDefault = hasEmail ? "Y/n" : "y/N";
5049
+ console.log(`
5050
+ ${bold("Email access (read & send emails via IMAP/SMTP)?")}`);
5051
+ console.log(`${dim("Works with Gmail, Outlook, or any IMAP/SMTP provider.")}`);
5052
+ const emailAnswer = (await rl.question(`${YELLOW}> ${RESET}${dim(`[${emailDefault}] `)}`)).trim().toLowerCase();
5053
+ const enableEmail = emailAnswer === "" ? hasEmail : emailAnswer === "y" || emailAnswer === "yes";
5054
+ let emailUser = "";
5055
+ let emailPass = "";
5056
+ let emailImapHost = "";
5057
+ let emailImapPort = 993;
5058
+ let emailSmtpHost = "";
5059
+ let emailSmtpPort = 587;
5060
+ if (enableEmail) {
5061
+ console.log("");
5062
+ emailUser = await askWithDefault(rl, " Email address", existingEmailUser || "");
5063
+ if (!emailUser) {
5064
+ emailUser = await askRequired(rl, " Email address");
5065
+ }
5066
+ const existingPass = existing.env["ALFRED_EMAIL_PASS"] ?? "";
5067
+ if (existingPass) {
5068
+ emailPass = await askWithDefault(rl, " Password / App password", existingPass);
5069
+ } else {
5070
+ console.log(` ${dim("For Gmail: use an App Password (not your regular password)")}`);
5071
+ console.log(` ${dim(" \u2192 Google Account \u2192 Security \u2192 2-Step \u2192 App passwords")}`);
5072
+ emailPass = await askRequired(rl, " Password / App password");
5073
+ }
5074
+ const domain = emailUser.split("@")[1]?.toLowerCase() ?? "";
5075
+ const presets = {
5076
+ "gmail.com": { imap: "imap.gmail.com", smtp: "smtp.gmail.com" },
5077
+ "googlemail.com": { imap: "imap.gmail.com", smtp: "smtp.gmail.com" },
5078
+ "outlook.com": { imap: "outlook.office365.com", smtp: "smtp.office365.com" },
5079
+ "hotmail.com": { imap: "outlook.office365.com", smtp: "smtp.office365.com" },
5080
+ "live.com": { imap: "outlook.office365.com", smtp: "smtp.office365.com" },
5081
+ "yahoo.com": { imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com" },
5082
+ "icloud.com": { imap: "imap.mail.me.com", smtp: "smtp.mail.me.com" },
5083
+ "me.com": { imap: "imap.mail.me.com", smtp: "smtp.mail.me.com" },
5084
+ "gmx.de": { imap: "imap.gmx.net", smtp: "mail.gmx.net" },
5085
+ "gmx.net": { imap: "imap.gmx.net", smtp: "mail.gmx.net" },
5086
+ "web.de": { imap: "imap.web.de", smtp: "smtp.web.de" },
5087
+ "posteo.de": { imap: "posteo.de", smtp: "posteo.de" },
5088
+ "mailbox.org": { imap: "imap.mailbox.org", smtp: "smtp.mailbox.org" },
5089
+ "protonmail.com": { imap: "127.0.0.1", smtp: "127.0.0.1" },
5090
+ "proton.me": { imap: "127.0.0.1", smtp: "127.0.0.1" }
5091
+ };
5092
+ const preset = presets[domain];
5093
+ const defaultImap = existing.config.email?.imap?.host ?? preset?.imap ?? `imap.${domain}`;
5094
+ const defaultSmtp = existing.config.email?.smtp?.host ?? preset?.smtp ?? `smtp.${domain}`;
5095
+ const defaultImapPort = existing.config.email?.imap?.port ?? 993;
5096
+ const defaultSmtpPort = existing.config.email?.smtp?.port ?? 587;
5097
+ if (preset) {
5098
+ console.log(` ${green(">")} Detected ${domain} \u2014 using preset server settings`);
5099
+ }
5100
+ emailImapHost = await askWithDefault(rl, " IMAP server", defaultImap);
5101
+ const imapPortStr = await askWithDefault(rl, " IMAP port", String(defaultImapPort));
5102
+ emailImapPort = parseInt(imapPortStr, 10) || 993;
5103
+ emailSmtpHost = await askWithDefault(rl, " SMTP server", defaultSmtp);
5104
+ const smtpPortStr = await askWithDefault(rl, " SMTP port", String(defaultSmtpPort));
5105
+ emailSmtpPort = parseInt(smtpPortStr, 10) || 587;
5106
+ console.log(` ${green(">")} Email: ${dim(emailUser)} via ${dim(emailImapHost)}`);
5107
+ } else {
5108
+ console.log(` ${dim("Email disabled \u2014 you can configure it later.")}`);
5109
+ }
5110
+ console.log(`
5111
+ ${bold("Security configuration:")}`);
4189
5112
  const existingOwnerId = existing.config.security?.ownerUserId ?? existing.env["ALFRED_OWNER_USER_ID"] ?? "";
4190
- console.log("");
4191
5113
  let ownerUserId;
4192
5114
  if (existingOwnerId) {
4193
5115
  ownerUserId = await askWithDefault(rl, "Owner user ID (for elevated permissions)", existingOwnerId);
@@ -4200,21 +5122,41 @@ ${bold(platform.label + " configuration:")}`);
4200
5122
  if (ownerUserId) {
4201
5123
  const shellDefault = existing.shellEnabled ? "Y/n" : "y/N";
4202
5124
  console.log("");
4203
- console.log(`${bold("Enable shell access (admin commands) for the owner?")}`);
4204
- console.log(`${dim("This allows Alfred to execute shell commands on your behalf.")}`);
4205
- console.log(`${dim("Only the owner (user ID above) will have this permission.")}`);
4206
- const shellAnswer = (await rl.question(`${YELLOW}> ${RESET}${dim(`[${shellDefault}] `)}`)).trim().toLowerCase();
5125
+ console.log(` ${bold("Enable shell access (admin commands) for the owner?")}`);
5126
+ console.log(` ${dim("Allows Alfred to execute shell commands. Only for the owner.")}`);
5127
+ const shellAnswer = (await rl.question(` ${YELLOW}> ${RESET}${dim(`[${shellDefault}] `)}`)).trim().toLowerCase();
4207
5128
  if (shellAnswer === "") {
4208
5129
  enableShell = existing.shellEnabled;
4209
5130
  } else {
4210
5131
  enableShell = shellAnswer === "y" || shellAnswer === "yes";
4211
5132
  }
4212
5133
  if (enableShell) {
4213
- console.log(` ${green(">")} Shell access ${bold("enabled")} for owner ${dim(ownerUserId)}`);
5134
+ console.log(` ${green(">")} Shell access ${bold("enabled")} for owner ${dim(ownerUserId)}`);
4214
5135
  } else {
4215
- console.log(` ${dim("Shell access disabled.")}`);
5136
+ console.log(` ${dim("Shell access disabled.")}`);
4216
5137
  }
4217
5138
  }
5139
+ const writeGroupsDefault = existing.writeInGroups ? "Y/n" : "y/N";
5140
+ console.log("");
5141
+ console.log(` ${bold("Allow write actions (notes, reminders, memory) in group chats?")}`);
5142
+ console.log(` ${dim("By default, write actions are only allowed in DMs.")}`);
5143
+ const writeGroupsAnswer = (await rl.question(` ${YELLOW}> ${RESET}${dim(`[${writeGroupsDefault}] `)}`)).trim().toLowerCase();
5144
+ let writeInGroups;
5145
+ if (writeGroupsAnswer === "") {
5146
+ writeInGroups = existing.writeInGroups;
5147
+ } else {
5148
+ writeInGroups = writeGroupsAnswer === "y" || writeGroupsAnswer === "yes";
5149
+ }
5150
+ if (writeInGroups) {
5151
+ console.log(` ${green(">")} Write actions ${bold("enabled")} in groups`);
5152
+ } else {
5153
+ console.log(` ${dim("Write actions only in DMs (default).")}`);
5154
+ }
5155
+ const existingRateLimit = existing.rateLimit ?? 30;
5156
+ console.log("");
5157
+ const rateLimitStr = await askWithDefault(rl, " Rate limit (max write actions per hour per user)", String(existingRateLimit));
5158
+ const rateLimit = Math.max(1, parseInt(rateLimitStr, 10) || 30);
5159
+ console.log(` ${green(">")} Rate limit: ${bold(String(rateLimit))} per hour`);
4218
5160
  console.log(`
4219
5161
  ${bold("Writing configuration files...")}`);
4220
5162
  const envLines = [
@@ -4239,6 +5181,27 @@ ${bold("Writing configuration files...")}`);
4239
5181
  for (const [envKey, envVal] of Object.entries(envOverrides)) {
4240
5182
  envLines.push(`${envKey}=${envVal}`);
4241
5183
  }
5184
+ envLines.push("", "# === Web Search ===", "");
5185
+ if (searchProvider) {
5186
+ envLines.push(`ALFRED_SEARCH_PROVIDER=${searchProvider}`);
5187
+ if (searchApiKey) {
5188
+ envLines.push(`ALFRED_SEARCH_API_KEY=${searchApiKey}`);
5189
+ }
5190
+ if (searchBaseUrl) {
5191
+ envLines.push(`ALFRED_SEARCH_BASE_URL=${searchBaseUrl}`);
5192
+ }
5193
+ } else {
5194
+ envLines.push("# ALFRED_SEARCH_PROVIDER=brave");
5195
+ envLines.push("# ALFRED_SEARCH_API_KEY=");
5196
+ }
5197
+ envLines.push("", "# === Email ===", "");
5198
+ if (enableEmail) {
5199
+ envLines.push(`ALFRED_EMAIL_USER=${emailUser}`);
5200
+ envLines.push(`ALFRED_EMAIL_PASS=${emailPass}`);
5201
+ } else {
5202
+ envLines.push("# ALFRED_EMAIL_USER=");
5203
+ envLines.push("# ALFRED_EMAIL_PASS=");
5204
+ }
4242
5205
  envLines.push("", "# === Security ===", "");
4243
5206
  if (ownerUserId) {
4244
5207
  envLines.push(`ALFRED_OWNER_USER_ID=${ownerUserId}`);
@@ -4285,6 +5248,20 @@ ${bold("Writing configuration files...")}`);
4285
5248
  temperature: 0.7,
4286
5249
  maxTokens: 4096
4287
5250
  },
5251
+ ...searchProvider ? {
5252
+ search: {
5253
+ provider: searchProvider,
5254
+ ...searchApiKey ? { apiKey: searchApiKey } : {},
5255
+ ...searchBaseUrl ? { baseUrl: searchBaseUrl } : {}
5256
+ }
5257
+ } : {},
5258
+ ...enableEmail ? {
5259
+ email: {
5260
+ imap: { host: emailImapHost, port: emailImapPort, secure: emailImapPort === 993 },
5261
+ smtp: { host: emailSmtpHost, port: emailSmtpPort, secure: emailSmtpPort === 465 },
5262
+ auth: { user: emailUser, pass: emailPass }
5263
+ }
5264
+ } : {},
4288
5265
  storage: {
4289
5266
  path: "./data/alfred.db"
4290
5267
  },
@@ -4331,6 +5308,21 @@ ${bold("Writing configuration files...")}`);
4331
5308
  # conditions:
4332
5309
  # users: ["${ownerUserId || "YOUR_USER_ID_HERE"}"]
4333
5310
  `;
5311
+ const writeRule = writeInGroups ? ` # Allow write-level skills everywhere (DMs and groups)
5312
+ - id: allow-write-all
5313
+ effect: allow
5314
+ priority: 200
5315
+ scope: global
5316
+ actions: ["*"]
5317
+ riskLevels: [write]` : ` # Allow write-level skills in DMs only
5318
+ - id: allow-write-for-dm
5319
+ effect: allow
5320
+ priority: 200
5321
+ scope: global
5322
+ actions: ["*"]
5323
+ riskLevels: [write]
5324
+ conditions:
5325
+ chatType: dm`;
4334
5326
  const rulesYaml = `# Alfred \u2014 Default Security Rules
4335
5327
  # Rules are evaluated in priority order (lower number = higher priority).
4336
5328
  # First matching rule wins.
@@ -4344,17 +5336,9 @@ rules:
4344
5336
  actions: ["*"]
4345
5337
  riskLevels: [read]
4346
5338
 
4347
- # Allow write-level skills in DMs
4348
- - id: allow-write-for-dm
4349
- effect: allow
4350
- priority: 200
4351
- scope: global
4352
- actions: ["*"]
4353
- riskLevels: [write]
4354
- conditions:
4355
- chatType: dm
5339
+ ${writeRule}
4356
5340
 
4357
- # Rate-limit write actions: max 30 per hour per user
5341
+ # Rate-limit write actions: max ${rateLimit} per hour per user
4358
5342
  - id: rate-limit-write
4359
5343
  effect: allow
4360
5344
  priority: 250
@@ -4362,7 +5346,7 @@ rules:
4362
5346
  actions: ["*"]
4363
5347
  riskLevels: [write]
4364
5348
  rateLimit:
4365
- maxInvocations: 30
5349
+ maxInvocations: ${rateLimit}
4366
5350
  windowSeconds: 3600
4367
5351
  ${ownerAdminRule}
4368
5352
  # Deny destructive and admin actions by default
@@ -4404,10 +5388,28 @@ ${ownerAdminRule}
4404
5388
  } else {
4405
5389
  console.log(` ${bold("Platforms:")} none (configure later)`);
4406
5390
  }
5391
+ if (searchProvider) {
5392
+ const searchLabelMap = {
5393
+ brave: "Brave Search",
5394
+ tavily: "Tavily",
5395
+ duckduckgo: "DuckDuckGo",
5396
+ searxng: `SearXNG (${searchBaseUrl})`
5397
+ };
5398
+ console.log(` ${bold("Web search:")} ${searchLabelMap[searchProvider]}`);
5399
+ } else {
5400
+ console.log(` ${bold("Web search:")} ${dim("disabled")}`);
5401
+ }
5402
+ if (enableEmail) {
5403
+ console.log(` ${bold("Email:")} ${emailUser} (${emailImapHost})`);
5404
+ } else {
5405
+ console.log(` ${bold("Email:")} ${dim("disabled")}`);
5406
+ }
4407
5407
  if (ownerUserId) {
4408
5408
  console.log(` ${bold("Owner ID:")} ${ownerUserId}`);
4409
5409
  console.log(` ${bold("Shell access:")} ${enableShell ? green("enabled") : dim("disabled")}`);
4410
5410
  }
5411
+ console.log(` ${bold("Write scope:")} ${writeInGroups ? "DMs + Groups" : "DMs only"}`);
5412
+ console.log(` ${bold("Rate limit:")} ${rateLimit}/hour per user`);
4411
5413
  console.log("");
4412
5414
  console.log(`${CYAN}Next steps:${RESET}`);
4413
5415
  console.log(` ${bold("alfred start")} Start Alfred`);