@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.
- package/LICENSE +22 -0
- package/README.md +152 -0
- package/USAGE.md +59 -0
- package/bin/detect-user.sh +53 -0
- package/bin/index-conversations.ts +34 -0
- package/bin/macrodata-daemon.ts +28 -0
- package/bin/macrodata-hook.sh +277 -0
- package/dist/bin/index-conversations.js +31 -0
- package/dist/bin/macrodata-daemon.js +30 -0
- package/dist/opencode/context.js +210 -0
- package/dist/opencode/conversations.js +367 -0
- package/dist/opencode/index.js +155 -0
- package/dist/opencode/journal.js +108 -0
- package/dist/opencode/logger.js +29 -0
- package/dist/opencode/search.js +210 -0
- package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
- package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
- package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
- package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
- package/dist/opencode/tools.js +367 -0
- package/dist/src/config.js +55 -0
- package/dist/src/conversations.js +513 -0
- package/dist/src/daemon.js +582 -0
- package/dist/src/detect-user.js +73 -0
- package/dist/src/embeddings.js +190 -0
- package/dist/src/index.js +413 -0
- package/dist/src/indexer.js +286 -0
- package/opencode/context.ts +322 -0
- package/opencode/conversations.ts +467 -0
- package/opencode/index.ts +208 -0
- package/opencode/journal.ts +153 -0
- package/opencode/logger.ts +32 -0
- package/opencode/search.ts +288 -0
- package/opencode/skills/macrodata-distill/SKILL.md +171 -0
- package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
- package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
- package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
- package/opencode/tools.ts +453 -0
- package/package.json +87 -0
- package/src/config.ts +66 -0
- package/src/conversations.ts +709 -0
- package/src/daemon.ts +785 -0
- package/src/detect-user.ts +97 -0
- package/src/embeddings.ts +262 -0
- package/src/index.ts +726 -0
- 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
|
+
}
|