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.
- package/README.md +35 -31
- package/package.json +3 -3
- package/template/CLAUDE.md +23 -1
- package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
- package/template/claude-task-manager/db.js +38 -0
- package/template/claude-task-manager/public/css/walle.css +123 -0
- package/template/claude-task-manager/public/index.html +962 -69
- package/template/claude-task-manager/public/js/walle.js +374 -121
- package/template/claude-task-manager/public/prompts.html +84 -26
- package/template/claude-task-manager/public/walle-icon.svg +45 -0
- package/template/claude-task-manager/server.js +69 -4
- package/template/docs/openclaw-vs-walle-comparison.md +103 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +63 -3
- package/template/wall-e/api-walle.js +42 -0
- package/template/wall-e/brain.js +182 -5
- package/template/wall-e/channels/imessage-channel.js +4 -1
- package/template/wall-e/channels/slack-channel.js +3 -1
- package/template/wall-e/chat.js +106 -224
- package/template/wall-e/context/compactor.js +163 -0
- package/template/wall-e/context/context-builder.js +355 -0
- package/template/wall-e/context/state-snapshot.js +209 -0
- package/template/wall-e/context/token-counter.js +55 -0
- package/template/wall-e/context/topic-matcher.js +79 -0
- package/template/wall-e/core-tasks.js +24 -0
- package/template/wall-e/events/event-bus.js +23 -0
- package/template/wall-e/loops/ingest.js +4 -0
- package/template/wall-e/loops/initiative.js +316 -0
- package/template/wall-e/loops/tasks.js +55 -5
- package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
- package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
- package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
- package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
- package/template/wall-e/tests/brain.test.js +4 -4
- package/template/wall-e/tests/compactor.test.js +323 -0
- package/template/wall-e/tests/context-builder.test.js +215 -0
- package/template/wall-e/tests/event-bus.test.js +74 -0
- package/template/wall-e/tests/initiative.test.js +354 -0
- package/template/wall-e/tests/proactive-alerts.test.js +140 -0
- package/template/wall-e/tests/session-persistence.test.js +335 -0
package/template/wall-e/chat.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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¤t=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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
+
};
|