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
@@ -4,10 +4,13 @@ const brain = require('./brain');
4
4
  const { buildClientOpts } = require('./extraction/knowledge-extractor');
5
5
  const { executeLocalTool, LOCAL_TOOL_DEFINITIONS } = require('./tools/local-tools');
6
6
  const slackMcp = require('./tools/slack-mcp');
7
+ const { estimateTokens, estimateMessagesTokens } = require('./context/token-counter');
8
+ const { shouldCompact, compactToolResult, compactMessages } = require('./context/compactor');
9
+ const { buildSystemPrompt } = require('./context/context-builder');
7
10
 
8
11
  /**
9
12
  * Core "talk to WALL-E" handler.
10
- * Builds a system prompt from brain context and calls Claude.
13
+ * Uses dynamic context builder (Phase 3) instead of static system prompt.
11
14
  */
12
15
  function ensureBrainInit() {
13
16
  try { brain.getDb(); } catch {
@@ -17,214 +20,25 @@ function ensureBrainInit() {
17
20
 
18
21
  async function chat(message, opts = {}) {
19
22
  ensureBrainInit();
20
- const ownerName = brain.getOwnerName() || 'Owner';
21
23
  const channel = opts.channel || 'ctm';
22
24
 
23
- // Build brain contextpull ALL available data so WALL-E can answer directly
24
- const knowledge = brain.findKnowledge({ status: 'active' });
25
- const recentMemories = brain.listMemories({ limit: 20 });
26
- const people = brain.listPeople({});
27
- const stats = brain.getBrainStats();
28
- const pendingQuestions = brain.listQuestions({ status: 'pending', limit: 5 });
29
- const skills = brain.listSkills({});
30
- let domainConfidences = [];
31
- try { domainConfidences = brain.getDb().prepare('SELECT * FROM domain_confidence').all(); } catch {}
32
-
33
- // Pull a diverse sample of Slack messages to give WALL-E personality/style context
34
- let slackSample = '';
35
- try {
36
- const db = brain.getDb();
37
- // Get a spread of Slack messages across time — owner's outbound messages reveal personality
38
- const slackMsgs = db.prepare(`
39
- SELECT content, source_channel, timestamp FROM memories
40
- WHERE source = 'slack' AND direction = 'outbound' AND length(content) > 20
41
- ORDER BY RANDOM() LIMIT 20
42
- `).all();
43
- if (slackMsgs.length > 0) {
44
- slackSample = slackMsgs.map(m => {
45
- const date = m.timestamp ? m.timestamp.slice(0, 10) : '';
46
- return `[${date} #${m.source_channel || '?'}] ${(m.content || '').slice(0, 200)}`;
47
- }).join('\n');
48
- }
49
- } catch {}
25
+ // Build dynamic system prompt selects relevant context based on the user's message
26
+ const sessionId = opts.session_id || 'default';
27
+ const existingSession = brain.getSession(sessionId);
28
+ const systemPrompt = buildSystemPrompt(message, channel, {
29
+ sessionSummary: existingSession?.summary || null,
30
+ });
50
31
 
51
- // Load available MCP servers and their cached tools so WALL-E knows what it can do
52
- let mcpServerList = '';
53
- try {
54
- const { loadMcpConfigs } = require('./skills/mcp-client');
55
- const configs = loadMcpConfigs();
56
- // Read cached tools from brain DB
57
- let toolsByServer = {};
58
- try {
59
- const rows = brain.getDb().prepare('SELECT server, tool_name FROM mcp_tools_cache ORDER BY server').all();
60
- for (const r of rows) {
61
- if (!toolsByServer[r.server]) toolsByServer[r.server] = [];
62
- toolsByServer[r.server].push(r.tool_name);
63
- }
64
- } catch {} // table may not exist yet
65
- mcpServerList = Object.entries(configs).map(([name, cfg]) => {
66
- const hasAuth = cfg.oauth?.accessToken ? 'authenticated' : 'needs auth';
67
- const tools = toolsByServer[name];
68
- const toolStr = tools ? ` — tools: ${tools.slice(0, 10).join(', ')}${tools.length > 10 ? ' +' + (tools.length - 10) + ' more' : ''}` : '';
69
- return `- ${name}: ${cfg.type || 'stdio'} [${hasAuth}]${toolStr}`;
70
- }).join('\n');
71
- } catch {}
72
-
73
- // Build knowledge summary
74
- const knowledgeSummary = knowledge.slice(0, 50).map(k =>
75
- `- ${k.subject} ${k.predicate} ${k.object} (${Math.round((k.confidence || 0.5) * 100)}%)`
76
- ).join('\n');
77
-
78
- // Recent memories summary — prefer Slack over CTM tool calls
79
- const slackRecent = brain.getDb().prepare(`
80
- SELECT content, timestamp, source_channel FROM memories
81
- WHERE source = 'slack' AND direction = 'outbound' AND length(content) > 20
82
- ORDER BY timestamp DESC LIMIT 10
83
- `).all();
84
- const memorySummary = slackRecent.map(m =>
85
- `[${m.timestamp?.slice(0, 10)} ${m.source_channel || 'DM'}] ${m.content?.slice(0, 200)}`
86
- ).join('\n');
87
-
88
- // Load pre-computed metadata for richer context
89
- let peopleMeta = '', topicMeta = '', slackCoverage = '';
90
- try {
91
- const getMeta = brain.getDb().prepare('SELECT value FROM brain_metadata WHERE key = ?');
92
- peopleMeta = getMeta.get('people_interaction_summary')?.value || '';
93
- topicMeta = getMeta.get('topic_frequency')?.value || '';
94
- slackCoverage = getMeta.get('slack_coverage')?.value || '';
95
- } catch {}
96
-
97
- // People summary
98
- const peopleSummary = people.map(p =>
99
- `- ${p.name}: ${p.relationship || 'unknown'} (trust: ${p.trust_level || 0.5})`
100
- ).join('\n');
101
-
102
- // Pending questions
103
- const questionsSummary = pendingQuestions.map(q =>
104
- `- [${q.question_type}] ${q.question}`
105
- ).join('\n');
106
-
107
- // Skills summary
108
- const skillsSummary = skills.map(s => {
109
- const rate = (s.success_count + s.failure_count) > 0
110
- ? Math.round(s.success_count / (s.success_count + s.failure_count) * 100) + '%' : 'not run yet';
111
- return `- ${s.name}: ${s.description || 'no description'} (${s.enabled ? 'enabled' : 'disabled'}, success rate: ${rate})`;
112
- }).join('\n');
113
-
114
- // Memory source breakdown
115
- let sourceBreakdown = '';
32
+ // Expire old sessions occasionally (at most once per hour)
116
33
  try {
117
- const sources = brain.getDb().prepare('SELECT source, count(*) as c FROM memories GROUP BY source ORDER BY c DESC').all();
118
- sourceBreakdown = sources.map(s => `- ${s.source}: ${s.c} memories`).join('\n');
119
- } catch {}
120
-
121
- // Domain confidence summary
122
- const tierNames = { 1: 'Observe', 2: 'Draft', 3: 'Guarded', 4: 'Autonomous' };
123
- const confidenceSummary = domainConfidences.map(dc =>
124
- `- ${dc.domain}: Tier ${dc.current_tier} (${tierNames[dc.current_tier] || '?'}), ${dc.approved_actions}/${dc.total_actions} approved`
125
- ).join('\n');
126
-
127
- const systemPrompt = `You are WALL-E, ${ownerName}'s personal digital twin. You know ${ownerName} deeply through ${stats.memory_count} memories including ${slackCoverage || '1500+ Slack messages'}.
128
-
129
- ## ${ownerName}'s Key Relationships (by message volume)
130
- ${peopleMeta || peopleSummary || 'No people data yet.'}
131
-
132
- ## Topics ${ownerName} Cares About (by frequency in conversations)
133
- ${topicMeta || 'No topic data yet.'}
134
-
135
- ## Knowledge Base
136
- ${knowledgeSummary || 'Still learning...'}
137
-
138
- ## Recent Slack Messages (${ownerName}'s actual words)
139
- ${memorySummary || 'No recent activity.'}
140
-
141
- ${slackSample ? `## ${ownerName}'s Voice — Random Sample\n${slackSample}` : ''}
142
-
143
- ## Memory Sources
144
- ${sourceBreakdown || 'No breakdown available'}
145
-
146
- ## Skills & Tools
147
- ${skillsSummary || 'No skills configured yet.'}
148
- MCP Servers: ${mcpServerList || 'None'}
149
-
150
- ${pendingQuestions.length > 0 ? `## Questions Pending\n${questionsSummary}` : ''}
151
-
152
- ## How to Reason and Respond
153
-
154
- ### Step 1: SEARCH — gather evidence (call ALL searches in ONE turn)
155
- For ANY question beyond small talk, call search_memories MULTIPLE TIMES IN THE SAME TURN. Do NOT do one search per turn — batch them all together.
156
- Example: if asked about leadership, call these ALL AT ONCE in one response:
157
- - search_memories({query: "leadership coaching feedback"})
158
- - search_memories({query: "团队 管理 反馈", source: "slack"})
159
- - search_memories({query: "Danni Mengyang Zohaib"})
160
-
161
- ### Step 2: THINK — reason through the evidence (same turn as search results if possible)
162
- After gathering evidence, ALWAYS use the **think** tool before responding. This is your internal scratchpad — ${ownerName} won't see it. Use it to:
163
-
164
- **Analyze patterns, not just surface content:**
165
- - What does the evidence ACTUALLY show vs what it SEEMS to show?
166
- - Am I attributing behavior correctly? (Is this ${ownerName}'s gap, or someone else's?)
167
- - What's the counterargument? What evidence contradicts my initial take?
168
- - Are there multiple interpretations of the same message?
169
-
170
- **Challenge your own conclusions:**
171
- - If I'm about to say "${ownerName} does X poorly" — do I have 3+ examples? Or am I over-generalizing from one message?
172
- - Could this behavior be DELIBERATE and STRATEGIC rather than a gap?
173
- - What's the full context? Who was ${ownerName} talking to, and why?
174
-
175
- **Think about nuance:**
176
- - ${ownerName} is a director managing 60+ engineers. What looks like "venting" might be calculated information sharing.
177
- - What looks like "brevity" might be appropriate trust-based communication with close reports.
178
- - What looks like "delegating the hard conversation" might be empowering direct reports.
179
-
180
- ### Step 3: RESPOND — with depth and nuance
181
- - Use **bold** for key names, dates, and decisions
182
- - Use > blockquotes when quoting actual Slack messages
183
- - Use ### headers to organize multi-part answers
184
- - Include dates and people: "On **2024-12-12**, you told **Zohaib**: ..."
185
- - **Bilingual-aware**: Translate Chinese quotes — they contain the most candid opinions
186
- - Present BOTH sides before drawing conclusions
187
- - Acknowledge where the evidence is thin or ambiguous
188
-
189
- ### What makes a DEEP answer vs a SHALLOW one
190
- **SHALLOW** (bad): "You sometimes vent in group DMs" → no examples, no analysis of WHY
191
- **DEEP** (good): "You coached Xiao Bai on venting technique (Dec 2023), but in your own group DMs you do X, Y, Z — however, this may be intentional because your audience (Sonic, Mengyang) is your inner circle where candor is valued"
192
-
193
- ### Tool usage
194
- - **think**: Internal reasoning scratchpad. Use BEFORE every substantive response. ${ownerName} won't see this.
195
- - **search_memories**: Full-text search with BM25 ranking. Use source:"slack" for Slack only.
196
- - remember_fact: Store new knowledge the user teaches you.
197
- - run_skill, mcp_call, list_mcp_tools: For actions and external services.
198
- - When mcp_call returns auth_required, tell the user which MCP server needs authentication and suggest they connect it via Claude Code or the MCP tab in the dashboard.
199
-
200
- ### Local Machine Tools (macOS)
201
- - **web_fetch**: Fetch any URL — weather, news, APIs, documentation. Use for ALL real-time data requests.
202
- - **run_shell**: Execute shell commands (git, node, grep, mdfind, open, etc.). Destructive commands blocked.
203
- - **read_file / write_file**: Read or write local files (under $HOME).
204
- - **search_files**: Spotlight search (mdfind) for fast file discovery.
205
- - **clipboard_read / clipboard_write**: System clipboard access.
206
- - **open_url / open_app**: Open URLs in browser or launch macOS apps.
207
- - **notification**: Show macOS notification banner.
208
- - **applescript**: Run AppleScript for deep macOS automation (Finder, Mail, etc.).
209
- - **calendar_events / calendar_create**: Read/create events in macOS Calendar.
210
- - **reminder_create**: Create reminders in macOS Reminders app.
211
- - **screenshot**: Capture screen to file.
212
- - **system_info**: Get macOS version, uptime, disk space.
213
-
214
- **IMPORTANT**: You are an AGENT, not just a chatbot. NEVER say "I can't access that" or "I don't have access to real-time data." You have tools — USE THEM.
215
-
216
- **Location awareness**: When ${ownerName} asks location-dependent questions (weather, "where am I", local time, nearby places), FIRST determine their current location by:
217
- 1. Check calendar_events for travel/meetings that reveal location
218
- 2. Search memories for recent travel plans or location mentions
219
- 3. Only after determining location, fetch the relevant data
220
-
221
- **Weather**: Use web_fetch with Open-Meteo API (free, no key needed):
222
- \`https://api.open-meteo.com/v1/forecast?latitude=LAT&longitude=LON&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m&timezone=auto\`
223
- Common coords: Seattle(47.61,-122.33), London(51.51,-0.13), Helsinki(60.17,24.94), San Francisco(37.77,-122.42), Beijing(39.90,116.40)
224
- Weather codes: 0=clear, 1-3=partly cloudy, 45-48=fog, 51-55=drizzle, 61-65=rain, 71-77=snow, 80-82=showers, 95-99=thunderstorm
225
-
226
- When ${ownerName} asks you to DO something (create a file, set a reminder, search for a document, etc.), use these tools directly.
227
- - Channel: ${channel}${channel === 'imessage' ? ' (keep responses brief)' : ''}`;
34
+ if (!chat._lastExpiry || Date.now() - chat._lastExpiry > 3600000) {
35
+ const expired = brain.expireSessions(24);
36
+ if (expired > 0) console.log(`[chat] Expired ${expired} old sessions`);
37
+ chat._lastExpiry = Date.now();
38
+ }
39
+ } catch (expErr) {
40
+ console.error('[chat] Session expiry check failed:', expErr.message);
41
+ }
228
42
 
229
43
  // Use injected client (for testing) or build one from env
230
44
  const client = _clientOverride || getClientForChat();
@@ -413,21 +227,29 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
413
227
  terms = terms[0].split(/\s+/).filter(t => t.length >= 2);
414
228
  }
415
229
  // FTS5 OR query: "word1 OR word2 OR word3"
416
- let ftsQuery = terms.map(t => t.replace(/['"]/g, '')).join(' OR ');
417
- // Add source filter if specified
230
+ const ftsQuery = terms.map(t => t.replace(/['"]/g, '')).join(' OR ');
231
+
232
+ console.log('[chat] FTS5 query:', ftsQuery, input.source ? `(source: ${input.source})` : '');
233
+ // Use JOIN filter for source instead of FTS5 column filter (memories_fts may not have source column)
418
234
  if (input.source) {
419
- ftsQuery = `source:${input.source} AND (${ftsQuery})`;
235
+ results = db.prepare(`
236
+ SELECT m.*, bm25(memories_fts) as relevance_score
237
+ FROM memories_fts f
238
+ JOIN memories m ON m.rowid = f.rowid
239
+ WHERE memories_fts MATCH ? AND m.source = ?
240
+ ORDER BY bm25(memories_fts)
241
+ LIMIT ?
242
+ `).all(ftsQuery, input.source, limit);
243
+ } else {
244
+ results = db.prepare(`
245
+ SELECT m.*, bm25(memories_fts) as relevance_score
246
+ FROM memories_fts f
247
+ JOIN memories m ON m.rowid = f.rowid
248
+ WHERE memories_fts MATCH ?
249
+ ORDER BY bm25(memories_fts)
250
+ LIMIT ?
251
+ `).all(ftsQuery, limit);
420
252
  }
421
-
422
- console.log('[chat] FTS5 query:', ftsQuery);
423
- results = db.prepare(`
424
- SELECT m.*, bm25(memories_fts) as relevance_score
425
- FROM memories_fts f
426
- JOIN memories m ON m.rowid = f.rowid
427
- WHERE memories_fts MATCH ?
428
- ORDER BY bm25(memories_fts)
429
- LIMIT ?
430
- `).all(ftsQuery, limit);
431
253
  console.log('[chat] FTS5 returned', results.length, 'results (ranked by relevance)');
432
254
  } catch (ftsErr) {
433
255
  console.log('[chat] FTS5 error, falling back to LIKE:', ftsErr.message);
@@ -603,7 +425,6 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
603
425
 
604
426
  try {
605
427
  // Save user message FIRST (before calling Claude) so it gets an earlier timestamp
606
- const sessionId = opts.session_id || 'default';
607
428
  brain.insertChatMessage({ role: 'user', content: message, channel, session_id: sessionId });
608
429
  brain.insertMemory({
609
430
  source: 'wall-e-chat',
@@ -615,20 +436,57 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
615
436
  metadata: JSON.stringify({ channel }),
616
437
  });
617
438
 
618
- // Load recent chat history limit to last 10 messages to reduce input tokens
619
- // (20 messages was ~5K tokens; 10 is ~2.5K — saves ~2s per turn)
439
+ // Load recent chat history, resuming from compacted state if available
620
440
  const chatSessionId = sessionId;
621
- const recentChat = brain.listChatMessages({ session_id: chatSessionId, limit: 10 });
622
- const historyMessages = recentChat.map(m => ({ role: m.role, content: m.content }));
441
+ let historyMessages;
442
+ if (existingSession?.compacted_messages) {
443
+ // Resume from compacted state + recent messages
444
+ try {
445
+ const compacted = JSON.parse(existingSession.compacted_messages);
446
+ const recentChat = brain.listChatMessages({ session_id: chatSessionId, limit: 4 });
447
+ const recent = recentChat.map(m => ({ role: m.role, content: m.content }));
448
+ historyMessages = [...compacted, ...recent];
449
+ } catch {
450
+ // If parse fails, fall back to normal loading
451
+ const recentChat = brain.listChatMessages({ session_id: chatSessionId, limit: 10 });
452
+ historyMessages = recentChat.map(m => ({ role: m.role, content: m.content }));
453
+ }
454
+ } else {
455
+ const recentChat = brain.listChatMessages({ session_id: chatSessionId, limit: 10 });
456
+ historyMessages = recentChat.map(m => ({ role: m.role, content: m.content }));
457
+ }
623
458
  // Agentic chat loop — history already includes the new user message
624
459
  const messages = [...historyMessages];
625
460
  let finalText = '';
461
+ let lastTurn = 0;
626
462
  const MAX_TURNS = 8; // search(2-3) + think(1) + response(1) + possible follow-up tools
627
463
 
628
464
  const chatStart = Date.now();
629
465
  for (let turn = 0; turn < MAX_TURNS; turn++) {
466
+ lastTurn = turn;
630
467
  const turnStart = Date.now();
631
468
  onProgress({ type: 'thinking', turn });
469
+
470
+ // Check if context needs compaction before calling Claude
471
+ const systemTokens = estimateTokens(systemPrompt);
472
+ if (shouldCompact(messages, systemTokens)) {
473
+ console.log('[chat] Context approaching limit — compacting...');
474
+ try {
475
+ const { messages: compacted, summary } = await compactMessages(messages, client);
476
+ messages.length = 0;
477
+ messages.push(...compacted);
478
+ brain.upsertSession({
479
+ id: sessionId, channel, summary,
480
+ compacted_messages: JSON.stringify(compacted),
481
+ turn_count: turn,
482
+ token_estimate: estimateMessagesTokens(compacted),
483
+ });
484
+ console.log('[chat] Compaction complete, messages:', messages.length);
485
+ } catch (compactErr) {
486
+ console.error('[chat] Compaction failed, continuing with full context:', compactErr.message);
487
+ }
488
+ }
489
+
632
490
  resetTurnTimeout();
633
491
  const response = await client.messages.create({
634
492
  model: opts.model || process.env.WALLE_MODEL || 'claude-haiku-4-5-20251001',
@@ -705,7 +563,9 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
705
563
  ? 'Done thinking'
706
564
  : `Completed in ${elapsed}ms`;
707
565
  onProgress({ type: 'tool_done', tool: tu.name, summary: resultSummary });
708
- return { type: 'tool_result', tool_use_id: tu.id, content: resultStr };
566
+ // Compact tool results immediately to reduce token usage
567
+ const compactedResult = compactToolResult(tu.name, resultStr);
568
+ return { type: 'tool_result', tool_use_id: tu.id, content: compactedResult };
709
569
  }));
710
570
 
711
571
  messages.push({ role: 'assistant', content: response.content });
@@ -725,6 +585,7 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
725
585
  // If no text was produced after tool calls, make one more call to get a summary
726
586
  if (!finalText.trim() && messages.length > historyMessages.length + 1) {
727
587
  try {
588
+ resetTurnTimeout();
728
589
  const summaryResponse = await client.messages.create({
729
590
  model: opts.model || process.env.WALLE_MODEL || 'claude-haiku-4-5-20251001',
730
591
  max_tokens: 1024,
@@ -737,6 +598,27 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
737
598
 
738
599
  const text = finalText || 'I completed the action but couldn\'t generate a summary.';
739
600
 
601
+ // Save enriched session state
602
+ try {
603
+ const metadata = {
604
+ lastTopic: message.slice(0, 200),
605
+ turnCount: lastTurn + 1,
606
+ toolsUsed: [...new Set(messages
607
+ .filter(m => m.role === 'assistant' && Array.isArray(m.content))
608
+ .flatMap(m => m.content.filter(b => b.type === 'tool_use').map(b => b.name))
609
+ )],
610
+ };
611
+ brain.upsertSession({
612
+ id: sessionId, channel,
613
+ summary: text.slice(0, 500),
614
+ turn_count: lastTurn + 1,
615
+ token_estimate: estimateMessagesTokens(messages),
616
+ metadata: JSON.stringify(metadata),
617
+ });
618
+ } catch (sessionErr) {
619
+ console.error('[chat] Failed to save session:', sessionErr.message);
620
+ }
621
+
740
622
  // Save assistant response (user message was already saved before calling Claude)
741
623
  brain.insertChatMessage({ role: 'assistant', content: text, channel, session_id: sessionId });
742
624
 
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const { estimateTokens, estimateMessagesTokens } = require('./token-counter');
4
+
5
+ const DEFAULT_CONTEXT_WINDOW = 200000; // Haiku 4.5 — 200K context
6
+ const COMPACTION_THRESHOLD = 0.75; // Compact at 75% of context window
7
+ const KEEP_RECENT_MESSAGES = 4; // Always keep last 4 messages verbatim
8
+
9
+ /**
10
+ * Check whether the conversation should be compacted.
11
+ * @param {Array} messages
12
+ * @param {number} systemPromptTokens - estimated tokens in the system prompt
13
+ * @param {number} contextWindow
14
+ * @returns {boolean}
15
+ */
16
+ function shouldCompact(messages, systemPromptTokens, contextWindow = DEFAULT_CONTEXT_WINDOW) {
17
+ const totalTokens = (systemPromptTokens || 0) + estimateMessagesTokens(messages);
18
+ return totalTokens > COMPACTION_THRESHOLD * contextWindow;
19
+ }
20
+
21
+ /**
22
+ * Immediately truncate a tool result based on tool type.
23
+ * Called right after each tool execution to keep results lean.
24
+ * @param {string} toolName
25
+ * @param {string} resultStr - JSON string of the tool result
26
+ * @returns {string} - truncated JSON string
27
+ */
28
+ function compactToolResult(toolName, resultStr) {
29
+ if (!resultStr) return resultStr;
30
+
31
+ // think tool: keep full — cheap and important for reasoning
32
+ if (toolName === 'think') return resultStr;
33
+
34
+ // search_memories: parse and keep top 5 results
35
+ if (toolName === 'search_memories') {
36
+ try {
37
+ const parsed = JSON.parse(resultStr);
38
+ if (parsed.memories && Array.isArray(parsed.memories)) {
39
+ parsed.memories = parsed.memories.slice(0, 5);
40
+ parsed.count = parsed.memories.length;
41
+ parsed._compacted = true;
42
+ return JSON.stringify(parsed);
43
+ }
44
+ } catch {
45
+ // If parse fails, fall through to default truncation
46
+ }
47
+ }
48
+
49
+ // Slack / MCP calls: truncate to 2000 chars
50
+ if (toolName === 'mcp_call' || toolName.startsWith('slack_')) {
51
+ if (resultStr.length > 2000) {
52
+ return resultStr.slice(0, 2000) + '... [truncated]';
53
+ }
54
+ return resultStr;
55
+ }
56
+
57
+ // Default: truncate to 3000 chars
58
+ if (resultStr.length > 3000) {
59
+ return resultStr.slice(0, 3000) + '... [truncated]';
60
+ }
61
+ return resultStr;
62
+ }
63
+
64
+ /**
65
+ * Progressively compact messages by summarizing older history.
66
+ * Keeps the last KEEP_RECENT_MESSAGES verbatim and summarizes everything else.
67
+ *
68
+ * @param {Array} messages - conversation messages
69
+ * @param {object} client - Portkey-compatible Claude client (must have client.messages.create)
70
+ * @param {object} opts - { contextWindow, model }
71
+ * @returns {Promise<{ messages: Array, summary: string, tokensBeforeCompaction: number, tokensAfterCompaction: number }>}
72
+ */
73
+ async function compactMessages(messages, client, opts = {}) {
74
+ const tokensBeforeCompaction = estimateMessagesTokens(messages);
75
+
76
+ if (messages.length <= KEEP_RECENT_MESSAGES) {
77
+ return {
78
+ messages,
79
+ summary: null,
80
+ tokensBeforeCompaction,
81
+ tokensAfterCompaction: tokensBeforeCompaction,
82
+ };
83
+ }
84
+
85
+ // Split: older messages to summarize, recent to keep verbatim
86
+ const olderMessages = messages.slice(0, messages.length - KEEP_RECENT_MESSAGES);
87
+ const recentMessages = messages.slice(messages.length - KEEP_RECENT_MESSAGES);
88
+
89
+ // Build a text representation of older messages for summarization
90
+ const olderText = olderMessages.map(m => {
91
+ const role = m.role || 'unknown';
92
+ let content = '';
93
+ if (typeof m.content === 'string') {
94
+ content = m.content;
95
+ } else if (Array.isArray(m.content)) {
96
+ content = m.content.map(block => {
97
+ if (block.type === 'text') return block.text;
98
+ if (block.type === 'tool_use') return `[Tool: ${block.name}(${JSON.stringify(block.input).slice(0, 200)})]`;
99
+ if (block.type === 'tool_result') return `[Result: ${(typeof block.content === 'string' ? block.content : JSON.stringify(block.content)).slice(0, 300)}]`;
100
+ return `[${block.type}]`;
101
+ }).join('\n');
102
+ }
103
+ return `${role}: ${content}`;
104
+ }).join('\n\n');
105
+
106
+ // Call Claude to summarize
107
+ const model = opts.model || 'claude-haiku-4-5-20251001';
108
+ const summaryPrompt = `Summarize this conversation history into a compact reference block. Preserve:
109
+ - All decisions made and their rationale
110
+ - Action items and pending tasks
111
+ - Key identifiers (names, IDs, URLs, file paths)
112
+ - Any constraints or commitments stated
113
+ - The current topic/thread being discussed
114
+ - Tool results and their key findings
115
+
116
+ Be concise but preserve ALL important details. Output only the summary, no preamble.
117
+
118
+ CONVERSATION HISTORY:
119
+ ${olderText.slice(0, 50000)}`;
120
+
121
+ let summary;
122
+ try {
123
+ const summaryResponse = await client.messages.create({
124
+ model,
125
+ max_tokens: 1024,
126
+ messages: [{ role: 'user', content: summaryPrompt }],
127
+ });
128
+ summary = summaryResponse.content
129
+ .filter(b => b.type === 'text')
130
+ .map(b => b.text)
131
+ .join('');
132
+ } catch (err) {
133
+ console.error('[compactor] Summarization failed, using naive truncation:', err.message);
134
+ // Fallback: just take the last portion of older text
135
+ summary = 'Previous conversation summary (auto-truncated):\n' + olderText.slice(-2000);
136
+ }
137
+
138
+ // Build compacted messages: summary as first message, then recent messages
139
+ const compactedMessages = [
140
+ { role: 'user', content: `[Conversation history summary]\n${summary}` },
141
+ { role: 'assistant', content: 'Understood. I have the conversation context. Continuing from where we left off.' },
142
+ ...recentMessages,
143
+ ];
144
+
145
+ const tokensAfterCompaction = estimateMessagesTokens(compactedMessages);
146
+ console.log(`[compactor] Compacted ${messages.length} messages (${tokensBeforeCompaction} tokens) -> ${compactedMessages.length} messages (${tokensAfterCompaction} tokens)`);
147
+
148
+ return {
149
+ messages: compactedMessages,
150
+ summary,
151
+ tokensBeforeCompaction,
152
+ tokensAfterCompaction,
153
+ };
154
+ }
155
+
156
+ module.exports = {
157
+ DEFAULT_CONTEXT_WINDOW,
158
+ COMPACTION_THRESHOLD,
159
+ KEEP_RECENT_MESSAGES,
160
+ shouldCompact,
161
+ compactToolResult,
162
+ compactMessages,
163
+ };