create-walle 0.9.0 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +35 -31
  2. package/package.json +3 -3
  3. package/template/CLAUDE.md +23 -1
  4. package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
  5. package/template/claude-task-manager/db.js +38 -0
  6. package/template/claude-task-manager/public/css/walle.css +123 -0
  7. package/template/claude-task-manager/public/index.html +962 -69
  8. package/template/claude-task-manager/public/js/walle.js +374 -121
  9. package/template/claude-task-manager/public/prompts.html +84 -26
  10. package/template/claude-task-manager/public/walle-icon.svg +45 -0
  11. package/template/claude-task-manager/server.js +69 -4
  12. package/template/docs/openclaw-vs-walle-comparison.md +103 -0
  13. package/template/package.json +1 -1
  14. package/template/wall-e/agent.js +63 -3
  15. package/template/wall-e/api-walle.js +42 -0
  16. package/template/wall-e/brain.js +182 -5
  17. package/template/wall-e/channels/imessage-channel.js +4 -1
  18. package/template/wall-e/channels/slack-channel.js +3 -1
  19. package/template/wall-e/chat.js +106 -224
  20. package/template/wall-e/context/compactor.js +163 -0
  21. package/template/wall-e/context/context-builder.js +355 -0
  22. package/template/wall-e/context/state-snapshot.js +209 -0
  23. package/template/wall-e/context/token-counter.js +55 -0
  24. package/template/wall-e/context/topic-matcher.js +79 -0
  25. package/template/wall-e/core-tasks.js +24 -0
  26. package/template/wall-e/events/event-bus.js +23 -0
  27. package/template/wall-e/loops/ingest.js +4 -0
  28. package/template/wall-e/loops/initiative.js +316 -0
  29. package/template/wall-e/loops/tasks.js +55 -5
  30. package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
  31. package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
  32. package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
  33. package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
  34. package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
  35. package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
  36. package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
  37. package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
  38. package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
  39. package/template/wall-e/tests/brain.test.js +4 -4
  40. package/template/wall-e/tests/compactor.test.js +323 -0
  41. package/template/wall-e/tests/context-builder.test.js +215 -0
  42. package/template/wall-e/tests/event-bus.test.js +74 -0
  43. package/template/wall-e/tests/initiative.test.js +354 -0
  44. package/template/wall-e/tests/proactive-alerts.test.js +140 -0
  45. package/template/wall-e/tests/session-persistence.test.js +335 -0
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Lightweight keyword/regex topic classifier.
5
+ * No Claude call — pure pattern matching for fast context selection.
6
+ */
7
+
8
+ const TOPIC_PATTERNS = {
9
+ people: {
10
+ // Names of known people, relationship words
11
+ keywords: ['who', 'person', 'team', 'report', 'manager', 'colleague', 'relationship', 'trust'],
12
+ regex: /\b(who\s+is|reports?\s+to|works?\s+with|relationship|team\s+member|direct\s+report|skip.?level)\b/i,
13
+ },
14
+ slack: {
15
+ keywords: ['slack', 'channel', 'message', 'dm', 'thread', 'mention', 'said', 'told', 'wrote', 'posted'],
16
+ regex: /\b(slack|channel|#\w+|message[sd]?|DM|thread|mention|said|told|wrote|posted|conversation)\b/i,
17
+ },
18
+ calendar: {
19
+ keywords: ['calendar', 'meeting', 'event', 'schedule', 'tomorrow', 'today', 'appointment', '1:1', 'standup'],
20
+ regex: /\b(calendar|meeting|event|schedule|tomorrow|today|tonight|this\s+week|next\s+week|appointment|1[:-]1|standup|sync|deadline)\b/i,
21
+ },
22
+ tasks: {
23
+ keywords: ['task', 'todo', 'reminder', 'deadline', 'priority', 'action item', 'backlog'],
24
+ regex: /\b(task|todo|to.?do|reminder|deadline|priority|action\s+item|backlog|sprint|jira|ticket)\b/i,
25
+ },
26
+ technical: {
27
+ keywords: ['code', 'bug', 'deploy', 'api', 'database', 'server', 'git', 'pr', 'review', 'test'],
28
+ regex: /\b(code|bug|deploy|api|database|server|git|pr|pull\s+request|review|test|build|pipeline|docker|kubernetes|aws|gcp)\b/i,
29
+ },
30
+ tools: {
31
+ keywords: ['tool', 'mcp', 'skill', 'fetch', 'search', 'run', 'execute', 'automation'],
32
+ regex: /\b(tool|mcp|skill|fetch|search\s+for|run\s+a|execute|automation|script|command|shell)\b/i,
33
+ },
34
+ weather: {
35
+ keywords: ['weather', 'temperature', 'rain', 'snow', 'forecast', 'outside'],
36
+ regex: /\b(weather|temperature|rain|snow|forecast|outside|humid|cold|hot|warm|sunny|cloudy)\b/i,
37
+ },
38
+ personal: {
39
+ keywords: ['feel', 'think about', 'opinion', 'preference', 'like', 'dislike', 'hobby', 'family'],
40
+ regex: /\b(feel|opinion|preference|like|dislike|hobby|family|personal|habit|routine|favorite)\b/i,
41
+ },
42
+ work: {
43
+ keywords: ['project', 'okr', 'goal', 'strategy', 'roadmap', 'planning', 'quarterly', 'perf'],
44
+ regex: /\b(project|okr|goal|strategy|roadmap|planning|quarterly|perf|review|promotion|career|leadership)\b/i,
45
+ },
46
+ };
47
+
48
+ /**
49
+ * Classify a user message into topic tags.
50
+ * Returns array of matching topic names, e.g. ['people', 'slack'].
51
+ * Always returns at least ['general'] if no specific match.
52
+ */
53
+ function classifyTopics(message) {
54
+ if (!message || typeof message !== 'string') return ['general'];
55
+
56
+ const topics = [];
57
+ const lower = message.toLowerCase();
58
+
59
+ for (const [topic, { keywords, regex }] of Object.entries(TOPIC_PATTERNS)) {
60
+ if (regex.test(message)) {
61
+ topics.push(topic);
62
+ continue;
63
+ }
64
+ // Fallback: check if any keyword appears as a substring
65
+ if (keywords.some(kw => lower.includes(kw))) {
66
+ topics.push(topic);
67
+ }
68
+ }
69
+
70
+ // Check for people names — if message contains a capitalized word that looks like a name
71
+ if (!topics.includes('people') && /\b[A-Z][a-z]{2,}\b/.test(message)) {
72
+ // Could be a person name — add 'people' as a soft match
73
+ topics.push('people');
74
+ }
75
+
76
+ return topics.length > 0 ? topics : ['general'];
77
+ }
78
+
79
+ module.exports = { classifyTopics, TOPIC_PATTERNS };
@@ -50,4 +50,28 @@ module.exports = [
50
50
  skill_config: JSON.stringify({ days_back: 3650, sync_inbox: true }),
51
51
  priority: 'normal',
52
52
  },
53
+ {
54
+ title: 'Proactive Alerts',
55
+ description: 'Check for time-sensitive items: upcoming meetings, @mentions, approaching deadlines, failed tasks.',
56
+ type: 'recurring',
57
+ schedule: 'every 5m',
58
+ skill: 'proactive-alerts',
59
+ priority: 'normal',
60
+ },
61
+ {
62
+ title: 'Slack: @walle Mentions',
63
+ description: 'Poll Slack for @walle mentions from the owner. Creates tasks for assignments, replies in-thread for questions.',
64
+ type: 'recurring',
65
+ schedule: 'every 30s',
66
+ skill: 'slack-mentions',
67
+ priority: 'normal',
68
+ },
69
+ {
70
+ title: 'Weekly Reflection',
71
+ description: 'Generate a weekly reflection analyzing patterns, decisions, and insights from the past 7 days.',
72
+ type: 'recurring',
73
+ schedule: 'weekly Sunday at 8pm',
74
+ skill: 'weekly-reflection',
75
+ priority: 'normal',
76
+ },
53
77
  ];
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+ const EventEmitter = require('events');
3
+
4
+ class WalleEventBus extends EventEmitter {
5
+ constructor() {
6
+ super();
7
+ this.setMaxListeners(20);
8
+ }
9
+
10
+ emitMessage(channel, text, sender) {
11
+ this.emit('message', { channel, text, sender, ts: Date.now() });
12
+ }
13
+
14
+ emitHighImportance(memoryId, content) {
15
+ this.emit('high_importance', { memoryId, content, ts: Date.now() });
16
+ }
17
+
18
+ emitWebhook(source, payload) {
19
+ this.emit('webhook', { source, payload, ts: Date.now() });
20
+ }
21
+ }
22
+
23
+ module.exports = new WalleEventBus();
@@ -1,4 +1,5 @@
1
1
  const brain = require('../brain');
2
+ const eventBus = require('../events/event-bus');
2
3
 
3
4
  async function runOnce(adapters) {
4
5
  const checkpoint = brain.getCheckpoint('ingest');
@@ -16,6 +17,9 @@ async function runOnce(adapters) {
16
17
  if (!latestTimestamp || mem.timestamp > latestTimestamp) {
17
18
  latestTimestamp = mem.timestamp;
18
19
  }
20
+ if (mem.importance >= 0.8 || mem.memory_type === 'message_received') {
21
+ eventBus.emitHighImportance(result?.id, (mem.content || '').slice(0, 200));
22
+ }
19
23
  }
20
24
  }
21
25
  } catch (err) {
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ const brain = require('../brain');
4
+ const { buildStateSnapshot, isStateEmpty, formatSnapshot } = require('../context/state-snapshot');
5
+ const { canActAutonomously, getDomainConfidence } = require('../decision/confidence');
6
+ const { buildClientOpts } = require('../extraction/knowledge-extractor');
7
+ const Anthropic = require('@anthropic-ai/sdk');
8
+ const { v4: uuidv4 } = require('uuid');
9
+
10
+ const COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes between same decisions
11
+
12
+ /**
13
+ * Get an Anthropic client configured for Portkey gateway or direct API.
14
+ * Reuses the same pattern as knowledge-extractor.js
15
+ */
16
+ function getInitiativeClient() {
17
+ const opts = buildClientOpts();
18
+ const isPortkey = opts.defaultHeaders && opts.defaultHeaders['x-portkey-api-key'];
19
+
20
+ if (isPortkey) {
21
+ return {
22
+ messages: {
23
+ async create(params, fetchOpts) {
24
+ const headers = {
25
+ 'content-type': 'application/json',
26
+ 'anthropic-version': '2023-06-01',
27
+ ...opts.defaultHeaders,
28
+ };
29
+ const res = await fetch(opts.baseURL + '/messages', {
30
+ method: 'POST',
31
+ headers,
32
+ body: JSON.stringify(params),
33
+ signal: fetchOpts?.signal,
34
+ });
35
+ if (!res.ok) {
36
+ const text = await res.text();
37
+ throw new Error(`${res.status} ${text}`);
38
+ }
39
+ return res.json();
40
+ }
41
+ }
42
+ };
43
+ }
44
+
45
+ return new Anthropic(opts);
46
+ }
47
+
48
+ /**
49
+ * Build the system prompt for the initiative engine.
50
+ */
51
+ function buildInitiativePrompt(snapshot, recentDecisions) {
52
+ const snapshotText = formatSnapshot(snapshot);
53
+
54
+ const recentText = recentDecisions.length > 0
55
+ ? recentDecisions.map(d => `- ${d.decision}: ${d.decision_data || '(no data)'}`).join('\n')
56
+ : '(none)';
57
+
58
+ return `You are WALL-E's initiative engine. You observe the owner's current state and decide whether to take action.
59
+
60
+ CURRENT STATE:
61
+ ${snapshotText}
62
+
63
+ RECENT DECISIONS (avoid repeating):
64
+ ${recentText}
65
+
66
+ PROACTIVITY RULES:
67
+ - Default to "noop" -- only act when there's clear value
68
+ - Time-sensitive items (calendar in <15min, direct @mention) are highest priority
69
+ - Never send the same notification twice within 30 minutes
70
+ - Consider: is this ACTUALLY useful right now, or just busy work?
71
+
72
+ Respond in this EXACT format:
73
+ DECISION: noop|notify|create_task|run_skill
74
+ DOMAIN: general|calendar|slack|tasks|knowledge
75
+ REASONING: <1-2 sentences explaining why>
76
+ DATA: <JSON with details if not noop>`;
77
+ }
78
+
79
+ /**
80
+ * Parse Claude's structured decision output.
81
+ */
82
+ function parseDecision(text) {
83
+ const result = {
84
+ decision: 'noop',
85
+ domain: 'general',
86
+ reasoning: '',
87
+ data: null,
88
+ };
89
+
90
+ const decisionMatch = text.match(/^DECISION:\s*(\S+)/m);
91
+ if (decisionMatch) {
92
+ const d = decisionMatch[1].toLowerCase().trim();
93
+ if (['noop', 'notify', 'create_task', 'run_skill'].includes(d)) {
94
+ result.decision = d;
95
+ }
96
+ }
97
+
98
+ const domainMatch = text.match(/^DOMAIN:\s*(\S+)/m);
99
+ if (domainMatch) {
100
+ result.domain = domainMatch[1].toLowerCase().trim();
101
+ }
102
+
103
+ const reasoningMatch = text.match(/^REASONING:\s*(.+)/m);
104
+ if (reasoningMatch) {
105
+ result.reasoning = reasoningMatch[1].trim();
106
+ }
107
+
108
+ const dataMatch = text.match(/^DATA:\s*(.+)/ms);
109
+ if (dataMatch) {
110
+ const raw = dataMatch[1].trim();
111
+ if (raw && raw !== 'null' && raw !== 'none' && raw !== 'N/A') {
112
+ try {
113
+ // Try to extract JSON from the data line (may span multiple lines)
114
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
115
+ if (jsonMatch) {
116
+ result.data = JSON.parse(jsonMatch[0]);
117
+ } else {
118
+ // No JSON object found, store as raw string
119
+ result.data = { raw: raw.slice(0, 500) };
120
+ }
121
+ } catch (_) {
122
+ // If JSON parse fails, store as string
123
+ result.data = { raw: raw.slice(0, 500) };
124
+ }
125
+ }
126
+ }
127
+
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * Get the autonomy tier for a domain.
133
+ */
134
+ function getTier(domain) {
135
+ try {
136
+ const dc = getDomainConfidence(domain);
137
+ return dc.current_tier;
138
+ } catch (_) {
139
+ return 1;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Log an initiative decision to the database.
145
+ */
146
+ function logInitiative(trigger, snapshot, reasoning, decision, decisionData, autonomyTier) {
147
+ return brain.insertInitiativeLog({
148
+ trigger,
149
+ state_snapshot: JSON.stringify(snapshot).slice(0, 5000),
150
+ reasoning: reasoning ? reasoning.slice(0, 2000) : null,
151
+ decision,
152
+ decision_data: decisionData ? JSON.stringify(decisionData).slice(0, 5000) : null,
153
+ autonomy_tier: autonomyTier || 1,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Execute a decision based on its type.
159
+ */
160
+ async function executeDecision(decision, snapshot) {
161
+ switch (decision.decision) {
162
+ case 'noop':
163
+ return;
164
+
165
+ case 'notify': {
166
+ // Send notification via chat message log (visible in CTM dashboard)
167
+ const msg = decision.data?.message || decision.reasoning || 'Initiative notification';
168
+ brain.insertChatMessage({
169
+ role: 'assistant',
170
+ content: `[Initiative] ${msg}`,
171
+ channel: 'initiative',
172
+ session_id: 'initiative-' + new Date().toISOString().slice(0, 10),
173
+ });
174
+ // Also send macOS notification for visibility
175
+ try {
176
+ const { sendNotification } = require('../tools/local-tools');
177
+ await sendNotification('WALL-E', msg.slice(0, 200));
178
+ } catch {}
179
+ return;
180
+ }
181
+
182
+ case 'create_task': {
183
+ const taskData = decision.data || {};
184
+ brain.insertTask({
185
+ title: taskData.title || decision.reasoning || 'Auto-created task',
186
+ description: taskData.description || decision.reasoning,
187
+ priority: taskData.priority || 'normal',
188
+ type: 'once',
189
+ due_at: taskData.due_at || null,
190
+ });
191
+ return;
192
+ }
193
+
194
+ case 'run_skill': {
195
+ // Attempt to run a skill by name
196
+ const skillName = decision.data?.skill;
197
+ if (!skillName) return;
198
+ try {
199
+ const { executeSkill } = require('../skills/skill-executor');
200
+ const skill = brain.getSkillByName(skillName);
201
+ if (skill && skill.enabled) {
202
+ await executeSkill(skill);
203
+ }
204
+ } catch (err) {
205
+ console.error('[wall-e] Initiative skill execution failed:', err.message);
206
+ }
207
+ return;
208
+ }
209
+
210
+ default:
211
+ return;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Check cooldown: is the same decision type + domain already in recent logs?
217
+ */
218
+ function isOnCooldown(decision, recentDecisions) {
219
+ if (decision.decision === 'noop') return false;
220
+ return recentDecisions.some(d => {
221
+ if (d.decision !== decision.decision) return false;
222
+ // For notify, only cooldown if the message is the same
223
+ if (decision.decision === 'notify') {
224
+ try {
225
+ const prev = JSON.parse(d.decision_data);
226
+ return prev?.message && decision.data?.message &&
227
+ prev.message === decision.data.message;
228
+ } catch (_) {
229
+ return true; // Can't parse — assume duplicate
230
+ }
231
+ }
232
+ // For other decision types, same type = on cooldown
233
+ return true;
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Main initiative loop function. Called on an interval from agent.js
239
+ */
240
+ async function runInitiativeLoop(opts = {}) {
241
+ const snapshot = buildStateSnapshot();
242
+
243
+ // Check if there's anything worth reasoning about
244
+ if (isStateEmpty(snapshot)) {
245
+ return { decision: 'noop', reasoning: 'Nothing to evaluate' };
246
+ }
247
+
248
+ // Check cooldown -- avoid repeating recent decisions
249
+ const db = brain.getDb();
250
+ const recentDecisions = db.prepare(
251
+ "SELECT decision, decision_data FROM initiative_log WHERE created_at > datetime('now', '-30 minutes') ORDER BY created_at DESC LIMIT 10"
252
+ ).all();
253
+
254
+ // Call Claude with a focused prompt
255
+ const client = opts.client || getInitiativeClient();
256
+ const model = opts.model || process.env.WALLE_MODEL || 'claude-haiku-4-5-20251001';
257
+
258
+ const controller = new AbortController();
259
+ const timeout = setTimeout(() => controller.abort(), 30000);
260
+
261
+ let decision;
262
+ try {
263
+ const response = await client.messages.create({
264
+ model,
265
+ max_tokens: 1024,
266
+ system: buildInitiativePrompt(snapshot, recentDecisions),
267
+ messages: [{ role: 'user', content: 'Evaluate current state and decide your next action.' }],
268
+ }, { signal: controller.signal });
269
+
270
+ const text = response.content
271
+ .filter(b => b.type === 'text')
272
+ .map(b => b.text)
273
+ .join('');
274
+
275
+ decision = parseDecision(text);
276
+ } finally {
277
+ clearTimeout(timeout);
278
+ }
279
+
280
+ // Gate by autonomy tier
281
+ const domain = decision.domain || 'general';
282
+ const isHighRisk = decision.decision === 'notify';
283
+
284
+ if (decision.decision !== 'noop') {
285
+ // Check cooldown
286
+ if (isOnCooldown(decision, recentDecisions)) {
287
+ logInitiative(opts.trigger || 'scheduled', snapshot, decision.reasoning, 'cooldown', decision.data, getTier(domain));
288
+ return { decision: 'cooldown', reasoning: decision.reasoning };
289
+ }
290
+
291
+ // Proactivity model: start conservative, graduate
292
+ if (!canActAutonomously(domain, isHighRisk)) {
293
+ // Below tier threshold -- log observation but don't act
294
+ logInitiative(opts.trigger || 'scheduled', snapshot, decision.reasoning, 'observed', decision.data, getTier(domain));
295
+ return { decision: 'observed', reasoning: decision.reasoning, original: decision.decision };
296
+ }
297
+ }
298
+
299
+ // Execute the decision
300
+ if (decision.decision !== 'noop') {
301
+ await executeDecision(decision, snapshot);
302
+ }
303
+ logInitiative(opts.trigger || 'scheduled', snapshot, decision.reasoning, decision.decision, decision.data, getTier(domain));
304
+
305
+ return decision;
306
+ }
307
+
308
+ module.exports = {
309
+ runInitiativeLoop,
310
+ parseDecision,
311
+ buildInitiativePrompt,
312
+ isOnCooldown,
313
+ getInitiativeClient,
314
+ // Exported for testing
315
+ _test: { logInitiative, executeDecision, getTier },
316
+ };
@@ -115,8 +115,8 @@ async function runDueTasks() {
115
115
  console.error('[tasks] Briefing item consolidation failed:', e.message);
116
116
  }
117
117
 
118
- // Notify (skip frequent recurring)
119
- if (task.type !== 'recurring' || !task.schedule?.match(/every\s+\d+\s*m/i)) {
118
+ // Notify (skip frequent recurring — minutes or seconds intervals)
119
+ if (task.type !== 'recurring' || !task.schedule?.match(/every\s+\d+\s*[ms]/i)) {
120
120
  try {
121
121
  const { sendNotification } = require('../tools/local-tools');
122
122
  await sendNotification('WALL-E Task', task.title + ' — done');
@@ -326,6 +326,12 @@ function executeScript(taskId, script, checkpoint, extraEnv) {
326
326
  // ── Chat execution with log streaming ──
327
327
 
328
328
  async function executeChat(taskId, task) {
329
+ // Use multi-turn for complex tasks
330
+ if (task.execution === 'multi-turn') {
331
+ return executeMultiTurnChat(taskId, task);
332
+ }
333
+
334
+ // Single-turn (existing behavior)
329
335
  const chatModule = require('../chat');
330
336
  const prompt = buildTaskPrompt(task);
331
337
 
@@ -344,6 +350,50 @@ async function executeChat(taskId, task) {
344
350
  return result.reply || '';
345
351
  }
346
352
 
353
+ /**
354
+ * Execute a task using multiple chat turns, maintaining session state.
355
+ * Each turn can use tools, and progress is checkpointed between turns.
356
+ */
357
+ async function executeMultiTurnChat(taskId, task) {
358
+ const chatModule = require('../chat');
359
+ const sessionId = `task-${task.id}`;
360
+ const MAX_TASK_TURNS = 5;
361
+ let lastReply = '';
362
+
363
+ for (let turn = 0; turn < MAX_TASK_TURNS; turn++) {
364
+ const prompt = turn === 0
365
+ ? buildTaskPrompt(task)
366
+ : 'Continue working on this task. Review your progress so far and take the next step. If you are done, include [TASK COMPLETE] in your response.';
367
+
368
+ appendLog(taskId, `Multi-turn ${turn + 1}/${MAX_TASK_TURNS}...`);
369
+
370
+ const result = await chatModule.chat(prompt, {
371
+ channel: 'task',
372
+ session_id: sessionId,
373
+ onProgress: (event) => {
374
+ if (event.type === 'tool_call') appendLog(taskId, event.summary);
375
+ else if (event.type === 'tool_done') appendLog(taskId, `Done: ${event.summary}`);
376
+ },
377
+ });
378
+
379
+ lastReply = result.reply || '';
380
+ appendLog(taskId, `Turn ${turn + 1} result: ${lastReply.slice(0, 200)}`);
381
+
382
+ // Check if task declared itself complete
383
+ if (lastReply.includes('[TASK COMPLETE]') || lastReply.includes('[DONE]')) {
384
+ appendLog(taskId, 'Task self-declared complete');
385
+ break;
386
+ }
387
+
388
+ // Checkpoint between turns
389
+ brain.updateTask(taskId, {
390
+ checkpoint: JSON.stringify({ turn: turn + 1, partial: lastReply.slice(0, 2000) }),
391
+ });
392
+ }
393
+
394
+ return lastReply;
395
+ }
396
+
347
397
  function buildTaskPrompt(task) {
348
398
  let prompt = `[TASK] ${task.title}`;
349
399
  if (task.description) prompt += `\n\n${task.description}`;
@@ -394,11 +444,11 @@ function computeNextDue(schedule) {
394
444
  if (schedule === 'daily') return new Date(now.getTime() + 86400000).toISOString();
395
445
  if (schedule === 'weekly') return new Date(now.getTime() + 604800000).toISOString();
396
446
 
397
- const everyMatch = schedule.match(/^every\s+(\d+)\s*(m|h|d)$/i);
447
+ const everyMatch = schedule.match(/^every\s+(\d+)\s*(s|m|h|d)$/i);
398
448
  if (everyMatch) {
399
449
  const n = parseInt(everyMatch[1]);
400
450
  const unit = everyMatch[2].toLowerCase();
401
- const ms = unit === 'h' ? n * 3600000 : unit === 'm' ? n * 60000 : n * 86400000;
451
+ const ms = unit === 's' ? n * 1000 : unit === 'h' ? n * 3600000 : unit === 'm' ? n * 60000 : n * 86400000;
402
452
  return new Date(now.getTime() + ms).toISOString();
403
453
  }
404
454
 
@@ -484,4 +534,4 @@ function consolidateBriefingItems(task, resultText, timestamp) {
484
534
  }
485
535
  }
486
536
 
487
- module.exports = { runDueTasks, runTaskById, recoverInterruptedTasks, buildTaskPrompt, computeNextDue, getTaskLogs, clearTaskLogs, stopTask, executeSkill, consolidateBriefingItems, taskLogs };
537
+ module.exports = { runDueTasks, runTaskById, recoverInterruptedTasks, buildTaskPrompt, computeNextDue, getTaskLogs, clearTaskLogs, stopTask, executeSkill, executeMultiTurnChat, consolidateBriefingItems, taskLogs };
@@ -44,8 +44,10 @@ function readEmails() {
44
44
  const jxaArgs = ['-l', 'JavaScript', JXA_SCRIPT, '--days-back', String(daysBack)];
45
45
  if (syncInbox) jxaArgs.push('--inbox');
46
46
 
47
+ // Scale timeout with daysBack: 5 min for ≤7 days, up to 2 hours for a full year
48
+ const timeoutMs = daysBack <= 7 ? 300_000 : Math.min(daysBack * 20_000, 7_200_000);
47
49
  const result = execFileSync('osascript', jxaArgs, {
48
- timeout: 300_000, // 5 min for content fetching
50
+ timeout: timeoutMs,
49
51
  maxBuffer: 50 * 1024 * 1024,
50
52
  });
51
53
 
@@ -71,6 +71,47 @@ async function main() {
71
71
  }
72
72
  sections.push(calSection);
73
73
 
74
+ // ── 1b. Initiative Activity (last 24h) ──
75
+ let initiativeSection = '';
76
+ try {
77
+ const yesterday = new Date(now.getTime() - 24 * 3600000).toISOString();
78
+ const initiatives = db.prepare(`
79
+ SELECT decision, reasoning, decision_data, created_at
80
+ FROM initiative_log
81
+ WHERE decision != 'noop' AND decision != 'cooldown' AND created_at > ?
82
+ ORDER BY created_at DESC LIMIT 10
83
+ `).all(yesterday);
84
+
85
+ if (initiatives.length > 0) {
86
+ initiativeSection = '## WALL-E Activity (last 24h)\n\n';
87
+ for (const init of initiatives) {
88
+ const time = formatTime(init.created_at);
89
+ const action = init.decision === 'observed' ? 'Observed'
90
+ : init.decision === 'notify' ? 'Notified'
91
+ : init.decision === 'create_task' ? 'Created task'
92
+ : init.decision === 'run_skill' ? 'Ran skill'
93
+ : init.decision;
94
+ initiativeSection += `- ${time} -- ${action}: ${init.reasoning || '(no reason)'}\n`;
95
+ }
96
+ initiativeSection += '\n';
97
+ }
98
+ } catch {}
99
+ if (initiativeSection) sections.push(initiativeSection);
100
+
101
+ // ── 1c. Weekly Reflection (Mondays only) ──
102
+ if (now.getDay() === 1) { // Monday
103
+ try {
104
+ const lastWeekly = db.prepare(`
105
+ SELECT summary FROM daily_summaries
106
+ WHERE date > datetime('now', '-3 days') AND summary LIKE '%Weekly%'
107
+ ORDER BY date DESC LIMIT 1
108
+ `).get();
109
+ if (lastWeekly?.summary) {
110
+ sections.push(`## Last Week's Reflection\n\n${lastWeekly.summary.slice(0, 500)}\n`);
111
+ }
112
+ } catch {}
113
+ }
114
+
74
115
  // ── 2. Overnight Slack Activity ──
75
116
  const hoursBack = 18;
76
117
  const cutoff = new Date(now.getTime() - hoursBack * 3600000).toISOString();
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: proactive-alerts
3
+ description: >
4
+ Detect and surface time-sensitive items: upcoming meetings, direct @mentions,
5
+ approaching deadlines, failed tasks. Routes alerts through the initiative engine.
6
+ version: 1.0.0
7
+ author: wall-e
8
+ execution: script
9
+ entry: run.js
10
+ trigger:
11
+ type: interval
12
+ schedule: "every 5m"
13
+ tags: [alerts, proactive, mentions, calendar, deadlines]
14
+ permissions:
15
+ - brain:read
16
+ - calendar:read
17
+ ---
18
+ # Proactive Alerts
19
+
20
+ Checks for time-sensitive items and outputs structured alert data.