clamper-ai 1.1.6

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 (44) hide show
  1. package/PRD.md +416 -0
  2. package/README.md +156 -0
  3. package/bin/clamper.mjs +66 -0
  4. package/dashboard/app.js +287 -0
  5. package/dashboard/index.html +137 -0
  6. package/dashboard/style.css +121 -0
  7. package/package.json +24 -0
  8. package/skills/clawkit-sync/SKILL.md +160 -0
  9. package/skills/code-mentor/SKILL.md +85 -0
  10. package/skills/csv-pipeline/SKILL.md +82 -0
  11. package/skills/developer/SKILL.md +57 -0
  12. package/skills/image/SKILL.md +71 -0
  13. package/skills/json/SKILL.md +59 -0
  14. package/skills/productivity/SKILL.md +68 -0
  15. package/skills/quick-reminders/SKILL.md +70 -0
  16. package/skills/svg-draw/SKILL.md +75 -0
  17. package/skills/weather/SKILL.md +72 -0
  18. package/skills/workspace-manager/SKILL.md +69 -0
  19. package/src/cron.mjs +30 -0
  20. package/src/dashboard.mjs +69 -0
  21. package/src/doctor.mjs +69 -0
  22. package/src/init.mjs +145 -0
  23. package/src/log-activity.mjs +26 -0
  24. package/src/scaffold.mjs +57 -0
  25. package/src/skills.mjs +52 -0
  26. package/src/status.mjs +43 -0
  27. package/src/sync.mjs +585 -0
  28. package/src/update.mjs +100 -0
  29. package/src/upgrade.mjs +104 -0
  30. package/src/utils.mjs +67 -0
  31. package/src/validate.mjs +66 -0
  32. package/templates/AGENTS.md +77 -0
  33. package/templates/HEARTBEAT.md +12 -0
  34. package/templates/IDENTITY.md +7 -0
  35. package/templates/LEARNINGS.md +14 -0
  36. package/templates/MEMORY.md +14 -0
  37. package/templates/PROTOCOL_COST_EFFICIENCY.md +30 -0
  38. package/templates/SKILLS-INDEX.md +21 -0
  39. package/templates/SOUL.md +22 -0
  40. package/templates/TOOLS.md +14 -0
  41. package/templates/USER.md +5 -0
  42. package/templates/memory/knowledge/about-me.md +13 -0
  43. package/templates/scripts/nightly-consolidation.sh +54 -0
  44. package/templates/tasks/QUEUE.md +10 -0
package/src/status.mjs ADDED
@@ -0,0 +1,43 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { c, log, detectWorkspace } from './utils.mjs';
4
+
5
+ export function runStatus() {
6
+ const workspace = detectWorkspace();
7
+ if (!workspace) {
8
+ log.err('No workspace detected. Set CLAMPER_WORKSPACE or run from ~/.openclaw/workspace');
9
+ process.exit(1);
10
+ }
11
+
12
+ const configPath = join(workspace, 'skills', '.clamper', 'config.json');
13
+ let config = {};
14
+ if (existsSync(configPath)) {
15
+ try { config = JSON.parse(readFileSync(configPath, 'utf8')); } catch { /* ignore */ }
16
+ }
17
+
18
+ const hasAgents = existsSync(join(workspace, 'AGENTS.md'));
19
+ const hasMemory = existsSync(join(workspace, 'MEMORY.md'));
20
+ const hasCron = existsSync(join(workspace, 'scripts/nightly-consolidation.sh'));
21
+
22
+ let skillCount = 0;
23
+ const skillsDir = join(workspace, 'skills');
24
+ if (existsSync(skillsDir)) {
25
+ skillCount = readdirSync(skillsDir, { withFileTypes: true })
26
+ .filter(d => d.isDirectory() && d.name !== '.clamper' && d.name !== '.clawhub')
27
+ .length;
28
+ }
29
+
30
+ const check = (ok) => ok ? `${c.green}✔${c.reset}` : `${c.red}✖${c.reset}`;
31
+
32
+ console.log(`\n${c.bold}${c.cyan}🧰 Clamper Status${c.reset}\n`);
33
+ console.log(` Workspace: ${workspace}`);
34
+ console.log(` Agent: ${config.agentName || 'Unknown'}`);
35
+ console.log(` Tier: ${config.tier || 'free'}`);
36
+ console.log(` Version: ${config.version || 'unknown'}`);
37
+ console.log(` Installed: ${config.installedAt || 'unknown'}\n`);
38
+ console.log(` ${check(hasAgents)} AGENTS.md`);
39
+ console.log(` ${check(hasMemory)} Memory system`);
40
+ console.log(` ${check(hasCron)} Nightly consolidation`);
41
+ console.log(` ${check(skillCount > 0)} Skills (${skillCount} installed)`);
42
+ console.log();
43
+ }
package/src/sync.mjs ADDED
@@ -0,0 +1,585 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { c, log, detectWorkspace } from './utils.mjs';
5
+
6
+ const API_BASE = 'https://clamper-api.vercel.app';
7
+
8
+ function getApiKey() {
9
+ return process.env.CLAMPER_API_KEY || '';
10
+ }
11
+
12
+ // --- Connected Accounts: parse from TOOLS.md ---
13
+
14
+ const SERVICE_PATTERNS = [
15
+ { key: 'google_sheets', names: ['Google Sheets'], patterns: [/Google Sheets.*?(?:Connection|ID).*?[:`]\s*`?([0-9a-f-]{20,})/i] },
16
+ { key: 'google_docs', names: ['Google Docs'], patterns: [/Google Docs.*?(?:Connection|ID).*?[:`]\s*`?([0-9a-f-]{20,})/i] },
17
+ { key: 'twitter', names: ['Twitter', 'X API', '@Clamper'], patterns: [/(?:Consumer|API)\s*Key.*?[:`]\s*`?([A-Za-z0-9]{20,})/i, /Bearer.*?[:`]\s*`?(AAAA[^\s`]+)/i] },
18
+ { key: 'stripe', names: ['Stripe'], patterns: [/(?:Secret|Publishable).*?[:`]\s*`?([sp]k_[^\s`]+)/i] },
19
+ { key: 'openai', names: ['OpenAI', 'Sora API'], patterns: [/(?:Key|API).*?[:`]\s*`?(sk-proj-[^\s`]+)/i, /(?:Key|API).*?[:`]\s*`?(sk-[^\s`]+)/i] },
20
+ { key: 'anthropic', names: ['Anthropic', 'Claude API'], patterns: [/(?:Key|API).*?[:`]\s*`?(sk-ant-[^\s`]+)/i] },
21
+ { key: 'brave_search', names: ['Brave Search', 'Brave API'], patterns: [/Brave.*(?:Key|API).*?[:`]\s*`?([^\s`]+)/i] },
22
+ { key: 'telegram', names: ['Telegram'], patterns: [/Telegram.*(?:Token|Bot).*?[:`]\s*`?(\d+:[^\s`]+)/i] },
23
+ { key: 'discord', names: ['Discord'], patterns: [/Discord.*(?:Token|Bot).*?[:`]\s*`?([^\s`]+)/i] },
24
+ { key: 'github', names: ['GitHub'], patterns: [/\*\*PAT:\*\*\s*`?(ghp_[^\s`]+)/i, /github.*(?:Token|PAT).*?`?(ghp_[^\s`]+)/i] },
25
+ { key: 'email', names: ['Email', 'IMAP', 'SMTP'], patterns: [/\*\*Email:\*\*\s*`?([^\s`]+@[^\s`]+)/i, /App Password.*?[:`]\s*`?([a-z ]{10,})/i] },
26
+ { key: 'sogni', names: ['Sogni'], patterns: [/SOGNI_USERNAME.*?["'`]\s*([^\s"'`]+)/i, /SOGNI_PASSWORD.*?["'`]\s*([^\s"'`]+)/i] },
27
+ { key: 'upload_post', names: ['Upload-Post', 'upload-post'], patterns: [/\*\*API Key:\*\*\s*`?(ey[^\s`]+)/i, /upload-post.*?Key.*?`?(ey[^\s`]+)/i] },
28
+ { key: 'vercel', names: ['Vercel'], patterns: [/(?:Token|token).*?`?(vcp_[^\s`]+)/i, /--token\s+(vcp_[^\s`]+)/i] },
29
+ { key: 'netlify', names: ['Netlify'], patterns: [/(?:Site ID|site).*?`?([0-9a-f]{8}-[0-9a-f-]{27,})/i] },
30
+ { key: 'maton', names: ['Maton', 'Google Workspace'], patterns: [/MATON_API_KEY.*?`?([A-Za-z0-9_-]{30,})/i] },
31
+ ];
32
+
33
+ function parseAccountsFromTools(workspace) {
34
+ const accounts = {};
35
+
36
+ // Read TOOLS.md
37
+ const toolsPath = join(workspace, 'TOOLS.md');
38
+ let toolsContent = '';
39
+ if (existsSync(toolsPath)) {
40
+ toolsContent = readFileSync(toolsPath, 'utf8');
41
+ }
42
+
43
+ // Also check docs/tool-configs.md (some users split configs there)
44
+ const extPath = join(workspace, 'docs', 'tool-configs.md');
45
+ if (existsSync(extPath)) {
46
+ toolsContent += '\n' + readFileSync(extPath, 'utf8');
47
+ }
48
+
49
+ if (!toolsContent) return accounts;
50
+
51
+ for (const svc of SERVICE_PATTERNS) {
52
+ // First check if service is even mentioned
53
+ const mentioned = svc.names.some(n => toolsContent.includes(n));
54
+ if (!mentioned) continue;
55
+
56
+ // Try to extract a credential
57
+ let credential = '';
58
+ for (const pat of svc.patterns) {
59
+ const match = toolsContent.match(pat);
60
+ if (match) {
61
+ credential = match[1];
62
+ break;
63
+ }
64
+ }
65
+
66
+ if (credential) {
67
+ accounts[svc.key] = {
68
+ credential,
69
+ label: svc.names[0],
70
+ connectedAt: new Date().toISOString(),
71
+ };
72
+ } else {
73
+ // Service mentioned but no credential extracted — mark as connected without exposing key
74
+ accounts[svc.key] = {
75
+ credential: 'configured',
76
+ label: svc.names[0],
77
+ connectedAt: new Date().toISOString(),
78
+ };
79
+ }
80
+ }
81
+
82
+ return accounts;
83
+ }
84
+
85
+ // --- Apply pending changes from dashboard back to TOOLS.md ---
86
+
87
+ function applyPendingChanges(workspace, changes, quiet) {
88
+ if (!changes || changes.length === 0) return;
89
+
90
+ const toolsPath = join(workspace, 'TOOLS.md');
91
+ let tools = existsSync(toolsPath) ? readFileSync(toolsPath, 'utf8') : '# TOOLS.md - Quick Reference\n';
92
+
93
+ // Also handle docs/tool-configs.md for detailed configs
94
+ const extPath = join(workspace, 'docs', 'tool-configs.md');
95
+ let extTools = existsSync(extPath) ? readFileSync(extPath, 'utf8') : '';
96
+
97
+ let toolsChanged = false;
98
+ let extChanged = false;
99
+
100
+ for (const change of changes) {
101
+ if (change.type === 'connect_account' && change.service && change.credential) {
102
+ const svcDef = SERVICE_PATTERNS.find(s => s.key === change.service);
103
+ const label = change.label || svcDef?.names[0] || change.service;
104
+ const section = `\n## ${label}\n- **API Key:** \`${change.credential}\`\n- **Added via:** Clamper Dashboard (${new Date(change.timestamp).toISOString().slice(0, 10)})\n`;
105
+
106
+ // Add to docs/tool-configs.md if it exists, otherwise TOOLS.md
107
+ if (extTools) {
108
+ extTools += section;
109
+ extChanged = true;
110
+ } else {
111
+ tools += section;
112
+ toolsChanged = true;
113
+ }
114
+
115
+ if (!quiet) log.ok(`Added ${label} credentials to ${extTools ? 'docs/tool-configs.md' : 'TOOLS.md'}`);
116
+ }
117
+
118
+ if (change.type === 'disconnect_account' && change.service) {
119
+ const svcDef = SERVICE_PATTERNS.find(s => s.key === change.service);
120
+ const label = svcDef?.names[0] || change.service;
121
+ if (!quiet) log.warn(`Dashboard requested disconnect of ${label} — manual removal recommended`);
122
+ // We don't auto-delete credentials from files for safety
123
+ }
124
+
125
+ if (change.type === 'preferences_update' && change.preferences) {
126
+ // Update IDENTITY.md agent name
127
+ if (change.preferences.agentName) {
128
+ const idPath = join(workspace, 'IDENTITY.md');
129
+ if (existsSync(idPath)) {
130
+ let id = readFileSync(idPath, 'utf8');
131
+ id = id.replace(/(\*\*Name:\*\*\s*).+/, `$1${change.preferences.agentName}`);
132
+ writeFileSync(idPath, id);
133
+ if (!quiet) log.ok(`Updated agent name to "${change.preferences.agentName}" in IDENTITY.md`);
134
+ }
135
+ }
136
+
137
+ // Update USER.md timezone
138
+ if (change.preferences.timezone) {
139
+ const userPath = join(workspace, 'USER.md');
140
+ if (existsSync(userPath)) {
141
+ let user = readFileSync(userPath, 'utf8');
142
+ user = user.replace(/(Timezone:\s*)\S+/i, `$1${change.preferences.timezone}`);
143
+ writeFileSync(userPath, user);
144
+ if (!quiet) log.ok(`Updated timezone to "${change.preferences.timezone}" in USER.md`);
145
+ }
146
+ }
147
+ }
148
+
149
+ if (change.type === 'model_switch' && change.model) {
150
+ if (!quiet) log.info(`Model switch requested: ${change.model} (agent should pick this up)`);
151
+ }
152
+
153
+ // Toggle preferences → write to config so agent respects them
154
+ if (change.type === 'preferences_update' && change.preferences) {
155
+ const p = change.preferences;
156
+ const cfgDir = join(workspace, 'skills', '.clamper');
157
+ const cfgPath = join(cfgDir, 'config.json');
158
+ let cfg = {};
159
+ if (existsSync(cfgPath)) try { cfg = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch {}
160
+
161
+ if (p.emailChecking !== undefined) { cfg.emailChecking = p.emailChecking; if (!quiet) log.ok(`Email checking ${p.emailChecking ? 'enabled' : 'disabled'}`); }
162
+ if (p.socialMonitoring !== undefined) { cfg.socialMonitoring = p.socialMonitoring; if (!quiet) log.ok(`Social monitoring ${p.socialMonitoring ? 'enabled' : 'disabled'}`); }
163
+
164
+ // Quick actions → write flag for agent
165
+ if (p.quickAction) {
166
+ const logsDir = join(workspace, 'logs');
167
+ if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
168
+ writeFileSync(join(logsDir, 'quick-action.flag'), JSON.stringify({ action: p.quickAction, timestamp: change.timestamp }));
169
+ if (!quiet) log.ok(`Quick action queued: ${p.quickAction}`);
170
+ }
171
+
172
+ if (!existsSync(cfgDir)) mkdirSync(cfgDir, { recursive: true });
173
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
174
+ }
175
+
176
+ if (change.type === 'clear_context' || (change.type === 'preferences_update' && change.preferences?.clearContext)) {
177
+ const flagPath = join(workspace, 'logs', 'clear-context.flag');
178
+ const logsDir = join(workspace, 'logs');
179
+ if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
180
+ writeFileSync(flagPath, JSON.stringify({ requested: change.timestamp || new Date().toISOString(), source: 'dashboard' }));
181
+ if (!quiet) log.ok('Clear context requested — flag written to logs/clear-context.flag');
182
+ }
183
+ }
184
+
185
+ if (toolsChanged) writeFileSync(toolsPath, tools);
186
+ if (extChanged) writeFileSync(extPath, extTools);
187
+ }
188
+
189
+ // --- Gather full state ---
190
+
191
+ function gatherState(workspace) {
192
+ // Get OpenClaw version
193
+ let openclawVersion = '';
194
+ try {
195
+ openclawVersion = execSync('openclaw --version 2>/dev/null', { encoding: 'utf8' }).trim();
196
+ } catch { /* ignore */ }
197
+
198
+ const state = {
199
+ model: '',
200
+ uptime: '',
201
+ openclawVersion,
202
+ contextUsage: '',
203
+ cronJobs: [],
204
+ connectedAccounts: {},
205
+ recentActivity: [],
206
+ preferences: { timezone: 'UTC', heartbeatInterval: 30, defaultModel: '', costTarget: 6 },
207
+ skills: [],
208
+ memory: { dailyNotes: 0, knowledgeFiles: 0, memoryMdSize: '0KB', lastConsolidation: null },
209
+ };
210
+
211
+ // Config
212
+ const configPath = join(workspace, 'skills', '.clamper', 'config.json');
213
+ let config = {};
214
+ if (existsSync(configPath)) {
215
+ try { config = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
216
+ }
217
+
218
+ state.preferences.agentName = config.agentName || '';
219
+ state.preferences.defaultModel = config.defaultModel || '';
220
+ state.preferences.emailChecking = config.emailChecking || false;
221
+ state.preferences.socialMonitoring = config.socialMonitoring || false;
222
+
223
+ // Identity
224
+ const idPath = join(workspace, 'IDENTITY.md');
225
+ if (existsSync(idPath)) {
226
+ const id = readFileSync(idPath, 'utf8');
227
+ const nameMatch = id.match(/\*\*Name:\*\*\s*(.+)/);
228
+ if (nameMatch) state.preferences.agentName = nameMatch[1].trim();
229
+ }
230
+
231
+ // User preferences
232
+ const userPath = join(workspace, 'USER.md');
233
+ if (existsSync(userPath)) {
234
+ const user = readFileSync(userPath, 'utf8');
235
+ const tzMatch = user.match(/Timezone:\s*(\S+)/i);
236
+ if (tzMatch) state.preferences.timezone = tzMatch[1];
237
+ }
238
+
239
+ // Connected accounts from TOOLS.md + docs/tool-configs.md
240
+ state.connectedAccounts = parseAccountsFromTools(workspace);
241
+
242
+ // Also pull from OpenClaw gateway config
243
+ try {
244
+ const gwPath = join(process.env.HOME || '', '.openclaw', 'openclaw.json');
245
+ if (existsSync(gwPath)) {
246
+ const gw = JSON.parse(readFileSync(gwPath, 'utf8'));
247
+
248
+ // Telegram bot
249
+ const tgToken = gw.channels?.telegram?.botToken;
250
+ if (tgToken && !state.connectedAccounts.telegram) {
251
+ state.connectedAccounts.telegram = {
252
+ credential: tgToken === '__OPENCLAW_REDACTED__' ? 'configured (gateway)' : tgToken,
253
+ label: 'Telegram Bot',
254
+ connectedAt: new Date().toISOString(),
255
+ };
256
+ }
257
+
258
+ // Brave Search
259
+ const braveKey = gw.tools?.web?.search?.apiKey;
260
+ if (braveKey && !state.connectedAccounts.brave_search) {
261
+ state.connectedAccounts.brave_search = {
262
+ credential: braveKey === '__OPENCLAW_REDACTED__' ? 'configured (gateway)' : braveKey,
263
+ label: 'Brave Search',
264
+ connectedAt: new Date().toISOString(),
265
+ };
266
+ }
267
+
268
+ // Anthropic (from auth profiles or model providers)
269
+ const hasAnthropic = gw.auth?.profiles?.['anthropic:manual'] || gw.models?.providers?.anthropic;
270
+ if (hasAnthropic && !state.connectedAccounts.anthropic) {
271
+ state.connectedAccounts.anthropic = {
272
+ credential: 'configured (gateway)',
273
+ label: 'Anthropic',
274
+ connectedAt: new Date().toISOString(),
275
+ };
276
+ }
277
+
278
+ // Skip internal/infra services: OpenRouter, Qwen, Nvidia, Browser
279
+ // These are model routing, not user-facing connected accounts
280
+ }
281
+ } catch {}
282
+
283
+ // Skills
284
+ const skillsDir = join(workspace, 'skills');
285
+ if (existsSync(skillsDir)) {
286
+ state.skills = readdirSync(skillsDir, { withFileTypes: true })
287
+ .filter(d => d.isDirectory() && !d.name.startsWith('.'))
288
+ .map(d => ({ name: d.name, emoji: '⚡' }));
289
+ }
290
+
291
+ // Memory stats
292
+ const dailyDir = join(workspace, 'memory', 'daily');
293
+ if (existsSync(dailyDir)) {
294
+ state.memory.dailyNotes = readdirSync(dailyDir).filter(f => f.endsWith('.md')).length;
295
+ }
296
+ const memDir = join(workspace, 'memory');
297
+ if (existsSync(memDir)) {
298
+ const rootDaily = readdirSync(memDir).filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).length;
299
+ state.memory.dailyNotes = Math.max(state.memory.dailyNotes, rootDaily);
300
+ }
301
+
302
+ const knowledgeDir = join(workspace, 'memory', 'knowledge');
303
+ if (existsSync(knowledgeDir)) {
304
+ state.memory.knowledgeFiles = readdirSync(knowledgeDir).filter(f => f.endsWith('.md')).length;
305
+ }
306
+
307
+ const memoryMd = join(workspace, 'MEMORY.md');
308
+ if (existsSync(memoryMd)) {
309
+ const bytes = statSync(memoryMd).size;
310
+ state.memory.memoryMdSize = bytes < 1024 ? `${bytes}B` : `${Math.round(bytes / 1024)}KB`;
311
+ }
312
+
313
+ // Last consolidation
314
+ const consolLog = join(workspace, 'scripts', 'consolidation.log');
315
+ if (existsSync(consolLog)) {
316
+ const logContent = readFileSync(consolLog, 'utf8');
317
+ const dates = logContent.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g);
318
+ if (dates && dates.length > 0) {
319
+ state.memory.lastConsolidation = dates[dates.length - 1] + 'Z';
320
+ }
321
+ }
322
+
323
+ // --- Cron Jobs (from OpenClaw gateway API) ---
324
+ try {
325
+ // Try the local gateway WebSocket API via HTTP
326
+ const cronOut = execSync('curl -s -m 3 http://127.0.0.1:18789/api/cron/list 2>/dev/null || true', { timeout: 5000, encoding: 'utf8' });
327
+ const cronMatch = cronOut.match(/\{[\s\S]*\}/);
328
+ if (cronMatch) {
329
+ const cronData = JSON.parse(cronMatch[0]);
330
+ const jobs = cronData.jobs || [];
331
+ state.cronJobs = jobs.map(j => ({
332
+ name: j.name || (j.payload?.text || '').slice(0, 40) || 'Unnamed',
333
+ schedule: j.schedule?.kind === 'cron' ? j.schedule.expr :
334
+ j.schedule?.kind === 'every' ? `every ${Math.round((j.schedule.everyMs || 0) / 60000)}m` :
335
+ j.schedule?.kind === 'at' ? `at ${j.schedule.at}` : 'unknown',
336
+ lastRun: j.state?.lastRunAtMs ? new Date(j.state.lastRunAtMs).toISOString() : undefined,
337
+ status: j.state?.lastStatus || (j.enabled ? 'ok' : 'disabled'),
338
+ }));
339
+ }
340
+ } catch {}
341
+
342
+ // Fallback: parse cron info from HEARTBEAT.md
343
+ if (state.cronJobs.length === 0) {
344
+ const hbPath = join(workspace, 'HEARTBEAT.md');
345
+ if (existsSync(hbPath)) {
346
+ const hb = readFileSync(hbPath, 'utf8');
347
+
348
+ // Detect heartbeat interval from config or default
349
+ let hbInterval = '30 min';
350
+ const cfgPath = join(workspace, 'skills', '.clamper', 'config.json');
351
+ if (existsSync(cfgPath)) {
352
+ try {
353
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
354
+ if (cfg.heartbeatInterval) hbInterval = `${cfg.heartbeatInterval} min`;
355
+ } catch {}
356
+ }
357
+
358
+ // Parse sections to determine frequency
359
+ const lines = hb.split('\n');
360
+ let currentFreq = hbInterval; // default
361
+
362
+ const tasks = [];
363
+ for (const line of lines) {
364
+ // Detect frequency headers
365
+ if (/every heartbeat|immediate/i.test(line)) currentFreq = hbInterval;
366
+ else if (/daily/i.test(line)) currentFreq = '24 hours';
367
+ else if (/bi-daily|3 times/i.test(line)) currentFreq = '8 hours';
368
+ else if (/weekly/i.test(line)) currentFreq = '7 days';
369
+ else if (/hourly/i.test(line)) currentFreq = '1 hour';
370
+
371
+ // Extract task names
372
+ const taskMatch = line.match(/\*\*(?:Task|Check):\*\*\s*(.+)/);
373
+ if (taskMatch) {
374
+ tasks.push({ name: taskMatch[1].trim(), freq: currentFreq });
375
+ }
376
+ }
377
+
378
+ for (const t of tasks.slice(0, 12)) {
379
+ state.cronJobs.push({
380
+ name: t.name,
381
+ schedule: `every ${t.freq}`,
382
+ status: 'ok',
383
+ });
384
+ }
385
+ }
386
+ }
387
+
388
+ // --- Recent Activity ---
389
+
390
+ // Primary: activity log file (JSONL format)
391
+ const activityLog = join(workspace, 'logs', 'activity.jsonl');
392
+ if (existsSync(activityLog)) {
393
+ const lines = readFileSync(activityLog, 'utf8').trim().split('\n').filter(Boolean);
394
+ for (const line of lines) {
395
+ try {
396
+ const entry = JSON.parse(line);
397
+ state.recentActivity.push({
398
+ timestamp: entry.ts || entry.timestamp || new Date().toISOString(),
399
+ message: entry.msg || entry.message || '',
400
+ });
401
+ } catch {}
402
+ }
403
+ }
404
+
405
+ // Secondary: git commits (if not enough from activity log)
406
+ if (state.recentActivity.length < 10) {
407
+ try {
408
+ // Find git repos in common locations
409
+ const home = process.env.HOME || '';
410
+ const searchDirs = [join(home, 'Desktop'), join(home, 'Projects'), join(home, 'dev')];
411
+ for (const searchDir of searchDirs) {
412
+ if (!existsSync(searchDir)) continue;
413
+ try {
414
+ const repos = execSync(`find "${searchDir}" -maxdepth 2 -name ".git" -type d 2>/dev/null`, { timeout: 3000, encoding: 'utf8' });
415
+ for (const gitDir of repos.trim().split('\n').filter(Boolean)) {
416
+ const repoDir = join(gitDir, '..');
417
+ try {
418
+ const gitLog = execSync(`cd "${repoDir}" && git log --oneline --format="%ai|%s" -3 2>/dev/null`, { timeout: 2000, encoding: 'utf8' });
419
+ const repoName = repoDir.split('/').pop();
420
+ for (const line of gitLog.trim().split('\n').filter(Boolean)) {
421
+ const [date, ...msgParts] = line.split('|');
422
+ state.recentActivity.push({
423
+ timestamp: new Date(date.trim()).toISOString(),
424
+ message: `[${repoName}] ${msgParts.join('|').slice(0, 100)}`,
425
+ });
426
+ }
427
+ } catch {}
428
+ }
429
+ } catch {}
430
+ }
431
+ } catch {}
432
+ }
433
+
434
+ // Tertiary: recent file changes
435
+ if (state.recentActivity.length < 15) {
436
+ try {
437
+ const recentFiles = execSync(`find "${workspace}" -name "*.md" -mmin -1440 -not -path "*/node_modules/*" -not -path "*/.git/*" -printf "%T@|%f\n" 2>/dev/null | sort -rn | head -5`, { timeout: 3000, encoding: 'utf8' });
438
+ for (const line of recentFiles.trim().split('\n').filter(Boolean)) {
439
+ const [ts, file] = line.split('|');
440
+ state.recentActivity.push({
441
+ timestamp: new Date(parseFloat(ts) * 1000).toISOString(),
442
+ message: `Updated ${file}`,
443
+ });
444
+ }
445
+ } catch {}
446
+ }
447
+
448
+ // Sort by timestamp descending, dedupe by message, limit
449
+ state.recentActivity.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
450
+ const seen = new Set();
451
+ state.recentActivity = state.recentActivity.filter(a => {
452
+ if (seen.has(a.message)) return false;
453
+ seen.add(a.message);
454
+ return true;
455
+ }).slice(0, 20);
456
+
457
+ // Agent activity status
458
+ const statusFile = join(workspace, 'logs', 'agent-status.txt');
459
+ if (existsSync(statusFile)) {
460
+ state.agentActivity = readFileSync(statusFile, 'utf8').trim().slice(0, 100);
461
+ } else if (state.recentActivity.length > 0) {
462
+ state.agentActivity = state.recentActivity[0].message.slice(0, 80);
463
+ } else {
464
+ state.agentActivity = 'Waiting for instructions';
465
+ }
466
+
467
+ // --- Token Usage & Cost + Context Breakdown ---
468
+ state.usage = { inputTokens: 0, outputTokens: 0, contextUsed: 0, contextMax: 0, costEstimate: '', model: '', compactions: 0 };
469
+
470
+ // Read context stats from logs/context-stats.json (updated by agent)
471
+ const ctxStatsPath = join(workspace, 'logs', 'context-stats.json');
472
+ if (existsSync(ctxStatsPath)) {
473
+ try {
474
+ const cs = JSON.parse(readFileSync(ctxStatsPath, 'utf8'));
475
+ state.usage.model = cs.model || state.usage.model;
476
+ state.usage.inputTokens = cs.inputTokens || 0;
477
+ state.usage.outputTokens = cs.outputTokens || 0;
478
+ state.usage.contextUsed = cs.contextUsed || cs.contextTokens || 0;
479
+ state.usage.contextMax = cs.contextMax || cs.contextLimit || 0;
480
+ state.usage.compactions = cs.compactions || 0;
481
+ } catch {}
482
+ }
483
+
484
+ // Fallback: read model from gateway config
485
+ if (!state.usage.model) {
486
+ try {
487
+ const gwPath = join(process.env.HOME || '', '.openclaw', 'openclaw.json');
488
+ if (existsSync(gwPath)) {
489
+ const gw = JSON.parse(readFileSync(gwPath, 'utf8'));
490
+ const primary = gw.agents?.defaults?.model?.primary;
491
+ if (primary) state.usage.model = primary;
492
+ }
493
+ } catch {}
494
+ }
495
+
496
+ // If we couldn't get from CLI, estimate from daily note sizes
497
+ if (!state.usage.model) {
498
+ state.usage.model = state.preferences.defaultModel || 'unknown';
499
+ }
500
+
501
+ // Cost estimation based on model
502
+ const MODEL_COSTS = {
503
+ 'claude-opus-4-6': { input: 15, output: 75 }, // per 1M tokens
504
+ 'claude-opus-4': { input: 15, output: 75 },
505
+ 'claude-sonnet-4-5': { input: 3, output: 15 },
506
+ 'claude-sonnet-4': { input: 3, output: 15 },
507
+ 'claude-haiku-3-5': { input: 0.8, output: 4 },
508
+ 'gpt-4o': { input: 2.5, output: 10 },
509
+ 'gpt-4o-mini': { input: 0.15, output: 0.6 },
510
+ 'gemini-2.0-flash': { input: 0.1, output: 0.4 },
511
+ };
512
+ const modelKey = Object.keys(MODEL_COSTS).find(k => state.usage.model?.includes(k));
513
+ const costs = MODEL_COSTS[modelKey] || { input: 3, output: 15 };
514
+ const inCost = (state.usage.inputTokens / 1_000_000) * costs.input;
515
+ const outCost = (state.usage.outputTokens / 1_000_000) * costs.output;
516
+ state.usage.costEstimate = `$${(inCost + outCost).toFixed(4)}`;
517
+ state.usage.costPerMInput = costs.input;
518
+ state.usage.costPerMOutput = costs.output;
519
+
520
+ return state;
521
+ }
522
+
523
+ // --- Main sync ---
524
+
525
+ export async function runSync(flags) {
526
+ const workspace = detectWorkspace();
527
+ if (!workspace) {
528
+ log.err('No workspace found. Run clamper init first.');
529
+ process.exit(1);
530
+ }
531
+
532
+ const apiKey = flags.key || getApiKey();
533
+ if (!apiKey) {
534
+ log.err('No API key. Set CLAMPER_API_KEY or use --key=YOUR_KEY');
535
+ process.exit(1);
536
+ }
537
+
538
+ const quiet = flags.quiet || flags.q;
539
+ if (!quiet) console.log(`\n${c.bold}${c.cyan}🔄 Clamper Sync${c.reset}\n`);
540
+
541
+ // Gather state
542
+ if (!quiet) log.step(1, 'Gathering agent state...');
543
+ const state = gatherState(workspace);
544
+
545
+ const accountCount = Object.keys(state.connectedAccounts).length;
546
+ if (!quiet) {
547
+ log.ok(`${state.skills.length} skills, ${accountCount} accounts, ${state.memory.dailyNotes} daily notes, ${state.memory.memoryMdSize} memory`);
548
+ }
549
+
550
+ // Push to API
551
+ if (!quiet) log.step(2, 'Pushing to Clamper API...');
552
+ try {
553
+ const res = await fetch(`${API_BASE}/api/dashboard/sync`, {
554
+ method: 'POST',
555
+ headers: { 'Content-Type': 'application/json' },
556
+ body: JSON.stringify({ apiKey, state }),
557
+ });
558
+ const data = await res.json();
559
+
560
+ if (data.success) {
561
+ if (!quiet) log.ok('State synced successfully');
562
+
563
+ // Apply pending changes from dashboard → local files
564
+ if (data.pendingChanges && data.pendingChanges.length > 0) {
565
+ if (!quiet) {
566
+ console.log(`\n ${c.yellow}📥 ${data.pendingChanges.length} pending change(s) from dashboard${c.reset}`);
567
+ }
568
+ applyPendingChanges(workspace, data.pendingChanges, quiet);
569
+ }
570
+
571
+ if (!quiet) {
572
+ console.log(`\n ${c.green}✔${c.reset} Dashboard: ${c.cyan}https://clamper.tech/dashboard${c.reset}\n`);
573
+ } else {
574
+ // Quiet mode — output pending changes as JSON for agent
575
+ if (data.pendingChanges && data.pendingChanges.length > 0) {
576
+ console.log(JSON.stringify(data.pendingChanges));
577
+ }
578
+ }
579
+ } else {
580
+ log.err(`Sync failed: ${data.error || 'Unknown error'}`);
581
+ }
582
+ } catch (err) {
583
+ log.err(`Network error: ${err.message}`);
584
+ }
585
+ }