skimpyclaw 0.1.0

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 (219) hide show
  1. package/README.md +230 -0
  2. package/dist/__tests__/agent.test.d.ts +1 -0
  3. package/dist/__tests__/agent.test.js +131 -0
  4. package/dist/__tests__/api.test.d.ts +1 -0
  5. package/dist/__tests__/api.test.js +1227 -0
  6. package/dist/__tests__/audit.test.d.ts +1 -0
  7. package/dist/__tests__/audit.test.js +122 -0
  8. package/dist/__tests__/cache.test.d.ts +1 -0
  9. package/dist/__tests__/cache.test.js +65 -0
  10. package/dist/__tests__/channels.test.d.ts +1 -0
  11. package/dist/__tests__/channels.test.js +85 -0
  12. package/dist/__tests__/cli.integration.test.d.ts +1 -0
  13. package/dist/__tests__/cli.integration.test.js +16 -0
  14. package/dist/__tests__/cli.test.d.ts +1 -0
  15. package/dist/__tests__/cli.test.js +230 -0
  16. package/dist/__tests__/code-agents-executor.test.d.ts +1 -0
  17. package/dist/__tests__/code-agents-executor.test.js +75 -0
  18. package/dist/__tests__/code-agents-orchestrator.test.d.ts +1 -0
  19. package/dist/__tests__/code-agents-orchestrator.test.js +149 -0
  20. package/dist/__tests__/code-agents-parser.test.d.ts +1 -0
  21. package/dist/__tests__/code-agents-parser.test.js +39 -0
  22. package/dist/__tests__/code-agents-utils.test.d.ts +1 -0
  23. package/dist/__tests__/code-agents-utils.test.js +41 -0
  24. package/dist/__tests__/config.test.d.ts +1 -0
  25. package/dist/__tests__/config.test.js +46 -0
  26. package/dist/__tests__/cron.test.d.ts +1 -0
  27. package/dist/__tests__/cron.test.js +66 -0
  28. package/dist/__tests__/dashboard-mode.test.d.ts +1 -0
  29. package/dist/__tests__/dashboard-mode.test.js +145 -0
  30. package/dist/__tests__/dashboard.test.d.ts +1 -0
  31. package/dist/__tests__/dashboard.test.js +43 -0
  32. package/dist/__tests__/doctor.formatters.test.d.ts +1 -0
  33. package/dist/__tests__/doctor.formatters.test.js +65 -0
  34. package/dist/__tests__/doctor.index.test.d.ts +1 -0
  35. package/dist/__tests__/doctor.index.test.js +48 -0
  36. package/dist/__tests__/doctor.runner.test.d.ts +1 -0
  37. package/dist/__tests__/doctor.runner.test.js +204 -0
  38. package/dist/__tests__/exec-approval.test.d.ts +1 -0
  39. package/dist/__tests__/exec-approval.test.js +323 -0
  40. package/dist/__tests__/file-lock.test.d.ts +1 -0
  41. package/dist/__tests__/file-lock.test.js +92 -0
  42. package/dist/__tests__/langfuse.test.d.ts +1 -0
  43. package/dist/__tests__/langfuse.test.js +40 -0
  44. package/dist/__tests__/model-selection.test.d.ts +1 -0
  45. package/dist/__tests__/model-selection.test.js +62 -0
  46. package/dist/__tests__/orchestrator.test.d.ts +1 -0
  47. package/dist/__tests__/orchestrator.test.js +425 -0
  48. package/dist/__tests__/providers-init.test.d.ts +1 -0
  49. package/dist/__tests__/providers-init.test.js +32 -0
  50. package/dist/__tests__/providers-routing.test.d.ts +1 -0
  51. package/dist/__tests__/providers-routing.test.js +25 -0
  52. package/dist/__tests__/providers-utils.test.d.ts +1 -0
  53. package/dist/__tests__/providers-utils.test.js +54 -0
  54. package/dist/__tests__/security.test.d.ts +1 -0
  55. package/dist/__tests__/security.test.js +22 -0
  56. package/dist/__tests__/sessions.test.d.ts +1 -0
  57. package/dist/__tests__/sessions.test.js +147 -0
  58. package/dist/__tests__/setup.test.d.ts +1 -0
  59. package/dist/__tests__/setup.test.js +114 -0
  60. package/dist/__tests__/skills.test.d.ts +1 -0
  61. package/dist/__tests__/skills.test.js +333 -0
  62. package/dist/__tests__/subagent.test.d.ts +1 -0
  63. package/dist/__tests__/subagent.test.js +240 -0
  64. package/dist/__tests__/telegram-utils.test.d.ts +1 -0
  65. package/dist/__tests__/telegram-utils.test.js +22 -0
  66. package/dist/__tests__/telegram.test.d.ts +1 -0
  67. package/dist/__tests__/telegram.test.js +42 -0
  68. package/dist/__tests__/token-efficiency.test.d.ts +1 -0
  69. package/dist/__tests__/token-efficiency.test.js +38 -0
  70. package/dist/__tests__/tool-guard.test.d.ts +1 -0
  71. package/dist/__tests__/tool-guard.test.js +105 -0
  72. package/dist/__tests__/tools.test.d.ts +1 -0
  73. package/dist/__tests__/tools.test.js +589 -0
  74. package/dist/__tests__/usage.test.d.ts +1 -0
  75. package/dist/__tests__/usage.test.js +197 -0
  76. package/dist/__tests__/voice.test.d.ts +1 -0
  77. package/dist/__tests__/voice.test.js +214 -0
  78. package/dist/agent.d.ts +24 -0
  79. package/dist/agent.js +269 -0
  80. package/dist/api.d.ts +3 -0
  81. package/dist/api.js +943 -0
  82. package/dist/audit.d.ts +26 -0
  83. package/dist/audit.js +121 -0
  84. package/dist/cache.d.ts +8 -0
  85. package/dist/cache.js +24 -0
  86. package/dist/channels/telegram/handlers.d.ts +41 -0
  87. package/dist/channels/telegram/handlers.js +498 -0
  88. package/dist/channels/telegram/index.d.ts +14 -0
  89. package/dist/channels/telegram/index.js +326 -0
  90. package/dist/channels/telegram/types.d.ts +26 -0
  91. package/dist/channels/telegram/types.js +31 -0
  92. package/dist/channels/telegram/utils.d.ts +25 -0
  93. package/dist/channels/telegram/utils.js +256 -0
  94. package/dist/channels.d.ts +11 -0
  95. package/dist/channels.js +118 -0
  96. package/dist/cli.d.ts +5 -0
  97. package/dist/cli.js +768 -0
  98. package/dist/code-agents/executor.d.ts +5 -0
  99. package/dist/code-agents/executor.js +463 -0
  100. package/dist/code-agents/index.d.ts +22 -0
  101. package/dist/code-agents/index.js +199 -0
  102. package/dist/code-agents/orchestrator.d.ts +23 -0
  103. package/dist/code-agents/orchestrator.js +403 -0
  104. package/dist/code-agents/parser.d.ts +21 -0
  105. package/dist/code-agents/parser.js +197 -0
  106. package/dist/code-agents/registry.d.ts +27 -0
  107. package/dist/code-agents/registry.js +147 -0
  108. package/dist/code-agents/types.d.ts +66 -0
  109. package/dist/code-agents/types.js +4 -0
  110. package/dist/code-agents/utils.d.ts +36 -0
  111. package/dist/code-agents/utils.js +236 -0
  112. package/dist/config.d.ts +19 -0
  113. package/dist/config.js +123 -0
  114. package/dist/cron.d.ts +49 -0
  115. package/dist/cron.js +400 -0
  116. package/dist/dashboard/assets/index-CZJCvMSN.js +65 -0
  117. package/dist/dashboard/assets/index-EAg6lqF5.css +1 -0
  118. package/dist/dashboard/favicon.svg +3 -0
  119. package/dist/dashboard/index.html +21 -0
  120. package/dist/dashboard-frontend.d.ts +7 -0
  121. package/dist/dashboard-frontend.js +86 -0
  122. package/dist/dashboard.d.ts +8 -0
  123. package/dist/dashboard.js +4071 -0
  124. package/dist/digests.d.ts +36 -0
  125. package/dist/digests.js +338 -0
  126. package/dist/discord.d.ts +8 -0
  127. package/dist/discord.js +828 -0
  128. package/dist/doctor/checks.d.ts +18 -0
  129. package/dist/doctor/checks.js +368 -0
  130. package/dist/doctor/formatters.d.ts +3 -0
  131. package/dist/doctor/formatters.js +44 -0
  132. package/dist/doctor/index.d.ts +8 -0
  133. package/dist/doctor/index.js +7 -0
  134. package/dist/doctor/runner.d.ts +3 -0
  135. package/dist/doctor/runner.js +109 -0
  136. package/dist/doctor/types.d.ts +20 -0
  137. package/dist/doctor/types.js +1 -0
  138. package/dist/exec-approval.d.ts +101 -0
  139. package/dist/exec-approval.js +432 -0
  140. package/dist/file-lock.d.ts +34 -0
  141. package/dist/file-lock.js +81 -0
  142. package/dist/gateway.d.ts +8 -0
  143. package/dist/gateway.js +114 -0
  144. package/dist/heartbeat.d.ts +4 -0
  145. package/dist/heartbeat.js +101 -0
  146. package/dist/index.d.ts +1 -0
  147. package/dist/index.js +75 -0
  148. package/dist/langfuse.d.ts +34 -0
  149. package/dist/langfuse.js +145 -0
  150. package/dist/mcp-context-a8c.d.ts +13 -0
  151. package/dist/mcp-context-a8c.js +34 -0
  152. package/dist/model-selection.d.ts +18 -0
  153. package/dist/model-selection.js +50 -0
  154. package/dist/orchestrator.d.ts +15 -0
  155. package/dist/orchestrator.js +676 -0
  156. package/dist/providers/anthropic.d.ts +7 -0
  157. package/dist/providers/anthropic.js +319 -0
  158. package/dist/providers/codex.d.ts +17 -0
  159. package/dist/providers/codex.js +508 -0
  160. package/dist/providers/content.d.ts +21 -0
  161. package/dist/providers/content.js +55 -0
  162. package/dist/providers/index.d.ts +13 -0
  163. package/dist/providers/index.js +138 -0
  164. package/dist/providers/observability.d.ts +19 -0
  165. package/dist/providers/observability.js +94 -0
  166. package/dist/providers/openai.d.ts +10 -0
  167. package/dist/providers/openai.js +310 -0
  168. package/dist/providers/tool-guard.d.ts +30 -0
  169. package/dist/providers/tool-guard.js +89 -0
  170. package/dist/providers/types.d.ts +34 -0
  171. package/dist/providers/types.js +2 -0
  172. package/dist/providers/utils.d.ts +65 -0
  173. package/dist/providers/utils.js +199 -0
  174. package/dist/security.d.ts +8 -0
  175. package/dist/security.js +113 -0
  176. package/dist/service.d.ts +8 -0
  177. package/dist/service.js +38 -0
  178. package/dist/sessions.d.ts +35 -0
  179. package/dist/sessions.js +142 -0
  180. package/dist/setup.d.ts +36 -0
  181. package/dist/setup.js +821 -0
  182. package/dist/skills-types.d.ts +65 -0
  183. package/dist/skills-types.js +2 -0
  184. package/dist/skills.d.ts +32 -0
  185. package/dist/skills.js +260 -0
  186. package/dist/subagent.d.ts +19 -0
  187. package/dist/subagent.js +376 -0
  188. package/dist/telegram.d.ts +2 -0
  189. package/dist/telegram.js +11 -0
  190. package/dist/tools/bash-tool.d.ts +3 -0
  191. package/dist/tools/bash-tool.js +59 -0
  192. package/dist/tools/browser-tool.d.ts +3 -0
  193. package/dist/tools/browser-tool.js +265 -0
  194. package/dist/tools/definitions.d.ts +432 -0
  195. package/dist/tools/definitions.js +181 -0
  196. package/dist/tools/execute-context.d.ts +26 -0
  197. package/dist/tools/execute-context.js +1 -0
  198. package/dist/tools/file-tools.d.ts +8 -0
  199. package/dist/tools/file-tools.js +67 -0
  200. package/dist/tools/path-utils.d.ts +1 -0
  201. package/dist/tools/path-utils.js +8 -0
  202. package/dist/tools.d.ts +24 -0
  203. package/dist/tools.js +281 -0
  204. package/dist/types.d.ts +259 -0
  205. package/dist/types.js +2 -0
  206. package/dist/usage.d.ts +76 -0
  207. package/dist/usage.js +150 -0
  208. package/dist/voice.d.ts +37 -0
  209. package/dist/voice.js +461 -0
  210. package/package.json +70 -0
  211. package/templates/AGENTS.md +38 -0
  212. package/templates/BOOT.md +23 -0
  213. package/templates/BOOTSTRAP.md +26 -0
  214. package/templates/HEARTBEAT.md +5 -0
  215. package/templates/IDENTITY.md +5 -0
  216. package/templates/MEMORY.md +24 -0
  217. package/templates/SOUL.md +92 -0
  218. package/templates/TOOLS.md +30 -0
  219. package/templates/USER.md +31 -0
package/dist/cron.js ADDED
@@ -0,0 +1,400 @@
1
+ // Cron scheduler using croner
2
+ import { Cron } from 'croner';
3
+ import { exec } from 'child_process';
4
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, watch } from 'fs';
5
+ import { join } from 'path';
6
+ import { getLogsDir, getConfigPath, loadConfig } from './config.js';
7
+ import { runAgentTurn } from './agent.js';
8
+ import { startTrace, addEvent, endTrace } from './audit.js';
9
+ import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
10
+ import { parseAndSaveDigest } from './digests.js';
11
+ import { synthesizeSpeech } from './voice.js';
12
+ const scheduledJobs = new Map();
13
+ let configWatcher = null;
14
+ function getCronLogDir() {
15
+ const dir = join(getLogsDir(), 'cron');
16
+ if (!existsSync(dir)) {
17
+ mkdirSync(dir, { recursive: true });
18
+ }
19
+ return dir;
20
+ }
21
+ function getCronLogPath(jobId) {
22
+ const date = new Date().toISOString().split('T')[0];
23
+ return join(getCronLogDir(), `${jobId}-${date}.log`);
24
+ }
25
+ function writeCronLog(entry) {
26
+ const logPath = getCronLogPath(entry.jobId);
27
+ const separator = '\n' + '='.repeat(60) + '\n';
28
+ const header = `[${entry.startedAt}] ${entry.jobName} (${entry.jobId})`;
29
+ const status = `Status: ${entry.status}`;
30
+ const duration = entry.durationMs != null ? `Duration: ${(entry.durationMs / 1000).toFixed(1)}s` : '';
31
+ const error = entry.error ? `Error: ${entry.error}` : '';
32
+ const output = entry.output ? `Output:\n${entry.output}` : '';
33
+ const lines = [separator, header, status, duration, error, output]
34
+ .filter(Boolean)
35
+ .join('\n');
36
+ appendFileSync(logPath, lines + '\n');
37
+ }
38
+ function appendCronLogLine(jobId, line) {
39
+ const logPath = getCronLogPath(jobId);
40
+ const timestamp = new Date().toISOString();
41
+ appendFileSync(logPath, `[${timestamp}] ${line}\n`);
42
+ }
43
+ // Track currently running jobs for status queries
44
+ const runningJobs = new Map();
45
+ export function getCronRunStatus() {
46
+ return {
47
+ running: Array.from(runningJobs.keys()),
48
+ recent: Array.from(runningJobs.values()),
49
+ };
50
+ }
51
+ export function initCron(config) {
52
+ // Clear existing jobs
53
+ for (const job of scheduledJobs.values()) {
54
+ job.job.stop();
55
+ }
56
+ scheduledJobs.clear();
57
+ // Schedule new jobs
58
+ for (const jobDef of config.cron.jobs) {
59
+ scheduleJob(jobDef, config);
60
+ }
61
+ console.log(`[cron] Initialized ${scheduledJobs.size} jobs`);
62
+ // Watch config.json for changes and re-initialize cron jobs
63
+ if (configWatcher) {
64
+ configWatcher.close();
65
+ configWatcher = null;
66
+ }
67
+ let debounceTimer = null;
68
+ try {
69
+ configWatcher = watch(getConfigPath(), () => {
70
+ if (debounceTimer)
71
+ clearTimeout(debounceTimer);
72
+ debounceTimer = setTimeout(() => {
73
+ try {
74
+ const newConfig = loadConfig();
75
+ console.log('[cron] Config changed, reloading cron jobs...');
76
+ for (const job of scheduledJobs.values()) {
77
+ job.job.stop();
78
+ }
79
+ scheduledJobs.clear();
80
+ for (const jobDef of newConfig.cron.jobs) {
81
+ scheduleJob(jobDef, newConfig);
82
+ }
83
+ console.log(`[cron] Reloaded ${scheduledJobs.size} jobs`);
84
+ }
85
+ catch (err) {
86
+ console.error('[cron] Failed to reload config:', err);
87
+ }
88
+ }, 1000);
89
+ });
90
+ }
91
+ catch (err) {
92
+ console.warn('[cron] Could not watch config file:', err);
93
+ }
94
+ }
95
+ function scheduleJob(jobDef, config) {
96
+ if (jobDef.schedule.kind !== 'cron') {
97
+ console.warn(`[cron] Unsupported schedule kind: ${jobDef.schedule.kind}`);
98
+ return;
99
+ }
100
+ const cronJob = new Cron(jobDef.schedule.expr, {
101
+ timezone: jobDef.schedule.tz || 'America/Chicago',
102
+ }, async () => {
103
+ console.log(`[cron] Running job: ${jobDef.name}`);
104
+ await executeJobPayload(jobDef, config);
105
+ });
106
+ scheduledJobs.set(jobDef.id, {
107
+ id: jobDef.id,
108
+ name: jobDef.name,
109
+ job: cronJob,
110
+ nextRun: cronJob.nextRun() ?? undefined,
111
+ });
112
+ console.log(`[cron] Scheduled: ${jobDef.name} (${jobDef.schedule.expr})`);
113
+ }
114
+ async function executeJobPayload(jobDef, config) {
115
+ const logEntry = {
116
+ jobId: jobDef.id,
117
+ jobName: jobDef.name,
118
+ startedAt: new Date().toISOString(),
119
+ status: 'running',
120
+ };
121
+ runningJobs.set(jobDef.id, logEntry);
122
+ // Log start immediately
123
+ appendCronLogLine(jobDef.id, `=== STARTED: ${jobDef.name} (${jobDef.id}) ===`);
124
+ appendCronLogLine(jobDef.id, `Model: ${jobDef.model || 'default'}`);
125
+ appendCronLogLine(jobDef.id, `Payload: ${jobDef.payload.kind}`);
126
+ // Notify channel at start
127
+ try {
128
+ await sendActiveChannelProactiveMessage(config, `🔄 Cron starting: ${jobDef.name}`);
129
+ }
130
+ catch {
131
+ // Non-critical
132
+ }
133
+ try {
134
+ if (jobDef.payload.kind === 'agentTurn') {
135
+ const message = expandVariables(resolveMessageSource(jobDef.payload.message || ''));
136
+ appendCronLogLine(jobDef.id, `Agent turn started (prompt: ${message.slice(0, 100)}...)`);
137
+ const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, jobDef.payload.tools, undefined, {
138
+ channel: getActiveChannelId() || 'telegram',
139
+ trigger: 'cron',
140
+ sessionId: jobDef.id,
141
+ metadata: { jobName: jobDef.name },
142
+ });
143
+ appendCronLogLine(jobDef.id, `Agent turn completed (${response.length} chars)`);
144
+ // Parse dual output (voice + text) if delimiters present
145
+ const { voice: voicePortion, text: textPortion } = parseDualOutput(response);
146
+ if (voicePortion) {
147
+ appendCronLogLine(jobDef.id, `Dual output parsed: voice=${voicePortion.length} chars, text=${textPortion.length} chars`);
148
+ }
149
+ // Use text portion for log output and notifications
150
+ logEntry.output = textPortion.slice(0, 5000);
151
+ // Parse and save digest from the text portion
152
+ try {
153
+ parseAndSaveDigest(jobDef.id, jobDef.name, textPortion);
154
+ appendCronLogLine(jobDef.id, 'Digest saved');
155
+ }
156
+ catch (digestErr) {
157
+ const errMsg = digestErr instanceof Error ? digestErr.message : String(digestErr);
158
+ appendCronLogLine(jobDef.id, `Failed to save digest: ${errMsg}`);
159
+ }
160
+ // Synthesize and send voice if configured
161
+ if (jobDef.payload.sendAsVoice && config.voice) {
162
+ try {
163
+ // Use voice portion if available, fall back to text
164
+ const voiceContent = voicePortion || textPortion;
165
+ appendCronLogLine(jobDef.id, `Synthesizing voice (${voicePortion ? 'voice portion' : 'full text fallback'})...`);
166
+ const speech = await synthesizeSpeech(voiceContent, config.voice);
167
+ appendCronLogLine(jobDef.id, `Voice synthesized (${speech.format}, ${speech.provider}, ${speech.buffer.length} bytes)`);
168
+ const sent = await sendActiveChannelProactiveVoice(config, speech.buffer, speech.format);
169
+ if (sent) {
170
+ appendCronLogLine(jobDef.id, 'Voice message sent to active channel');
171
+ }
172
+ else {
173
+ appendCronLogLine(jobDef.id, 'No active channel for voice message');
174
+ }
175
+ }
176
+ catch (voiceErr) {
177
+ const errMsg = voiceErr instanceof Error ? voiceErr.message : String(voiceErr);
178
+ appendCronLogLine(jobDef.id, `Voice synthesis failed: ${errMsg}`);
179
+ // Non-fatal — text notification still sends
180
+ }
181
+ }
182
+ }
183
+ else if (jobDef.payload.kind === 'script') {
184
+ const scriptTraceId = startTrace('cron');
185
+ addEvent(scriptTraceId, {
186
+ type: 'script_start',
187
+ summary: `${jobDef.name}: ${(jobDef.payload.script || '').slice(0, 100)}`,
188
+ durationMs: 0,
189
+ });
190
+ appendCronLogLine(jobDef.id, `Script started: ${(jobDef.payload.script || '').slice(0, 100)}`);
191
+ try {
192
+ const output = await executeScript(jobDef);
193
+ logEntry.output = output.slice(0, 50000);
194
+ appendCronLogLine(jobDef.id, `Script completed (${output.length} chars)`);
195
+ addEvent(scriptTraceId, {
196
+ type: 'script_complete',
197
+ summary: `Output: ${output.slice(0, 150)}`,
198
+ durationMs: 0,
199
+ });
200
+ await endTrace(scriptTraceId, 'ok');
201
+ }
202
+ catch (scriptErr) {
203
+ const scriptErrMsg = scriptErr instanceof Error ? scriptErr.message : String(scriptErr);
204
+ addEvent(scriptTraceId, {
205
+ type: 'script_error',
206
+ summary: scriptErrMsg.slice(0, 150),
207
+ durationMs: 0,
208
+ });
209
+ await endTrace(scriptTraceId, 'error');
210
+ throw scriptErr;
211
+ }
212
+ }
213
+ logEntry.status = 'success';
214
+ appendCronLogLine(jobDef.id, `=== COMPLETED: success ===`);
215
+ }
216
+ catch (err) {
217
+ const msg = err instanceof Error ? err.message : String(err);
218
+ logEntry.status = msg.includes('TIMEOUT') || msg.includes('timed out') ? 'timeout' : 'error';
219
+ logEntry.error = msg;
220
+ appendCronLogLine(jobDef.id, `=== FAILED: ${logEntry.status} — ${msg.slice(0, 200)} ===`);
221
+ throw err;
222
+ }
223
+ finally {
224
+ logEntry.finishedAt = new Date().toISOString();
225
+ logEntry.durationMs = new Date(logEntry.finishedAt).getTime() - new Date(logEntry.startedAt).getTime();
226
+ writeCronLog(logEntry);
227
+ runningJobs.delete(jobDef.id);
228
+ const elapsed = (logEntry.durationMs / 1000).toFixed(1);
229
+ console.log(`[cron] Job ${jobDef.id} finished: ${logEntry.status} (${elapsed}s)`);
230
+ // Notify active channel
231
+ try {
232
+ const icon = logEntry.status === 'success' ? '✅' : logEntry.status === 'timeout' ? '⏰' : '❌';
233
+ let notification = `${icon} Cron: ${jobDef.name} — ${logEntry.status} (${elapsed}s)`;
234
+ if (logEntry.error) {
235
+ notification += `\nError: ${logEntry.error.slice(0, 200)}`;
236
+ }
237
+ // Send status + output preview (truncated at 4000 chars for Telegram limit)
238
+ if (logEntry.status === 'success' && logEntry.output) {
239
+ const output = logEntry.output.length > 4000
240
+ ? logEntry.output.slice(0, 4000) + '...'
241
+ : logEntry.output;
242
+ notification += `\n\n${output}`;
243
+ }
244
+ await sendActiveChannelProactiveMessage(config, notification);
245
+ }
246
+ catch (notifyErr) {
247
+ console.error(`[cron] Failed to send notification: ${notifyErr}`);
248
+ }
249
+ }
250
+ }
251
+ async function executeScript(jobDef) {
252
+ const script = expandVariables(jobDef.payload.script || '');
253
+ if (!script) {
254
+ throw new Error(`Script payload is empty for job: ${jobDef.id}`);
255
+ }
256
+ const cwd = jobDef.payload.cwd;
257
+ if (cwd && !existsSync(cwd)) {
258
+ throw new Error(`Working directory does not exist: ${cwd}`);
259
+ }
260
+ const timeoutMs = jobDef.payload.timeoutMs || 600000; // 10 min default
261
+ return new Promise((resolve, reject) => {
262
+ const startTime = Date.now();
263
+ console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
264
+ if (cwd)
265
+ console.log(`[cron:script] cwd: ${cwd}`);
266
+ const child = exec(script, {
267
+ cwd: cwd || undefined,
268
+ timeout: timeoutMs,
269
+ env: { ...process.env },
270
+ maxBuffer: 10 * 1024 * 1024, // 10MB output buffer
271
+ }, (error, stdout, stderr) => {
272
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
273
+ if (error) {
274
+ console.error(`[cron:script] Failed after ${elapsed}s: ${error.message}`);
275
+ if (stderr)
276
+ console.error(`[cron:script] stderr: ${stderr.slice(0, 500)}`);
277
+ reject(error);
278
+ return;
279
+ }
280
+ console.log(`[cron:script] Completed in ${elapsed}s`);
281
+ if (stdout) {
282
+ const lines = stdout.trim().split('\n');
283
+ const preview = lines.length > 5
284
+ ? lines.slice(0, 3).join('\n') + `\n... (${lines.length} lines total)`
285
+ : stdout.trim();
286
+ console.log(`[cron:script] Output:\n${preview}`);
287
+ }
288
+ resolve(stdout || '');
289
+ });
290
+ // Log PID for debugging
291
+ if (child.pid) {
292
+ console.log(`[cron:script] PID: ${child.pid}`);
293
+ }
294
+ });
295
+ }
296
+ /**
297
+ * If message is a path to a .md file, read and return its contents.
298
+ * Supports ~ home expansion. Otherwise returns the string as-is.
299
+ */
300
+ function resolveMessageSource(message) {
301
+ const trimmed = message.trim();
302
+ if (!trimmed.endsWith('.md'))
303
+ return message;
304
+ // Expand ~ to home directory
305
+ const resolved = trimmed.startsWith('~/')
306
+ ? join(process.env.HOME || '', trimmed.slice(2))
307
+ : trimmed;
308
+ if (existsSync(resolved)) {
309
+ console.log(`[cron] Loading prompt from file: ${resolved}`);
310
+ return readFileSync(resolved, 'utf-8');
311
+ }
312
+ // Fallback: check ~/.skimpyclaw/prompts/ directory
313
+ const promptsDir = join(process.env.HOME || '', '.skimpyclaw', 'prompts', trimmed);
314
+ if (existsSync(promptsDir)) {
315
+ console.log(`[cron] Loading prompt from prompts dir: ${promptsDir}`);
316
+ return readFileSync(promptsDir, 'utf-8');
317
+ }
318
+ console.warn(`[cron] Could not resolve prompt file: ${trimmed}`);
319
+ // Not a valid file path — treat as regular message text
320
+ return message;
321
+ }
322
+ /**
323
+ * Parse dual-output format with ---VOICE--- and ---TEXT--- delimiters.
324
+ * Returns voice (concise, no links) and text (full detail) portions.
325
+ * If delimiters not found, returns full response as text with voice = null (backward compatible).
326
+ */
327
+ export function parseDualOutput(response) {
328
+ const voiceMarker = '---VOICE---';
329
+ const textMarker = '---TEXT---';
330
+ const voiceIdx = response.indexOf(voiceMarker);
331
+ const textIdx = response.indexOf(textMarker);
332
+ if (voiceIdx === -1 || textIdx === -1) {
333
+ // No delimiters — backward compatible
334
+ return { voice: null, text: response };
335
+ }
336
+ // Extract content between markers
337
+ const voiceStart = voiceIdx + voiceMarker.length;
338
+ const voice = response.slice(voiceStart, textIdx).trim();
339
+ const text = response.slice(textIdx + textMarker.length).trim();
340
+ return {
341
+ voice: voice || null,
342
+ text: text || response,
343
+ };
344
+ }
345
+ function expandVariables(message) {
346
+ const now = new Date();
347
+ const date = now.toLocaleDateString('en-US', {
348
+ month: 'long',
349
+ day: 'numeric',
350
+ year: 'numeric',
351
+ });
352
+ return message
353
+ .replace(/\{\{date\}\}/g, date)
354
+ .replace(/\{\{time\}\}/g, now.toLocaleTimeString())
355
+ .replace(/\{\{iso\}\}/g, now.toISOString());
356
+ }
357
+ export function getCronJobs() {
358
+ return Array.from(scheduledJobs.values()).map(j => ({
359
+ id: j.id,
360
+ name: j.name,
361
+ nextRun: j.job.nextRun() ?? undefined,
362
+ }));
363
+ }
364
+ export async function runCronJob(id, config) {
365
+ const jobDef = config.cron.jobs.find(j => j.id === id);
366
+ if (!jobDef) {
367
+ throw new Error(`Cron job not found: ${id}`);
368
+ }
369
+ await executeJobPayload(jobDef, config);
370
+ }
371
+ export function getCronJobDetails(config) {
372
+ return config.cron.jobs.map(jobDef => {
373
+ const scheduled = scheduledJobs.get(jobDef.id);
374
+ return {
375
+ id: jobDef.id,
376
+ name: jobDef.name,
377
+ schedule: {
378
+ kind: jobDef.schedule.kind,
379
+ expr: jobDef.schedule.expr,
380
+ tz: jobDef.schedule.tz,
381
+ },
382
+ payload: {
383
+ kind: jobDef.payload.kind,
384
+ message: jobDef.payload.message,
385
+ },
386
+ model: jobDef.model,
387
+ nextRun: scheduled?.job.nextRun() ?? undefined,
388
+ };
389
+ });
390
+ }
391
+ export function stopCron() {
392
+ if (configWatcher) {
393
+ configWatcher.close();
394
+ configWatcher = null;
395
+ }
396
+ for (const job of scheduledJobs.values()) {
397
+ job.job.stop();
398
+ }
399
+ scheduledJobs.clear();
400
+ }