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,355 @@
1
+ 'use strict';
2
+
3
+ const brain = require('../brain');
4
+ const { classifyTopics } = require('./topic-matcher');
5
+
6
+ /**
7
+ * Dynamic context builder for Wall-E chat.
8
+ * Replaces the static ~4KB system prompt with a three-layer prompt:
9
+ * 1. Core (always, ~400 tokens): identity, capabilities, reasoning instructions
10
+ * 2. Relevant (per-message, ~800-1500 tokens): FTS5/LIKE matched knowledge & memories
11
+ * 3. Situational (conditional, 0-500 tokens): calendar, Slack, tasks — only when relevant
12
+ *
13
+ * Estimated savings: 50-70% fewer tokens vs the old static prompt.
14
+ */
15
+
16
+ /**
17
+ * Build a dynamic system prompt based on the user's message.
18
+ * @param {string} userMessage - The current user message
19
+ * @param {string} channel - Channel name (ctm, imessage, slack_dm)
20
+ * @param {object} [opts] - Optional overrides
21
+ * @param {string} [opts.sessionSummary] - Compacted session summary to include
22
+ * @returns {string} The assembled system prompt
23
+ */
24
+ function buildSystemPrompt(userMessage, channel, opts = {}) {
25
+ const ownerName = brain.getOwnerName() || 'Owner';
26
+ const stats = brain.getBrainStats();
27
+ const topics = classifyTopics(userMessage);
28
+
29
+ const core = buildCoreLayer(ownerName, stats, channel);
30
+ const relevant = buildRelevantLayer(userMessage, topics, ownerName);
31
+ const situational = buildSituationalLayer(topics, ownerName, channel);
32
+ const instructions = buildInstructionLayer(ownerName, channel);
33
+
34
+ const parts = [core];
35
+ if (opts.sessionSummary) {
36
+ parts.push(`## Previous Conversation\n${opts.sessionSummary}`);
37
+ }
38
+ if (relevant) parts.push(relevant);
39
+ if (situational) parts.push(situational);
40
+ parts.push(instructions);
41
+
42
+ return parts.join('\n\n');
43
+ }
44
+
45
+ /** Layer 1: Core identity — always included (~400 tokens) */
46
+ function buildCoreLayer(ownerName, stats, channel) {
47
+ let slackCoverage = '';
48
+ try {
49
+ const getMeta = brain.getDb().prepare('SELECT value FROM brain_metadata WHERE key = ?');
50
+ slackCoverage = getMeta.get('slack_coverage')?.value || '';
51
+ } catch {}
52
+
53
+ return `You are WALL-E, ${ownerName}'s personal digital twin. You know ${ownerName} deeply through ${stats.memory_count} memories including ${slackCoverage || 'Slack messages across years'}.
54
+
55
+ You are an AGENT, not just a chatbot. You have tools — USE THEM. Never say "I can't access that."
56
+ Channel: ${channel}${channel === 'imessage' ? ' (keep responses brief)' : ''}`;
57
+ }
58
+
59
+ /** Layer 2: Relevant context — matched to the user's message (~800-1500 tokens) */
60
+ function buildRelevantLayer(userMessage, topics, ownerName) {
61
+ const sections = [];
62
+
63
+ // FTS5 search for relevant knowledge triples
64
+ const relevantKnowledge = searchRelevantKnowledge(userMessage, 15);
65
+ if (relevantKnowledge.length > 0) {
66
+ const kSummary = relevantKnowledge.map(k =>
67
+ `- ${k.subject} ${k.predicate} ${k.object} (${Math.round((k.confidence || 0.5) * 100)}%)`
68
+ ).join('\n');
69
+ sections.push(`## Relevant Knowledge\n${kSummary}`);
70
+ }
71
+
72
+ // FTS5 search for relevant memories
73
+ const relevantMemories = searchRelevantMemories(userMessage, 8);
74
+ if (relevantMemories.length > 0) {
75
+ const mSummary = relevantMemories.map(m => {
76
+ const date = m.timestamp ? m.timestamp.slice(0, 10) : '';
77
+ const ch = m.source_channel ? ` #${m.source_channel}` : '';
78
+ return `[${date}${ch}] ${(m.content || '').slice(0, 200)}`;
79
+ }).join('\n');
80
+ sections.push(`## Relevant Memories\n${mSummary}`);
81
+ }
82
+
83
+ // Include people context only if the message mentions people
84
+ if (topics.includes('people')) {
85
+ const peopleSummary = buildPeopleContext(userMessage);
86
+ if (peopleSummary) sections.push(peopleSummary);
87
+ }
88
+
89
+ // Include pre-computed metadata if asking about relationships or topics
90
+ if (topics.includes('people') || topics.includes('work') || topics.includes('slack')) {
91
+ const meta = buildMetadataContext(topics);
92
+ if (meta) sections.push(meta);
93
+ }
94
+
95
+ return sections.join('\n\n');
96
+ }
97
+
98
+ /** Layer 3: Situational context — only when relevant (0-500 tokens) */
99
+ function buildSituationalLayer(topics, ownerName, channel) {
100
+ const sections = [];
101
+
102
+ // Calendar context if morning or asking about schedule
103
+ if (topics.includes('calendar')) {
104
+ sections.push(`## Calendar\nUse the calendar_events tool to check ${ownerName}'s schedule.`);
105
+ }
106
+
107
+ // Slack context if asking about messages
108
+ if (topics.includes('slack')) {
109
+ // Include a small sample of recent Slack messages for voice/context
110
+ try {
111
+ const db = brain.getDb();
112
+ const recentSlack = db.prepare(`
113
+ SELECT content, timestamp, source_channel FROM memories
114
+ WHERE source = 'slack' AND direction = 'outbound' AND length(content) > 20
115
+ ORDER BY timestamp DESC LIMIT 5
116
+ `).all();
117
+ if (recentSlack.length > 0) {
118
+ const sample = recentSlack.map(m =>
119
+ `[${m.timestamp?.slice(0, 10)} ${m.source_channel || 'DM'}] ${(m.content || '').slice(0, 150)}`
120
+ ).join('\n');
121
+ sections.push(`## Recent Slack (${ownerName}'s words)\n${sample}`);
122
+ }
123
+ } catch {}
124
+ }
125
+
126
+ // Tools/MCP context if asking about tools or automation
127
+ if (topics.includes('tools')) {
128
+ const mcpList = buildMcpContext();
129
+ if (mcpList) sections.push(`## MCP Servers\n${mcpList}`);
130
+ }
131
+
132
+ // Tasks context if asking about tasks
133
+ if (topics.includes('tasks')) {
134
+ try {
135
+ const pending = brain.listTasks({ status: 'pending', limit: 5 });
136
+ if (pending.length > 0) {
137
+ const taskSummary = pending.map(t => `- ${t.title} (${t.priority || 'normal'}, due: ${t.due_at || 'not set'})`).join('\n');
138
+ sections.push(`## Pending Tasks\n${taskSummary}`);
139
+ }
140
+ } catch {}
141
+ }
142
+
143
+ // Weather context — include location hints
144
+ if (topics.includes('weather')) {
145
+ sections.push(`## Weather\nUse web_fetch with Open-Meteo API (no key needed):
146
+ \`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\`
147
+ 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)
148
+ 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
149
+ First determine ${ownerName}'s location from calendar or recent memories, then fetch weather.`);
150
+ }
151
+
152
+ return sections.join('\n\n');
153
+ }
154
+
155
+ /** Reasoning and tool usage instructions — always included */
156
+ function buildInstructionLayer(ownerName, channel) {
157
+ return `## How to Reason and Respond
158
+
159
+ ### Step 1: SEARCH — gather evidence (call ALL searches in ONE turn)
160
+ For ANY question beyond small talk, call search_memories MULTIPLE TIMES IN THE SAME TURN.
161
+ Example: if asked about leadership, call these ALL AT ONCE:
162
+ - search_memories({query: "leadership coaching feedback"})
163
+ - search_memories({query: "团队 管理 反馈", source: "slack"})
164
+ - search_memories({query: "Danni Mengyang Zohaib"})
165
+
166
+ ### Step 2: THINK — reason through the evidence
167
+ ALWAYS use the **think** tool before responding. Use it to:
168
+ - Analyze what the evidence ACTUALLY shows vs what it SEEMS to show
169
+ - Challenge your conclusions: do you have 3+ examples, or are you over-generalizing?
170
+ - Consider if behavior is DELIBERATE and STRATEGIC rather than a gap
171
+ - ${ownerName} is a director managing 60+ engineers — context matters
172
+
173
+ ### Step 3: RESPOND — with depth and nuance
174
+ - Use **bold** for key names, dates, and decisions
175
+ - Use > blockquotes when quoting actual Slack messages
176
+ - Include dates and people: "On **2024-12-12**, you told **Zohaib**: ..."
177
+ - **Bilingual-aware**: Translate Chinese quotes — they contain the most candid opinions
178
+ - Present BOTH sides before drawing conclusions
179
+
180
+ ### Tools
181
+ - **think**: Internal scratchpad (${ownerName} won't see). Use BEFORE every substantive response.
182
+ - **search_memories**: Full-text search (BM25). source:"slack" for Slack only. Batch multiple searches.
183
+ - **remember_fact**: Store facts the user teaches you.
184
+ - **run_skill / mcp_call / list_mcp_tools**: Actions and external services.
185
+ - **Local tools**: web_fetch, run_shell, read_file, write_file, search_files, calendar_events, calendar_create, reminder_create, notification, applescript, open_url, open_app, screenshot, system_info, clipboard_read/write
186
+ - **Slack**: slack_search, slack_read_channel, slack_send_message, pull_slack
187
+ - **Glean**: When using reportsto: queries, "entities" = direct reports only. Check manager.email to verify.
188
+ - When mcp_call returns auth_required, tell the user which server needs auth.
189
+
190
+ **Location awareness**: For weather/location questions, determine location from calendar or memories first.`;
191
+ }
192
+
193
+ // --- Helper functions ---
194
+
195
+ /** Search knowledge triples using FTS5 or LIKE fallback */
196
+ function searchRelevantKnowledge(query, limit) {
197
+ try {
198
+ const db = brain.getDb();
199
+ // Extract search terms from the user message
200
+ const terms = extractSearchTerms(query);
201
+ if (terms.length === 0) return [];
202
+
203
+ // Try matching against knowledge subject/predicate/object
204
+ const likeConditions = terms.map(() => '(subject LIKE ? OR predicate LIKE ? OR object LIKE ?)').join(' OR ');
205
+ const params = [];
206
+ for (const t of terms) {
207
+ params.push(`%${t}%`, `%${t}%`, `%${t}%`);
208
+ }
209
+
210
+ return db.prepare(`
211
+ SELECT * FROM knowledge
212
+ WHERE status = 'active' AND (${likeConditions})
213
+ ORDER BY confidence DESC
214
+ LIMIT ?
215
+ `).all(...params, limit);
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
221
+ /** Search memories using FTS5 with LIKE fallback */
222
+ function searchRelevantMemories(query, limit) {
223
+ try {
224
+ const db = brain.getDb();
225
+ const terms = extractSearchTerms(query);
226
+ if (terms.length === 0) return [];
227
+
228
+ // Try FTS5 first
229
+ const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
230
+ if (hasFts) {
231
+ try {
232
+ const ftsQuery = terms.join(' OR ');
233
+ return db.prepare(`
234
+ SELECT m.*, bm25(memories_fts) as relevance
235
+ FROM memories_fts f
236
+ JOIN memories m ON m.rowid = f.rowid
237
+ WHERE memories_fts MATCH ?
238
+ ORDER BY bm25(memories_fts)
239
+ LIMIT ?
240
+ `).all(ftsQuery, limit);
241
+ } catch {}
242
+ }
243
+
244
+ // LIKE fallback
245
+ const likeConditions = terms.map(() => 'content LIKE ?').join(' OR ');
246
+ const params = terms.map(t => `%${t}%`);
247
+ return db.prepare(`
248
+ SELECT * FROM memories
249
+ WHERE (${likeConditions})
250
+ ORDER BY timestamp DESC
251
+ LIMIT ?
252
+ `).all(...params, limit);
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ /** Extract meaningful search terms from a message */
259
+ function extractSearchTerms(message) {
260
+ if (!message) return [];
261
+ // Remove common stop words, keep words 3+ chars
262
+ const stopWords = new Set([
263
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her',
264
+ 'was', 'one', 'our', 'out', 'has', 'his', 'how', 'its', 'may', 'new', 'now',
265
+ 'old', 'see', 'way', 'who', 'did', 'get', 'let', 'say', 'she', 'too', 'use',
266
+ 'what', 'when', 'where', 'which', 'with', 'would', 'could', 'should', 'about',
267
+ 'after', 'before', 'between', 'does', 'each', 'from', 'have', 'just', 'like',
268
+ 'make', 'many', 'more', 'most', 'much', 'must', 'name', 'only', 'over', 'such',
269
+ 'take', 'than', 'them', 'then', 'they', 'this', 'very', 'well', 'were', 'will',
270
+ 'your', 'tell', 'know', 'think', 'some', 'want', 'been', 'into', 'that',
271
+ ]);
272
+
273
+ return message
274
+ .replace(/[^\w\s\u4e00-\u9fff]/g, ' ') // Keep alphanumeric + Chinese chars
275
+ .split(/\s+/)
276
+ .filter(w => w.length >= 3 && !stopWords.has(w.toLowerCase()))
277
+ .slice(0, 8); // Cap at 8 terms to avoid huge queries
278
+ }
279
+
280
+ /** Build people context — only for mentioned people */
281
+ function buildPeopleContext(userMessage) {
282
+ try {
283
+ const people = brain.listPeople({});
284
+ if (people.length === 0) return null;
285
+
286
+ const lower = userMessage.toLowerCase();
287
+ // Find people mentioned in the message
288
+ const mentioned = people.filter(p =>
289
+ p.name && lower.includes(p.name.toLowerCase())
290
+ );
291
+
292
+ if (mentioned.length > 0) {
293
+ const details = mentioned.map(p =>
294
+ `- **${p.name}**: ${p.relationship || 'unknown'} (trust: ${p.trust_level || 0.5})`
295
+ ).join('\n');
296
+ return `## People Mentioned\n${details}`;
297
+ }
298
+
299
+ // No specific people mentioned — include top relationships summary
300
+ const top = people.slice(0, 10).map(p =>
301
+ `- ${p.name}: ${p.relationship || 'unknown'}`
302
+ ).join('\n');
303
+ return `## Key People\n${top}`;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /** Build pre-computed metadata context */
310
+ function buildMetadataContext(topics) {
311
+ try {
312
+ const getMeta = brain.getDb().prepare('SELECT value FROM brain_metadata WHERE key = ?');
313
+ const sections = [];
314
+
315
+ if (topics.includes('people')) {
316
+ const peopleMeta = getMeta.get('people_interaction_summary')?.value;
317
+ if (peopleMeta) sections.push(`## Relationships (by interaction)\n${peopleMeta}`);
318
+ }
319
+ if (topics.includes('work') || topics.includes('slack')) {
320
+ const topicMeta = getMeta.get('topic_frequency')?.value;
321
+ if (topicMeta) sections.push(`## Topics (by frequency)\n${topicMeta}`);
322
+ }
323
+
324
+ return sections.join('\n\n') || null;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ /** Build MCP server context */
331
+ function buildMcpContext() {
332
+ try {
333
+ const { loadMcpConfigs } = require('../skills/mcp-client');
334
+ const configs = loadMcpConfigs();
335
+ let toolsByServer = {};
336
+ try {
337
+ const rows = brain.getDb().prepare('SELECT server, tool_name FROM mcp_tools_cache ORDER BY server').all();
338
+ for (const r of rows) {
339
+ if (!toolsByServer[r.server]) toolsByServer[r.server] = [];
340
+ toolsByServer[r.server].push(r.tool_name);
341
+ }
342
+ } catch {}
343
+
344
+ return Object.entries(configs).map(([name, cfg]) => {
345
+ const hasAuth = cfg.oauth?.accessToken ? 'authenticated' : 'needs auth';
346
+ const tools = toolsByServer[name];
347
+ const toolStr = tools ? ` — ${tools.slice(0, 8).join(', ')}${tools.length > 8 ? ` +${tools.length - 8} more` : ''}` : '';
348
+ return `- ${name} [${hasAuth}]${toolStr}`;
349
+ }).join('\n') || null;
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ module.exports = { buildSystemPrompt, classifyTopics: classifyTopics, extractSearchTerms, buildCoreLayer, buildRelevantLayer, buildSituationalLayer, buildInstructionLayer };
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const brain = require('../brain');
4
+ const { execFile } = require('child_process');
5
+ const { promisify } = require('util');
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Build a compact state snapshot (~500 tokens) of Wall-E's current awareness.
10
+ * Resilient: returns whatever data is available, skips sections that fail.
11
+ */
12
+ function buildStateSnapshot() {
13
+ const snapshot = {};
14
+ const db = brain.getDb();
15
+
16
+ // 1. Time context
17
+ const now = new Date();
18
+ snapshot.time = {
19
+ datetime: now.toISOString(),
20
+ day_of_week: now.toLocaleDateString('en-US', { weekday: 'long' }),
21
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
22
+ hour: now.getHours(),
23
+ };
24
+
25
+ // 2. Calendar: check for cached calendar events in the next 2 hours
26
+ try {
27
+ // Look in briefing_items for calendar-related items
28
+ const calItems = db.prepare(
29
+ "SELECT title, context, urgency FROM briefing_items WHERE (skill = 'google-calendar' OR category = 'calendar') AND status NOT IN ('done', 'dismissed') ORDER BY urgency DESC, last_seen DESC LIMIT 5"
30
+ ).all();
31
+ if (calItems.length > 0) {
32
+ snapshot.calendar = calItems.map(c => ({
33
+ title: c.title,
34
+ context: c.context ? c.context.slice(0, 200) : null,
35
+ urgency: c.urgency,
36
+ }));
37
+ }
38
+ } catch (_) {
39
+ // Calendar data unavailable -- skip
40
+ }
41
+
42
+ // 3. Recent memories: last 5 high-importance memories from today
43
+ try {
44
+ const todayStart = now.toISOString().slice(0, 10) + 'T00:00:00.000Z';
45
+ const recentMemories = db.prepare(
46
+ "SELECT id, source, content, importance, timestamp FROM memories WHERE timestamp >= ? ORDER BY importance DESC, timestamp DESC LIMIT 5"
47
+ ).all(todayStart);
48
+ if (recentMemories.length > 0) {
49
+ snapshot.recent_memories = recentMemories.map(m => ({
50
+ id: m.id,
51
+ source: m.source,
52
+ summary: m.content ? m.content.slice(0, 150) : '',
53
+ importance: m.importance,
54
+ }));
55
+ }
56
+ } catch (_) {}
57
+
58
+ // 4. Pending tasks: tasks due in next 2 hours
59
+ try {
60
+ const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000).toISOString();
61
+ const dueTasks = db.prepare(
62
+ "SELECT id, title, priority, due_at FROM tasks WHERE status IN ('pending', 'active') AND due_at IS NOT NULL AND due_at <= ? ORDER BY due_at ASC LIMIT 5"
63
+ ).all(twoHoursFromNow);
64
+ if (dueTasks.length > 0) {
65
+ snapshot.pending_tasks = dueTasks.map(t => ({
66
+ id: t.id,
67
+ title: t.title,
68
+ priority: t.priority,
69
+ due_at: t.due_at,
70
+ }));
71
+ }
72
+ } catch (_) {}
73
+
74
+ // 5. Pending questions: unresolved questions (limit 3)
75
+ try {
76
+ const questions = db.prepare(
77
+ "SELECT id, question, priority FROM pending_questions WHERE status = 'pending' ORDER BY CASE WHEN priority = 'high' THEN 0 WHEN priority = 'normal' THEN 1 ELSE 2 END, created_at DESC LIMIT 3"
78
+ ).all();
79
+ if (questions.length > 0) {
80
+ snapshot.pending_questions = questions.map(q => ({
81
+ id: q.id,
82
+ question: q.question.slice(0, 200),
83
+ priority: q.priority,
84
+ }));
85
+ }
86
+ } catch (_) {}
87
+
88
+ // 6. Recent decisions: last 5 initiative_log entries (to avoid repeating)
89
+ try {
90
+ const recentDecisions = db.prepare(
91
+ "SELECT decision, decision_data, reasoning, created_at FROM initiative_log ORDER BY created_at DESC LIMIT 5"
92
+ ).all();
93
+ if (recentDecisions.length > 0) {
94
+ snapshot.recent_decisions = recentDecisions.map(d => ({
95
+ decision: d.decision,
96
+ data: d.decision_data ? d.decision_data.slice(0, 200) : null,
97
+ reasoning: d.reasoning ? d.reasoning.slice(0, 150) : null,
98
+ at: d.created_at,
99
+ }));
100
+ }
101
+ } catch (_) {}
102
+
103
+ // 7. Knowledge gaps: domains with low confidence (tier 1-2)
104
+ try {
105
+ const gaps = db.prepare(
106
+ "SELECT domain, current_tier, confidence FROM domain_confidence WHERE current_tier <= 2 ORDER BY confidence ASC LIMIT 5"
107
+ ).all();
108
+ if (gaps.length > 0) {
109
+ snapshot.knowledge_gaps = gaps.map(g => ({
110
+ domain: g.domain,
111
+ tier: g.current_tier,
112
+ confidence: g.confidence,
113
+ }));
114
+ }
115
+ } catch (_) {}
116
+
117
+ // 8. Briefing items: urgent/today items
118
+ try {
119
+ const briefing = db.prepare(
120
+ "SELECT title, category, urgency, context FROM briefing_items WHERE status NOT IN ('done', 'dismissed') AND (urgency IN ('high', 'critical') OR last_seen >= date('now')) ORDER BY CASE WHEN urgency = 'critical' THEN 0 WHEN urgency = 'high' THEN 1 ELSE 2 END LIMIT 5"
121
+ ).all();
122
+ if (briefing.length > 0) {
123
+ snapshot.briefing_items = briefing.map(b => ({
124
+ title: b.title,
125
+ category: b.category,
126
+ urgency: b.urgency,
127
+ }));
128
+ }
129
+ } catch (_) {}
130
+
131
+ return snapshot;
132
+ }
133
+
134
+ /**
135
+ * Check if the snapshot contains no actionable data.
136
+ */
137
+ function isStateEmpty(snapshot) {
138
+ const keys = Object.keys(snapshot).filter(k => k !== 'time');
139
+ if (keys.length === 0) return true;
140
+ // Check if all data arrays are empty
141
+ return keys.every(k => {
142
+ const val = snapshot[k];
143
+ return Array.isArray(val) ? val.length === 0 : !val;
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Format the snapshot as human-readable text for the initiative prompt.
149
+ */
150
+ function formatSnapshot(snapshot) {
151
+ const lines = [];
152
+
153
+ if (snapshot.time) {
154
+ lines.push(`TIME: ${snapshot.time.day_of_week} ${snapshot.time.datetime} (${snapshot.time.timezone})`);
155
+ }
156
+
157
+ if (snapshot.calendar && snapshot.calendar.length > 0) {
158
+ lines.push('\nCALENDAR (next 2 hours):');
159
+ for (const c of snapshot.calendar) {
160
+ lines.push(` - [${c.urgency}] ${c.title}`);
161
+ }
162
+ }
163
+
164
+ if (snapshot.recent_memories && snapshot.recent_memories.length > 0) {
165
+ lines.push('\nRECENT MEMORIES (today):');
166
+ for (const m of snapshot.recent_memories) {
167
+ lines.push(` - [${m.source}, importance=${m.importance}] ${m.summary}`);
168
+ }
169
+ }
170
+
171
+ if (snapshot.pending_tasks && snapshot.pending_tasks.length > 0) {
172
+ lines.push('\nPENDING TASKS (due soon):');
173
+ for (const t of snapshot.pending_tasks) {
174
+ lines.push(` - [${t.priority}] ${t.title} (due: ${t.due_at})`);
175
+ }
176
+ }
177
+
178
+ if (snapshot.pending_questions && snapshot.pending_questions.length > 0) {
179
+ lines.push('\nPENDING QUESTIONS:');
180
+ for (const q of snapshot.pending_questions) {
181
+ lines.push(` - [${q.priority}] ${q.question}`);
182
+ }
183
+ }
184
+
185
+ if (snapshot.recent_decisions && snapshot.recent_decisions.length > 0) {
186
+ lines.push('\nRECENT INITIATIVE DECISIONS:');
187
+ for (const d of snapshot.recent_decisions) {
188
+ lines.push(` - ${d.decision}: ${d.reasoning || '(no reason)'} (${d.at})`);
189
+ }
190
+ }
191
+
192
+ if (snapshot.knowledge_gaps && snapshot.knowledge_gaps.length > 0) {
193
+ lines.push('\nKNOWLEDGE GAPS (low confidence):');
194
+ for (const g of snapshot.knowledge_gaps) {
195
+ lines.push(` - ${g.domain}: tier ${g.tier}, confidence ${g.confidence}`);
196
+ }
197
+ }
198
+
199
+ if (snapshot.briefing_items && snapshot.briefing_items.length > 0) {
200
+ lines.push('\nBRIEFING ITEMS (urgent/today):');
201
+ for (const b of snapshot.briefing_items) {
202
+ lines.push(` - [${b.urgency}] ${b.title} (${b.category || 'general'})`);
203
+ }
204
+ }
205
+
206
+ return lines.join('\n');
207
+ }
208
+
209
+ module.exports = { buildStateSnapshot, isStateEmpty, formatSnapshot };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Fast token estimation utilities for context compaction.
5
+ * Uses chars/4 with a 1.2x safety margin (overestimates slightly — safer than underestimating).
6
+ */
7
+
8
+ const CHARS_PER_TOKEN = 4;
9
+ const SAFETY_MARGIN = 1.2;
10
+
11
+ /**
12
+ * Estimate token count for a string.
13
+ * @param {string} text
14
+ * @returns {number}
15
+ */
16
+ function estimateTokens(text) {
17
+ if (!text) return 0;
18
+ return Math.ceil((text.length / CHARS_PER_TOKEN) * SAFETY_MARGIN);
19
+ }
20
+
21
+ /**
22
+ * Estimate total tokens across an array of messages.
23
+ * Handles both string content and structured content blocks (tool_use, tool_result, etc.).
24
+ * @param {Array} messages - Array of { role, content } objects
25
+ * @returns {number}
26
+ */
27
+ function estimateMessagesTokens(messages) {
28
+ if (!messages || !Array.isArray(messages)) return 0;
29
+ let total = 0;
30
+ for (const msg of messages) {
31
+ if (!msg) continue;
32
+ if (typeof msg.content === 'string') {
33
+ total += estimateTokens(msg.content);
34
+ } else if (Array.isArray(msg.content)) {
35
+ // Structured content: tool_use blocks, tool_result blocks, text blocks, etc.
36
+ for (const block of msg.content) {
37
+ if (block.type === 'text') {
38
+ total += estimateTokens(block.text || '');
39
+ } else if (block.type === 'tool_use') {
40
+ total += estimateTokens(JSON.stringify(block.input || {}));
41
+ total += estimateTokens(block.name || '');
42
+ } else if (block.type === 'tool_result') {
43
+ total += estimateTokens(typeof block.content === 'string' ? block.content : JSON.stringify(block.content || ''));
44
+ } else {
45
+ total += estimateTokens(JSON.stringify(block));
46
+ }
47
+ }
48
+ }
49
+ // Add small overhead per message for role, etc.
50
+ total += 4;
51
+ }
52
+ return total;
53
+ }
54
+
55
+ module.exports = { estimateTokens, estimateMessagesTokens };