create-walle 0.9.3 → 0.9.4

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 (75) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/template/claude-task-manager/db.js +5 -1
  4. package/template/claude-task-manager/public/css/walle.css +317 -0
  5. package/template/claude-task-manager/public/index.html +404 -101
  6. package/template/claude-task-manager/public/js/walle.js +1256 -86
  7. package/template/claude-task-manager/server.js +189 -14
  8. package/template/docs/site/api/README.md +146 -0
  9. package/template/docs/site/skills/README.md +99 -5
  10. package/template/package.json +1 -1
  11. package/template/wall-e/agent.js +54 -0
  12. package/template/wall-e/api-walle.js +452 -3
  13. package/template/wall-e/brain.js +45 -1
  14. package/template/wall-e/channels/telegram-channel.js +96 -0
  15. package/template/wall-e/chat.js +61 -2
  16. package/template/wall-e/coding-context.js +252 -0
  17. package/template/wall-e/coding-orchestrator.js +625 -0
  18. package/template/wall-e/coding-review.js +189 -0
  19. package/template/wall-e/core-tasks.js +12 -3
  20. package/template/wall-e/deploy.sh +4 -4
  21. package/template/wall-e/fly.toml +2 -2
  22. package/template/wall-e/package.json +4 -1
  23. package/template/wall-e/skills/_bundled/coding-agent/SKILL.md +17 -0
  24. package/template/wall-e/skills/_bundled/coding-agent/run.js +142 -0
  25. package/template/wall-e/skills/_bundled/email-sync/SKILL.md +12 -7
  26. package/template/wall-e/skills/_bundled/email-sync/mail-reader.jxa +76 -46
  27. package/template/wall-e/skills/_bundled/email-sync/run.js +42 -2
  28. package/template/wall-e/skills/_bundled/glean-team-sync/SKILL.md +57 -0
  29. package/template/wall-e/skills/_bundled/glean-team-sync/run.js +254 -0
  30. package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +1 -1
  31. package/template/wall-e/skills/_bundled/slack-mentions/run.js +268 -121
  32. package/template/wall-e/skills/_templates/data-fetcher.md +27 -0
  33. package/template/wall-e/skills/_templates/manual-action.md +19 -0
  34. package/template/wall-e/skills/_templates/periodic-checker.md +29 -0
  35. package/template/wall-e/skills/_templates/script-runner.md +21 -0
  36. package/template/wall-e/skills/claude-code-reader.js +16 -4
  37. package/template/wall-e/skills/skill-executor.js +23 -1
  38. package/template/wall-e/skills/skill-validator.js +73 -0
  39. package/template/wall-e/tests/brain.test.js +3 -3
  40. package/template/wall-e/tests/coding-agent-integration.test.js +240 -0
  41. package/template/wall-e/tests/coding-context.test.js +212 -0
  42. package/template/wall-e/tests/coding-orchestrator.test.js +303 -0
  43. package/template/wall-e/tests/coding-review.test.js +141 -0
  44. package/template/claude-task-manager/package-lock.json +0 -1607
  45. package/template/claude-task-manager/tests/test-ai-search.js +0 -61
  46. package/template/claude-task-manager/tests/test-editor-ux.js +0 -76
  47. package/template/claude-task-manager/tests/test-editor-ux2.js +0 -51
  48. package/template/claude-task-manager/tests/test-features-v2.js +0 -127
  49. package/template/claude-task-manager/tests/test-insights-cached.js +0 -78
  50. package/template/claude-task-manager/tests/test-insights.js +0 -124
  51. package/template/claude-task-manager/tests/test-permissions-v2.js +0 -127
  52. package/template/claude-task-manager/tests/test-permissions.js +0 -122
  53. package/template/claude-task-manager/tests/test-pin.js +0 -51
  54. package/template/claude-task-manager/tests/test-prompts.js +0 -164
  55. package/template/claude-task-manager/tests/test-recent-sessions.js +0 -96
  56. package/template/claude-task-manager/tests/test-review.js +0 -104
  57. package/template/claude-task-manager/tests/test-send-dropdown.js +0 -76
  58. package/template/claude-task-manager/tests/test-send-final.js +0 -30
  59. package/template/claude-task-manager/tests/test-send-fixes.js +0 -76
  60. package/template/claude-task-manager/tests/test-send-integration.js +0 -107
  61. package/template/claude-task-manager/tests/test-send-visual.js +0 -34
  62. package/template/claude-task-manager/tests/test-session-create.js +0 -147
  63. package/template/claude-task-manager/tests/test-sidebar-ux.js +0 -83
  64. package/template/claude-task-manager/tests/test-url-hash.js +0 -68
  65. package/template/claude-task-manager/tests/test-ux-crop.js +0 -34
  66. package/template/claude-task-manager/tests/test-ux-review.js +0 -130
  67. package/template/claude-task-manager/tests/test-zoom-card.js +0 -76
  68. package/template/claude-task-manager/tests/test-zoom.js +0 -92
  69. package/template/claude-task-manager/tests/test-zoom2.js +0 -67
  70. package/template/docs/openclaw-vs-walle-comparison.md +0 -103
  71. package/template/docs/ux-improvement-plan.md +0 -84
  72. package/template/wall-e/docs/specs/2026-04-01-publish-plan.md +0 -112
  73. package/template/wall-e/docs/specs/SKILL-FORMAT.md +0 -326
  74. package/template/wall-e/package-lock.json +0 -533
  75. package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +0 -4
@@ -7,8 +7,8 @@ const brain = require(path.resolve(__dirname, '..', '..', '..', 'brain'));
7
7
  const slackMcp = require(path.resolve(__dirname, '..', '..', '..', 'tools', 'slack-mcp'));
8
8
 
9
9
  const WATERMARK_FILE = path.join(__dirname, '.watermark.json');
10
- const OWNER_HANDLE = process.env.SLACK_OWNER_HANDLE || 'juncao';
11
- const OWNER_NAME = process.env.WALLE_OWNER_NAME || 'Juncao Li';
10
+ const OWNER_HANDLE = process.env.SLACK_OWNER_HANDLE || '';
11
+ const OWNER_NAME = process.env.WALLE_OWNER_NAME || '';
12
12
  const PAUSE_MS = 800;
13
13
  const WATCH_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
14
14
 
@@ -49,6 +49,12 @@ function classifyMention(text) {
49
49
  const cleaned = trimmed.replace(/@wall-?e/gi, '').trim();
50
50
  if (/\?\s*$/.test(cleaned)) return 'question';
51
51
  if (/^(what|who|where|when|why|how|is|are|do|does|did|can|could|would|should|will)\b/i.test(cleaned)) return 'question';
52
+
53
+ // Coding requests: action verbs + code concepts
54
+ const hasActionVerb = /\b(add|fix|build|create|implement|refactor|update|remove|delete|write|change|move|rename|extract|optimize)\b/i.test(cleaned);
55
+ const hasCodeConcept = /\b(api|endpoint|function|module|class|component|middleware|route|handler|test|database|schema|migration|config|server|client|ui|page|service|model)\b/i.test(cleaned);
56
+ if (hasActionVerb && hasCodeConcept) return 'coding';
57
+
52
58
  return 'task';
53
59
  }
54
60
 
@@ -244,6 +250,151 @@ async function pollWatchedThreads() {
244
250
 
245
251
  // ── New mention processing ──
246
252
 
253
+ // Shared per-message processor — creates tasks/questions from a mention
254
+ async function _processMention(msg, seenTs, stats) {
255
+ const dedupeKey = msg.ts || msg.permalink || msg.content;
256
+ if (seenTs.has(dedupeKey)) return;
257
+ seenTs.add(dedupeKey);
258
+
259
+ const now = new Date();
260
+ const sourceRef = (msg.permalink || (msg.channelId ? `${msg.channelId}:${msg.ts || 'unknown'}` : `unknown:${msg.ts}`)).split('?')[0];
261
+ const existing = brain.listTasks({ source: 'slack' }).find(t => t.source_ref && t.source_ref.split('?')[0] === sourceRef);
262
+ if (existing) {
263
+ console.log(`[slack-mentions] Skipping already-processed mention: ${sourceRef}`);
264
+ return;
265
+ }
266
+
267
+ stats.newMentions++;
268
+ const kind = classifyMention(msg.content);
269
+ const effectiveTs = msg.ts || msg.isoTimestamp || now.toISOString();
270
+ const threadTs = msg.threadTs || msg.ts;
271
+
272
+ // Fetch full thread context
273
+ let threadContext = null;
274
+ if (msg.channelId && threadTs) {
275
+ threadContext = await fetchThreadContext(msg.channelId, threadTs);
276
+ await sleep(PAUSE_MS);
277
+ }
278
+ const contextBlock = threadContext ? `\n\nFull thread context:\n${threadContext}` : '';
279
+
280
+ if (kind === 'coding') {
281
+ const title = extractTaskTitle(msg.content);
282
+ try {
283
+ const { id } = brain.insertTask({
284
+ title: title.slice(0, 100),
285
+ description: `Coding request from Slack:\n\n${msg.content}`,
286
+ priority: 'normal',
287
+ type: 'once',
288
+ execution: 'skill',
289
+ skill: 'coding-agent',
290
+ skill_config: JSON.stringify({
291
+ request: msg.content.replace(/@wall-?e/gi, '').trim(),
292
+ cwd: process.env.HOME || '/tmp',
293
+ options: { delivery: 'commit' },
294
+ }),
295
+ source: 'slack',
296
+ source_ref: sourceRef,
297
+ });
298
+ stats.tasksCreated++;
299
+ console.log(`[slack-mentions] Coding task created: ${id} — ${title}`);
300
+
301
+ if (msg.channelId) {
302
+ try {
303
+ await slackMcp.callSlackMcp('slack_send_message', {
304
+ channel_id: msg.channelId,
305
+ message: `On it — planning the implementation now. I'll update this thread when done.`,
306
+ thread_ts: threadTs,
307
+ });
308
+ } catch (replyErr) {
309
+ console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
310
+ }
311
+ await sleep(PAUSE_MS);
312
+ }
313
+ } catch (taskErr) {
314
+ console.error(`[slack-mentions] Failed to create coding task: ${taskErr.message}`);
315
+ }
316
+ return;
317
+ }
318
+
319
+ if (kind === 'task') {
320
+ const title = extractTaskTitle(msg.content);
321
+ try {
322
+ const { id } = brain.insertTask({
323
+ title,
324
+ description: `Slack task from ${OWNER_NAME}:\n\n${msg.content}${contextBlock}\n\n---\n**IMPORTANT**: When done, reply with results in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${msg.channelId}", "thread_ts": "${threadTs}", "message": "<your results>" }`,
325
+ priority: 'normal',
326
+ type: 'once',
327
+ execution: 'chat',
328
+ source: 'slack',
329
+ source_ref: sourceRef,
330
+ });
331
+ stats.tasksCreated++;
332
+ console.log(`[slack-mentions] Task created: ${id} — ${title}`);
333
+
334
+ try {
335
+ brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
336
+ } catch (upsertErr) {
337
+ console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
338
+ }
339
+
340
+ if (msg.channelId) {
341
+ try {
342
+ await slackMcp.callSlackMcp('slack_send_message', {
343
+ channel_id: msg.channelId,
344
+ message: `Got it! I've created a task for this. I'll update you here when it's done.`,
345
+ thread_ts: threadTs,
346
+ });
347
+ } catch (replyErr) {
348
+ console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
349
+ }
350
+ await sleep(PAUSE_MS);
351
+ }
352
+ } catch (taskErr) {
353
+ console.error(`[slack-mentions] Failed to create task: ${taskErr.message}`);
354
+ }
355
+ } else {
356
+ stats.questionsFound++;
357
+ console.log(`[slack-mentions] Question detected: ${msg.content.slice(0, 80)}...`);
358
+
359
+ if (msg.channelId) {
360
+ try {
361
+ await slackMcp.callSlackMcp('slack_send_message', {
362
+ channel_id: msg.channelId,
363
+ message: `Let me look into that — I'll get back to you shortly.`,
364
+ thread_ts: threadTs,
365
+ });
366
+ } catch (replyErr) {
367
+ console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
368
+ }
369
+ await sleep(PAUSE_MS);
370
+ }
371
+
372
+ try {
373
+ const { id } = brain.insertTask({
374
+ title: `Answer Slack question: ${extractTaskTitle(msg.content)}`,
375
+ description: `Question from ${OWNER_NAME} in Slack:\n\n${msg.content}${contextBlock}\n\n---\n**IMPORTANT**: After answering, you MUST reply in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${msg.channelId}", "thread_ts": "${threadTs}", "message": "<your answer>" }`,
376
+ priority: 'high',
377
+ type: 'once',
378
+ execution: 'chat',
379
+ source: 'slack',
380
+ source_ref: sourceRef,
381
+ });
382
+
383
+ try {
384
+ brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
385
+ } catch (upsertErr) {
386
+ console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
387
+ }
388
+ } catch (taskErr) {
389
+ console.error(`[slack-mentions] Failed to create question task: ${taskErr.message}`);
390
+ }
391
+ }
392
+
393
+ if (effectiveTs > (stats.maxTs || '')) {
394
+ stats.maxTs = effectiveTs;
395
+ }
396
+ }
397
+
247
398
  async function processNewMentions() {
248
399
  const lastTs = loadWatermark();
249
400
  const now = new Date();
@@ -252,14 +403,12 @@ async function processNewMentions() {
252
403
 
253
404
  console.log(`[slack-mentions] Checking for @walle mentions (watermark: ${lastTs || 'none'})`);
254
405
 
255
- let newMentions = 0;
256
- let tasksCreated = 0;
257
- let questionsFound = 0;
258
- let maxTs = lastTs;
406
+ const stats = { newMentions: 0, tasksCreated: 0, questionsFound: 0, maxTs: lastTs };
259
407
  const seenTs = new Set();
260
408
 
261
409
  const queries = ['@walle', '@wall-e'];
262
410
 
411
+ // Phase 2a: Search-based discovery
263
412
  for (const query of queries) {
264
413
  try {
265
414
  const result = await slackMcp.callSlackMcp('slack_search_public_and_private', {
@@ -280,124 +429,15 @@ async function processNewMentions() {
280
429
  if (lastTs && msg.ts && msg.ts <= lastTs) continue;
281
430
  if (lastTs && msg.isoTimestamp && msg.isoTimestamp <= lastTs) continue;
282
431
 
283
- const dedupeKey = msg.ts || msg.permalink || msg.content;
284
- if (seenTs.has(dedupeKey)) continue;
285
- seenTs.add(dedupeKey);
286
-
287
432
  const senderName = (msg.fromName || '').toLowerCase();
288
433
  const isFromOwner = senderName.includes(OWNER_HANDLE.toLowerCase()) ||
289
434
  senderName.includes(OWNER_NAME.split(' ')[0].toLowerCase());
290
-
291
435
  if (!isFromOwner) {
292
436
  console.log(`[slack-mentions] Skipping non-owner mention from: ${msg.fromName || 'unknown'}`);
293
437
  continue;
294
438
  }
295
439
 
296
- newMentions++;
297
- const kind = classifyMention(msg.content);
298
- const effectiveTs = msg.ts || msg.isoTimestamp || now.toISOString();
299
-
300
- const rawRef = msg.permalink || (msg.channelId ? `${msg.channelId}:${msg.ts || 'unknown'}` : `${kind}:${effectiveTs}`);
301
- const sourceRef = rawRef.split('?')[0];
302
- const existing = brain.listTasks({ source: 'slack' }).find(t => t.source_ref && t.source_ref.split('?')[0] === sourceRef);
303
- if (existing) {
304
- console.log(`[slack-mentions] Skipping already-processed mention: ${sourceRef}`);
305
- continue;
306
- }
307
-
308
- // Fetch full thread context before creating the task
309
- const threadTs = msg.threadTs || msg.ts;
310
- let threadContext = null;
311
- if (msg.channelId && threadTs) {
312
- threadContext = await fetchThreadContext(msg.channelId, threadTs);
313
- await sleep(PAUSE_MS);
314
- }
315
-
316
- const contextBlock = threadContext
317
- ? `\n\nFull thread context:\n${threadContext}`
318
- : '';
319
-
320
- if (kind === 'task') {
321
- const title = extractTaskTitle(msg.content);
322
- try {
323
- const { id } = brain.insertTask({
324
- title,
325
- description: `Slack task from ${OWNER_NAME}:\n\n${msg.content}${contextBlock}\n\n---\n**IMPORTANT**: When done, reply with results in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${msg.channelId}", "thread_ts": "${threadTs}", "message": "<your results>" }`,
326
- priority: 'normal',
327
- type: 'once',
328
- execution: 'chat',
329
- source: 'slack',
330
- source_ref: sourceRef,
331
- });
332
- tasksCreated++;
333
- console.log(`[slack-mentions] Task created: ${id} — ${title}`);
334
-
335
- // Watch this thread for follow-ups (in DB)
336
- try {
337
- const threadId1 = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
338
- console.log(`[slack-mentions] Upserted watched thread: ${threadId1} (task, channel=${msg.channelId})`);
339
- } catch (upsertErr) {
340
- console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message} (channelId=${msg.channelId}, threadTs=${threadTs})`);
341
- }
342
-
343
- if (msg.channelId) {
344
- try {
345
- await slackMcp.callSlackMcp('slack_send_message', {
346
- channel_id: msg.channelId,
347
- message: `Got it! I've created a task for this. I'll update you here when it's done.`,
348
- thread_ts: threadTs,
349
- });
350
- } catch (replyErr) {
351
- console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
352
- }
353
- await sleep(PAUSE_MS);
354
- }
355
- } catch (taskErr) {
356
- console.error(`[slack-mentions] Failed to create task: ${taskErr.message}`);
357
- }
358
- } else {
359
- questionsFound++;
360
- console.log(`[slack-mentions] Question detected: ${msg.content.slice(0, 80)}...`);
361
-
362
- if (msg.channelId) {
363
- try {
364
- await slackMcp.callSlackMcp('slack_send_message', {
365
- channel_id: msg.channelId,
366
- message: `Let me look into that — I'll get back to you shortly.`,
367
- thread_ts: threadTs,
368
- });
369
- } catch (replyErr) {
370
- console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
371
- }
372
- await sleep(PAUSE_MS);
373
- }
374
-
375
- try {
376
- const { id } = brain.insertTask({
377
- title: `Answer Slack question: ${extractTaskTitle(msg.content)}`,
378
- description: `Question from ${OWNER_NAME} in Slack:\n\n${msg.content}${contextBlock}\n\n---\n**IMPORTANT**: After answering, you MUST reply in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${msg.channelId}", "thread_ts": "${threadTs}", "message": "<your answer>" }`,
379
- priority: 'high',
380
- type: 'once',
381
- execution: 'chat',
382
- source: 'slack',
383
- source_ref: sourceRef,
384
- });
385
-
386
- // Watch this thread for follow-ups (in DB)
387
- try {
388
- const threadId2 = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
389
- console.log(`[slack-mentions] Upserted watched thread: ${threadId2} (question, channel=${msg.channelId})`);
390
- } catch (upsertErr) {
391
- console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message} (channelId=${msg.channelId}, threadTs=${threadTs})`);
392
- }
393
- } catch (taskErr) {
394
- console.error(`[slack-mentions] Failed to create question task: ${taskErr.message}`);
395
- }
396
- }
397
-
398
- if (effectiveTs > (maxTs || '')) {
399
- maxTs = effectiveTs;
400
- }
440
+ await _processMention(msg, seenTs, stats);
401
441
  }
402
442
  } catch (err) {
403
443
  if (err.message.includes('401') || err.message.includes('expired')) {
@@ -411,15 +451,122 @@ async function processNewMentions() {
411
451
  await sleep(PAUSE_MS);
412
452
  }
413
453
 
414
- if (maxTs && maxTs !== lastTs) {
415
- saveWatermark(maxTs);
416
- console.log(`[slack-mentions] Watermark updated: ${maxTs}`);
454
+ // Phase 2b: DM channel polling — catches thread replies that search misses
455
+ try {
456
+ await pollDmChannels(lastTs, seenTs, async (msg, seen) => {
457
+ await _processMention(msg, seen, stats);
458
+ });
459
+ } catch (err) {
460
+ console.warn(`[slack-mentions] DM polling error: ${err.message}`);
461
+ }
462
+
463
+ if (stats.maxTs && stats.maxTs !== lastTs) {
464
+ saveWatermark(stats.maxTs);
465
+ console.log(`[slack-mentions] Watermark updated: ${stats.maxTs}`);
417
466
  }
418
467
 
419
- if (newMentions === 0) {
468
+ if (stats.newMentions === 0) {
420
469
  console.log('[slack-mentions] No new mentions.');
421
470
  } else {
422
- console.log(`[slack-mentions] Processed ${newMentions} mention(s): ${tasksCreated} task(s), ${questionsFound} question(s).`);
471
+ console.log(`[slack-mentions] Processed ${stats.newMentions} mention(s): ${stats.tasksCreated} task(s), ${stats.questionsFound} question(s).`);
472
+ }
473
+ }
474
+
475
+ // ── DM channel polling (catches thread replies that search misses) ──
476
+
477
+ function parseDmMessages(rawText) {
478
+ const messages = [];
479
+ // Slack MCP read_channel returns messages in a structured format
480
+ const blocks = rawText.split(/(?=--- (?:Message|Reply) \d)/);
481
+
482
+ for (const block of blocks) {
483
+ if (!block.trim()) continue;
484
+
485
+ const fromMatch = block.match(/From:\s*(.+?)(?:\s*\(ID:\s*(\w+)\)|\n)/);
486
+ const fromName = fromMatch ? fromMatch[1].trim() : null;
487
+ const fromUserId = fromMatch ? fromMatch[2] : null;
488
+
489
+ const tsMatch = block.match(/(?:Message[_ ]ts|TS|Timestamp):\s*(\d+\.\d+)/i);
490
+ const msgTs = tsMatch ? tsMatch[1] : null;
491
+
492
+ const threadMatch = block.match(/Thread[_ ]ts:\s*(\d+\.\d+)/i);
493
+ const threadTs = threadMatch ? threadMatch[1] : null;
494
+
495
+ const linkMatch = block.match(/Permalink:\s*\[.*?\]\((https?:\/\/[^\s)]+)\)/);
496
+ let permalink = linkMatch ? linkMatch[1] : null;
497
+ if (permalink) permalink = permalink.replace(/^(https:\/\/\w+)\.slack\.com/, '$1.enterprise.slack.com');
498
+
499
+ // Extract text content — everything after metadata lines
500
+ const textMatch = block.match(/(?:Text|Content|Message):\s*\n?([\s\S]*?)(?:\n---|$)/i);
501
+ let content = textMatch ? textMatch[1].trim() : '';
502
+ if (!content) {
503
+ // Fallback: grab everything after the last metadata line
504
+ const lines = block.split('\n');
505
+ const metaEnd = lines.reduce((last, l, i) => /^(?:From|Time|TS|Thread|Permalink|Channel):/.test(l) ? i : last, -1);
506
+ content = lines.slice(metaEnd + 1).join('\n').trim();
507
+ }
508
+
509
+ if (msgTs && /@wall-?e/i.test(content)) {
510
+ messages.push({ ts: msgTs, threadTs, fromName, fromUserId, permalink, content });
511
+ }
512
+ }
513
+
514
+ return messages;
515
+ }
516
+
517
+ async function pollDmChannels(lastTs, seenTs, processMsg) {
518
+ // Gather known channel IDs from existing Slack tasks and watched threads
519
+ const channelIds = new Set();
520
+
521
+ const allTasks = brain.listTasks({ source: 'slack' });
522
+ for (const t of allTasks) {
523
+ if (t.source_ref) {
524
+ const m = t.source_ref.match(/\/archives\/([A-Z0-9]+)\//);
525
+ if (m) channelIds.add(m[1]);
526
+ }
527
+ }
528
+
529
+ const activeThreads = brain.listActiveSlackThreads(WATCH_DURATION_MS);
530
+ for (const th of activeThreads) {
531
+ if (th.channel_id) channelIds.add(th.channel_id);
532
+ }
533
+
534
+ if (channelIds.size === 0) return;
535
+
536
+ // Only poll DM channels (D prefix) and group DMs (G prefix) — skip public channels to avoid noise
537
+ const dmChannels = [...channelIds].filter(id => /^[DG]/.test(id));
538
+ if (dmChannels.length === 0) return;
539
+
540
+ console.log(`[slack-mentions] Polling ${dmChannels.length} DM channel(s) for missed mentions`);
541
+
542
+ for (const channelId of dmChannels) {
543
+ try {
544
+ const result = await slackMcp.callSlackMcp('slack_read_channel', {
545
+ channel_id: channelId,
546
+ limit: 20,
547
+ });
548
+ const rawText = extractText(result);
549
+ if (!rawText || rawText.length < 10) continue;
550
+
551
+ const messages = parseDmMessages(rawText);
552
+ for (const msg of messages) {
553
+ if (lastTs && msg.ts <= lastTs) continue;
554
+ if (seenTs.has(msg.ts)) continue;
555
+
556
+ // Check sender is owner
557
+ const senderName = (msg.fromName || '').toLowerCase();
558
+ const isFromOwner = senderName.includes(OWNER_HANDLE.toLowerCase()) ||
559
+ senderName.includes(OWNER_NAME.split(' ')[0].toLowerCase());
560
+ if (!isFromOwner) continue;
561
+
562
+ msg.channelId = channelId;
563
+ await processMsg(msg, seenTs);
564
+ }
565
+
566
+ await sleep(PAUSE_MS);
567
+ } catch (err) {
568
+ console.warn(`[slack-mentions] DM poll error for ${channelId}: ${err.message}`);
569
+ }
423
570
  }
424
571
  }
425
572
 
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: my-data-fetcher
3
+ description: Fetches data from an external source and stores observations as memories
4
+ version: 0.1.0
5
+ execution: agent
6
+ trigger:
7
+ type: interval
8
+ interval_ms: 3600000
9
+ tags: [data, sync]
10
+ requires:
11
+ env: []
12
+ config:
13
+ source_url:
14
+ type: string
15
+ description: "URL or identifier of the data source"
16
+ ---
17
+
18
+ # Data Fetcher
19
+
20
+ Use the available tools to fetch data from the configured source. Parse the response
21
+ and return a structured list of observations. Each observation should include:
22
+
23
+ - A clear subject line
24
+ - The relevant data points
25
+ - A timestamp if available
26
+
27
+ Focus on new or changed data since the last run.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: my-action
3
+ description: A manually triggered one-shot action
4
+ version: 0.1.0
5
+ execution: agent
6
+ trigger:
7
+ type: manual
8
+ tags: [utility]
9
+ ---
10
+
11
+ # Manual Action
12
+
13
+ When triggered, perform the following steps:
14
+
15
+ 1. Gather the necessary context
16
+ 2. Execute the action
17
+ 3. Report the result
18
+
19
+ This skill runs on-demand only — it will not run automatically.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: my-checker
3
+ description: Periodically checks a condition and reports changes
4
+ version: 0.1.0
5
+ execution: agent
6
+ trigger:
7
+ type: interval
8
+ interval_ms: 300000
9
+ tags: [monitoring]
10
+ config:
11
+ check_target:
12
+ type: string
13
+ description: "What to monitor (URL, file path, service name)"
14
+ alert_on:
15
+ type: string
16
+ default: change
17
+ description: "When to alert: 'change', 'error', 'threshold'"
18
+ ---
19
+
20
+ # Periodic Checker
21
+
22
+ Check the configured target at each interval. Compare the current state
23
+ with the previous run. If a notable change is detected:
24
+
25
+ 1. Summarize what changed
26
+ 2. Assess the importance (low/medium/high)
27
+ 3. Store the observation as a memory
28
+
29
+ Only report meaningful changes — skip routine/expected variations.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: my-script
3
+ description: Runs a custom Node.js script
4
+ version: 0.1.0
5
+ execution: script
6
+ entry: run.js
7
+ trigger:
8
+ type: manual
9
+ tags: [automation]
10
+ config:
11
+ input_path:
12
+ type: string
13
+ description: "Path to input file or directory"
14
+ ---
15
+
16
+ # Script Runner
17
+
18
+ This skill executes `run.js` in the skill directory. The script receives
19
+ configuration via the `WALL_E_SKILL_CONFIG` environment variable (JSON).
20
+
21
+ Create a `run.js` file alongside this SKILL.md with your logic.
@@ -59,10 +59,15 @@ function readClaudeSkills() {
59
59
  } catch {}
60
60
  }
61
61
 
62
- return skills;
62
+ // Deduplicate: keep latest entry per name (last one wins since deeper/newer versions scan later)
63
+ const seen = new Map();
64
+ for (const s of skills) {
65
+ seen.set(s.name, s);
66
+ }
67
+ return Array.from(seen.values());
63
68
  }
64
69
 
65
- function scanPluginSkills(dir, skills, depth) {
70
+ function scanPluginSkills(dir, skills, depth, parentName) {
66
71
  if ((depth || 0) > 4) return;
67
72
  try {
68
73
  for (const entry of fs.readdirSync(dir)) {
@@ -77,11 +82,18 @@ function scanPluginSkills(dir, skills, depth) {
77
82
  try {
78
83
  const content = fs.readFileSync(sfPath, 'utf8');
79
84
  const description = extractDescription(content);
80
- skills.push({ name: entry, description, source: 'plugin', path: sfPath });
85
+ // Derive name from path: prefer parent dir over version-like entry names
86
+ const isVersion = /^[\da-f]{6,}$|^\d+\.\d+/.test(entry);
87
+ const name = isVersion ? (parentName || entry) : entry;
88
+ // Derive group from path (e.g., "slack" from .../slack/1.0.0/README.md)
89
+ const pathParts = sfPath.split(path.sep);
90
+ const pluginIdx = pathParts.findIndex(p => p.includes('plugins'));
91
+ const group = pluginIdx >= 0 && pluginIdx + 1 < pathParts.length ? pathParts[pluginIdx + 1] : '';
92
+ skills.push({ name, description, source: 'plugin', path: sfPath, group });
81
93
  } catch {}
82
94
  }
83
95
  }
84
- scanPluginSkills(fullPath, skills, (depth || 0) + 1);
96
+ scanPluginSkills(fullPath, skills, (depth || 0) + 1, entry);
85
97
  }
86
98
  }
87
99
  } catch {}
@@ -26,8 +26,12 @@ async function runSkill(skill, opts = {}) {
26
26
  let memoriesCreated = 0;
27
27
 
28
28
  // Agentic loop — Claude may make multiple tool calls
29
- const MAX_TURNS = 5;
29
+ // Allow SKILL.md to override max_turns and timeout_ms
30
+ const MAX_TURNS = opts.max_turns || 5;
31
+ const timeoutMs = opts.timeout_ms || 120000;
32
+ try {
30
33
  for (let turn = 0; turn < MAX_TURNS; turn++) {
34
+ if (Date.now() - startTime > timeoutMs) break;
31
35
  const response = await client.messages.create({
32
36
  model: opts.model || process.env.WALLE_MODEL || 'claude-haiku-4-5-20251001',
33
37
  max_tokens: 4096,
@@ -98,6 +102,24 @@ async function runSkill(skill, opts = {}) {
98
102
  brain.updateSkillStats(skill.id, success);
99
103
 
100
104
  return { success, memoriesCreated, toolCalls: allToolCalls.length, duration };
105
+ } catch (err) {
106
+ const duration = Date.now() - startTime;
107
+ // Store full error stack for debugging
108
+ const errorDetail = err.stack || err.message || String(err);
109
+ try {
110
+ brain.insertSkillExecution({
111
+ skill_id: skill.id,
112
+ status: 'failure',
113
+ tool_calls: JSON.stringify(allToolCalls),
114
+ tool_results: JSON.stringify(allToolResults.map(r => ({ name: r.name, success: r.success, error: r.error }))),
115
+ memories_created: memoriesCreated,
116
+ error: errorDetail,
117
+ duration_ms: duration,
118
+ });
119
+ brain.updateSkillStats(skill.id, false);
120
+ } catch {}
121
+ throw err;
122
+ }
101
123
  }
102
124
 
103
125
  function buildDefaultPrompt(skill, ownerName) {