@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
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { getEntitiesDir, getIndexDir, getJournalDir, getRemindersDir, getStateRoot } from "./config.js";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { basename, join } from "path";
|
|
4
|
+
import { execSync, spawn } from "child_process";
|
|
5
|
+
import { watch } from "chokidar";
|
|
6
|
+
import { Cron } from "croner";
|
|
7
|
+
|
|
8
|
+
//#region src/daemon.ts
|
|
9
|
+
/**
|
|
10
|
+
* Macrodata Local Daemon (core logic)
|
|
11
|
+
*
|
|
12
|
+
* Handles scheduled tasks, file watching for index updates, and triggers
|
|
13
|
+
* Claude Code or OpenCode via CLI when reminders fire.
|
|
14
|
+
*
|
|
15
|
+
* The `bin/macrodata-daemon.ts` entry is a thin wrapper around `runDaemon()`;
|
|
16
|
+
* all logic lives here so it can be imported and unit-tested directly instead
|
|
17
|
+
* of only through a spawned child process.
|
|
18
|
+
*
|
|
19
|
+
* Environment:
|
|
20
|
+
* MACRODATA_AGENT=opencode|claude (default: auto-detect)
|
|
21
|
+
* MACRODATA_ROOT=/path/to/state
|
|
22
|
+
* MACRODATA_CHILD_TIMEOUT_MS=<ms> (default: 10 minutes)
|
|
23
|
+
*/
|
|
24
|
+
async function loadIndexer() {
|
|
25
|
+
return import("./indexer.js");
|
|
26
|
+
}
|
|
27
|
+
async function loadConversationIndexers() {
|
|
28
|
+
const [oc, cc] = await Promise.all([import("../opencode/conversations.js"), import("./conversations.js")]);
|
|
29
|
+
return {
|
|
30
|
+
updateOpenCodeConversations: oc.updateConversationIndex,
|
|
31
|
+
updateClaudeCodeConversations: cc.updateConversationIndex
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Find an executable in PATH
|
|
36
|
+
*/
|
|
37
|
+
async function findExecutable(name) {
|
|
38
|
+
try {
|
|
39
|
+
const result = execSync(`which ${name}`, { encoding: "utf-8" }).trim();
|
|
40
|
+
return result || null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function getDaemonDir() {
|
|
46
|
+
return getStateRoot();
|
|
47
|
+
}
|
|
48
|
+
function getPidFile() {
|
|
49
|
+
return join(getDaemonDir(), ".daemon.pid");
|
|
50
|
+
}
|
|
51
|
+
function getLogFile() {
|
|
52
|
+
return join(getDaemonDir(), ".daemon.log");
|
|
53
|
+
}
|
|
54
|
+
function getPendingContext() {
|
|
55
|
+
return join(getStateRoot(), ".pending-context");
|
|
56
|
+
}
|
|
57
|
+
function getHeartbeatFile() {
|
|
58
|
+
return join(getDaemonDir(), ".daemon.heartbeat");
|
|
59
|
+
}
|
|
60
|
+
const HEARTBEAT_INTERVAL_MS = 6e4;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the per-child hard timeout from the environment. Re-read each call so
|
|
63
|
+
* tests can flip MACRODATA_CHILD_TIMEOUT_MS without reloading the module.
|
|
64
|
+
*/
|
|
65
|
+
function getChildTimeoutMs() {
|
|
66
|
+
const raw = process.env.MACRODATA_CHILD_TIMEOUT_MS;
|
|
67
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
68
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 6e4;
|
|
69
|
+
}
|
|
70
|
+
function log(message) {
|
|
71
|
+
const ts = new Date().toISOString();
|
|
72
|
+
const line = `[${ts}] ${message}\n`;
|
|
73
|
+
appendFileSync(getLogFile(), line);
|
|
74
|
+
}
|
|
75
|
+
function logError(message) {
|
|
76
|
+
const ts = new Date().toISOString();
|
|
77
|
+
const line = `[${ts}] ERROR: ${message}\n`;
|
|
78
|
+
appendFileSync(getLogFile(), line);
|
|
79
|
+
}
|
|
80
|
+
function writePendingContext(message) {
|
|
81
|
+
try {
|
|
82
|
+
appendFileSync(getPendingContext(), message + "\n");
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logError(`Failed to write pending context: ${String(err)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Trigger an agent with a message
|
|
89
|
+
*/
|
|
90
|
+
async function triggerAgent(agent, message, options = {}) {
|
|
91
|
+
if (!agent) {
|
|
92
|
+
log("No agent specified in schedule, skipping trigger");
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const timestamp = new Date().toLocaleString();
|
|
96
|
+
const fullMessage = `[Scheduled reminder: ${options.description || "reminder"}]
|
|
97
|
+
Current time: ${timestamp}
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
${message}`;
|
|
102
|
+
try {
|
|
103
|
+
if (agent === "opencode") {
|
|
104
|
+
const args = ["run", fullMessage];
|
|
105
|
+
if (options.model) {
|
|
106
|
+
args.push("--model", options.model);
|
|
107
|
+
}
|
|
108
|
+
const opencodePath = await findExecutable("opencode") || "npx";
|
|
109
|
+
const finalArgs = opencodePath === "npx" ? ["opencode", ...args] : args;
|
|
110
|
+
log(`Triggering OpenCode: ${opencodePath} ${finalArgs.join(" ").substring(0, 50)}...`);
|
|
111
|
+
spawnSupervisedChild(opencodePath, finalArgs, "opencode");
|
|
112
|
+
return true;
|
|
113
|
+
} else {
|
|
114
|
+
const args = ["--print", fullMessage];
|
|
115
|
+
log(`Triggering Claude Code: claude --print "..."`);
|
|
116
|
+
spawnSupervisedChild("claude", args, "claude");
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logError(`Failed to trigger ${agent}: ${String(err)}`);
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Spawn an agent child process with a hard timeout so a hung child can never
|
|
126
|
+
* wedge the daemon's scheduling (#25). The child runs in its own process
|
|
127
|
+
* group; on timeout the whole group is killed and the daemon keeps running.
|
|
128
|
+
*/
|
|
129
|
+
function spawnSupervisedChild(command, args, label) {
|
|
130
|
+
const childTimeoutMs = getChildTimeoutMs();
|
|
131
|
+
const proc = spawn(command, args, {
|
|
132
|
+
stdio: [
|
|
133
|
+
"ignore",
|
|
134
|
+
"pipe",
|
|
135
|
+
"pipe"
|
|
136
|
+
],
|
|
137
|
+
detached: true,
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
PATH: process.env.PATH
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
proc.unref();
|
|
144
|
+
const killTimer = setTimeout(() => {
|
|
145
|
+
log(`[${label}] child exceeded ${childTimeoutMs}ms timeout, killing process group ${proc.pid}`);
|
|
146
|
+
if (proc.pid) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
149
|
+
} catch {
|
|
150
|
+
try {
|
|
151
|
+
proc.kill("SIGKILL");
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}, childTimeoutMs);
|
|
156
|
+
killTimer.unref();
|
|
157
|
+
proc.stdout?.on("data", (data) => {
|
|
158
|
+
log(`[${label} stdout] ${data.toString().trim()}`);
|
|
159
|
+
});
|
|
160
|
+
proc.stderr?.on("data", (data) => {
|
|
161
|
+
log(`[${label} stderr] ${data.toString().trim()}`);
|
|
162
|
+
});
|
|
163
|
+
proc.on("error", (err) => {
|
|
164
|
+
clearTimeout(killTimer);
|
|
165
|
+
logError(`[${label}] child process error: ${String(err)}`);
|
|
166
|
+
});
|
|
167
|
+
proc.on("exit", (code, signal) => {
|
|
168
|
+
clearTimeout(killTimer);
|
|
169
|
+
log(`[${label}] child exited (code=${code}, signal=${signal})`);
|
|
170
|
+
});
|
|
171
|
+
return proc;
|
|
172
|
+
}
|
|
173
|
+
function ensureDirectories() {
|
|
174
|
+
const entitiesDir = getEntitiesDir();
|
|
175
|
+
const dirs = [
|
|
176
|
+
getDaemonDir(),
|
|
177
|
+
getStateRoot(),
|
|
178
|
+
getIndexDir(),
|
|
179
|
+
entitiesDir,
|
|
180
|
+
getJournalDir(),
|
|
181
|
+
getRemindersDir(),
|
|
182
|
+
join(entitiesDir, "people"),
|
|
183
|
+
join(entitiesDir, "projects")
|
|
184
|
+
];
|
|
185
|
+
for (const dir of dirs) {
|
|
186
|
+
if (!existsSync(dir)) {
|
|
187
|
+
mkdirSync(dir, { recursive: true });
|
|
188
|
+
log(`Created directory: ${dir}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function updateAllConversationIndexes(loaders = loadConversationIndexers) {
|
|
193
|
+
const { updateClaudeCodeConversations, updateOpenCodeConversations } = await loaders();
|
|
194
|
+
try {
|
|
195
|
+
const claude = await updateClaudeCodeConversations();
|
|
196
|
+
if (claude.filesUpdated > 0) {
|
|
197
|
+
log(`Claude Code conversations: +${claude.filesUpdated} files (${claude.exchangeCount} total)`);
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
logError(`Claude Code conversation index failed: ${String(err)}`);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const opencode = await updateOpenCodeConversations();
|
|
204
|
+
if (opencode.newCount > 0) {
|
|
205
|
+
log(`OpenCode conversations: +${opencode.newCount} (${opencode.totalCount} total)`);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logError(`OpenCode conversation index failed: ${String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function loadAllSchedules() {
|
|
212
|
+
const remindersDir = getRemindersDir();
|
|
213
|
+
const schedules = [];
|
|
214
|
+
try {
|
|
215
|
+
if (!existsSync(remindersDir)) return schedules;
|
|
216
|
+
const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
try {
|
|
219
|
+
const content = readFileSync(join(remindersDir, file), "utf-8");
|
|
220
|
+
const schedule = JSON.parse(content);
|
|
221
|
+
schedules.push(schedule);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logError(`Failed to load schedule ${file}: ${String(err)}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logError(`Failed to read reminders directory: ${String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
return schedules;
|
|
230
|
+
}
|
|
231
|
+
function saveSchedule(schedule) {
|
|
232
|
+
const remindersDir = getRemindersDir();
|
|
233
|
+
const filePath = join(remindersDir, `${schedule.id}.json`);
|
|
234
|
+
try {
|
|
235
|
+
writeFileSync(filePath, JSON.stringify(schedule, null, 2));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
logError(`Failed to save schedule ${schedule.id}: ${String(err)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function deleteScheduleFile(id) {
|
|
241
|
+
const remindersDir = getRemindersDir();
|
|
242
|
+
const filePath = join(remindersDir, `${id}.json`);
|
|
243
|
+
try {
|
|
244
|
+
if (existsSync(filePath)) {
|
|
245
|
+
unlinkSync(filePath);
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logError(`Failed to delete schedule file ${id}: ${String(err)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function defaultBackgroundIndexing(deps = {}) {
|
|
252
|
+
const load = deps.loadIndexer ?? loadIndexer;
|
|
253
|
+
const updateAll = deps.updateAll ?? updateAllConversationIndexes;
|
|
254
|
+
const indexer = await load();
|
|
255
|
+
await indexer.preloadModel();
|
|
256
|
+
log("Embedding model preloaded");
|
|
257
|
+
await updateAll();
|
|
258
|
+
}
|
|
259
|
+
var MacrodataLocalDaemon = class {
|
|
260
|
+
cronJobs = new Map();
|
|
261
|
+
watcher = null;
|
|
262
|
+
schedulesWatcher = null;
|
|
263
|
+
heartbeatTimer = null;
|
|
264
|
+
shouldRun = true;
|
|
265
|
+
backgroundIndexing;
|
|
266
|
+
indexerLoader;
|
|
267
|
+
constructor(options = {}) {
|
|
268
|
+
this.backgroundIndexing = options.backgroundIndexing ?? defaultBackgroundIndexing;
|
|
269
|
+
this.indexerLoader = options.indexerLoader ?? loadIndexer;
|
|
270
|
+
}
|
|
271
|
+
async start() {
|
|
272
|
+
log("Starting macrodata local daemon");
|
|
273
|
+
log(`State root: ${getStateRoot()}`);
|
|
274
|
+
ensureDirectories();
|
|
275
|
+
const pidFile = getPidFile();
|
|
276
|
+
if (existsSync(pidFile)) {
|
|
277
|
+
const existingPid = readFileSync(pidFile, "utf-8").trim();
|
|
278
|
+
try {
|
|
279
|
+
process.kill(parseInt(existingPid, 10), 0);
|
|
280
|
+
log(`Daemon already running (PID ${existingPid}), exiting`);
|
|
281
|
+
process.exit(0);
|
|
282
|
+
} catch {
|
|
283
|
+
log(`Removing stale PID file (was ${existingPid})`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
writeFileSync(pidFile, process.pid.toString());
|
|
287
|
+
process.on("SIGTERM", () => this.shutdown("SIGTERM"));
|
|
288
|
+
process.on("SIGINT", () => this.shutdown("SIGINT"));
|
|
289
|
+
process.on("SIGHUP", () => this.reload());
|
|
290
|
+
process.on("unhandledRejection", (reason) => {
|
|
291
|
+
logError(`Unhandled rejection (daemon continues): ${String(reason)}`);
|
|
292
|
+
});
|
|
293
|
+
process.on("uncaughtException", (err) => {
|
|
294
|
+
logError(`Uncaught exception (daemon continues): ${String(err?.stack || err)}`);
|
|
295
|
+
});
|
|
296
|
+
this.backgroundIndexing().catch((err) => logError(`Failed to preload/index: ${err}`));
|
|
297
|
+
this.loadAndStartSchedules();
|
|
298
|
+
this.watchRemindersDir();
|
|
299
|
+
this.startFileWatcher();
|
|
300
|
+
this.startHeartbeat();
|
|
301
|
+
log("Daemon running");
|
|
302
|
+
}
|
|
303
|
+
startHeartbeat() {
|
|
304
|
+
const beat = () => {
|
|
305
|
+
try {
|
|
306
|
+
writeFileSync(getHeartbeatFile(), Date.now().toString());
|
|
307
|
+
} catch (err) {
|
|
308
|
+
logError(`Heartbeat write failed: ${String(err)}`);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
beat();
|
|
312
|
+
this.heartbeatTimer = setInterval(beat, HEARTBEAT_INTERVAL_MS);
|
|
313
|
+
}
|
|
314
|
+
watchRemindersDir() {
|
|
315
|
+
const remindersDir = getRemindersDir();
|
|
316
|
+
log(`Watching for reminders in: ${remindersDir}`);
|
|
317
|
+
this.schedulesWatcher = watch(remindersDir, {
|
|
318
|
+
ignoreInitial: true,
|
|
319
|
+
awaitWriteFinish: { stabilityThreshold: 100 }
|
|
320
|
+
});
|
|
321
|
+
this.schedulesWatcher.on("add", (path) => {
|
|
322
|
+
if (!path.endsWith(".json")) return;
|
|
323
|
+
log(`Reminder added: ${basename(path)}`);
|
|
324
|
+
this.reloadSchedules();
|
|
325
|
+
try {
|
|
326
|
+
const schedule = JSON.parse(readFileSync(path, "utf-8"));
|
|
327
|
+
writePendingContext(`<macrodata-update type="schedule-added" id="${schedule.id}">${schedule.description}</macrodata-update>`);
|
|
328
|
+
} catch {}
|
|
329
|
+
});
|
|
330
|
+
this.schedulesWatcher.on("error", (err) => {
|
|
331
|
+
logError(`Reminders watcher error: ${String(err)}`);
|
|
332
|
+
});
|
|
333
|
+
this.schedulesWatcher.on("change", (path) => {
|
|
334
|
+
if (!path.endsWith(".json")) return;
|
|
335
|
+
log(`Reminder changed: ${basename(path)}`);
|
|
336
|
+
this.reloadSchedules();
|
|
337
|
+
try {
|
|
338
|
+
const schedule = JSON.parse(readFileSync(path, "utf-8"));
|
|
339
|
+
writePendingContext(`<macrodata-update type="schedule-updated" id="${schedule.id}">${schedule.description}</macrodata-update>`);
|
|
340
|
+
} catch {}
|
|
341
|
+
});
|
|
342
|
+
this.schedulesWatcher.on("unlink", (path) => this.onReminderUnlinked(path));
|
|
343
|
+
}
|
|
344
|
+
onReminderUnlinked(path) {
|
|
345
|
+
if (!path.endsWith(".json")) return;
|
|
346
|
+
const id = basename(path, ".json");
|
|
347
|
+
log(`Reminder removed: ${id}`);
|
|
348
|
+
writePendingContext(`<macrodata-update type="schedule-removed" id="${id}" />`);
|
|
349
|
+
if (this.cronJobs.has(id)) {
|
|
350
|
+
this.stopJob(id);
|
|
351
|
+
log(`Stopped job: ${id}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
scheduleFor(schedule) {
|
|
355
|
+
if (schedule.type === "cron") {
|
|
356
|
+
this.startCronJob(schedule);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (schedule.type === "once") {
|
|
360
|
+
if (new Date(schedule.expression).getTime() > Date.now()) {
|
|
361
|
+
this.startOnceJob(schedule);
|
|
362
|
+
} else {
|
|
363
|
+
log(`Skipping expired one-shot: ${schedule.id}`);
|
|
364
|
+
this.removeSchedule(schedule.id);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
stopJob(id) {
|
|
369
|
+
const job = this.cronJobs.get(id);
|
|
370
|
+
if (job) {
|
|
371
|
+
job.stop();
|
|
372
|
+
this.cronJobs.delete(id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
reloadSchedules() {
|
|
376
|
+
const schedules = loadAllSchedules();
|
|
377
|
+
const currentIds = new Set(this.cronJobs.keys());
|
|
378
|
+
for (const schedule of schedules) {
|
|
379
|
+
if (currentIds.has(schedule.id)) {
|
|
380
|
+
currentIds.delete(schedule.id);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
this.scheduleFor(schedule);
|
|
384
|
+
}
|
|
385
|
+
for (const id of currentIds) {
|
|
386
|
+
this.stopJob(id);
|
|
387
|
+
log(`Stopped removed job: ${id}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
loadAndStartSchedules() {
|
|
391
|
+
for (const schedule of loadAllSchedules()) {
|
|
392
|
+
this.scheduleFor(schedule);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
startCronJob(schedule) {
|
|
396
|
+
try {
|
|
397
|
+
const job = new Cron(schedule.expression, () => {
|
|
398
|
+
void this.fireSchedule(schedule);
|
|
399
|
+
});
|
|
400
|
+
this.cronJobs.set(schedule.id, job);
|
|
401
|
+
log(`Started cron job: ${schedule.id} (${schedule.expression})`);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
logError(`Failed to start cron job ${schedule.id}: ${String(err)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
startOnceJob(schedule) {
|
|
407
|
+
try {
|
|
408
|
+
const fireTime = new Date(schedule.expression);
|
|
409
|
+
const job = new Cron(fireTime, () => {
|
|
410
|
+
void this.fireSchedule(schedule);
|
|
411
|
+
this.removeSchedule(schedule.id);
|
|
412
|
+
});
|
|
413
|
+
this.cronJobs.set(schedule.id, job);
|
|
414
|
+
log(`Scheduled one-shot: ${schedule.id} at ${schedule.expression}`);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
log(`Failed to schedule one-shot ${schedule.id}: ${String(err)}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async fireSchedule(schedule) {
|
|
420
|
+
log(`Firing schedule: ${schedule.id} - ${schedule.description}`);
|
|
421
|
+
const triggered = await triggerAgent(schedule.agent, schedule.payload, {
|
|
422
|
+
model: schedule.model,
|
|
423
|
+
description: schedule.description
|
|
424
|
+
});
|
|
425
|
+
if (triggered) {
|
|
426
|
+
log(`Successfully triggered ${schedule.agent} for: ${schedule.id}`);
|
|
427
|
+
} else if (schedule.agent) {
|
|
428
|
+
log(`Failed to trigger ${schedule.agent} for: ${schedule.id}`);
|
|
429
|
+
} else {
|
|
430
|
+
log(`No agent specified for: ${schedule.id} (pending context written)`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
addSchedule(schedule) {
|
|
434
|
+
saveSchedule(schedule);
|
|
435
|
+
if (schedule.type === "cron") {
|
|
436
|
+
this.startCronJob(schedule);
|
|
437
|
+
} else {
|
|
438
|
+
this.startOnceJob(schedule);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
removeSchedule(id) {
|
|
442
|
+
this.stopJob(id);
|
|
443
|
+
deleteScheduleFile(id);
|
|
444
|
+
log(`Removed schedule: ${id}`);
|
|
445
|
+
}
|
|
446
|
+
startFileWatcher() {
|
|
447
|
+
const stateRoot = getStateRoot();
|
|
448
|
+
const entitiesDir = getEntitiesDir();
|
|
449
|
+
const stateDir = join(stateRoot, "state");
|
|
450
|
+
this.watcher = watch([stateDir, entitiesDir], {
|
|
451
|
+
ignoreInitial: true,
|
|
452
|
+
persistent: true
|
|
453
|
+
});
|
|
454
|
+
this.watcher.on("all", (event, path) => this.onWatchedFileEvent(event, path));
|
|
455
|
+
log(`Watching for state/entity changes in: ${stateRoot}`);
|
|
456
|
+
}
|
|
457
|
+
onWatchedFileEvent(event, path) {
|
|
458
|
+
if (!path.endsWith(".md")) return;
|
|
459
|
+
if (event !== "add" && event !== "change") return;
|
|
460
|
+
log(`File ${event}: ${path}`);
|
|
461
|
+
const stateDir = join(getStateRoot(), "state");
|
|
462
|
+
const entitiesDir = getEntitiesDir();
|
|
463
|
+
if (path.startsWith(stateDir)) {
|
|
464
|
+
this.injectStateFileDelta(path);
|
|
465
|
+
} else if (path.startsWith(entitiesDir)) {
|
|
466
|
+
const relative = path.slice(entitiesDir.length + 1);
|
|
467
|
+
writePendingContext(`<macrodata-update type="entity" file="${relative}" />`);
|
|
468
|
+
this.queueReindex(path);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
injectStateFileDelta(path) {
|
|
472
|
+
try {
|
|
473
|
+
const raw = readFileSync(path, "utf-8");
|
|
474
|
+
const cap = 4e3;
|
|
475
|
+
const content = raw.length > cap ? `${raw.slice(0, cap)}\n[…truncated: ${cap} of ${raw.length} chars. This file is over budget — compact it.]` : raw;
|
|
476
|
+
writePendingContext(`<macrodata-update type="state" file="${basename(path)}">\n${content}\n</macrodata-update>`);
|
|
477
|
+
} catch {}
|
|
478
|
+
}
|
|
479
|
+
reindexQueue = new Set();
|
|
480
|
+
reindexTimer = null;
|
|
481
|
+
queueReindex(path) {
|
|
482
|
+
this.reindexQueue.add(path);
|
|
483
|
+
if (this.reindexTimer) {
|
|
484
|
+
clearTimeout(this.reindexTimer);
|
|
485
|
+
}
|
|
486
|
+
this.reindexTimer = setTimeout(() => {
|
|
487
|
+
void this.processReindexQueue();
|
|
488
|
+
}, 1e3);
|
|
489
|
+
}
|
|
490
|
+
async processReindexQueue() {
|
|
491
|
+
if (this.reindexQueue.size === 0) return;
|
|
492
|
+
const paths = Array.from(this.reindexQueue);
|
|
493
|
+
this.reindexQueue.clear();
|
|
494
|
+
log(`Reindexing ${paths.length} file(s)`);
|
|
495
|
+
const indexer = await this.indexerLoader();
|
|
496
|
+
for (const path of paths) {
|
|
497
|
+
try {
|
|
498
|
+
await indexer.indexEntityFile(path);
|
|
499
|
+
log(` ✓ ${basename(path)}`);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
log(` ✗ ${basename(path)}: ${String(err)}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
reload() {
|
|
506
|
+
log("Reloading config (SIGHUP)");
|
|
507
|
+
log(`New state root: ${getStateRoot()}`);
|
|
508
|
+
if (this.watcher) {
|
|
509
|
+
void this.watcher.close();
|
|
510
|
+
this.watcher = null;
|
|
511
|
+
}
|
|
512
|
+
if (this.schedulesWatcher) {
|
|
513
|
+
void this.schedulesWatcher.close();
|
|
514
|
+
this.schedulesWatcher = null;
|
|
515
|
+
}
|
|
516
|
+
for (const [, job] of this.cronJobs) {
|
|
517
|
+
job.stop();
|
|
518
|
+
}
|
|
519
|
+
this.cronJobs.clear();
|
|
520
|
+
ensureDirectories();
|
|
521
|
+
this.loadAndStartSchedules();
|
|
522
|
+
this.watchRemindersDir();
|
|
523
|
+
this.startFileWatcher();
|
|
524
|
+
log("Reload complete");
|
|
525
|
+
}
|
|
526
|
+
shutdown(signal) {
|
|
527
|
+
log(`Shutting down (${signal})`);
|
|
528
|
+
this.shouldRun = false;
|
|
529
|
+
if (this.heartbeatTimer) {
|
|
530
|
+
clearInterval(this.heartbeatTimer);
|
|
531
|
+
this.heartbeatTimer = null;
|
|
532
|
+
}
|
|
533
|
+
for (const [, job] of this.cronJobs) {
|
|
534
|
+
job.stop();
|
|
535
|
+
}
|
|
536
|
+
this.cronJobs.clear();
|
|
537
|
+
if (this.watcher) {
|
|
538
|
+
void this.watcher.close();
|
|
539
|
+
this.watcher = null;
|
|
540
|
+
}
|
|
541
|
+
if (this.schedulesWatcher) {
|
|
542
|
+
void this.schedulesWatcher.close();
|
|
543
|
+
this.schedulesWatcher = null;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const pidFile = getPidFile();
|
|
547
|
+
if (existsSync(pidFile)) {
|
|
548
|
+
const pid = readFileSync(pidFile, "utf-8").trim();
|
|
549
|
+
if (pid === process.pid.toString()) {
|
|
550
|
+
unlinkSync(pidFile);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch {}
|
|
554
|
+
process.exit(0);
|
|
555
|
+
}
|
|
556
|
+
/** Test-only accessor: whether the daemon considers itself running. */
|
|
557
|
+
get running() {
|
|
558
|
+
return this.shouldRun;
|
|
559
|
+
}
|
|
560
|
+
/** Test-only accessor: number of active cron jobs. */
|
|
561
|
+
get jobCount() {
|
|
562
|
+
return this.cronJobs.size;
|
|
563
|
+
}
|
|
564
|
+
/** Test-only accessor: the reminders-directory watcher, for error injection. */
|
|
565
|
+
get remindersWatcher() {
|
|
566
|
+
return this.schedulesWatcher;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
/**
|
|
570
|
+
* Entrypoint used by bin/macrodata-daemon.ts.
|
|
571
|
+
*/
|
|
572
|
+
function runDaemon() {
|
|
573
|
+
const daemon = new MacrodataLocalDaemon();
|
|
574
|
+
daemon.start().catch((err) => {
|
|
575
|
+
log(`Fatal error: ${err}`);
|
|
576
|
+
process.exit(1);
|
|
577
|
+
});
|
|
578
|
+
return daemon;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
//#endregion
|
|
582
|
+
export { HEARTBEAT_INTERVAL_MS, MacrodataLocalDaemon, defaultBackgroundIndexing, deleteScheduleFile, ensureDirectories, findExecutable, getChildTimeoutMs, getDaemonDir, getHeartbeatFile, getLogFile, getPendingContext, getPidFile, loadAllSchedules, loadConversationIndexers, loadIndexer, log, logError, runDaemon, saveSchedule, spawnSupervisedChild, triggerAgent, updateAllConversationIndexes, writePendingContext };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
|
|
6
|
+
//#region src/detect-user.ts
|
|
7
|
+
/**
|
|
8
|
+
* Detect user information for onboarding
|
|
9
|
+
* Returns JSON with system, git, github, and code directory info
|
|
10
|
+
*/
|
|
11
|
+
function exec(cmd) {
|
|
12
|
+
try {
|
|
13
|
+
return execSync(cmd, {
|
|
14
|
+
encoding: "utf-8",
|
|
15
|
+
stdio: [
|
|
16
|
+
"pipe",
|
|
17
|
+
"pipe",
|
|
18
|
+
"pipe"
|
|
19
|
+
]
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function detectUser() {
|
|
26
|
+
const username = exec("whoami");
|
|
27
|
+
const fullName = exec("id -F") || exec(`getent passwd ${username} | cut -d: -f5 | cut -d, -f1`);
|
|
28
|
+
let timezone = "";
|
|
29
|
+
if (existsSync("/etc/timezone")) {
|
|
30
|
+
timezone = exec("cat /etc/timezone");
|
|
31
|
+
} else if (existsSync("/etc/localtime")) {
|
|
32
|
+
timezone = exec("readlink /etc/localtime | sed 's|.*/zoneinfo/||'");
|
|
33
|
+
}
|
|
34
|
+
const gitName = exec("git config --global user.name");
|
|
35
|
+
const gitEmail = exec("git config --global user.email");
|
|
36
|
+
let github = {};
|
|
37
|
+
const ghCheck = exec("command -v gh");
|
|
38
|
+
if (ghCheck) {
|
|
39
|
+
const ghJson = exec("gh api user --jq '{login: .login, name: .name, blog: .blog, bio: .bio}'");
|
|
40
|
+
if (ghJson) {
|
|
41
|
+
try {
|
|
42
|
+
github = JSON.parse(ghJson);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const home = homedir();
|
|
47
|
+
const possibleDirs = [
|
|
48
|
+
"Repos",
|
|
49
|
+
"repos",
|
|
50
|
+
"Code",
|
|
51
|
+
"code",
|
|
52
|
+
"Projects",
|
|
53
|
+
"projects",
|
|
54
|
+
"Developer",
|
|
55
|
+
"dev",
|
|
56
|
+
"src"
|
|
57
|
+
];
|
|
58
|
+
const codeDirs = possibleDirs.map((dir) => join(home, dir)).filter((dir) => existsSync(dir));
|
|
59
|
+
return {
|
|
60
|
+
username,
|
|
61
|
+
fullName,
|
|
62
|
+
timezone,
|
|
63
|
+
git: {
|
|
64
|
+
name: gitName,
|
|
65
|
+
email: gitEmail
|
|
66
|
+
},
|
|
67
|
+
github,
|
|
68
|
+
codeDirs
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { detectUser };
|