clementine-agent 1.0.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 (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,1278 @@
1
+ /**
2
+ * Clementine TypeScript — Admin/System MCP tools.
3
+ *
4
+ * set_timer, workspace_config/list/info, cron_run_history, cron_list,
5
+ * add_cron_job, trigger_cron_job, workflow_list/create/run,
6
+ * analyze_image, feedback_log/report, teach_skill, create_tool,
7
+ * self_restart, self_improve_status/run, source_self_edit,
8
+ * cron_progress_read/write
9
+ */
10
+ import { execSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import Anthropic from '@anthropic-ai/sdk';
16
+ import { z } from 'zod';
17
+ import { BASE_DIR, CRON_FILE, SYSTEM_DIR, env, getStore, logger, textResult, } from './shared.js';
18
+ function readEnvFile() { return env; }
19
+ export function registerAdminTools(server) {
20
+ // ── 16. set_timer ──────────────────────────────────────────────────────
21
+ const TIMERS_FILE = path.join(BASE_DIR, '.timers.json');
22
+ function readTimers() {
23
+ if (!existsSync(TIMERS_FILE))
24
+ return [];
25
+ try {
26
+ return JSON.parse(readFileSync(TIMERS_FILE, 'utf-8'));
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ function writeTimers(timers) {
33
+ writeFileSync(TIMERS_FILE, JSON.stringify(timers, null, 2));
34
+ }
35
+ server.tool('set_timer', 'Set a short-term reminder/timer. Fires in N minutes and sends a notification. Use this instead of cron for reminders under 24 hours.', {
36
+ minutes: z.number().describe('Minutes from now to fire the reminder'),
37
+ message: z.string().describe('The reminder message to send'),
38
+ }, async ({ minutes, message }) => {
39
+ if (minutes < 1 || minutes > 1440) {
40
+ return textResult('Timer must be between 1 and 1440 minutes (24 hours). Use cron for longer schedules.');
41
+ }
42
+ const now = Date.now();
43
+ const fireAt = now + minutes * 60 * 1000;
44
+ const timer = {
45
+ id: `timer-${now}`,
46
+ message,
47
+ fireAt,
48
+ createdAt: now,
49
+ };
50
+ const timers = readTimers();
51
+ timers.push(timer);
52
+ writeTimers(timers);
53
+ const fireTime = new Date(fireAt).toLocaleTimeString('en-US', {
54
+ hour: 'numeric',
55
+ minute: '2-digit',
56
+ hour12: true,
57
+ });
58
+ return textResult(`Timer set. Reminder in ${minutes} minute${minutes !== 1 ? 's' : ''} (~${fireTime}): "${message}"`);
59
+ });
60
+ // ── Workspace Tools ─────────────────────────────────────────────────────
61
+ /** Common developer directories to auto-scan (relative to home). */
62
+ const DEFAULT_WORKSPACE_CANDIDATES = [
63
+ 'Desktop', 'Documents', 'Developer', 'Projects', 'projects',
64
+ 'repos', 'Repos', 'src', 'code', 'Code', 'work', 'Work',
65
+ 'dev', 'Dev', 'github', 'GitHub', 'gitlab', 'GitLab',
66
+ ];
67
+ /**
68
+ * Build the effective workspace dirs list:
69
+ * 1. Auto-scan common locations that exist on this machine
70
+ * 2. Merge with explicit WORKSPACE_DIRS from .env
71
+ * 3. Deduplicate by resolved path
72
+ */
73
+ function getWorkspaceDirs() {
74
+ const home = os.homedir();
75
+ const seen = new Set();
76
+ const dirs = [];
77
+ const add = (d) => {
78
+ const resolved = path.resolve(d);
79
+ if (!seen.has(resolved) && existsSync(resolved) && statSync(resolved).isDirectory()) {
80
+ seen.add(resolved);
81
+ dirs.push(resolved);
82
+ }
83
+ };
84
+ // Auto-scan common locations
85
+ for (const candidate of DEFAULT_WORKSPACE_CANDIDATES) {
86
+ add(path.join(home, candidate));
87
+ }
88
+ // Merge explicit WORKSPACE_DIRS from .env
89
+ const fresh = readEnvFile();
90
+ const explicit = (fresh['WORKSPACE_DIRS'] ?? '')
91
+ .split(',').map(s => s.trim()).filter(Boolean)
92
+ .map(d => d.startsWith('~') ? d.replace('~', home) : d);
93
+ for (const d of explicit) {
94
+ add(d);
95
+ }
96
+ return dirs;
97
+ }
98
+ /** Update a single key in the .env file, preserving all other content. */
99
+ function updateEnvKey(key, value) {
100
+ const envPath = path.join(BASE_DIR, '.env');
101
+ let lines = [];
102
+ if (existsSync(envPath)) {
103
+ lines = readFileSync(envPath, 'utf-8').split('\n');
104
+ }
105
+ let found = false;
106
+ for (let i = 0; i < lines.length; i++) {
107
+ if (lines[i].startsWith(`${key}=`)) {
108
+ lines[i] = `${key}=${value}`;
109
+ found = true;
110
+ break;
111
+ }
112
+ }
113
+ if (!found) {
114
+ // Find or create the Workspace section
115
+ let insertIdx = lines.length;
116
+ for (let i = 0; i < lines.length; i++) {
117
+ if (lines[i].trim() === '# Workspace') {
118
+ insertIdx = i + 1;
119
+ break;
120
+ }
121
+ }
122
+ if (insertIdx === lines.length) {
123
+ lines.push('', '# Workspace');
124
+ insertIdx = lines.length;
125
+ }
126
+ lines.splice(insertIdx, 0, `${key}=${value}`);
127
+ }
128
+ writeFileSync(envPath, lines.join('\n'));
129
+ }
130
+ server.tool('workspace_config', 'View or modify workspace directories. Add/remove parent directories that contain your projects. Changes take effect immediately.', {
131
+ action: z.enum(['list', 'add', 'remove']).describe('"list" to show current dirs, "add" to add a directory, "remove" to remove one'),
132
+ directory: z.string().optional().describe('Directory path to add or remove (required for add/remove)'),
133
+ }, async ({ action, directory }) => {
134
+ const currentDirs = getWorkspaceDirs();
135
+ if (action === 'list') {
136
+ if (currentDirs.length === 0) {
137
+ return textResult('No workspace directories found. Use action "add" to add one.');
138
+ }
139
+ // Mark which are explicit vs auto-detected
140
+ const fresh = readEnvFile();
141
+ const explicitSet = new Set((fresh['WORKSPACE_DIRS'] ?? '').split(',').map(s => s.trim()).filter(Boolean)
142
+ .map(d => path.resolve(d.startsWith('~') ? d.replace('~', os.homedir()) : d)));
143
+ const lines = currentDirs.map((d, i) => {
144
+ const tag = explicitSet.has(d) ? ' *(explicit)*' : ' *(auto-detected)*';
145
+ return `${i + 1}. \`${d}\`${tag}`;
146
+ });
147
+ return textResult(`Workspace directories (${currentDirs.length}):\n\n${lines.join('\n')}`);
148
+ }
149
+ if (!directory) {
150
+ throw new Error('directory is required for add/remove actions');
151
+ }
152
+ const resolved = path.resolve(directory.startsWith('~') ? directory.replace('~', os.homedir()) : directory);
153
+ if (action === 'add') {
154
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
155
+ throw new Error(`Not a directory: ${resolved}`);
156
+ }
157
+ // Store with ~ for portability
158
+ const display = resolved.startsWith(os.homedir())
159
+ ? resolved.replace(os.homedir(), '~')
160
+ : resolved;
161
+ // Check for duplicates
162
+ const currentRaw = (readEnvFile()['WORKSPACE_DIRS'] ?? '').split(',').map(s => s.trim()).filter(Boolean);
163
+ if (currentRaw.includes(display) || currentRaw.includes(resolved)) {
164
+ return textResult(`\`${display}\` is already in workspace directories.`);
165
+ }
166
+ const updated = [...currentRaw, display].join(',');
167
+ updateEnvKey('WORKSPACE_DIRS', updated);
168
+ return textResult(`Added \`${display}\` to workspace directories. ${currentRaw.length + 1} total.`);
169
+ }
170
+ if (action === 'remove') {
171
+ const currentRaw = (readEnvFile()['WORKSPACE_DIRS'] ?? '').split(',').map(s => s.trim()).filter(Boolean);
172
+ const display = resolved.startsWith(os.homedir())
173
+ ? resolved.replace(os.homedir(), '~')
174
+ : resolved;
175
+ const filtered = currentRaw.filter(d => {
176
+ const dResolved = path.resolve(d.startsWith('~') ? d.replace('~', os.homedir()) : d);
177
+ return dResolved !== resolved;
178
+ });
179
+ if (filtered.length === currentRaw.length) {
180
+ return textResult(`\`${display}\` was not found in workspace directories.`);
181
+ }
182
+ updateEnvKey('WORKSPACE_DIRS', filtered.join(','));
183
+ return textResult(`Removed \`${display}\` from workspace directories. ${filtered.length} remaining.`);
184
+ }
185
+ throw new Error(`Unknown action: ${action}`);
186
+ });
187
+ const PROJECT_MARKERS = [
188
+ '.git', 'package.json', 'pyproject.toml', 'Cargo.toml',
189
+ 'go.mod', 'Makefile', 'CMakeLists.txt', 'build.gradle',
190
+ 'pom.xml', 'Gemfile', 'mix.exs', '.claude/CLAUDE.md',
191
+ ];
192
+ function detectProjectType(entries) {
193
+ if (entries.includes('package.json'))
194
+ return 'node';
195
+ if (entries.includes('pyproject.toml') || entries.includes('setup.py'))
196
+ return 'python';
197
+ if (entries.includes('Cargo.toml'))
198
+ return 'rust';
199
+ if (entries.includes('go.mod'))
200
+ return 'go';
201
+ if (entries.includes('build.gradle') || entries.includes('pom.xml'))
202
+ return 'java';
203
+ if (entries.includes('Gemfile'))
204
+ return 'ruby';
205
+ if (entries.includes('mix.exs'))
206
+ return 'elixir';
207
+ if (entries.includes('CMakeLists.txt'))
208
+ return 'c/c++';
209
+ if (entries.includes('Makefile'))
210
+ return 'make';
211
+ return 'unknown';
212
+ }
213
+ function extractDescription(dirPath, entries) {
214
+ // Try package.json
215
+ if (entries.includes('package.json')) {
216
+ try {
217
+ const pkg = JSON.parse(readFileSync(path.join(dirPath, 'package.json'), 'utf-8'));
218
+ if (pkg.description)
219
+ return pkg.description;
220
+ }
221
+ catch { /* ignore */ }
222
+ }
223
+ // Try pyproject.toml (basic parse)
224
+ if (entries.includes('pyproject.toml')) {
225
+ try {
226
+ const toml = readFileSync(path.join(dirPath, 'pyproject.toml'), 'utf-8');
227
+ const match = toml.match(/description\s*=\s*"([^"]+)"/);
228
+ if (match)
229
+ return match[1];
230
+ }
231
+ catch { /* ignore */ }
232
+ }
233
+ // Try first non-heading line of README
234
+ for (const readme of ['README.md', 'readme.md', 'README.rst', 'README']) {
235
+ if (entries.includes(readme)) {
236
+ try {
237
+ const lines = readFileSync(path.join(dirPath, readme), 'utf-8').split('\n');
238
+ for (const line of lines) {
239
+ const trimmed = line.trim();
240
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('=')) {
241
+ return trimmed.slice(0, 200);
242
+ }
243
+ }
244
+ }
245
+ catch { /* ignore */ }
246
+ }
247
+ }
248
+ return '';
249
+ }
250
+ server.tool('workspace_list', 'List local projects found in configured workspace directories. Scans WORKSPACE_DIRS for project roots.', {
251
+ filter: z.string().optional().describe('Filter project names (case-insensitive substring match)'),
252
+ }, async ({ filter }) => {
253
+ const workspaceDirs = getWorkspaceDirs();
254
+ if (workspaceDirs.length === 0) {
255
+ return textResult('No workspace directories found (none of the common locations exist and WORKSPACE_DIRS is empty). ' +
256
+ 'Use workspace_config to add a directory.');
257
+ }
258
+ const projects = [];
259
+ const seenProjects = new Set();
260
+ const addProject = (fullPath, name) => {
261
+ const resolvedProject = path.resolve(fullPath);
262
+ if (seenProjects.has(resolvedProject))
263
+ return;
264
+ seenProjects.add(resolvedProject);
265
+ if (filter && !name.toLowerCase().includes(filter.toLowerCase()))
266
+ return;
267
+ let subEntries;
268
+ try {
269
+ subEntries = readdirSync(fullPath);
270
+ }
271
+ catch {
272
+ return;
273
+ }
274
+ projects.push({
275
+ name,
276
+ path: fullPath,
277
+ type: detectProjectType(subEntries),
278
+ description: extractDescription(fullPath, subEntries),
279
+ hasClaude: existsSync(path.join(fullPath, '.claude', 'CLAUDE.md')),
280
+ });
281
+ };
282
+ for (const wsDir of workspaceDirs) {
283
+ const resolved = path.resolve(wsDir);
284
+ if (!existsSync(resolved))
285
+ continue;
286
+ let entries;
287
+ try {
288
+ entries = readdirSync(resolved);
289
+ }
290
+ catch {
291
+ continue;
292
+ }
293
+ // Check if the workspace dir itself is a project
294
+ const wsDirIsProject = PROJECT_MARKERS.some(marker => {
295
+ if (marker.includes('/'))
296
+ return existsSync(path.join(resolved, marker));
297
+ return entries.includes(marker);
298
+ });
299
+ if (wsDirIsProject) {
300
+ addProject(resolved, path.basename(resolved));
301
+ }
302
+ // Scan subdirectories for projects
303
+ for (const entry of entries) {
304
+ if (entry.startsWith('.'))
305
+ continue;
306
+ const fullPath = path.join(resolved, entry);
307
+ try {
308
+ if (!statSync(fullPath).isDirectory())
309
+ continue;
310
+ }
311
+ catch {
312
+ continue;
313
+ }
314
+ let subEntries;
315
+ try {
316
+ subEntries = readdirSync(fullPath);
317
+ }
318
+ catch {
319
+ continue;
320
+ }
321
+ const isProject = PROJECT_MARKERS.some(marker => {
322
+ if (marker.includes('/')) {
323
+ return existsSync(path.join(fullPath, marker));
324
+ }
325
+ return subEntries.includes(marker);
326
+ });
327
+ if (!isProject)
328
+ continue;
329
+ addProject(fullPath, entry);
330
+ }
331
+ }
332
+ if (projects.length === 0) {
333
+ return textResult(filter
334
+ ? `No projects matching "${filter}" found in workspace directories.`
335
+ : 'No projects found in workspace directories.');
336
+ }
337
+ const lines = projects.map(p => {
338
+ const parts = [`**${p.name}** (${p.type})`];
339
+ if (p.description)
340
+ parts.push(` ${p.description}`);
341
+ parts.push(` Path: \`${p.path}\``);
342
+ if (p.hasClaude)
343
+ parts.push(' Has `.claude/CLAUDE.md`');
344
+ return parts.join('\n');
345
+ });
346
+ return textResult(`Found ${projects.length} project(s):\n\n${lines.join('\n\n')}`);
347
+ });
348
+ server.tool('workspace_info', 'Get detailed info about a local project: README, CLAUDE.md, manifest, structure.', {
349
+ project_path: z.string().describe('Absolute path to the project root'),
350
+ include_tree: z.boolean().optional().describe('Include directory tree (default true, depth 2)'),
351
+ }, async ({ project_path, include_tree }) => {
352
+ const resolved = path.resolve(project_path.startsWith('~') ? project_path.replace('~', os.homedir()) : project_path);
353
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
354
+ throw new Error(`Not a directory: ${resolved}`);
355
+ }
356
+ const sections = [`# ${path.basename(resolved)}\n\nPath: \`${resolved}\``];
357
+ // CLAUDE.md
358
+ const claudeMd = path.join(resolved, '.claude', 'CLAUDE.md');
359
+ if (existsSync(claudeMd)) {
360
+ const content = readFileSync(claudeMd, 'utf-8').slice(0, 3000);
361
+ sections.push(`## CLAUDE.md\n\n${content}`);
362
+ }
363
+ // README
364
+ for (const readme of ['README.md', 'readme.md', 'README.rst', 'README']) {
365
+ const readmePath = path.join(resolved, readme);
366
+ if (existsSync(readmePath)) {
367
+ const content = readFileSync(readmePath, 'utf-8').slice(0, 3000);
368
+ sections.push(`## ${readme}\n\n${content}`);
369
+ break;
370
+ }
371
+ }
372
+ // package.json summary
373
+ const pkgPath = path.join(resolved, 'package.json');
374
+ if (existsSync(pkgPath)) {
375
+ try {
376
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
377
+ const info = [];
378
+ if (pkg.name)
379
+ info.push(`Name: ${pkg.name}`);
380
+ if (pkg.version)
381
+ info.push(`Version: ${pkg.version}`);
382
+ if (pkg.description)
383
+ info.push(`Description: ${pkg.description}`);
384
+ if (pkg.scripts)
385
+ info.push(`Scripts: ${Object.keys(pkg.scripts).join(', ')}`);
386
+ if (pkg.dependencies)
387
+ info.push(`Dependencies: ${Object.keys(pkg.dependencies).length}`);
388
+ if (pkg.devDependencies)
389
+ info.push(`Dev dependencies: ${Object.keys(pkg.devDependencies).length}`);
390
+ sections.push(`## package.json\n\n${info.join('\n')}`);
391
+ }
392
+ catch { /* ignore */ }
393
+ }
394
+ // pyproject.toml summary
395
+ const pyprojectPath = path.join(resolved, 'pyproject.toml');
396
+ if (existsSync(pyprojectPath)) {
397
+ const content = readFileSync(pyprojectPath, 'utf-8').slice(0, 2000);
398
+ sections.push(`## pyproject.toml\n\n${content}`);
399
+ }
400
+ // Directory tree (depth 2)
401
+ if (include_tree !== false) {
402
+ const tree = [];
403
+ try {
404
+ const topEntries = readdirSync(resolved).filter(e => !e.startsWith('.')).sort();
405
+ for (const entry of topEntries) {
406
+ const fullPath = path.join(resolved, entry);
407
+ try {
408
+ if (statSync(fullPath).isDirectory()) {
409
+ tree.push(`${entry}/`);
410
+ const subEntries = readdirSync(fullPath)
411
+ .filter(e => !e.startsWith('.') && e !== 'node_modules' && e !== '__pycache__' && e !== '.git')
412
+ .sort()
413
+ .slice(0, 20);
414
+ for (const sub of subEntries) {
415
+ tree.push(` ${sub}${statSync(path.join(fullPath, sub)).isDirectory() ? '/' : ''}`);
416
+ }
417
+ if (readdirSync(fullPath).filter(e => !e.startsWith('.')).length > 20) {
418
+ tree.push(' ...');
419
+ }
420
+ }
421
+ else {
422
+ tree.push(entry);
423
+ }
424
+ }
425
+ catch {
426
+ tree.push(entry);
427
+ }
428
+ }
429
+ }
430
+ catch { /* ignore */ }
431
+ if (tree.length > 0) {
432
+ sections.push(`## Directory Structure\n\n\`\`\`\n${tree.join('\n')}\n\`\`\``);
433
+ }
434
+ }
435
+ return textResult(sections.join('\n\n---\n\n'));
436
+ });
437
+ // ── Cron Run History ──────────────────────────────────────────────────
438
+ server.tool('cron_run_history', 'Query your own cron job execution history — statuses, durations, errors, and reflection scores. ' +
439
+ 'Use this to understand your past performance and identify patterns.', {
440
+ job_name: z.string().describe('Name of the cron job to query history for'),
441
+ limit: z.number().optional().describe('Number of recent runs to return (default: 10, max: 50)'),
442
+ }, async ({ job_name, limit }) => {
443
+ const count = Math.min(limit ?? 10, 50);
444
+ // Read run log
445
+ const runDir = path.join(BASE_DIR, 'cron', 'runs');
446
+ const safeJob = job_name.replace(/[^a-zA-Z0-9_-]/g, '_');
447
+ const runLogPath = path.join(runDir, `${safeJob}.jsonl`);
448
+ let runs = [];
449
+ if (existsSync(runLogPath)) {
450
+ const lines = readFileSync(runLogPath, 'utf-8').trim().split('\n').filter(Boolean);
451
+ runs = lines.slice(-count).map(l => { try {
452
+ return JSON.parse(l);
453
+ }
454
+ catch {
455
+ return null;
456
+ } }).filter(Boolean).reverse();
457
+ }
458
+ // Read reflections
459
+ const reflectionsDir = path.join(BASE_DIR, 'cron', 'reflections');
460
+ const reflPath = path.join(reflectionsDir, `${safeJob}.jsonl`);
461
+ let reflections = [];
462
+ if (existsSync(reflPath)) {
463
+ const lines = readFileSync(reflPath, 'utf-8').trim().split('\n').filter(Boolean);
464
+ reflections = lines.slice(-count).map(l => { try {
465
+ return JSON.parse(l);
466
+ }
467
+ catch {
468
+ return null;
469
+ } }).filter(Boolean).reverse();
470
+ }
471
+ if (runs.length === 0 && reflections.length === 0) {
472
+ return textResult(`No execution history found for job '${job_name}'.`);
473
+ }
474
+ const parts = [`## Run History: ${job_name} (last ${count})`];
475
+ if (runs.length > 0) {
476
+ parts.push('\n### Executions');
477
+ for (const r of runs) {
478
+ const dur = r.durationMs ? `${(r.durationMs / 1000).toFixed(1)}s` : '?';
479
+ const err = r.error ? ` | Error: ${r.error.slice(0, 100)}` : '';
480
+ parts.push(`- [${r.status}] ${r.startedAt} (${dur})${err}`);
481
+ }
482
+ }
483
+ if (reflections.length > 0) {
484
+ parts.push('\n### Quality Reflections');
485
+ for (const r of reflections) {
486
+ const flags = [
487
+ r.existence ? 'exists' : 'MISSING',
488
+ r.substance ? 'substantive' : 'EMPTY',
489
+ r.actionable ? 'actionable' : 'NOT-ACTIONABLE',
490
+ ].join(', ');
491
+ const gap = r.gap && r.gap !== 'none' ? ` | Gap: ${r.gap.slice(0, 100)}` : '';
492
+ parts.push(`- Quality: ${r.quality}/5 (${flags})${gap} — ${r.timestamp}`);
493
+ }
494
+ }
495
+ return textResult(parts.join('\n'));
496
+ });
497
+ // ── List Cron Jobs ──────────────────────────────────────────────────────
498
+ function describeCronSchedule(expr) {
499
+ const parts = expr.trim().split(/\s+/);
500
+ if (parts.length < 5)
501
+ return expr;
502
+ const [min, hour, dom, mon, dow] = parts;
503
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
504
+ const monNames = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
505
+ let timeStr = '';
506
+ if (hour !== '*' && min !== '*') {
507
+ const h = parseInt(hour, 10);
508
+ const m = parseInt(min, 10);
509
+ if (!isNaN(h) && !isNaN(m)) {
510
+ const ampm = h >= 12 ? 'PM' : 'AM';
511
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
512
+ timeStr = `${h12}:${String(m).padStart(2, '0')} ${ampm}`;
513
+ }
514
+ }
515
+ else if (min.startsWith('*/')) {
516
+ timeStr = `every ${min.slice(2)} min`;
517
+ }
518
+ else if (hour.startsWith('*/')) {
519
+ timeStr = `every ${hour.slice(2)} hours`;
520
+ }
521
+ let dayStr = '';
522
+ if (dow !== '*') {
523
+ const days = dow.split(',').map(d => {
524
+ const n = parseInt(d, 10);
525
+ return !isNaN(n) ? (dayNames[n % 7] || d) : d;
526
+ });
527
+ dayStr = days.join(', ');
528
+ }
529
+ else if (dom !== '*') {
530
+ dayStr = `day ${dom}`;
531
+ if (mon !== '*') {
532
+ const m = parseInt(mon, 10);
533
+ dayStr += ` of ${!isNaN(m) ? (monNames[m] || mon) : mon}`;
534
+ }
535
+ }
536
+ else {
537
+ dayStr = 'daily';
538
+ }
539
+ return [timeStr, dayStr].filter(Boolean).join(' ');
540
+ }
541
+ function getNextRun(expr) {
542
+ const parts = expr.trim().split(/\s+/);
543
+ if (parts.length < 5)
544
+ return null;
545
+ const [minF, hourF, domF, monF, dowF] = parts;
546
+ const now = new Date();
547
+ // Check the next 48 hours minute by minute (max 2880 iterations)
548
+ for (let offset = 1; offset <= 2880; offset++) {
549
+ const t = new Date(now.getTime() + offset * 60_000);
550
+ const matches = fieldMatch(minF, t.getMinutes()) &&
551
+ fieldMatch(hourF, t.getHours()) &&
552
+ fieldMatch(domF, t.getDate()) &&
553
+ fieldMatch(monF, t.getMonth() + 1) &&
554
+ fieldMatch(dowF, t.getDay());
555
+ if (matches) {
556
+ const h = t.getHours();
557
+ const m = t.getMinutes();
558
+ const ampm = h >= 12 ? 'PM' : 'AM';
559
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
560
+ const today = t.toDateString() === now.toDateString();
561
+ const tomorrow = t.toDateString() === new Date(now.getTime() + 86400000).toDateString();
562
+ const dayLabel = today ? 'today' : tomorrow ? 'tomorrow' : t.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
563
+ return `${dayLabel} at ${h12}:${String(m).padStart(2, '0')} ${ampm}`;
564
+ }
565
+ }
566
+ return null;
567
+ }
568
+ function fieldMatch(field, value) {
569
+ if (field === '*')
570
+ return true;
571
+ if (field.startsWith('*/')) {
572
+ const step = parseInt(field.slice(2), 10);
573
+ return !isNaN(step) && step > 0 && value % step === 0;
574
+ }
575
+ for (const part of field.split(',')) {
576
+ if (part.includes('-')) {
577
+ const [a, b] = part.split('-').map(Number);
578
+ if (!isNaN(a) && !isNaN(b) && value >= a && value <= b)
579
+ return true;
580
+ }
581
+ else {
582
+ if (parseInt(part, 10) === value)
583
+ return true;
584
+ }
585
+ }
586
+ return false;
587
+ }
588
+ server.tool('cron_list', 'List all scheduled cron jobs with human-readable schedules, next run times, and recent run status.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
589
+ if (!existsSync(CRON_FILE)) {
590
+ return textResult('No cron jobs configured (CRON.md not found).');
591
+ }
592
+ const matterMod = await import('gray-matter');
593
+ const raw = readFileSync(CRON_FILE, 'utf-8');
594
+ let parsed;
595
+ try {
596
+ parsed = matterMod.default(raw);
597
+ }
598
+ catch (err) {
599
+ return textResult(`CRON.md has a YAML syntax error — fix the file before listing jobs.\nError: ${err instanceof Error ? err.message : err}`);
600
+ }
601
+ const jobDefs = (parsed.data.jobs ?? []);
602
+ if (jobDefs.length === 0) {
603
+ return textResult('No cron jobs defined in CRON.md.');
604
+ }
605
+ // Load recent run history
606
+ const runsDir = path.join(BASE_DIR, 'cron', 'runs');
607
+ const lines = [];
608
+ for (const job of jobDefs) {
609
+ const name = String(job.name ?? '');
610
+ const schedule = String(job.schedule ?? '');
611
+ const prompt = String(job.prompt ?? '');
612
+ const enabled = job.enabled !== false;
613
+ const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
614
+ const workDir = job.work_dir ? String(job.work_dir) : null;
615
+ const humanSchedule = describeCronSchedule(schedule);
616
+ const nextRun = enabled ? getNextRun(schedule) : null;
617
+ let lastRunInfo = '';
618
+ if (existsSync(runsDir)) {
619
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
620
+ const logFile = path.join(runsDir, `${safeName}.jsonl`);
621
+ if (existsSync(logFile)) {
622
+ try {
623
+ const logLines = readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
624
+ if (logLines.length > 0) {
625
+ const last = JSON.parse(logLines[logLines.length - 1]);
626
+ const ago = Math.round((Date.now() - new Date(last.finishedAt).getTime()) / 60000);
627
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
628
+ lastRunInfo = `last run: ${last.status} (${agoStr})`;
629
+ if (last.deliveryFailed)
630
+ lastRunInfo += ' [delivery failed]';
631
+ }
632
+ }
633
+ catch { /* ignore */ }
634
+ }
635
+ }
636
+ const status = enabled ? 'enabled' : 'disabled';
637
+ lines.push(`**${name}** [${status}] ${mode === 'unleashed' ? '[unleashed] ' : ''}` +
638
+ `\n Schedule: ${humanSchedule} (\`${schedule}\`)` +
639
+ (nextRun ? `\n Next run: ${nextRun}` : '') +
640
+ (lastRunInfo ? `\n ${lastRunInfo}` : '') +
641
+ (workDir ? `\n Work dir: ${workDir}` : '') +
642
+ `\n Prompt: ${prompt.slice(0, 120)}${prompt.length > 120 ? '...' : ''}`);
643
+ }
644
+ return textResult(lines.join('\n\n'));
645
+ });
646
+ // ── Add Cron Job ────────────────────────────────────────────────────────
647
+ server.tool('add_cron_job', 'Add a new scheduled cron job. Validates the schedule expression and writes to CRON.md. The daemon auto-reloads on file change. Use mode "unleashed" for multi-step tasks (browser automation, batch processing, multi-contact workflows) — they need more turns than standard mode provides. Auto-escalates to unleashed when complex patterns are detected.', {
648
+ name: z.string().describe('Job name (unique identifier)'),
649
+ schedule: z.string().describe('Cron expression (e.g., "0 9 * * 1" for Monday 9 AM)'),
650
+ prompt: z.string().describe('The prompt/instruction for the assistant to execute'),
651
+ tier: z.number().optional().default(1).describe('Security tier (1=auto, 2=logged, 3=approval)'),
652
+ enabled: z.boolean().optional().default(true).describe('Whether the job is enabled'),
653
+ work_dir: z.string().optional().describe('Project directory to run in (agent gets access to project tools, CLAUDE.md, files)'),
654
+ mode: z.enum(['standard', 'unleashed']).optional().default('standard').describe('standard = normal cron, unleashed = long-running phased execution with checkpointing'),
655
+ max_hours: z.number().optional().describe('Max hours for unleashed mode (default 6). Ignored for standard mode.'),
656
+ }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, mode: rawMode, max_hours: rawMaxHours }) => {
657
+ let mode = rawMode;
658
+ let max_hours = rawMaxHours;
659
+ // Validate cron expression
660
+ const cronMod = await import('node-cron');
661
+ if (!cronMod.default.validate(schedule)) {
662
+ return textResult(`Invalid cron expression: "${schedule}". Examples: "0 9 * * 1" (Mon 9 AM), "*/30 * * * *" (every 30 min).`);
663
+ }
664
+ // Auto-escalate to unleashed when the job clearly needs it.
665
+ // Tier 2 jobs with complex prompts (browser automation, multi-contact workflows,
666
+ // multi-step sequences) will exhaust standard turn limits silently.
667
+ if (mode !== 'unleashed' && tier >= 2) {
668
+ const complexSignals = [
669
+ /\bfor each\b.*\bcontact\b/i,
670
+ /\bfor each\b.*\bprospect\b/i,
671
+ /\bfor each\b.*\baccount\b/i,
672
+ /\bfor each\b.*\blead\b/i,
673
+ /\bfor each\b.*\bprofile\b/i,
674
+ /\bplaywright\b/i,
675
+ /\bkernel\s+browsers?\b/i,
676
+ /\bbrowser\b.*\bautomati/i,
677
+ /\bstep\s+\d+\b.*\bstep\s+\d+\b/is,
678
+ ];
679
+ const isComplex = complexSignals.some(p => p.test(prompt))
680
+ || prompt.length > 2000;
681
+ if (isComplex) {
682
+ mode = 'unleashed';
683
+ if (!max_hours)
684
+ max_hours = 1;
685
+ logger.info({ jobName }, 'Auto-escalated to unleashed mode (complex prompt detected)');
686
+ }
687
+ }
688
+ // Read existing CRON.md or create empty structure
689
+ const matterMod = await import('gray-matter');
690
+ let parsed;
691
+ if (existsSync(CRON_FILE)) {
692
+ const raw = readFileSync(CRON_FILE, 'utf-8');
693
+ parsed = matterMod.default(raw);
694
+ }
695
+ else {
696
+ const dir = path.dirname(CRON_FILE);
697
+ if (!existsSync(dir))
698
+ mkdirSync(dir, { recursive: true });
699
+ parsed = matterMod.default('');
700
+ parsed.data = {};
701
+ }
702
+ const jobs = (parsed.data.jobs ?? []);
703
+ // Check for duplicate name
704
+ const duplicate = jobs.find((j) => String(j.name ?? '').toLowerCase() === jobName.toLowerCase());
705
+ if (duplicate) {
706
+ return textResult(`A job named "${jobName}" already exists. Use a different name or remove the existing job first.`);
707
+ }
708
+ // Create and append the new job
709
+ const newJob = {
710
+ name: jobName,
711
+ schedule,
712
+ prompt,
713
+ enabled,
714
+ tier,
715
+ };
716
+ if (work_dir)
717
+ newJob.work_dir = work_dir;
718
+ if (mode === 'unleashed') {
719
+ newJob.mode = 'unleashed';
720
+ if (max_hours)
721
+ newJob.max_hours = max_hours;
722
+ }
723
+ jobs.push(newJob);
724
+ parsed.data.jobs = jobs;
725
+ // Write back preserving body content — validate first to prevent daemon crash
726
+ const output = matterMod.default.stringify(parsed.content, parsed.data);
727
+ const { validateCronYaml } = await import('../gateway/heartbeat.js');
728
+ const yamlErr = validateCronYaml(output);
729
+ if (yamlErr) {
730
+ logger.error({ yamlErr, jobName }, 'Generated CRON.md has invalid YAML — aborting write');
731
+ return textResult(`Failed to add job "${jobName}": generated YAML is invalid. Error: ${yamlErr}`);
732
+ }
733
+ writeFileSync(CRON_FILE, output);
734
+ logger.info({ jobName, schedule, tier, mode, work_dir }, 'Added cron job via MCP tool');
735
+ // Read-back verification: confirm the job was persisted correctly
736
+ let verified = false;
737
+ try {
738
+ const verifyRaw = readFileSync(CRON_FILE, 'utf-8');
739
+ const verifyParsed = matterMod.default(verifyRaw);
740
+ const verifyJobs = (verifyParsed.data.jobs ?? []);
741
+ const found = verifyJobs.find((j) => String(j.name ?? '').toLowerCase() === jobName.toLowerCase());
742
+ verified = !!found && String(found.schedule ?? '') === schedule;
743
+ }
744
+ catch {
745
+ // Verification failed but file was written
746
+ }
747
+ const details = [
748
+ ` Schedule: ${schedule}`,
749
+ ` Prompt: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}`,
750
+ ` Tier: ${tier}`,
751
+ ` Enabled: ${enabled}`,
752
+ ];
753
+ if (work_dir)
754
+ details.push(` Project: ${work_dir}`);
755
+ if (mode === 'unleashed') {
756
+ const escalated = rawMode !== 'unleashed' ? ' (auto-escalated — complex prompt detected)' : '';
757
+ details.push(` Mode: unleashed (max ${max_hours ?? 6} hours)${escalated}`);
758
+ }
759
+ const verifyMsg = verified
760
+ ? 'Verified: job persisted to CRON.md and will be picked up by the daemon.'
761
+ : 'WARNING: Could not verify the job was written correctly. Check CRON.md manually.';
762
+ const goalHint = `\n\n💡 **Goal tracking:** What goal does this cron job serve? Consider creating a persistent goal (\`goal_create\`) and linking it (\`goal_update\` with \`linkedCronJobs: ["${jobName}"]\`) so self-improvement can optimize this job against measurable outcomes.`;
763
+ return textResult(`Added cron job "${jobName}":\n${details.join('\n')}\n\n${verifyMsg}${goalHint}`);
764
+ });
765
+ // ── Trigger Cron Job ────────────────────────────────────────────────────
766
+ const TRIGGER_DIR = path.join(BASE_DIR, 'cron', 'triggers');
767
+ server.tool('trigger_cron_job', 'Trigger an existing cron job to run immediately in the background. The daemon picks up the trigger and runs the job asynchronously — results are delivered via notifications. Use this when committing to background work (audits, research, etc.) instead of trying to do it all in the current chat turn.', {
768
+ job_name: z.string().describe('Exact name of the cron job to trigger (use list_cron_jobs to see available jobs)'),
769
+ }, async ({ job_name }) => {
770
+ // Verify the job exists in CRON.md
771
+ const cronPath = path.join(SYSTEM_DIR, 'CRON.md');
772
+ if (!existsSync(cronPath)) {
773
+ return textResult('No CRON.md found. Create cron jobs first with add_cron_job.');
774
+ }
775
+ const raw = readFileSync(cronPath, 'utf-8');
776
+ const matterMod = await import('gray-matter');
777
+ const { data } = matterMod.default(raw);
778
+ const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
779
+ const job = jobs.find((j) => String(j.name ?? '') === job_name);
780
+ if (!job) {
781
+ const available = jobs.map((j) => String(j.name ?? '')).filter(Boolean).join(', ');
782
+ return textResult(`Job "${job_name}" not found. Available: ${available || 'none'}`);
783
+ }
784
+ // Write trigger file for the daemon to pick up
785
+ mkdirSync(TRIGGER_DIR, { recursive: true });
786
+ const triggerFile = path.join(TRIGGER_DIR, `${Date.now()}-${job_name.replace(/[^a-zA-Z0-9_-]/g, '_')}.trigger`);
787
+ writeFileSync(triggerFile, job_name, 'utf-8');
788
+ return textResult(`Triggered "${job_name}" — the daemon will pick it up within a few seconds and run it in the background. ` +
789
+ `Results will be delivered via notifications when complete.`);
790
+ });
791
+ // ── Workflow Tools ──────────────────────────────────────────────────────
792
+ const WORKFLOWS_DIR = path.join(SYSTEM_DIR, 'workflows');
793
+ server.tool('workflow_list', 'List all multi-step workflows with name, description, step count, trigger, and enabled status.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
794
+ if (!existsSync(WORKFLOWS_DIR)) {
795
+ return textResult('No workflows directory found. Create `vault/00-System/workflows/` and add workflow .md files.');
796
+ }
797
+ const { parseAllWorkflows } = await import('../agent/workflow-runner.js');
798
+ const workflows = parseAllWorkflows(WORKFLOWS_DIR);
799
+ if (workflows.length === 0) {
800
+ return textResult('No workflow files found in `vault/00-System/workflows/`.');
801
+ }
802
+ const lines = [];
803
+ for (const wf of workflows) {
804
+ const status = wf.enabled ? 'enabled' : 'disabled';
805
+ const trigger = wf.trigger.schedule ? `schedule: \`${wf.trigger.schedule}\`` : 'manual only';
806
+ lines.push(`**${wf.name}** [${status}]` +
807
+ `\n ${wf.description || '(no description)'}` +
808
+ `\n Trigger: ${trigger}` +
809
+ `\n Steps (${wf.steps.length}): ${wf.steps.map(s => s.id).join(' → ')}` +
810
+ (Object.keys(wf.inputs).length > 0
811
+ ? `\n Inputs: ${Object.entries(wf.inputs).map(([k, v]) => `${k}${v.default ? `="${v.default}"` : ''}`).join(', ')}`
812
+ : ''));
813
+ }
814
+ return textResult(lines.join('\n\n'));
815
+ });
816
+ server.tool('workflow_create', 'Create a new multi-step workflow file. Validates dependencies and writes to vault/00-System/workflows/. The daemon auto-reloads on file change.', {
817
+ name: z.string().describe('Workflow name (used as filename and identifier)'),
818
+ description: z.string().describe('What the workflow does'),
819
+ steps: z.array(z.object({
820
+ id: z.string().describe('Unique step identifier'),
821
+ prompt: z.string().describe('Prompt for the step (supports {{input.*}}, {{steps.*.output}}, {{date}} variables)'),
822
+ dependsOn: z.array(z.string()).default([]).describe('Step IDs this depends on'),
823
+ model: z.string().optional().describe('Model tier: haiku or sonnet'),
824
+ tier: z.number().optional().default(1).describe('Security tier (1-3)'),
825
+ maxTurns: z.number().optional().default(15).describe('Max agent turns'),
826
+ })).describe('Workflow steps'),
827
+ trigger_schedule: z.string().optional().describe('Cron expression for scheduled trigger'),
828
+ inputs: z.record(z.string(), z.object({
829
+ type: z.enum(['string', 'number']).default('string'),
830
+ default: z.string().optional(),
831
+ description: z.string().optional(),
832
+ })).optional().default({}).describe('Input parameters with optional defaults'),
833
+ synthesis_prompt: z.string().optional().describe('Prompt to synthesize final output from all step results'),
834
+ }, async ({ name, description, steps, trigger_schedule, inputs, synthesis_prompt }) => {
835
+ // Validate step IDs are unique
836
+ const ids = new Set(steps.map(s => s.id));
837
+ if (ids.size !== steps.length) {
838
+ return textResult('Error: Duplicate step IDs found.');
839
+ }
840
+ // Validate dependencies exist
841
+ for (const step of steps) {
842
+ for (const dep of step.dependsOn) {
843
+ if (!ids.has(dep)) {
844
+ return textResult(`Error: Step "${step.id}" depends on unknown step "${dep}".`);
845
+ }
846
+ }
847
+ }
848
+ // Validate cron expression if provided
849
+ if (trigger_schedule) {
850
+ const cronMod = await import('node-cron');
851
+ if (!cronMod.default.validate(trigger_schedule)) {
852
+ return textResult(`Invalid cron expression: "${trigger_schedule}".`);
853
+ }
854
+ }
855
+ // Build frontmatter
856
+ const frontmatter = {
857
+ type: 'workflow',
858
+ name,
859
+ description,
860
+ enabled: true,
861
+ trigger: {
862
+ ...(trigger_schedule ? { schedule: trigger_schedule } : {}),
863
+ manual: true,
864
+ },
865
+ };
866
+ if (Object.keys(inputs).length > 0) {
867
+ frontmatter.inputs = inputs;
868
+ }
869
+ frontmatter.steps = steps.map(s => ({
870
+ id: s.id,
871
+ prompt: s.prompt,
872
+ dependsOn: s.dependsOn,
873
+ ...(s.model ? { model: s.model } : {}),
874
+ ...(s.tier && s.tier !== 1 ? { tier: s.tier } : {}),
875
+ ...(s.maxTurns && s.maxTurns !== 15 ? { maxTurns: s.maxTurns } : {}),
876
+ }));
877
+ if (synthesis_prompt) {
878
+ frontmatter.synthesis = { prompt: synthesis_prompt };
879
+ }
880
+ // Write file
881
+ if (!existsSync(WORKFLOWS_DIR)) {
882
+ mkdirSync(WORKFLOWS_DIR, { recursive: true });
883
+ }
884
+ const matterMod = await import('gray-matter');
885
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
886
+ const filePath = path.join(WORKFLOWS_DIR, `${safeName}.md`);
887
+ if (existsSync(filePath)) {
888
+ return textResult(`Workflow file already exists: ${safeName}.md. Delete or rename it first.`);
889
+ }
890
+ const body = `# ${name}\n\n${description}\n`;
891
+ const output = matterMod.default.stringify(body, frontmatter);
892
+ writeFileSync(filePath, output);
893
+ logger.info({ name, steps: steps.length }, 'Created workflow via MCP tool');
894
+ const goalHint = `\n\n💡 **Goal tracking:** What goal does this workflow serve? Consider creating a persistent goal (\`goal_create\`) and linking related cron jobs so self-improvement can optimize this workflow against measurable outcomes.`;
895
+ return textResult(`Created workflow "${name}" with ${steps.length} steps.\n` +
896
+ `File: vault/00-System/workflows/${safeName}.md\n` +
897
+ `Steps: ${steps.map(s => s.id).join(' → ')}\n` +
898
+ (trigger_schedule ? `Schedule: ${trigger_schedule}\n` : 'Trigger: manual\n') +
899
+ 'The daemon will auto-detect it via file watcher.' +
900
+ goalHint);
901
+ });
902
+ server.tool('workflow_run', 'Trigger a workflow by name with optional input overrides. Returns the workflow result.', {
903
+ name: z.string().describe('Workflow name'),
904
+ inputs: z.record(z.string(), z.string()).optional().default({}).describe('Input overrides (key=value pairs)'),
905
+ }, async ({ name: workflowName, inputs }) => {
906
+ const { parseAllWorkflows } = await import('../agent/workflow-runner.js');
907
+ const { WorkflowRunner } = await import('../agent/workflow-runner.js');
908
+ const workflows = parseAllWorkflows(WORKFLOWS_DIR);
909
+ const wf = workflows.find(w => w.name === workflowName);
910
+ if (!wf) {
911
+ const available = workflows.map(w => w.name).join(', ');
912
+ return textResult(`Workflow "${workflowName}" not found. Available: ${available || 'none'}`);
913
+ }
914
+ if (!wf.enabled) {
915
+ return textResult(`Workflow "${workflowName}" is disabled.`);
916
+ }
917
+ // Build a minimal assistant for standalone MCP execution
918
+ // In daemon mode, the CronScheduler.runWorkflow() path is preferred
919
+ // For MCP standalone, we need to create an assistant instance
920
+ try {
921
+ const { PersonalAssistant } = await import('../agent/assistant.js');
922
+ const assistant = new PersonalAssistant();
923
+ const runner = new WorkflowRunner(assistant);
924
+ const result = await runner.run(wf, inputs);
925
+ return textResult(`**Workflow: ${workflowName}** — ${result.status}\n\n${result.output.slice(0, 3000)}`);
926
+ }
927
+ catch (err) {
928
+ logger.error({ err, workflow: workflowName }, 'Workflow execution failed');
929
+ return textResult(`Workflow "${workflowName}" failed: ${err instanceof Error ? err.message : err}`);
930
+ }
931
+ });
932
+ // ── Analyze Image ───────────────────────────────────────────────────────
933
+ server.tool('analyze_image', 'Analyze an image by URL. Fetches the image, converts to base64, and uses Claude vision to describe it. Works with any image URL — channel attachments, email attachments, web images.', {
934
+ url: z.string().describe('URL of the image to analyze'),
935
+ question: z.string().optional().default('Describe this image in detail.').describe('Specific question about the image'),
936
+ }, async ({ url, question }) => {
937
+ try {
938
+ // Fetch the image (include auth headers for Slack URLs)
939
+ const headers = {};
940
+ if (url.includes('slack.com') || url.includes('slack-files.com')) {
941
+ const slackToken = env['SLACK_BOT_TOKEN'] ?? '';
942
+ if (slackToken) {
943
+ headers['Authorization'] = `Bearer ${slackToken}`;
944
+ }
945
+ }
946
+ const response = await fetch(url, { headers });
947
+ if (!response.ok)
948
+ throw new Error(`Failed to fetch image: ${response.status}`);
949
+ const buffer = await response.arrayBuffer();
950
+ const base64 = Buffer.from(buffer).toString('base64');
951
+ const contentType = response.headers.get('content-type') || 'image/jpeg';
952
+ // Validate it's an image
953
+ if (!contentType.startsWith('image/')) {
954
+ return textResult(`URL does not point to an image (content-type: ${contentType})`);
955
+ }
956
+ // Call Anthropic Messages API with vision
957
+ const anthropic = new Anthropic({
958
+ apiKey: env['ANTHROPIC_API_KEY'] || process.env.ANTHROPIC_API_KEY,
959
+ });
960
+ const result = await anthropic.messages.create({
961
+ model: 'claude-haiku-4-5-20251001',
962
+ max_tokens: 1024,
963
+ messages: [{
964
+ role: 'user',
965
+ content: [
966
+ {
967
+ type: 'image',
968
+ source: { type: 'base64', media_type: contentType, data: base64 },
969
+ },
970
+ { type: 'text', text: question },
971
+ ],
972
+ }],
973
+ });
974
+ const description = result.content.map(b => b.type === 'text' ? b.text : '').join('');
975
+ return textResult(description);
976
+ }
977
+ catch (err) {
978
+ return textResult(`Image analysis failed: ${err.message}`);
979
+ }
980
+ });
981
+ // ── Feedback: feedback_log ──────────────────────────────────────────────
982
+ server.tool('feedback_log', 'Record verbal feedback from the owner about a response quality.', {
983
+ rating: z.enum(['positive', 'negative', 'mixed']).describe('Feedback rating'),
984
+ comment: z.string().optional().describe('Additional context about the feedback'),
985
+ messageContext: z.string().optional().describe('What the feedback is about'),
986
+ }, async ({ rating, comment, messageContext }) => {
987
+ const store = await getStore();
988
+ store.logFeedback({
989
+ channel: 'verbal',
990
+ rating,
991
+ comment: comment ?? undefined,
992
+ messageSnippet: messageContext ?? undefined,
993
+ });
994
+ return textResult(`Feedback recorded: ${rating}${comment ? ` — ${comment}` : ''}`);
995
+ });
996
+ // ── Feedback: feedback_report ───────────────────────────────────────────
997
+ server.tool('feedback_report', 'Show feedback statistics and recent entries.', {
998
+ limit: z.number().optional().default(10).describe('Number of recent entries'),
999
+ }, async ({ limit }) => {
1000
+ const store = await getStore();
1001
+ const stats = store.getFeedbackStats();
1002
+ const recent = store.getRecentFeedback(limit);
1003
+ const statsLine = `Stats: ${stats.positive} positive, ${stats.negative} negative, ${stats.mixed} mixed (${stats.total} total)`;
1004
+ if (recent.length === 0) {
1005
+ return textResult(`${statsLine}\n\nNo feedback entries yet.`);
1006
+ }
1007
+ const entries = recent.map((f, i) => `${i + 1}. [${f.rating}] ${f.createdAt} via ${f.channel}${f.comment ? `: ${f.comment}` : ''}${f.responseSnippet ? `\n Response: "${f.responseSnippet.slice(0, 100)}"` : ''}`).join('\n');
1008
+ return textResult(`${statsLine}\n\nRecent:\n${entries}`);
1009
+ });
1010
+ // ── Dynamic Tool Creation ───────────────────────────────────────────────
1011
+ server.tool('create_tool', 'Create a new reusable tool script that becomes available as an MCP tool after daemon restart. Write bash or python scripts that automate recurring tasks.', {
1012
+ name: z.string().describe('Tool name (lowercase, underscores). Will be the MCP tool name.'),
1013
+ description: z.string().describe('What this tool does (shown in tool list)'),
1014
+ language: z.enum(['bash', 'python']).describe('Script language'),
1015
+ code: z.string().describe('The script code. First line should be the shebang (#!/bin/bash or #!/usr/bin/env python3)'),
1016
+ args_description: z.string().optional().describe('Description of expected arguments'),
1017
+ }, async ({ name, description, language, code, args_description }) => {
1018
+ const toolsDir = path.join(BASE_DIR, 'tools');
1019
+ if (!existsSync(toolsDir))
1020
+ mkdirSync(toolsDir, { recursive: true });
1021
+ const safeName = name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
1022
+ const ext = language === 'python' ? '.py' : '.sh';
1023
+ const filePath = path.join(toolsDir, `${safeName}${ext}`);
1024
+ // Prepend description as comment + shebang if not present
1025
+ let scriptContent = code;
1026
+ const shebang = language === 'python' ? '#!/usr/bin/env python3' : '#!/bin/bash';
1027
+ if (!scriptContent.startsWith('#!')) {
1028
+ scriptContent = `${shebang}\n# ${description}\n${scriptContent}`;
1029
+ }
1030
+ else if (!scriptContent.includes(description)) {
1031
+ // Add description after shebang
1032
+ const lines = scriptContent.split('\n');
1033
+ lines.splice(1, 0, `# ${description}`);
1034
+ scriptContent = lines.join('\n');
1035
+ }
1036
+ writeFileSync(filePath, scriptContent, { mode: 0o755 });
1037
+ // Write metadata file for richer registration
1038
+ writeFileSync(filePath + '.meta.json', JSON.stringify({
1039
+ name: safeName,
1040
+ description,
1041
+ language,
1042
+ args_description: args_description ?? '',
1043
+ createdAt: new Date().toISOString(),
1044
+ }, null, 2));
1045
+ // Hot-register: make the tool available immediately without restart
1046
+ try {
1047
+ server.tool(safeName, description, { args: z.string().optional().describe(args_description ?? 'Optional arguments') }, async ({ args }) => {
1048
+ const { execSync: execTool } = await import('node:child_process');
1049
+ try {
1050
+ const result = execTool(`"${filePath}" ${args || ''}`, {
1051
+ encoding: 'utf-8',
1052
+ timeout: 30000,
1053
+ cwd: BASE_DIR,
1054
+ env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
1055
+ });
1056
+ return textResult(result.trim() || '(no output)');
1057
+ }
1058
+ catch (err) {
1059
+ return textResult(`Tool error: ${err.stderr || err.message || String(err)}`.slice(0, 500));
1060
+ }
1061
+ });
1062
+ return textResult(`Tool "${safeName}" created and registered — available immediately.\n` +
1063
+ `Saved to ~/.clementine/tools/${safeName}${ext}\n` +
1064
+ (args_description ? `Args: ${args_description}` : ''));
1065
+ }
1066
+ catch {
1067
+ return textResult(`Tool "${safeName}" created at ~/.clementine/tools/${safeName}${ext}\n` +
1068
+ `It will be available after daemon restart.\n` +
1069
+ (args_description ? `Args: ${args_description}` : ''));
1070
+ }
1071
+ });
1072
+ // ── Self-Restart ────────────────────────────────────────────────────────
1073
+ server.tool('self_restart', 'Restart the Clementine daemon to pick up code changes. Sends SIGUSR1 to the running process, which triggers a graceful restart.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
1074
+ const pidFile = path.join(BASE_DIR, `.${(env['ASSISTANT_NAME'] ?? 'clementine').toLowerCase()}.pid`);
1075
+ if (!existsSync(pidFile)) {
1076
+ return textResult('No PID file found — daemon may not be running.');
1077
+ }
1078
+ const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
1079
+ if (isNaN(pid)) {
1080
+ return textResult('Invalid PID file.');
1081
+ }
1082
+ try {
1083
+ process.kill(pid, 0); // check if alive
1084
+ }
1085
+ catch {
1086
+ return textResult(`Process ${pid} is not running.`);
1087
+ }
1088
+ process.kill(pid, 'SIGUSR1');
1089
+ return textResult(`Restart signal (SIGUSR1) sent to PID ${pid}. Daemon will restart momentarily.`);
1090
+ });
1091
+ // ── Self-Improvement Tools ───────────────────────────────────────────
1092
+ server.tool('self_improve_status', 'Check the self-improvement system status: current state, pending approvals, baseline metrics, and recent experiment history.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
1093
+ const siDir = path.join(BASE_DIR, 'self-improve');
1094
+ const stateFile = path.join(siDir, 'state.json');
1095
+ const logFile = path.join(siDir, 'experiment-log.jsonl');
1096
+ const pendingDir = path.join(siDir, 'pending-changes');
1097
+ let status = 'No self-improvement data found.';
1098
+ if (existsSync(stateFile)) {
1099
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
1100
+ const m = state.baselineMetrics ?? {};
1101
+ status = `**Self-Improvement Status**\n` +
1102
+ `Status: ${state.status}\n` +
1103
+ `Last run: ${state.lastRunAt || 'never'}\n` +
1104
+ `Total experiments: ${state.totalExperiments}\n` +
1105
+ `Pending approvals: ${state.pendingApprovals}\n` +
1106
+ `Baseline — Feedback: ${((m.feedbackPositiveRatio ?? 0) * 100).toFixed(0)}% positive, ` +
1107
+ `Cron: ${((m.cronSuccessRate ?? 0) * 100).toFixed(0)}% success`;
1108
+ }
1109
+ if (existsSync(logFile)) {
1110
+ const lines = readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
1111
+ const recent = lines.slice(-5).reverse().map(l => {
1112
+ const e = JSON.parse(l);
1113
+ return `#${e.iteration} | ${e.area} | "${(e.hypothesis ?? '').slice(0, 40)}" | ` +
1114
+ `${((e.score ?? 0) * 10).toFixed(1)}/10 ${e.accepted ? '✅' : '❌'}`;
1115
+ });
1116
+ if (recent.length > 0) {
1117
+ status += `\n\n**Recent Experiments:**\n${recent.join('\n')}`;
1118
+ }
1119
+ }
1120
+ if (existsSync(pendingDir)) {
1121
+ const pending = readdirSync(pendingDir).filter(f => f.endsWith('.json'));
1122
+ if (pending.length > 0) {
1123
+ const details = pending.map(f => {
1124
+ const p = JSON.parse(readFileSync(path.join(pendingDir, f), 'utf-8'));
1125
+ return `- **${p.id}** | ${p.area} → ${p.target}: ${(p.hypothesis ?? '').slice(0, 80)}`;
1126
+ });
1127
+ status += `\n\n**Pending Proposals:**\n${details.join('\n')}`;
1128
+ }
1129
+ }
1130
+ return textResult(status);
1131
+ });
1132
+ server.tool('self_improve_run', 'Trigger a self-improvement analysis cycle. This evaluates recent performance data and proposes improvements to system prompts, cron jobs, and workflows. Normally runs nightly via cron.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
1133
+ return textResult('Self-improvement cycle should be triggered via the CLI (`clementine self-improve run`) ' +
1134
+ 'or Discord (`!self-improve run` / `/self-improve run`). ' +
1135
+ 'The MCP server cannot directly run the loop as it requires the full assistant context.');
1136
+ });
1137
+ // ── Source Self-Edit Tools ──────────────────────────────────────────────
1138
+ const SELF_IMPROVE_DIR = path.join(BASE_DIR, 'self-improve');
1139
+ const PENDING_SOURCE_DIR = path.join(SELF_IMPROVE_DIR, 'pending-source-changes');
1140
+ server.tool('self_edit_source', 'Edit Clementine source code safely. Validates in a staging worktree, commits, builds, and triggers restart only if compilation succeeds. The daemon picks up the pending change and executes it.', {
1141
+ file: z.string().describe('Path relative to src/ (e.g., "channels/discord-agent-bot.ts")'),
1142
+ content: z.string().describe('Complete new file content'),
1143
+ reason: z.string().describe('Why this change is being made'),
1144
+ }, async ({ file, content, reason }) => {
1145
+ // Security blocklist
1146
+ const BLOCKLIST = ['config.ts', 'gateway/security-scanner.ts', 'security/scanner.ts'];
1147
+ if (BLOCKLIST.some(b => file === b || file.startsWith(b))) {
1148
+ return textResult(`Blocked: ${file} is on the security blocklist and cannot be self-edited.`);
1149
+ }
1150
+ // Write pending change for the daemon to pick up
1151
+ if (!existsSync(PENDING_SOURCE_DIR)) {
1152
+ mkdirSync(PENDING_SOURCE_DIR, { recursive: true });
1153
+ }
1154
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
1155
+ const pending = {
1156
+ id,
1157
+ file: `src/${file}`,
1158
+ content,
1159
+ reason,
1160
+ createdAt: new Date().toISOString(),
1161
+ };
1162
+ writeFileSync(path.join(PENDING_SOURCE_DIR, `${id}.json`), JSON.stringify(pending, null, 2));
1163
+ // Also signal the daemon via a file it watches
1164
+ const signalFile = path.join(BASE_DIR, '.pending-source-edit');
1165
+ writeFileSync(signalFile, JSON.stringify({ id, file: `src/${file}`, reason }));
1166
+ return textResult(`Source edit queued (id: ${id}).\n` +
1167
+ `File: src/${file}\n` +
1168
+ `Reason: ${reason}\n\n` +
1169
+ `The daemon will validate in a staging worktree, then commit + build + restart if compilation succeeds.`);
1170
+ });
1171
+ server.tool('update_self', 'Check for and apply upstream code updates. Can check without applying, or check and apply in one step.', {
1172
+ action: z.enum(['check', 'apply']).describe('"check" to see if updates are available, "apply" to pull and restart'),
1173
+ }, async ({ action }) => {
1174
+ const __mcp_dirname = path.dirname(fileURLToPath(import.meta.url));
1175
+ const pkgDir = path.resolve(__mcp_dirname, '..', '..');
1176
+ if (action === 'check') {
1177
+ try {
1178
+ execSync('git fetch origin main --quiet', { cwd: pkgDir, stdio: 'pipe', timeout: 30_000 });
1179
+ const countStr = execSync('git rev-list HEAD..origin/main --count', {
1180
+ cwd: pkgDir, encoding: 'utf-8',
1181
+ }).trim();
1182
+ const count = parseInt(countStr, 10) || 0;
1183
+ if (count === 0) {
1184
+ return textResult('Already up to date. No new commits on origin/main.');
1185
+ }
1186
+ const summary = execSync('git log HEAD..origin/main --oneline', {
1187
+ cwd: pkgDir, encoding: 'utf-8',
1188
+ }).trim();
1189
+ return textResult(`${count} update(s) available:\n${summary}\n\nUse update_self with action="apply" to install.`);
1190
+ }
1191
+ catch (err) {
1192
+ return textResult(`Update check failed: ${String(err)}`);
1193
+ }
1194
+ }
1195
+ // action === 'apply' — write a signal file for the daemon
1196
+ const signalFile = path.join(BASE_DIR, '.pending-update');
1197
+ writeFileSync(signalFile, JSON.stringify({ requestedAt: new Date().toISOString() }));
1198
+ return textResult('Update requested. The daemon will:\n' +
1199
+ '1. Fetch and pull origin/main\n' +
1200
+ '2. Rebase self-edits if any\n' +
1201
+ '3. Rebuild and restart\n\n' +
1202
+ 'You will be notified when the restart completes.');
1203
+ });
1204
+ // ── Cron Progress Continuity ────────────────────────────────────────────
1205
+ const CRON_PROGRESS_DIR = path.join(BASE_DIR, 'cron', 'progress');
1206
+ function ensureCronProgressDir() {
1207
+ if (!existsSync(CRON_PROGRESS_DIR))
1208
+ mkdirSync(CRON_PROGRESS_DIR, { recursive: true });
1209
+ }
1210
+ function safeJobName(name) {
1211
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_');
1212
+ }
1213
+ server.tool('cron_progress_read', 'Read progress state from a previous cron job run. Returns what was completed, what is pending, and free-form notes from the last run.', {
1214
+ job_name: z.string().describe('Cron job name'),
1215
+ }, async ({ job_name }) => {
1216
+ ensureCronProgressDir();
1217
+ const filePath = path.join(CRON_PROGRESS_DIR, `${safeJobName(job_name)}.json`);
1218
+ if (!existsSync(filePath)) {
1219
+ return textResult(`No previous progress found for job "${job_name}". This is a fresh run.`);
1220
+ }
1221
+ try {
1222
+ const progress = JSON.parse(readFileSync(filePath, 'utf-8'));
1223
+ const lines = [
1224
+ `## Progress for "${job_name}"`,
1225
+ `**Last run:** ${progress.lastRunAt} | **Run count:** ${progress.runCount}`,
1226
+ ];
1227
+ if (progress.completedItems?.length > 0) {
1228
+ lines.push(`\n### Completed\n${progress.completedItems.map((i) => `- ${i}`).join('\n')}`);
1229
+ }
1230
+ if (progress.pendingItems?.length > 0) {
1231
+ lines.push(`\n### Pending\n${progress.pendingItems.map((i) => `- [ ] ${i}`).join('\n')}`);
1232
+ }
1233
+ if (progress.notes) {
1234
+ lines.push(`\n### Notes\n${progress.notes}`);
1235
+ }
1236
+ if (progress.state && Object.keys(progress.state).length > 0) {
1237
+ lines.push(`\n### Custom State\n\`\`\`json\n${JSON.stringify(progress.state, null, 2)}\n\`\`\``);
1238
+ }
1239
+ return textResult(lines.join('\n'));
1240
+ }
1241
+ catch {
1242
+ return textResult(`Error reading progress for "${job_name}".`);
1243
+ }
1244
+ });
1245
+ server.tool('cron_progress_write', 'Save progress state for a cron job so the next run can continue where this one left off. Call this at the end of a cron job run.', {
1246
+ job_name: z.string().describe('Cron job name'),
1247
+ completedItems: z.array(z.string()).optional().describe('Items completed in this run'),
1248
+ pendingItems: z.array(z.string()).optional().describe('Items still pending for next run'),
1249
+ notes: z.string().optional().describe('Free-form observations or notes'),
1250
+ state: z.record(z.string(), z.unknown()).optional().describe('Custom key-value state to persist'),
1251
+ }, async ({ job_name, completedItems, pendingItems, notes, state }) => {
1252
+ ensureCronProgressDir();
1253
+ const filePath = path.join(CRON_PROGRESS_DIR, `${safeJobName(job_name)}.json`);
1254
+ // Merge with existing progress
1255
+ let existing = { jobName: job_name, lastRunAt: '', runCount: 0, state: {}, completedItems: [], pendingItems: [], notes: '' };
1256
+ if (existsSync(filePath)) {
1257
+ try {
1258
+ existing = JSON.parse(readFileSync(filePath, 'utf-8'));
1259
+ }
1260
+ catch { /* start fresh */ }
1261
+ }
1262
+ const updated = {
1263
+ jobName: job_name,
1264
+ lastRunAt: new Date().toISOString(),
1265
+ runCount: (existing.runCount || 0) + 1,
1266
+ state: state ?? existing.state ?? {},
1267
+ completedItems: completedItems
1268
+ ? [...(existing.completedItems || []), ...completedItems]
1269
+ : existing.completedItems || [],
1270
+ pendingItems: pendingItems ?? existing.pendingItems ?? [],
1271
+ notes: notes ?? existing.notes ?? '',
1272
+ };
1273
+ writeFileSync(filePath, JSON.stringify(updated, null, 2));
1274
+ logger.info({ jobName: job_name, runCount: updated.runCount }, 'Cron progress saved');
1275
+ return textResult(`Progress saved for "${job_name}" (run #${updated.runCount}). ${(completedItems?.length ?? 0)} items completed, ${(updated.pendingItems?.length ?? 0)} pending.`);
1276
+ });
1277
+ }
1278
+ //# sourceMappingURL=admin-tools.js.map