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
package/dist/index.js ADDED
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Clementine TypeScript — Main entry point.
3
+ *
4
+ * Initializes all layers (agent, gateway, heartbeat, cron, channels)
5
+ * and runs them concurrently.
6
+ */
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync, renameSync } from 'node:fs';
8
+ import { execSync } from 'node:child_process';
9
+ import path from 'node:path';
10
+ import { z } from 'zod';
11
+ import * as config from './config.js';
12
+ // Clear nested session guard so the SDK can spawn Claude CLI subprocesses
13
+ delete process.env['CLAUDECODE'];
14
+ import { lanes } from './gateway/lanes.js';
15
+ // ── Logging ──────────────────────────────────────────────────────────
16
+ import pino from 'pino';
17
+ const logger = pino({
18
+ level: 'info',
19
+ transport: {
20
+ target: 'pino/file',
21
+ options: { destination: 1 }, // stdout
22
+ },
23
+ });
24
+ // ── PID management ──────────────────────────────────────────────────
25
+ const PID_FILE = path.join(config.BASE_DIR, `.${config.ASSISTANT_NAME.toLowerCase()}.pid`);
26
+ const LAUNCHD_LABEL = `com.${config.ASSISTANT_NAME.toLowerCase()}.assistant`;
27
+ function killPid(pid) {
28
+ try {
29
+ process.kill(pid, 'SIGTERM');
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ // Wait up to 5s for graceful shutdown
35
+ const deadline = Date.now() + 5000;
36
+ while (Date.now() < deadline) {
37
+ try {
38
+ process.kill(pid, 0);
39
+ }
40
+ catch {
41
+ return; // dead
42
+ }
43
+ // Sync sleep ~100ms via busy wait
44
+ const wait = Date.now() + 100;
45
+ while (Date.now() < wait) { /* spin */ }
46
+ }
47
+ logger.warn({ pid }, 'Force-killing process');
48
+ try {
49
+ process.kill(pid, 'SIGKILL');
50
+ }
51
+ catch {
52
+ // already dead
53
+ }
54
+ }
55
+ function stopLaunchdService() {
56
+ if (process.platform !== 'darwin')
57
+ return false;
58
+ const home = process.env.HOME ?? '';
59
+ const plist = path.join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
60
+ if (!existsSync(plist))
61
+ return false;
62
+ try {
63
+ execSync(`launchctl list ${LAUNCHD_LABEL}`, { stdio: 'pipe' });
64
+ }
65
+ catch {
66
+ return false; // not loaded
67
+ }
68
+ logger.info({ label: LAUNCHD_LABEL }, 'Unloading launchd service');
69
+ try {
70
+ execSync(`launchctl unload "${plist}"`, { stdio: 'pipe' });
71
+ }
72
+ catch {
73
+ // ignore
74
+ }
75
+ return true;
76
+ }
77
+ function ensureSingleton() {
78
+ stopLaunchdService();
79
+ const myPid = process.pid;
80
+ if (existsSync(PID_FILE)) {
81
+ try {
82
+ const oldPid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
83
+ if (!isNaN(oldPid) && oldPid !== myPid) {
84
+ try {
85
+ process.kill(oldPid, 0); // test if alive
86
+ logger.info({ pid: oldPid }, 'Stopping previous instance');
87
+ killPid(oldPid);
88
+ // Verify it's actually dead
89
+ try {
90
+ process.kill(oldPid, 0);
91
+ logger.warn({ pid: oldPid }, 'Previous instance still alive after kill — forcing SIGKILL');
92
+ try {
93
+ process.kill(oldPid, 'SIGKILL');
94
+ }
95
+ catch { /* already dead */ }
96
+ }
97
+ catch {
98
+ // dead — good
99
+ }
100
+ }
101
+ catch {
102
+ // not running
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ // bad pid file
108
+ }
109
+ }
110
+ writeFileSync(PID_FILE, String(myPid));
111
+ }
112
+ function cleanupPid() {
113
+ try {
114
+ if (existsSync(PID_FILE)) {
115
+ const content = readFileSync(PID_FILE, 'utf-8').trim();
116
+ if (content === String(process.pid)) {
117
+ unlinkSync(PID_FILE);
118
+ }
119
+ }
120
+ }
121
+ catch {
122
+ // ignore
123
+ }
124
+ }
125
+ // ── Startup verification ─────────────────────────────────────────────
126
+ function verifySetup() {
127
+ const errors = [];
128
+ // Check Node version range (20–24 LTS)
129
+ const major = parseInt(process.version.slice(1), 10);
130
+ if (major < 20 || major > 24) {
131
+ errors.push(`Node.js v${major} detected. The Claude Code SDK requires Node 20–24 LTS.\n` +
132
+ ' Install Node 22: `nvm install 22`');
133
+ }
134
+ // Check claude CLI
135
+ try {
136
+ execSync('which claude', { stdio: 'pipe' });
137
+ }
138
+ catch {
139
+ errors.push('claude CLI not found. Install it: npm install -g @anthropic-ai/claude-code\n' +
140
+ ' See: https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview');
141
+ }
142
+ // Pre-flight: verify Claude CLI can actually execute in sandboxed env
143
+ if (errors.length === 0) {
144
+ try {
145
+ execSync('claude --version', {
146
+ stdio: 'pipe',
147
+ env: {
148
+ PATH: process.env.PATH ?? '',
149
+ HOME: process.env.HOME ?? '',
150
+ LANG: process.env.LANG ?? 'en_US.UTF-8',
151
+ USER: process.env.USER ?? '',
152
+ SHELL: process.env.SHELL ?? '',
153
+ },
154
+ timeout: 10000,
155
+ });
156
+ }
157
+ catch (e) {
158
+ errors.push(`Claude CLI failed to run in sandboxed env: ${e}\n` +
159
+ ' This usually means a Node version incompatibility.\n' +
160
+ ' Run: clementine doctor');
161
+ }
162
+ }
163
+ // Check better-sqlite3 native module — rebuild if Node version changed since last build
164
+ const nodeStampFile = path.join(config.BASE_DIR, '.node-version-stamp');
165
+ const currentNodeVersion = process.version;
166
+ let needsRebuild = false;
167
+ try {
168
+ execSync('node -e "require(\'better-sqlite3\')"', { cwd: config.PKG_DIR, stdio: 'pipe', timeout: 5000 });
169
+ // Module loads — stamp current version if not already stamped
170
+ if (!existsSync(nodeStampFile) || readFileSync(nodeStampFile, 'utf-8').trim() !== currentNodeVersion) {
171
+ writeFileSync(nodeStampFile, currentNodeVersion);
172
+ }
173
+ }
174
+ catch {
175
+ needsRebuild = true;
176
+ }
177
+ // Check if Node version changed since last successful build
178
+ if (!needsRebuild && existsSync(nodeStampFile)) {
179
+ const stamped = readFileSync(nodeStampFile, 'utf-8').trim();
180
+ if (stamped !== currentNodeVersion) {
181
+ logger.info({ stamped, current: currentNodeVersion }, 'Node version changed — rebuilding native modules');
182
+ needsRebuild = true;
183
+ }
184
+ }
185
+ if (needsRebuild) {
186
+ try {
187
+ execSync('npm rebuild better-sqlite3', {
188
+ cwd: config.PKG_DIR,
189
+ stdio: 'pipe',
190
+ timeout: 30000,
191
+ });
192
+ execSync('node -e "require(\'better-sqlite3\')"', { cwd: config.PKG_DIR, stdio: 'pipe', timeout: 5000 });
193
+ writeFileSync(nodeStampFile, currentNodeVersion);
194
+ logger.info('better-sqlite3 rebuilt for Node ' + currentNodeVersion);
195
+ }
196
+ catch {
197
+ errors.push('better-sqlite3 native module is broken.\n' +
198
+ ' Auto-rebuild failed. Fix manually: npm rebuild better-sqlite3\n' +
199
+ ' Run: clementine doctor');
200
+ }
201
+ }
202
+ // Check vault system files
203
+ const requiredFiles = [
204
+ [config.SOUL_FILE, 'SOUL.md'],
205
+ [config.AGENTS_FILE, 'AGENTS.md'],
206
+ ];
207
+ const missing = requiredFiles.filter(([p]) => !existsSync(p)).map(([, n]) => n);
208
+ if (missing.length > 0) {
209
+ errors.push(`Missing vault files: ${missing.join(', ')}`);
210
+ }
211
+ // At least one channel configured
212
+ const anyChannel = config.CHANNEL_DISCORD ||
213
+ config.CHANNEL_SLACK ||
214
+ config.CHANNEL_TELEGRAM ||
215
+ config.CHANNEL_WHATSAPP ||
216
+ config.CHANNEL_WEBHOOK;
217
+ if (!anyChannel) {
218
+ errors.push('No channels configured. Set at least one of:\n' +
219
+ ' DISCORD_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN,\n' +
220
+ ' TELEGRAM_BOT_TOKEN, TWILIO_ACCOUNT_SID+WHATSAPP_OWNER_PHONE, or WEBHOOK_ENABLED=true');
221
+ }
222
+ // Discord token format
223
+ if (config.CHANNEL_DISCORD && config.DISCORD_TOKEN.length < 50) {
224
+ errors.push('DISCORD_TOKEN looks too short. Check your .env file.');
225
+ }
226
+ // Owner ID check
227
+ if (config.CHANNEL_DISCORD && config.DISCORD_OWNER_ID === '0' && !config.ALLOW_ALL_USERS) {
228
+ errors.push('DISCORD_OWNER_ID not set and ALLOW_ALL_USERS is not true.\n' +
229
+ ' Set DISCORD_OWNER_ID in .env, or set ALLOW_ALL_USERS=true to skip.');
230
+ }
231
+ return errors;
232
+ }
233
+ // ── Banner ───────────────────────────────────────────────────────────
234
+ function printBanner(channels, profiles, cronJobs, graphEnabled = false) {
235
+ const DIM = '\x1b[0;90m';
236
+ const GREEN = '\x1b[0;32m';
237
+ const CYAN = '\x1b[0;36m';
238
+ const MAGENTA = '\x1b[0;35m';
239
+ const RESET = '\x1b[0m';
240
+ const ORANGE = '\x1b[38;5;208m';
241
+ const name = config.ASSISTANT_NAME;
242
+ const nick = config.ASSISTANT_NICKNAME;
243
+ const modelName = config.DEFAULT_MODEL_TIER.charAt(0).toUpperCase() +
244
+ config.DEFAULT_MODEL_TIER.slice(1);
245
+ const owner = config.OWNER_NAME || 'not set';
246
+ const modelColors = { Haiku: GREEN, Sonnet: CYAN, Opus: MAGENTA };
247
+ const modelColor = modelColors[modelName] ?? CYAN;
248
+ // Feature tags
249
+ const tags = [];
250
+ if (config.ENABLE_1M_CONTEXT)
251
+ tags.push('1M context');
252
+ if (config.GROQ_API_KEY)
253
+ tags.push('voice');
254
+ if (config.GOOGLE_API_KEY)
255
+ tags.push('video');
256
+ if (config.CHANNEL_OUTLOOK)
257
+ tags.push('outlook');
258
+ if (graphEnabled)
259
+ tags.push('graph');
260
+ if (profiles > 0)
261
+ tags.push(`${profiles} profile${profiles !== 1 ? 's' : ''}`);
262
+ // Block-letter banner
263
+ const FONT = {
264
+ C: [' ████', '██ ', '██ ', '██ ', ' ████'],
265
+ L: ['██ ', '██ ', '██ ', '██ ', '█████'],
266
+ E: ['█████', '██ ', '████ ', '██ ', '█████'],
267
+ M: ['██ ██', '███ ███', '██ █ ██', '██ ██', '██ ██'],
268
+ N: ['██ ██', '███ ██', '██████', '██ ███', '██ ██'],
269
+ T: ['██████', ' ██ ', ' ██ ', ' ██ ', ' ██ '],
270
+ I: ['██', '██', '██', '██', '██'],
271
+ };
272
+ const word = 'CLEMENTINE';
273
+ const blockRows = [];
274
+ for (let row = 0; row < 5; row++) {
275
+ const line = [...word].map((ch) => FONT[ch]?.[row] ?? '').join(' ');
276
+ blockRows.push(` ${ORANGE}${line}${RESET}`);
277
+ }
278
+ console.log();
279
+ console.log(blockRows.join('\n'));
280
+ const subtitle = nick && nick !== name ? `${nick} — ` : '';
281
+ console.log(` ${DIM}${'─'.repeat(61)}${RESET}`);
282
+ console.log(` ${DIM} ${subtitle}Personal AI Assistant${RESET}`);
283
+ console.log();
284
+ console.log(` ${DIM}Model${RESET} ${modelColor}${modelName}${RESET}`);
285
+ console.log(` ${DIM}Owner${RESET} ${owner}`);
286
+ console.log(` ${DIM}Channels${RESET} ${channels.join(', ')}`);
287
+ if (cronJobs > 0) {
288
+ console.log(` ${DIM}Cron jobs${RESET} ${cronJobs} scheduled`);
289
+ }
290
+ console.log(` ${DIM}Heartbeat${RESET} every ${config.HEARTBEAT_INTERVAL_MINUTES}min`);
291
+ if (tags.length > 0) {
292
+ console.log(` ${DIM}Features${RESET} ${tags.join(', ')}`);
293
+ }
294
+ console.log();
295
+ // Hints for missing optional features
296
+ const hints = [];
297
+ if (!config.GROQ_API_KEY)
298
+ hints.push(['GROQ_API_KEY', 'voice transcription']);
299
+ if (!config.ELEVENLABS_API_KEY)
300
+ hints.push(['ELEVENLABS_API_KEY', 'voice replies']);
301
+ if (!config.GOOGLE_API_KEY)
302
+ hints.push(['GOOGLE_API_KEY', 'video analysis']);
303
+ if (!config.CHANNEL_OUTLOOK)
304
+ hints.push(['MS_TENANT_ID + MS_CLIENT_ID + MS_CLIENT_SECRET', 'Outlook email & calendar']);
305
+ if (!graphEnabled)
306
+ hints.push(['clementine doctor', 'knowledge graph (run to diagnose)']);
307
+ if (hints.length > 0) {
308
+ console.log(` ${DIM}Unlock more:${RESET}`);
309
+ for (const [key, desc] of hints) {
310
+ console.log(` ${DIM} + ${key} for ${desc}${RESET}`);
311
+ }
312
+ console.log();
313
+ }
314
+ console.log(` ${DIM}${'─'.repeat(61)}${RESET}`);
315
+ console.log();
316
+ }
317
+ // ── Ensure vault directories ─────────────────────────────────────────
318
+ function ensureVaultDirs() {
319
+ const dirs = [
320
+ config.SYSTEM_DIR,
321
+ path.join(config.SYSTEM_DIR, 'skills'),
322
+ config.AGENTS_DIR,
323
+ path.join(config.BASE_DIR, 'tools'),
324
+ config.DAILY_NOTES_DIR,
325
+ config.PEOPLE_DIR,
326
+ config.PROJECTS_DIR,
327
+ config.TOPICS_DIR,
328
+ config.TASKS_DIR,
329
+ config.TEMPLATES_DIR,
330
+ config.INBOX_DIR,
331
+ config.PROFILES_DIR,
332
+ ];
333
+ for (const dir of dirs) {
334
+ if (!existsSync(dir)) {
335
+ mkdirSync(dir, { recursive: true });
336
+ }
337
+ }
338
+ // Ensure logs directory
339
+ const logDir = path.join(config.BASE_DIR, 'logs');
340
+ if (!existsSync(logDir)) {
341
+ mkdirSync(logDir, { recursive: true });
342
+ }
343
+ // Seed HEARTBEAT.md if it doesn't exist (new installs)
344
+ if (!existsSync(config.HEARTBEAT_FILE)) {
345
+ writeFileSync(config.HEARTBEAT_FILE, [
346
+ '---',
347
+ 'type: core-system',
348
+ 'role: heartbeat-config',
349
+ 'interval: 30',
350
+ 'active_hours: "08:00-22:00"',
351
+ 'allow_tier2: false',
352
+ 'web_allowed: true',
353
+ 'tags:',
354
+ ' - system',
355
+ ' - heartbeat',
356
+ '---',
357
+ '',
358
+ '# Heartbeat Standing Instructions',
359
+ '',
360
+ 'Every **{{interval}} minutes** during active hours ({{active_hours}}), I run an autonomous check-in.',
361
+ '',
362
+ '## What I Do',
363
+ '',
364
+ 'Check in like a colleague would — naturally and briefly. Lead with anything that needs attention. If everything is fine, say so in a sentence or two and move on.',
365
+ '',
366
+ '**Look for:**',
367
+ '- Overdue tasks — check the task list for tasks past their due date. If any are overdue, flag them immediately.',
368
+ '- Tasks due today that haven\'t been started yet.',
369
+ '- New items in the Inbox — try to sort them to the right folder.',
370
+ '- Recent cron/scheduled task outputs that are worth mentioning.',
371
+ '- Goal progress — if a recent cron output advances a goal, update the goal\'s notes.',
372
+ '',
373
+ '## Proactive Actions',
374
+ '',
375
+ 'During check-ins, I may take 1-2 small proactive actions per heartbeat (up to 6 per day):',
376
+ '- Promote durable facts from today\'s daily note to MEMORY or topic/person notes',
377
+ '- Update goal progress notes based on recent cron outputs',
378
+ '- Flag interesting (not just urgent) findings for the owner',
379
+ '- Create or update today\'s daily note if it doesn\'t exist',
380
+ '',
381
+ '## When to Alert',
382
+ '',
383
+ '- A task is overdue (always alert)',
384
+ '- A task is due today and not started',
385
+ '- Something I was monitoring has changed',
386
+ '- A scheduled job produced results worth reporting',
387
+ '- I found something interesting during a proactive check',
388
+ '',
389
+ '## When to Stay Quiet',
390
+ '',
391
+ '- Everything is on track',
392
+ '- No overdue tasks, nothing new',
393
+ '- Just log a brief note to today\'s daily log and move on',
394
+ '',
395
+ '## Limits',
396
+ '',
397
+ '- **Max turns:** 5 per heartbeat',
398
+ '- **Tier 1 actions only** by default (read, write to vault, search)',
399
+ '- **Tier 2** allowed if `allow_tier2: true` above (write outside vault, git commit, bash)',
400
+ '- **Tier 3 never** — no pushing, no external comms, no deletions',
401
+ '',
402
+ ].join('\n'), 'utf-8');
403
+ }
404
+ }
405
+ // ── Timer checker ─────────────────────────────────────────────────────
406
+ const TimerEntrySchema = z.object({
407
+ id: z.string(),
408
+ message: z.string(),
409
+ fireAt: z.number(),
410
+ createdAt: z.number(),
411
+ });
412
+ const TIMERS_FILE = path.join(config.BASE_DIR, '.timers.json');
413
+ const TIMER_CHECK_INTERVAL = 30_000; // 30 seconds
414
+ function startTimerChecker(dispatcher, gateway) {
415
+ return setInterval(() => {
416
+ try {
417
+ if (!existsSync(TIMERS_FILE))
418
+ return;
419
+ const raw = JSON.parse(readFileSync(TIMERS_FILE, 'utf-8'));
420
+ const parsed = z.array(TimerEntrySchema).safeParse(raw);
421
+ if (!parsed.success) {
422
+ logger.warn({ error: parsed.error.message }, 'Invalid timers file — skipping');
423
+ return;
424
+ }
425
+ const timers = parsed.data;
426
+ if (timers.length === 0)
427
+ return;
428
+ const now = Date.now();
429
+ const due = timers.filter((t) => t.fireAt <= now);
430
+ const remaining = timers.filter((t) => t.fireAt > now);
431
+ if (due.length === 0)
432
+ return;
433
+ // Update file first (remove fired timers)
434
+ writeFileSync(TIMERS_FILE, JSON.stringify(remaining, null, 2));
435
+ // Dispatch notifications and inject context so replies have reminder context
436
+ for (const timer of due) {
437
+ logger.info({ id: timer.id, message: timer.message }, 'Timer fired');
438
+ const reminderText = `⏰ **Reminder:** ${timer.message}`;
439
+ dispatcher.send(reminderText).catch((err) => {
440
+ logger.error({ err, id: timer.id }, 'Failed to dispatch timer notification');
441
+ });
442
+ // Inject into owner's session so their reply has context about the reminder
443
+ if (gateway && config.DISCORD_OWNER_ID) {
444
+ gateway.injectContext(`discord:user:${config.DISCORD_OWNER_ID}`, `[Timer fired: ${timer.message}]`, reminderText);
445
+ }
446
+ }
447
+ }
448
+ catch (err) {
449
+ logger.warn({ err }, 'Timer checker error — will retry next interval');
450
+ }
451
+ }, TIMER_CHECK_INTERVAL);
452
+ }
453
+ // ── Log rotation ─────────────────────────────────────────────────────
454
+ const LOG_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
455
+ const LOG_MAX_BACKUPS = 7;
456
+ function rotateLogIfNeeded() {
457
+ const logFile = path.join(config.BASE_DIR, 'logs', 'clementine.log');
458
+ try {
459
+ if (!existsSync(logFile))
460
+ return;
461
+ const size = statSync(logFile).size;
462
+ if (size < LOG_MAX_BYTES)
463
+ return;
464
+ // Rotate: delete .log.7, shift .log.6→.log.7, ... .log→.log.1
465
+ const oldest = `${logFile}.${LOG_MAX_BACKUPS}`;
466
+ if (existsSync(oldest))
467
+ unlinkSync(oldest);
468
+ for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
469
+ const src = `${logFile}.${i}`;
470
+ if (existsSync(src))
471
+ renameSync(src, `${logFile}.${i + 1}`);
472
+ }
473
+ renameSync(logFile, `${logFile}.1`);
474
+ writeFileSync(logFile, '');
475
+ }
476
+ catch (err) {
477
+ logger.warn({ err }, 'Log rotation failed — continuing startup');
478
+ }
479
+ }
480
+ // ── Async main ───────────────────────────────────────────────────────
481
+ // ── Restart sentinel ─────────────────────────────────────────────────
482
+ const SENTINEL_PATH = path.join(config.BASE_DIR, '.restart-sentinel.json');
483
+ function readAndClearSentinel() {
484
+ if (!existsSync(SENTINEL_PATH))
485
+ return null;
486
+ try {
487
+ const raw = JSON.parse(readFileSync(SENTINEL_PATH, 'utf-8'));
488
+ const sentinel = {
489
+ previousPid: Number(raw.previousPid) || 0,
490
+ restartedAt: String(raw.restartedAt ?? ''),
491
+ reason: raw.reason,
492
+ sourceChangeId: raw.sourceChangeId,
493
+ sessionKey: raw.sessionKey,
494
+ changedFiles: raw.changedFiles,
495
+ updateDetails: raw.updateDetails,
496
+ };
497
+ unlinkSync(SENTINEL_PATH);
498
+ return sentinel;
499
+ }
500
+ catch {
501
+ try {
502
+ unlinkSync(SENTINEL_PATH);
503
+ }
504
+ catch { /* ignore */ }
505
+ return null;
506
+ }
507
+ }
508
+ // ── Drain helper ─────────────────────────────────────────────────────
509
+ async function drainActiveSessions(gateway, timeoutMs = 60_000) {
510
+ gateway.setDraining(true);
511
+ const deadline = Date.now() + timeoutMs;
512
+ while (Date.now() < deadline) {
513
+ const status = lanes.status();
514
+ let active = 0;
515
+ for (const s of Object.values(status)) {
516
+ active += s.active;
517
+ }
518
+ if (active === 0)
519
+ break;
520
+ logger.info({ totalActive: active }, 'Draining active sessions...');
521
+ await new Promise(r => setTimeout(r, 500));
522
+ }
523
+ }
524
+ async function asyncMain() {
525
+ // ── Rotate log if over size limit ───────────────────────────────
526
+ rotateLogIfNeeded();
527
+ // ── Read restart sentinel (from a previous self-edit / update) ───
528
+ const sentinel = readAndClearSentinel();
529
+ if (sentinel) {
530
+ logger.info({ reason: sentinel.reason, previousPid: sentinel.previousPid, changedFiles: sentinel.changedFiles }, 'Restart sentinel detected — this process is a post-restart instance');
531
+ }
532
+ // ── Validate secrets (fail closed on misconfiguration) ──────────
533
+ const secretWarnings = config.validateSecrets();
534
+ for (const warning of secretWarnings) {
535
+ logger.warn(warning);
536
+ }
537
+ // ── Check MCP extension permissions ────────────────────────────
538
+ try {
539
+ const { checkPermissionsOnStartup, bootstrapClaudeIntegrationsFromAuditLog } = await import('./agent/mcp-bridge.js');
540
+ checkPermissionsOnStartup();
541
+ bootstrapClaudeIntegrationsFromAuditLog(path.join(config.BASE_DIR, 'logs', 'audit.log'));
542
+ }
543
+ catch { /* non-fatal */ }
544
+ // ── Initialize layers ────────────────────────────────────────────
545
+ // Agent layer
546
+ const { PersonalAssistant } = await import('./agent/assistant.js');
547
+ const assistant = new PersonalAssistant();
548
+ // Gateway layer
549
+ const { Gateway } = await import('./gateway/router.js');
550
+ const gateway = new Gateway(assistant);
551
+ // Wire approval callback
552
+ const { setApprovalCallback, setSendPolicyChecker } = await import('./agent/hooks.js');
553
+ setApprovalCallback(async (desc) => {
554
+ const result = await gateway.requestApproval(desc);
555
+ return result === true;
556
+ });
557
+ // Wire send policy checker — lightweight read-only DB access for suppression + daily cap
558
+ {
559
+ const Database = (await import('better-sqlite3')).default;
560
+ const { MEMORY_DB_PATH } = await import('./config.js');
561
+ const { existsSync } = await import('node:fs');
562
+ if (existsSync(MEMORY_DB_PATH)) {
563
+ const policyDb = new Database(MEMORY_DB_PATH, { readonly: true });
564
+ policyDb.pragma('journal_mode = WAL');
565
+ setSendPolicyChecker((agentSlug, recipientEmail) => {
566
+ try {
567
+ const suppRow = policyDb.prepare('SELECT 1 FROM suppression_list WHERE email = ?').get(recipientEmail.toLowerCase());
568
+ const countRow = policyDb.prepare(`SELECT COUNT(*) as cnt FROM send_log WHERE agent_slug = ? AND sent_at >= date('now')`).get(agentSlug);
569
+ return { suppressed: !!suppRow, dailyCount: countRow?.cnt ?? 0 };
570
+ }
571
+ catch {
572
+ // Tables may not exist yet (first run before MCP server initializes store)
573
+ return { suppressed: false, dailyCount: 0 };
574
+ }
575
+ });
576
+ }
577
+ }
578
+ // Notification dispatcher
579
+ const { NotificationDispatcher } = await import('./gateway/notifications.js');
580
+ const dispatcher = new NotificationDispatcher();
581
+ gateway.setDispatcher(dispatcher);
582
+ gateway.initSkillNotifications();
583
+ // Heartbeat + Cron schedulers
584
+ const { HeartbeatScheduler, CronScheduler } = await import('./gateway/heartbeat.js');
585
+ const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
586
+ const cronScheduler = new CronScheduler(gateway, dispatcher);
587
+ heartbeat.setCronScheduler(cronScheduler);
588
+ // ── Build channel tasks ──────────────────────────────────────────
589
+ const channelTasks = [];
590
+ const activeChannels = [];
591
+ if (config.CHANNEL_DISCORD) {
592
+ const { startDiscord } = await import('./channels/discord.js');
593
+ let botManager;
594
+ try {
595
+ const { BotManager } = await import('./channels/discord-bot-manager.js');
596
+ botManager = new BotManager({
597
+ gateway,
598
+ ownerId: config.DISCORD_OWNER_ID,
599
+ cronScheduler,
600
+ });
601
+ logger.info('BotManager: starting all agent bots...');
602
+ const botOwnedChannels = await botManager.startAll();
603
+ if (botOwnedChannels.length > 0) {
604
+ logger.info({ channels: botOwnedChannels }, `Started ${botOwnedChannels.length} agent bot(s)`);
605
+ }
606
+ }
607
+ catch (err) {
608
+ logger.error({ err }, 'BotManager startup failed — continuing without agent bots');
609
+ }
610
+ // Register BotManager with gateway so TeamBus can resolve agent bot channels
611
+ if (botManager)
612
+ gateway.setBotManager(botManager);
613
+ channelTasks.push(startDiscord(gateway, heartbeat, cronScheduler, dispatcher, botManager));
614
+ if (botManager)
615
+ botManager.startPolling(60_000);
616
+ activeChannels.push('Discord');
617
+ }
618
+ if (config.CHANNEL_SLACK) {
619
+ const { startSlack } = await import('./channels/slack.js');
620
+ let slackBotManager;
621
+ try {
622
+ const { SlackBotManager } = await import('./channels/slack-bot-manager.js');
623
+ slackBotManager = new SlackBotManager({
624
+ gateway,
625
+ ownerId: config.SLACK_OWNER_USER_ID,
626
+ });
627
+ logger.info('SlackBotManager: starting all Slack agent bots...');
628
+ const slackBotChannels = await slackBotManager.startAll();
629
+ if (slackBotChannels.length > 0) {
630
+ logger.info({ channels: slackBotChannels }, `Started ${slackBotChannels.length} Slack agent bot(s)`);
631
+ }
632
+ }
633
+ catch (err) {
634
+ logger.error({ err }, 'SlackBotManager startup failed — continuing without Slack agent bots');
635
+ }
636
+ if (slackBotManager)
637
+ gateway.setSlackBotManager(slackBotManager);
638
+ channelTasks.push(startSlack(gateway, dispatcher, slackBotManager));
639
+ if (slackBotManager)
640
+ slackBotManager.startPolling(60_000);
641
+ activeChannels.push('Slack');
642
+ }
643
+ if (config.CHANNEL_TELEGRAM) {
644
+ const { startTelegram } = await import('./channels/telegram.js');
645
+ channelTasks.push(startTelegram(gateway, dispatcher));
646
+ activeChannels.push('Telegram');
647
+ }
648
+ if (config.CHANNEL_WHATSAPP) {
649
+ const { startWhatsApp } = await import('./channels/whatsapp.js');
650
+ channelTasks.push(startWhatsApp(gateway, dispatcher));
651
+ activeChannels.push(`WhatsApp (:${config.WHATSAPP_WEBHOOK_PORT})`);
652
+ }
653
+ if (config.CHANNEL_WEBHOOK) {
654
+ const { startWebhook } = await import('./channels/webhook.js');
655
+ channelTasks.push(startWebhook(gateway));
656
+ activeChannels.push(`Webhook (:${config.WEBHOOK_PORT})`);
657
+ }
658
+ if (channelTasks.length === 0) {
659
+ logger.error('No channels configured — nothing to start');
660
+ return;
661
+ }
662
+ // Initialize graph store (non-blocking, graceful fallback)
663
+ // The daemon owns the embedded FalkorDB server; other processes connect via socket.
664
+ let graphAvailable = false;
665
+ let graphStore = null;
666
+ try {
667
+ const { GraphStore } = await import('./memory/graph-store.js');
668
+ graphStore = new GraphStore(config.GRAPH_DB_DIR);
669
+ await graphStore.initialize();
670
+ if (graphStore.isAvailable()) {
671
+ graphAvailable = true;
672
+ const stats = await graphStore.syncFromVault(config.VAULT_DIR, config.AGENTS_DIR);
673
+ if (stats.nodesCreated > 0) {
674
+ logger.info(stats, 'Graph sync populated from vault');
675
+ }
676
+ }
677
+ }
678
+ catch (err) {
679
+ logger.warn({ err }, 'Graph store init failed — continuing without graph features');
680
+ }
681
+ // Start heartbeat + cron + timers
682
+ heartbeat.start();
683
+ cronScheduler.start();
684
+ const timerInterval = startTimerChecker(dispatcher, gateway);
685
+ // Deliver pending team messages every 15s (picks up MCP-written messages)
686
+ const teamDeliveryInterval = setInterval(() => {
687
+ try {
688
+ gateway.getTeamBus().deliverPending();
689
+ }
690
+ catch (err) {
691
+ logger.warn({ err }, 'Team delivery error');
692
+ }
693
+ }, 15_000);
694
+ // Watch for pending source edits from MCP tools (every 10s)
695
+ const PENDING_SOURCE_SIGNAL = path.join(config.BASE_DIR, '.pending-source-edit');
696
+ const PENDING_UPDATE_SIGNAL = path.join(config.BASE_DIR, '.pending-update');
697
+ const PENDING_SOURCE_DIR = path.join(config.SELF_IMPROVE_DIR, 'pending-source-changes');
698
+ const sourceEditInterval = setInterval(async () => {
699
+ try {
700
+ // Check for pending source edits
701
+ if (existsSync(PENDING_SOURCE_SIGNAL)) {
702
+ const signalRaw = JSON.parse(readFileSync(PENDING_SOURCE_SIGNAL, 'utf-8'));
703
+ const signalParsed = z.object({ id: z.string() }).safeParse(signalRaw);
704
+ unlinkSync(PENDING_SOURCE_SIGNAL);
705
+ if (!signalParsed.success) {
706
+ logger.warn({ error: signalParsed.error.message }, 'Invalid source-edit signal file');
707
+ }
708
+ else {
709
+ const signal = signalParsed.data;
710
+ const pendingFile = path.join(PENDING_SOURCE_DIR, `${signal.id}.json`);
711
+ if (existsSync(pendingFile)) {
712
+ const pendingRaw = JSON.parse(readFileSync(pendingFile, 'utf-8'));
713
+ const pendingParsed = z.object({
714
+ file: z.string(),
715
+ content: z.string(),
716
+ reason: z.string(),
717
+ }).safeParse(pendingRaw);
718
+ unlinkSync(pendingFile);
719
+ if (!pendingParsed.success) {
720
+ logger.warn({ error: pendingParsed.error.message }, 'Invalid pending source-edit file');
721
+ }
722
+ else {
723
+ const pending = pendingParsed.data;
724
+ logger.info({ id: signal.id, file: pending.file }, 'Processing pending source edit from MCP');
725
+ const { safeSourceEdit } = await import('./agent/safe-restart.js');
726
+ const result = await safeSourceEdit(config.PKG_DIR, [
727
+ { relativePath: pending.file, content: pending.content },
728
+ ], { reason: pending.reason, description: pending.reason });
729
+ if (!result.success) {
730
+ logger.error({ error: result.error, preflightErrors: result.preflightErrors }, 'Pending source edit failed');
731
+ dispatcher.send(`Source edit failed: ${result.error}`).catch(() => { });
732
+ }
733
+ }
734
+ }
735
+ }
736
+ }
737
+ // Check for pending updates
738
+ if (existsSync(PENDING_UPDATE_SIGNAL)) {
739
+ unlinkSync(PENDING_UPDATE_SIGNAL);
740
+ logger.info('Processing pending update from MCP');
741
+ const { applyUpdate } = await import('./agent/auto-update.js');
742
+ const result = await applyUpdate(config.PKG_DIR);
743
+ if (!result.success) {
744
+ logger.error({ error: result.error }, 'Pending update failed');
745
+ dispatcher.send(`Update failed: ${result.error}`).catch(() => { });
746
+ }
747
+ }
748
+ }
749
+ catch (err) {
750
+ logger.error({ err }, 'Source edit/update watcher error');
751
+ }
752
+ }, 10_000);
753
+ // ── Banner ───────────────────────────────────────────────────────
754
+ const profileCount = 0; // ProfileManager can be loaded later if needed
755
+ const cronCount = 0; // Jobs loaded internally by CronScheduler.start()
756
+ printBanner(activeChannels, profileCount, cronCount, graphAvailable);
757
+ logger.info(`${config.ASSISTANT_NAME} is online`);
758
+ // ── Initialize all channels ─────────────────────────────────────
759
+ await Promise.all(channelTasks);
760
+ // Warn if no notification channels registered — cron/heartbeat output will be lost
761
+ if (!dispatcher.hasChannels) {
762
+ logger.warn('⚠ No notification channels connected — cron and heartbeat output will not be delivered. Configure at least one channel (Discord, Slack, Telegram) to receive notifications.');
763
+ }
764
+ // ── Deliver restart sentinel notification ──────────────────────
765
+ if (sentinel) {
766
+ let msg;
767
+ if (sentinel.reason === 'source-edit') {
768
+ msg = `Restart complete. Source change applied${sentinel.changedFiles ? ` (${sentinel.changedFiles.join(', ')})` : ''}.`;
769
+ }
770
+ else if (sentinel.reason === 'update' && sentinel.updateDetails) {
771
+ const d = sentinel.updateDetails;
772
+ const parts = [];
773
+ // Version info
774
+ if (d.commitHash) {
775
+ parts.push(`Updated to ${d.commitHash}${d.commitDate ? ` (${d.commitDate})` : ''}`);
776
+ }
777
+ else {
778
+ parts.push('Update applied');
779
+ }
780
+ // What changed upstream
781
+ if (d.commitsBehind && d.commitsBehind > 0) {
782
+ parts.push(`${d.commitsBehind} new commit${d.commitsBehind > 1 ? 's' : ''} pulled`);
783
+ }
784
+ if (d.summary) {
785
+ parts.push(`Changes: ${d.summary}`);
786
+ }
787
+ // Source mod reconciliation
788
+ const modParts = [];
789
+ if (d.modsReapplied && d.modsReapplied > 0)
790
+ modParts.push(`${d.modsReapplied} re-applied`);
791
+ if (d.modsSuperseded && d.modsSuperseded > 0)
792
+ modParts.push(`${d.modsSuperseded} already in upstream`);
793
+ if (d.modsNeedReconciliation && d.modsNeedReconciliation > 0)
794
+ modParts.push(`${d.modsNeedReconciliation} need my attention`);
795
+ if (d.modsFailed && d.modsFailed > 0)
796
+ modParts.push(`${d.modsFailed} failed`);
797
+ if (modParts.length > 0) {
798
+ parts.push(`Source mods: ${modParts.join(', ')}`);
799
+ }
800
+ msg = parts.join('. ') + '.';
801
+ }
802
+ else if (sentinel.reason === 'update') {
803
+ msg = 'Restart complete. Update applied successfully.';
804
+ }
805
+ else {
806
+ msg = 'Restart complete.';
807
+ }
808
+ dispatcher.send(msg).catch((err) => {
809
+ logger.warn({ err }, 'Failed to deliver restart notification');
810
+ });
811
+ // Also inject context into the originating session if known
812
+ if (sentinel.sessionKey) {
813
+ gateway.injectContext(sentinel.sessionKey, '[System: restart triggered]', msg);
814
+ }
815
+ }
816
+ // ── Keep alive until shutdown or restart signal ─────────────────
817
+ // The event loop stays active via Discord's websocket, node-cron
818
+ // timers, and heartbeat setInterval. We just need to gate on
819
+ // SIGTERM / SIGINT so cleanup runs before exit.
820
+ // SIGUSR1 triggers a self-restart: cleanup then spawn a new instance.
821
+ let restartRequested = false;
822
+ await new Promise((resolve) => {
823
+ process.once('SIGTERM', resolve);
824
+ process.once('SIGINT', resolve);
825
+ process.once('SIGUSR1', () => {
826
+ restartRequested = true;
827
+ resolve();
828
+ });
829
+ });
830
+ // ── Graceful cleanup ──────────────────────────────────────────
831
+ logger.info(restartRequested ? 'Restart signal received — restarting' : 'Shutdown signal received — cleaning up');
832
+ // Stop accepting new work immediately
833
+ clearInterval(timerInterval);
834
+ clearInterval(teamDeliveryInterval);
835
+ clearInterval(sourceEditInterval);
836
+ // Close graph store FIRST — FalkorDBLite's cleanup.js registers an
837
+ // uncaughtException handler that re-throws errors. If a Redis socket
838
+ // drops during the drain wait, that handler crashes the process.
839
+ // Closing (and unregistering) before draining prevents this.
840
+ if (graphStore) {
841
+ try {
842
+ await graphStore.close();
843
+ }
844
+ catch (err) {
845
+ logger.warn({ err }, 'Graph store close error');
846
+ }
847
+ graphStore = null;
848
+ }
849
+ // Drain active sessions BEFORE tearing down heartbeat/cron —
850
+ // active sessions may still need those services.
851
+ if (restartRequested) {
852
+ await drainActiveSessions(gateway);
853
+ }
854
+ // Now safe to tear down remaining infrastructure
855
+ heartbeat.stop();
856
+ cronScheduler.stop();
857
+ // ── Self-restart (enhanced with health check + rollback) ────────
858
+ if (restartRequested) {
859
+ // Clear our PID file BEFORE spawning the child, so ensureSingleton()
860
+ // in the child doesn't see our PID and kill us during the handoff.
861
+ cleanupPid();
862
+ const { spawn } = await import('node:child_process');
863
+ const { openSync } = await import('node:fs');
864
+ // Resolve the correct entry point — if started via `node -e` (e.g. from a
865
+ // leaked smoke test), argv[1] would be `-e` which is wrong. Use the known
866
+ // dist entry path instead.
867
+ let entry = process.argv[1];
868
+ let args = process.argv.slice(2);
869
+ if (entry === '-e' || entry === '--eval') {
870
+ entry = path.join(config.PKG_DIR, 'dist', 'index.js');
871
+ args = [];
872
+ logger.warn({ originalArgv: process.argv }, 'Self-restart: detected -e flag — using dist entry path instead');
873
+ }
874
+ logger.info({ entry, args }, 'Spawning new instance');
875
+ // Redirect child stdout/stderr to log file so pino logs are preserved
876
+ const logPath = path.join(config.BASE_DIR, 'logs', 'clementine.log');
877
+ let childStdio = 'ignore';
878
+ try {
879
+ const logFd = openSync(logPath, 'a');
880
+ childStdio = ['ignore', logFd, logFd];
881
+ }
882
+ catch { /* fallback to ignore if log file can't be opened */ }
883
+ const child = spawn(process.execPath, [entry, ...args], {
884
+ detached: true,
885
+ stdio: childStdio,
886
+ cwd: config.PKG_DIR,
887
+ env: process.env,
888
+ });
889
+ child.unref();
890
+ // Health check — wait up to 10s for the child to write a new PID
891
+ const childAlive = await new Promise((resolve) => {
892
+ child.once('exit', () => resolve(false));
893
+ const checkInterval = setInterval(() => {
894
+ try {
895
+ if (existsSync(PID_FILE)) {
896
+ const newPid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
897
+ if (!isNaN(newPid) && newPid !== process.pid) {
898
+ clearInterval(checkInterval);
899
+ resolve(true);
900
+ }
901
+ }
902
+ }
903
+ catch { /* ignore read errors */ }
904
+ }, 500);
905
+ setTimeout(() => {
906
+ clearInterval(checkInterval);
907
+ resolve(true); // Assume alive after 10s if no exit event
908
+ }, 10_000);
909
+ });
910
+ // Rollback on crash — if child died and sentinel exists with changedFiles
911
+ if (!childAlive) {
912
+ logger.error('Restart failed — new process exited immediately');
913
+ const crashSentinel = readAndClearSentinel();
914
+ if (crashSentinel?.changedFiles && crashSentinel.changedFiles.length > 0) {
915
+ logger.info({ changedFiles: crashSentinel.changedFiles }, 'Rolling back source edit...');
916
+ try {
917
+ // Roll back via source-mods registry (restores "before" snapshots)
918
+ if (crashSentinel.sourceChangeId) {
919
+ const { rollbackSourceMod } = await import('./agent/source-mods.js');
920
+ rollbackSourceMod(crashSentinel.sourceChangeId, config.PKG_DIR);
921
+ }
922
+ else {
923
+ // Fallback: reset src/ to git HEAD
924
+ execSync('git checkout -- src/', { cwd: config.PKG_DIR, stdio: 'pipe' });
925
+ }
926
+ // Use tsc directly — `npm run build` does `rm -rf dist` which would
927
+ // nuke the running process's code. tsc alone overwrites only changed .js files.
928
+ execSync('./node_modules/.bin/tsc', { cwd: config.PKG_DIR, stdio: 'pipe', timeout: 120_000 });
929
+ logger.info('Rollback successful — spawning clean instance');
930
+ const retryChild = spawn(process.execPath, [entry, ...args], {
931
+ detached: true,
932
+ stdio: 'ignore',
933
+ cwd: process.cwd(),
934
+ env: process.env,
935
+ });
936
+ retryChild.unref();
937
+ const retryAlive = await new Promise((resolve) => {
938
+ retryChild.once('exit', () => resolve(false));
939
+ setTimeout(() => resolve(true), 5000);
940
+ });
941
+ if (!retryAlive) {
942
+ logger.error('Rollback spawn also failed — exiting. launchd/systemd will respawn.');
943
+ }
944
+ process.exit(retryAlive ? 0 : 1);
945
+ }
946
+ catch (revertErr) {
947
+ logger.error({ revertErr }, 'Rollback failed — exiting');
948
+ }
949
+ }
950
+ logger.error('Run `clementine doctor` to diagnose.');
951
+ }
952
+ // Force exit — Discord websocket and other event loop handles
953
+ // will keep this process alive indefinitely if we just return.
954
+ process.exit(childAlive ? 0 : 1);
955
+ }
956
+ }
957
+ // ── Main ─────────────────────────────────────────────────────────────
958
+ function main() {
959
+ // Smoke test mode: verify the module loads then exit immediately.
960
+ // Set by `clementine update` to validate the build without starting a full daemon.
961
+ if (process.env.CLEMENTINE_SMOKE_TEST) {
962
+ process.exit(0);
963
+ }
964
+ // Singleton enforcement
965
+ ensureSingleton();
966
+ process.on('exit', cleanupPid);
967
+ // Global safety net — log unhandled errors instead of crashing the daemon
968
+ process.on('uncaughtException', (err) => {
969
+ logger.error({ err }, 'Uncaught exception — daemon staying alive');
970
+ });
971
+ process.on('unhandledRejection', (err) => {
972
+ logger.error({ err }, 'Unhandled promise rejection — daemon staying alive');
973
+ });
974
+ // First-run auto-setup
975
+ const envFile = path.join(config.BASE_DIR, '.env');
976
+ if (!existsSync(envFile)) {
977
+ console.log();
978
+ console.log(' No .env file found — looks like a fresh install.');
979
+ console.log(' Run: clementine config setup');
980
+ console.log();
981
+ process.exit(1);
982
+ }
983
+ // Startup verification
984
+ const errors = verifySetup();
985
+ if (errors.length > 0) {
986
+ for (const err of errors) {
987
+ logger.error(`Setup issue: ${err}`);
988
+ }
989
+ const anyChannel = config.CHANNEL_DISCORD ||
990
+ config.CHANNEL_SLACK ||
991
+ config.CHANNEL_TELEGRAM ||
992
+ config.CHANNEL_WHATSAPP ||
993
+ config.CHANNEL_WEBHOOK;
994
+ if (!anyChannel) {
995
+ process.exit(1);
996
+ }
997
+ }
998
+ // Ensure vault directories
999
+ ensureVaultDirs();
1000
+ // Run — SIGINT/SIGTERM are handled inside asyncMain (shutdown-signal gate).
1001
+ // When asyncMain resolves, cleanup has already run; just clean up the PID.
1002
+ asyncMain()
1003
+ .then(() => {
1004
+ cleanupPid();
1005
+ })
1006
+ .catch((err) => {
1007
+ logger.error({ err }, 'Fatal error');
1008
+ cleanupPid();
1009
+ process.exit(1);
1010
+ });
1011
+ }
1012
+ // ── Export for CLI and direct usage ──────────────────────────────────
1013
+ export { main, asyncMain, verifySetup, printBanner };
1014
+ main();
1015
+ //# sourceMappingURL=index.js.map