@geoql/mdr 0.0.1

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 (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +152 -0
  3. package/USAGE.md +59 -0
  4. package/bin/detect-user.sh +53 -0
  5. package/bin/index-conversations.ts +34 -0
  6. package/bin/macrodata-daemon.ts +28 -0
  7. package/bin/macrodata-hook.sh +277 -0
  8. package/dist/bin/index-conversations.js +31 -0
  9. package/dist/bin/macrodata-daemon.js +30 -0
  10. package/dist/opencode/context.js +210 -0
  11. package/dist/opencode/conversations.js +367 -0
  12. package/dist/opencode/index.js +155 -0
  13. package/dist/opencode/journal.js +108 -0
  14. package/dist/opencode/logger.js +29 -0
  15. package/dist/opencode/search.js +210 -0
  16. package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
  17. package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  18. package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  19. package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  20. package/dist/opencode/tools.js +367 -0
  21. package/dist/src/config.js +55 -0
  22. package/dist/src/conversations.js +513 -0
  23. package/dist/src/daemon.js +582 -0
  24. package/dist/src/detect-user.js +73 -0
  25. package/dist/src/embeddings.js +190 -0
  26. package/dist/src/index.js +413 -0
  27. package/dist/src/indexer.js +286 -0
  28. package/opencode/context.ts +322 -0
  29. package/opencode/conversations.ts +467 -0
  30. package/opencode/index.ts +208 -0
  31. package/opencode/journal.ts +153 -0
  32. package/opencode/logger.ts +32 -0
  33. package/opencode/search.ts +288 -0
  34. package/opencode/skills/macrodata-distill/SKILL.md +171 -0
  35. package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  36. package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  37. package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  38. package/opencode/tools.ts +453 -0
  39. package/package.json +87 -0
  40. package/src/config.ts +66 -0
  41. package/src/conversations.ts +709 -0
  42. package/src/daemon.ts +785 -0
  43. package/src/detect-user.ts +97 -0
  44. package/src/embeddings.ts +262 -0
  45. package/src/index.ts +726 -0
  46. package/src/indexer.ts +394 -0
package/src/daemon.ts ADDED
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Macrodata Local Daemon (core logic)
3
+ *
4
+ * Handles scheduled tasks, file watching for index updates, and triggers
5
+ * Claude Code or OpenCode via CLI when reminders fire.
6
+ *
7
+ * The `bin/macrodata-daemon.ts` entry is a thin wrapper around `runDaemon()`;
8
+ * all logic lives here so it can be imported and unit-tested directly instead
9
+ * of only through a spawned child process.
10
+ *
11
+ * Environment:
12
+ * MACRODATA_AGENT=opencode|claude (default: auto-detect)
13
+ * MACRODATA_ROOT=/path/to/state
14
+ * MACRODATA_CHILD_TIMEOUT_MS=<ms> (default: 10 minutes)
15
+ */
16
+
17
+ import { watch } from "chokidar";
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ appendFileSync,
23
+ mkdirSync,
24
+ readdirSync,
25
+ unlinkSync,
26
+ } from "fs";
27
+ import { join, basename } from "path";
28
+ import { Cron } from "croner";
29
+ import { spawn, execSync } from "child_process";
30
+ import {
31
+ getStateRoot,
32
+ getEntitiesDir,
33
+ getJournalDir,
34
+ getIndexDir,
35
+ getRemindersDir,
36
+ } from "./config.js";
37
+
38
+ // The indexing modules pull in @huggingface/transformers + vectra (multi-second
39
+ // import). Load them lazily so the daemon writes its PID file and starts
40
+ // scheduling immediately instead of blocking on heavy imports.
41
+ export async function loadIndexer() {
42
+ return import("./indexer.js");
43
+ }
44
+
45
+ export async function loadConversationIndexers() {
46
+ const [oc, cc] = await Promise.all([
47
+ import("../opencode/conversations.js"),
48
+ import("./conversations.js"),
49
+ ]);
50
+ return {
51
+ updateOpenCodeConversations: oc.updateConversationIndex,
52
+ updateClaudeCodeConversations: cc.updateConversationIndex,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Find an executable in PATH
58
+ */
59
+ export async function findExecutable(name: string): Promise<string | null> {
60
+ try {
61
+ const result = execSync(`which ${name}`, { encoding: "utf-8" }).trim();
62
+ return result || null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // Daemon-specific path helpers
69
+ // Use MACRODATA_ROOT for all daemon files (PID, log) to support testing with isolated directories
70
+ export function getDaemonDir() {
71
+ return getStateRoot();
72
+ }
73
+
74
+ export function getPidFile() {
75
+ return join(getDaemonDir(), ".daemon.pid");
76
+ }
77
+
78
+ export function getLogFile() {
79
+ return join(getDaemonDir(), ".daemon.log");
80
+ }
81
+
82
+ export function getPendingContext() {
83
+ return join(getStateRoot(), ".pending-context");
84
+ }
85
+
86
+ export function getHeartbeatFile() {
87
+ return join(getDaemonDir(), ".daemon.heartbeat");
88
+ }
89
+
90
+ export const HEARTBEAT_INTERVAL_MS = 60_000;
91
+
92
+ /**
93
+ * Resolve the per-child hard timeout from the environment. Re-read each call so
94
+ * tests can flip MACRODATA_CHILD_TIMEOUT_MS without reloading the module.
95
+ */
96
+ export function getChildTimeoutMs(): number {
97
+ const raw = process.env.MACRODATA_CHILD_TIMEOUT_MS;
98
+ const parsed = raw ? parseInt(raw, 10) : NaN;
99
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 60_000;
100
+ }
101
+
102
+ export interface Schedule {
103
+ id: string;
104
+ type: "cron" | "once";
105
+ expression: string; // cron expression or ISO datetime
106
+ description: string;
107
+ payload: string;
108
+ agent?: "opencode" | "claude"; // Which agent to trigger
109
+ model?: string; // Optional model override (e.g., "anthropic/claude-opus-4-6")
110
+ createdAt: string;
111
+ }
112
+
113
+ export function log(message: string) {
114
+ const ts = new Date().toISOString();
115
+ const line = `[${ts}] ${message}\n`;
116
+ appendFileSync(getLogFile(), line);
117
+ }
118
+
119
+ export function logError(message: string) {
120
+ const ts = new Date().toISOString();
121
+ const line = `[${ts}] ERROR: ${message}\n`;
122
+ appendFileSync(getLogFile(), line);
123
+ }
124
+
125
+ export function writePendingContext(message: string) {
126
+ try {
127
+ appendFileSync(getPendingContext(), message + "\n");
128
+ } catch (err) {
129
+ logError(`Failed to write pending context: ${String(err)}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Trigger an agent with a message
135
+ */
136
+ export async function triggerAgent(
137
+ agent: "opencode" | "claude" | undefined,
138
+ message: string,
139
+ options: { model?: string; description?: string } = {},
140
+ ): Promise<boolean> {
141
+ if (!agent) {
142
+ log("No agent specified in schedule, skipping trigger");
143
+ return false;
144
+ }
145
+
146
+ const timestamp = new Date().toLocaleString();
147
+ const fullMessage = `[Scheduled reminder: ${options.description || "reminder"}]
148
+ Current time: ${timestamp}
149
+
150
+ IMPORTANT: Use the macrodata_* tools (e.g., macrodata_log_journal, macrodata_search_memory) for memory operations. You are running in a non-interactive scheduled context.
151
+
152
+ ${message}`;
153
+
154
+ try {
155
+ if (agent === "opencode") {
156
+ // opencode run "message" --model provider/model
157
+ const args = ["run", fullMessage];
158
+ if (options.model) {
159
+ args.push("--model", options.model);
160
+ }
161
+
162
+ // Find opencode in PATH or use npx as fallback
163
+ const opencodePath = (await findExecutable("opencode")) || "npx";
164
+ const finalArgs = opencodePath === "npx" ? ["opencode", ...args] : args;
165
+
166
+ log(`Triggering OpenCode: ${opencodePath} ${finalArgs.join(" ").substring(0, 50)}...`);
167
+
168
+ spawnSupervisedChild(opencodePath, finalArgs, "opencode");
169
+
170
+ return true;
171
+ } else {
172
+ // claude --print "message" or claude -p "message"
173
+ const args = ["--print", fullMessage];
174
+
175
+ log(`Triggering Claude Code: claude --print "..."`);
176
+
177
+ spawnSupervisedChild("claude", args, "claude");
178
+
179
+ return true;
180
+ }
181
+ } catch (err) {
182
+ logError(`Failed to trigger ${agent}: ${String(err)}`);
183
+ }
184
+
185
+ return false;
186
+ }
187
+
188
+ /**
189
+ * Spawn an agent child process with a hard timeout so a hung child can never
190
+ * wedge the daemon's scheduling (#25). The child runs in its own process
191
+ * group; on timeout the whole group is killed and the daemon keeps running.
192
+ */
193
+ export function spawnSupervisedChild(command: string, args: string[], label: string) {
194
+ const childTimeoutMs = getChildTimeoutMs();
195
+ const proc = spawn(command, args, {
196
+ stdio: ["ignore", "pipe", "pipe"],
197
+ detached: true,
198
+ env: { ...process.env, PATH: process.env.PATH },
199
+ });
200
+
201
+ proc.unref();
202
+
203
+ const killTimer = setTimeout(() => {
204
+ log(`[${label}] child exceeded ${childTimeoutMs}ms timeout, killing process group ${proc.pid}`);
205
+ if (proc.pid) {
206
+ try {
207
+ process.kill(-proc.pid, "SIGKILL");
208
+ } catch {
209
+ try {
210
+ proc.kill("SIGKILL");
211
+ } catch {
212
+ // Child already gone
213
+ }
214
+ }
215
+ }
216
+ }, childTimeoutMs);
217
+ killTimer.unref();
218
+
219
+ proc.stdout?.on("data", (data) => {
220
+ log(`[${label} stdout] ${data.toString().trim()}`);
221
+ });
222
+ proc.stderr?.on("data", (data) => {
223
+ log(`[${label} stderr] ${data.toString().trim()}`);
224
+ });
225
+ proc.on("error", (err) => {
226
+ clearTimeout(killTimer);
227
+ logError(`[${label}] child process error: ${String(err)}`);
228
+ });
229
+ proc.on("exit", (code, signal) => {
230
+ clearTimeout(killTimer);
231
+ log(`[${label}] child exited (code=${code}, signal=${signal})`);
232
+ });
233
+
234
+ return proc;
235
+ }
236
+
237
+ export function ensureDirectories() {
238
+ const entitiesDir = getEntitiesDir();
239
+ const dirs = [
240
+ getDaemonDir(),
241
+ getStateRoot(),
242
+ getIndexDir(),
243
+ entitiesDir,
244
+ getJournalDir(),
245
+ getRemindersDir(),
246
+ join(entitiesDir, "people"),
247
+ join(entitiesDir, "projects"),
248
+ ];
249
+ for (const dir of dirs) {
250
+ if (!existsSync(dir)) {
251
+ mkdirSync(dir, { recursive: true });
252
+ log(`Created directory: ${dir}`);
253
+ }
254
+ }
255
+ }
256
+
257
+ export async function updateAllConversationIndexes(
258
+ loaders: () => Promise<{
259
+ updateClaudeCodeConversations: () => Promise<{ filesUpdated: number; exchangeCount: number }>;
260
+ updateOpenCodeConversations: () => Promise<{ newCount: number; totalCount: number }>;
261
+ }> = loadConversationIndexers,
262
+ ) {
263
+ const { updateClaudeCodeConversations, updateOpenCodeConversations } = await loaders();
264
+
265
+ // Update Claude Code conversations
266
+ try {
267
+ const claude = await updateClaudeCodeConversations();
268
+ if (claude.filesUpdated > 0) {
269
+ log(
270
+ `Claude Code conversations: +${claude.filesUpdated} files (${claude.exchangeCount} total)`,
271
+ );
272
+ }
273
+ } catch (err) {
274
+ logError(`Claude Code conversation index failed: ${String(err)}`);
275
+ }
276
+
277
+ // Update OpenCode conversations
278
+ try {
279
+ const opencode = await updateOpenCodeConversations();
280
+ if (opencode.newCount > 0) {
281
+ log(`OpenCode conversations: +${opencode.newCount} (${opencode.totalCount} total)`);
282
+ }
283
+ } catch (err) {
284
+ logError(`OpenCode conversation index failed: ${String(err)}`);
285
+ }
286
+ }
287
+
288
+ export function loadAllSchedules(): Schedule[] {
289
+ const remindersDir = getRemindersDir();
290
+ const schedules: Schedule[] = [];
291
+
292
+ try {
293
+ if (!existsSync(remindersDir)) return schedules;
294
+
295
+ const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
296
+ for (const file of files) {
297
+ try {
298
+ const content = readFileSync(join(remindersDir, file), "utf-8");
299
+ const schedule = JSON.parse(content) as Schedule;
300
+ schedules.push(schedule);
301
+ } catch (err) {
302
+ logError(`Failed to load schedule ${file}: ${String(err)}`);
303
+ }
304
+ }
305
+ } catch (err) {
306
+ logError(`Failed to read reminders directory: ${String(err)}`);
307
+ }
308
+
309
+ return schedules;
310
+ }
311
+
312
+ export function saveSchedule(schedule: Schedule) {
313
+ const remindersDir = getRemindersDir();
314
+ const filePath = join(remindersDir, `${schedule.id}.json`);
315
+
316
+ try {
317
+ writeFileSync(filePath, JSON.stringify(schedule, null, 2));
318
+ } catch (err) {
319
+ logError(`Failed to save schedule ${schedule.id}: ${String(err)}`);
320
+ }
321
+ }
322
+
323
+ export function deleteScheduleFile(id: string) {
324
+ const remindersDir = getRemindersDir();
325
+ const filePath = join(remindersDir, `${id}.json`);
326
+
327
+ try {
328
+ if (existsSync(filePath)) {
329
+ unlinkSync(filePath);
330
+ }
331
+ } catch (err) {
332
+ logError(`Failed to delete schedule file ${id}: ${String(err)}`);
333
+ }
334
+ }
335
+
336
+ export interface DaemonOptions {
337
+ // Overridable so tests skip the multi-second model import + real-history scan.
338
+ backgroundIndexing?: () => Promise<void>;
339
+ // Overridable indexer loader for the reindex queue (injectable failures).
340
+ indexerLoader?: () => Promise<{ indexEntityFile: (path: string) => Promise<void> }>;
341
+ }
342
+
343
+ export async function defaultBackgroundIndexing(
344
+ deps: {
345
+ loadIndexer?: () => Promise<{ preloadModel: () => Promise<void> }>;
346
+ updateAll?: typeof updateAllConversationIndexes;
347
+ } = {},
348
+ ): Promise<void> {
349
+ const load = deps.loadIndexer ?? loadIndexer;
350
+ const updateAll = deps.updateAll ?? updateAllConversationIndexes;
351
+ const indexer = await load();
352
+ await indexer.preloadModel();
353
+ log("Embedding model preloaded");
354
+ await updateAll();
355
+ }
356
+
357
+ export class MacrodataLocalDaemon {
358
+ private cronJobs: Map<string, Cron> = new Map();
359
+ private watcher: ReturnType<typeof watch> | null = null;
360
+ private schedulesWatcher: ReturnType<typeof watch> | null = null;
361
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
362
+ private shouldRun = true;
363
+ private backgroundIndexing: () => Promise<void>;
364
+ private indexerLoader: () => Promise<{ indexEntityFile: (path: string) => Promise<void> }>;
365
+
366
+ constructor(options: DaemonOptions = {}) {
367
+ this.backgroundIndexing = options.backgroundIndexing ?? defaultBackgroundIndexing;
368
+ this.indexerLoader = options.indexerLoader ?? loadIndexer;
369
+ }
370
+
371
+ async start() {
372
+ log("Starting macrodata local daemon");
373
+ log(`State root: ${getStateRoot()}`);
374
+
375
+ // Check if already running
376
+ ensureDirectories();
377
+ const pidFile = getPidFile();
378
+ if (existsSync(pidFile)) {
379
+ const existingPid = readFileSync(pidFile, "utf-8").trim();
380
+ try {
381
+ process.kill(parseInt(existingPid, 10), 0); // Check if process exists
382
+ log(`Daemon already running (PID ${existingPid}), exiting`);
383
+ process.exit(0);
384
+ } catch {
385
+ // Process doesn't exist, stale PID file - continue startup
386
+ log(`Removing stale PID file (was ${existingPid})`);
387
+ }
388
+ }
389
+
390
+ // Write PID file
391
+ writeFileSync(pidFile, process.pid.toString());
392
+
393
+ // Set up signal handlers
394
+ process.on("SIGTERM", () => this.shutdown("SIGTERM"));
395
+ process.on("SIGINT", () => this.shutdown("SIGINT"));
396
+ process.on("SIGHUP", () => this.reload());
397
+
398
+ // The daemon must be hard to stop: a failed child, watcher error, or
399
+ // rejected background promise should be logged, never fatal (#25).
400
+ process.on("unhandledRejection", (reason) => {
401
+ logError(`Unhandled rejection (daemon continues): ${String(reason)}`);
402
+ });
403
+ process.on("uncaughtException", (err) => {
404
+ logError(`Uncaught exception (daemon continues): ${String(err?.stack || err)}`);
405
+ });
406
+
407
+ // Preload embedding model and update conversation indexes in background
408
+ this.backgroundIndexing().catch((err) => logError(`Failed to preload/index: ${err}`));
409
+
410
+ // Load and start schedules
411
+ this.loadAndStartSchedules();
412
+
413
+ // Watch for schedule changes
414
+ this.watchRemindersDir();
415
+
416
+ // Start file watcher for entity changes
417
+ this.startFileWatcher();
418
+
419
+ // Heartbeat lets the plugin detect a dead/stale daemon and restart it
420
+ this.startHeartbeat();
421
+
422
+ // Keep process alive
423
+ log("Daemon running");
424
+ }
425
+
426
+ private startHeartbeat() {
427
+ const beat = () => {
428
+ try {
429
+ writeFileSync(getHeartbeatFile(), Date.now().toString());
430
+ } catch (err) {
431
+ logError(`Heartbeat write failed: ${String(err)}`);
432
+ }
433
+ };
434
+ beat();
435
+ this.heartbeatTimer = setInterval(beat, HEARTBEAT_INTERVAL_MS);
436
+ }
437
+
438
+ private watchRemindersDir() {
439
+ const remindersDir = getRemindersDir();
440
+ log(`Watching for reminders in: ${remindersDir}`);
441
+
442
+ this.schedulesWatcher = watch(remindersDir, {
443
+ ignoreInitial: true,
444
+ awaitWriteFinish: { stabilityThreshold: 100 },
445
+ });
446
+
447
+ this.schedulesWatcher.on("add", (path) => {
448
+ if (!path.endsWith(".json")) return;
449
+ log(`Reminder added: ${basename(path)}`);
450
+ this.reloadSchedules();
451
+ try {
452
+ const schedule = JSON.parse(readFileSync(path, "utf-8")) as Schedule;
453
+ writePendingContext(
454
+ `<macrodata-update type="schedule-added" id="${schedule.id}">${schedule.description}</macrodata-update>`,
455
+ );
456
+ } catch {
457
+ // Ignore unreadable/malformed reminder writes
458
+ }
459
+ });
460
+
461
+ this.schedulesWatcher.on("error", (err) => {
462
+ logError(`Reminders watcher error: ${String(err)}`);
463
+ });
464
+
465
+ this.schedulesWatcher.on("change", (path) => {
466
+ if (!path.endsWith(".json")) return;
467
+ log(`Reminder changed: ${basename(path)}`);
468
+ this.reloadSchedules();
469
+ try {
470
+ const schedule = JSON.parse(readFileSync(path, "utf-8")) as Schedule;
471
+ writePendingContext(
472
+ `<macrodata-update type="schedule-updated" id="${schedule.id}">${schedule.description}</macrodata-update>`,
473
+ );
474
+ } catch {
475
+ // Ignore unreadable/malformed reminder writes
476
+ }
477
+ });
478
+
479
+ this.schedulesWatcher.on("unlink", (path) => this.onReminderUnlinked(path));
480
+ }
481
+
482
+ private onReminderUnlinked(path: string) {
483
+ if (!path.endsWith(".json")) return;
484
+ const id = basename(path, ".json");
485
+ log(`Reminder removed: ${id}`);
486
+ writePendingContext(`<macrodata-update type="schedule-removed" id="${id}" />`);
487
+ if (this.cronJobs.has(id)) {
488
+ this.stopJob(id);
489
+ log(`Stopped job: ${id}`);
490
+ }
491
+ }
492
+
493
+ private scheduleFor(schedule: Schedule) {
494
+ if (schedule.type === "cron") {
495
+ this.startCronJob(schedule);
496
+ return;
497
+ }
498
+ if (schedule.type === "once") {
499
+ if (new Date(schedule.expression).getTime() > Date.now()) {
500
+ this.startOnceJob(schedule);
501
+ } else {
502
+ log(`Skipping expired one-shot: ${schedule.id}`);
503
+ this.removeSchedule(schedule.id);
504
+ }
505
+ }
506
+ }
507
+
508
+ private stopJob(id: string) {
509
+ const job = this.cronJobs.get(id);
510
+ if (job) {
511
+ job.stop();
512
+ this.cronJobs.delete(id);
513
+ }
514
+ }
515
+
516
+ reloadSchedules() {
517
+ const schedules = loadAllSchedules();
518
+ const currentIds = new Set(this.cronJobs.keys());
519
+
520
+ for (const schedule of schedules) {
521
+ if (currentIds.has(schedule.id)) {
522
+ currentIds.delete(schedule.id);
523
+ continue;
524
+ }
525
+ this.scheduleFor(schedule);
526
+ }
527
+
528
+ for (const id of currentIds) {
529
+ this.stopJob(id);
530
+ log(`Stopped removed job: ${id}`);
531
+ }
532
+ }
533
+
534
+ loadAndStartSchedules() {
535
+ for (const schedule of loadAllSchedules()) {
536
+ this.scheduleFor(schedule);
537
+ }
538
+ }
539
+
540
+ private startCronJob(schedule: Schedule) {
541
+ try {
542
+ const job = new Cron(schedule.expression, () => {
543
+ void this.fireSchedule(schedule);
544
+ });
545
+ this.cronJobs.set(schedule.id, job);
546
+ log(`Started cron job: ${schedule.id} (${schedule.expression})`);
547
+ } catch (err) {
548
+ logError(`Failed to start cron job ${schedule.id}: ${String(err)}`);
549
+ }
550
+ }
551
+
552
+ private startOnceJob(schedule: Schedule) {
553
+ try {
554
+ const fireTime = new Date(schedule.expression);
555
+ const job = new Cron(fireTime, () => {
556
+ void this.fireSchedule(schedule);
557
+ // Remove one-shot after firing
558
+ this.removeSchedule(schedule.id);
559
+ });
560
+ this.cronJobs.set(schedule.id, job);
561
+ log(`Scheduled one-shot: ${schedule.id} at ${schedule.expression}`);
562
+ } catch (err) {
563
+ log(`Failed to schedule one-shot ${schedule.id}: ${String(err)}`);
564
+ }
565
+ }
566
+
567
+ async fireSchedule(schedule: Schedule) {
568
+ log(`Firing schedule: ${schedule.id} - ${schedule.description}`);
569
+
570
+ // Trigger the agent specified in the schedule
571
+ const triggered = await triggerAgent(schedule.agent, schedule.payload, {
572
+ model: schedule.model,
573
+ description: schedule.description,
574
+ });
575
+
576
+ if (triggered) {
577
+ log(`Successfully triggered ${schedule.agent} for: ${schedule.id}`);
578
+ } else if (schedule.agent) {
579
+ log(`Failed to trigger ${schedule.agent} for: ${schedule.id}`);
580
+ } else {
581
+ log(`No agent specified for: ${schedule.id} (pending context written)`);
582
+ }
583
+ }
584
+
585
+ addSchedule(schedule: Schedule) {
586
+ // Save to individual file
587
+ saveSchedule(schedule);
588
+
589
+ // Start the job
590
+ if (schedule.type === "cron") {
591
+ this.startCronJob(schedule);
592
+ } else {
593
+ this.startOnceJob(schedule);
594
+ }
595
+ }
596
+
597
+ removeSchedule(id: string) {
598
+ this.stopJob(id);
599
+ deleteScheduleFile(id);
600
+ log(`Removed schedule: ${id}`);
601
+ }
602
+
603
+ private startFileWatcher() {
604
+ const stateRoot = getStateRoot();
605
+ const entitiesDir = getEntitiesDir();
606
+ const stateDir = join(stateRoot, "state");
607
+
608
+ // Watch both state files and entities
609
+ this.watcher = watch([stateDir, entitiesDir], {
610
+ ignoreInitial: true,
611
+ persistent: true,
612
+ });
613
+
614
+ this.watcher.on("all", (event, path) => this.onWatchedFileEvent(event, path));
615
+
616
+ log(`Watching for state/entity changes in: ${stateRoot}`);
617
+ }
618
+
619
+ onWatchedFileEvent(event: string, path: string) {
620
+ if (!path.endsWith(".md")) return;
621
+ if (event !== "add" && event !== "change") return;
622
+
623
+ log(`File ${event}: ${path}`);
624
+
625
+ const stateDir = join(getStateRoot(), "state");
626
+ const entitiesDir = getEntitiesDir();
627
+
628
+ if (path.startsWith(stateDir)) {
629
+ this.injectStateFileDelta(path);
630
+ } else if (path.startsWith(entitiesDir)) {
631
+ const relative = path.slice(entitiesDir.length + 1);
632
+ writePendingContext(`<macrodata-update type="entity" file="${relative}" />`);
633
+ this.queueReindex(path);
634
+ }
635
+ }
636
+
637
+ private injectStateFileDelta(path: string) {
638
+ try {
639
+ const raw = readFileSync(path, "utf-8");
640
+ const cap = 4000;
641
+ // Cap the injected delta so a mid-session write can't blow the budget.
642
+ const content =
643
+ raw.length > cap
644
+ ? `${raw.slice(0, cap)}\n[…truncated: ${cap} of ${raw.length} chars. This file is over budget — compact it.]`
645
+ : raw;
646
+ writePendingContext(
647
+ `<macrodata-update type="state" file="${basename(path)}">\n${content}\n</macrodata-update>`,
648
+ );
649
+ } catch {
650
+ // Ignore unreadable state file
651
+ }
652
+ }
653
+
654
+ private reindexQueue: Set<string> = new Set();
655
+ private reindexTimer: ReturnType<typeof setTimeout> | null = null;
656
+
657
+ queueReindex(path: string) {
658
+ this.reindexQueue.add(path);
659
+
660
+ // Debounce: wait 1 second for more changes before reindexing
661
+ if (this.reindexTimer) {
662
+ clearTimeout(this.reindexTimer);
663
+ }
664
+ this.reindexTimer = setTimeout(() => {
665
+ void this.processReindexQueue();
666
+ }, 1000);
667
+ }
668
+
669
+ async processReindexQueue() {
670
+ if (this.reindexQueue.size === 0) return;
671
+
672
+ const paths = Array.from(this.reindexQueue);
673
+ this.reindexQueue.clear();
674
+
675
+ log(`Reindexing ${paths.length} file(s)`);
676
+ const indexer = await this.indexerLoader();
677
+ for (const path of paths) {
678
+ try {
679
+ await indexer.indexEntityFile(path);
680
+ log(` ✓ ${basename(path)}`);
681
+ } catch (err) {
682
+ log(` ✗ ${basename(path)}: ${String(err)}`);
683
+ }
684
+ }
685
+ }
686
+
687
+ reload() {
688
+ log("Reloading config (SIGHUP)");
689
+ log(`New state root: ${getStateRoot()}`);
690
+
691
+ // Stop existing watchers
692
+ if (this.watcher) {
693
+ void this.watcher.close();
694
+ this.watcher = null;
695
+ }
696
+ if (this.schedulesWatcher) {
697
+ void this.schedulesWatcher.close();
698
+ this.schedulesWatcher = null;
699
+ }
700
+
701
+ // Stop all cron jobs
702
+ for (const [, job] of this.cronJobs) {
703
+ job.stop();
704
+ }
705
+ this.cronJobs.clear();
706
+
707
+ // Ensure directories exist with new paths
708
+ ensureDirectories();
709
+
710
+ // Restart everything with new paths
711
+ this.loadAndStartSchedules();
712
+ this.watchRemindersDir();
713
+ this.startFileWatcher();
714
+
715
+ log("Reload complete");
716
+ }
717
+
718
+ shutdown(signal: string) {
719
+ log(`Shutting down (${signal})`);
720
+ this.shouldRun = false;
721
+
722
+ if (this.heartbeatTimer) {
723
+ clearInterval(this.heartbeatTimer);
724
+ this.heartbeatTimer = null;
725
+ }
726
+
727
+ // Stop all cron jobs
728
+ for (const [, job] of this.cronJobs) {
729
+ job.stop();
730
+ }
731
+ this.cronJobs.clear();
732
+
733
+ // Stop file watchers
734
+ if (this.watcher) {
735
+ void this.watcher.close();
736
+ this.watcher = null;
737
+ }
738
+ if (this.schedulesWatcher) {
739
+ void this.schedulesWatcher.close();
740
+ this.schedulesWatcher = null;
741
+ }
742
+
743
+ // Clean up PID file
744
+ try {
745
+ const pidFile = getPidFile();
746
+ if (existsSync(pidFile)) {
747
+ const pid = readFileSync(pidFile, "utf-8").trim();
748
+ if (pid === process.pid.toString()) {
749
+ unlinkSync(pidFile);
750
+ }
751
+ }
752
+ } catch {
753
+ // Ignore cleanup errors
754
+ }
755
+
756
+ process.exit(0);
757
+ }
758
+
759
+ /** Test-only accessor: whether the daemon considers itself running. */
760
+ get running(): boolean {
761
+ return this.shouldRun;
762
+ }
763
+
764
+ /** Test-only accessor: number of active cron jobs. */
765
+ get jobCount(): number {
766
+ return this.cronJobs.size;
767
+ }
768
+
769
+ /** Test-only accessor: the reminders-directory watcher, for error injection. */
770
+ get remindersWatcher(): ReturnType<typeof watch> | null {
771
+ return this.schedulesWatcher;
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Entrypoint used by bin/macrodata-daemon.ts.
777
+ */
778
+ export function runDaemon(): MacrodataLocalDaemon {
779
+ const daemon = new MacrodataLocalDaemon();
780
+ daemon.start().catch((err) => {
781
+ log(`Fatal error: ${err}`);
782
+ process.exit(1);
783
+ });
784
+ return daemon;
785
+ }