1/**
2 * Scheduler Core
3 *
4 * Timeline builder, completion tracker, reliability calculator.
5 * Reads metadata.schedules.date (ISO date) and metadata.schedules.reeffectTime (hours)
6 * from nodes. Never writes to schedules data. Writes completions to
7 * metadata.scheduler on each node.
8 */
9
10import log from "../../seed/log.js";
11import { getDescendantIds } from "../../seed/tree/treeFetch.js";
12
13// ── Dependencies (set by configure) ──
14
15let _Node = null;
16let _hooks = null;
17let _metadata = null;
18
19export function configure({ Node, hooks, metadata }) {
20 _Node = Node;
21 _hooks = hooks;
22 _metadata = metadata;
23}
24
25// ── Per-tree cached timelines ──
26
27const timelines = new Map(); // rootId -> { due, upcoming, overdue, lastScan }
28
29// ── Notified items (avoid duplicate notifications per cycle) ──
30
31const notified = new Set(); // "nodeId:scheduledFor" keys
32
33// ── Default config ──
34
35const DEFAULTS = {
36 lookaheadHours: 24,
37 overdueThresholdHours: 1,
38 suppressDuringAttention: true,
39 maxCompletions: 50,
40};
41
42/**
43 * Read scheduler config from the .config system node.
44 */
45async function getConfig(rootId) {
46 if (!_Node) return DEFAULTS;
47 try {
48 const configNode = await _Node.findOne({
49 parent: rootId,
50 name: ".config",
51 }).select("metadata").lean();
52 if (!configNode) return DEFAULTS;
53 const meta = configNode.metadata instanceof Map
54 ? configNode.metadata.get("scheduler")
55 : configNode.metadata?.scheduler;
56 return { ...DEFAULTS, ...(meta || {}) };
57 } catch {
58 return DEFAULTS;
59 }
60}
61
62// ── Timeline scanning ──
63
64/**
65 * Scan a tree for scheduled items. Categorize into due/upcoming/overdue.
66 * Uses getDescendantIds to find all nodes in the tree.
67 */
68export async function scanTree(rootId) {
69 if (!_Node || !rootId) return null;
70
71 const config = await getConfig(rootId);
72 const now = Date.now();
73 const overdueThreshold = config.overdueThresholdHours * 3600000;
74 const lookahead = config.lookaheadHours * 3600000;
75
76 // Find all nodes in this tree that have a schedule set
77 const descendantIds = await getDescendantIds(rootId, { maxResults: 10000 });
78 const allIds = [rootId, ...descendantIds];
79
80 const scheduledNodes = await _Node.find({
81 _id: { $in: allIds },
82 "metadata.schedules.date": { $exists: true, $ne: null },
83 }).select("_id name status metadata").lean();
84
85 const due = [];
86 const upcoming = [];
87 const overdue = [];
88
89 for (const node of scheduledNodes) {
90 const meta = node.metadata instanceof Map
91 ? Object.fromEntries(node.metadata)
92 : (node.metadata || {});
93
94 const rawSchedule = meta.schedules?.date;
95 if (!rawSchedule) continue;
96
97 const scheduledFor = new Date(rawSchedule).getTime();
98 if (isNaN(scheduledFor)) continue;
99
100 const delta = scheduledFor - now;
101 const isCompleted = node.status === "completed";
102 const nodeId = String(node._id);
103 const nodeName = node.name;
104
105 if (delta <= 0 && !isCompleted && Math.abs(delta) > overdueThreshold) {
106 overdue.push({
107 nodeId,
108 nodeName,
109 scheduledFor: new Date(scheduledFor).toISOString(),
110 daysOverdue: Math.round((Math.abs(delta) / 86400000) * 10) / 10,
111 });
112 } else if (delta <= 0 && !isCompleted) {
113 due.push({
114 nodeId,
115 nodeName,
116 scheduledFor: new Date(scheduledFor).toISOString(),
117 overdueSince: Math.round(Math.abs(delta) / 60000),
118 });
119 } else if (delta > 0 && delta <= lookahead && !isCompleted) {
120 upcoming.push({
121 nodeId,
122 nodeName,
123 scheduledFor: new Date(scheduledFor).toISOString(),
124 hoursUntil: Math.round((delta / 3600000) * 10) / 10,
125 });
126 }
127 }
128
129 // Sort: due by most overdue first, upcoming by soonest first
130 due.sort((a, b) => b.overdueSince - a.overdueSince);
131 upcoming.sort((a, b) => a.hoursUntil - b.hoursUntil);
132 overdue.sort((a, b) => b.daysOverdue - a.daysOverdue);
133
134 const timeline = { due, upcoming, overdue, lastScan: now };
135 timelines.set(rootId, timeline);
136
137 return timeline;
138}
139
140/**
141 * Get cached timeline for a tree. Returns null if never scanned.
142 */
143export function getCachedTimeline(rootId) {
144 return timelines.get(rootId) || null;
145}
146
147// ── Signaling ──
148
149/**
150 * Signal newly due items via notifications and gateway.
151 * Only fires once per item per cycle (deduped by notified set).
152 */
153export async function signalDueItems(rootId, timeline, userId) {
154 if (!timeline?.due?.length && !timeline?.overdue?.length) return;
155
156 const items = [...(timeline.due || []), ...(timeline.overdue || [])];
157 const newItems = items.filter(item => {
158 const key = `${item.nodeId}:${item.scheduledFor}`;
159 if (notified.has(key)) return false;
160 notified.add(key);
161 return true;
162 });
163
164 if (newItems.length === 0) return;
165
166 // Fire scheduler:itemDue hook for each new item
167 if (_hooks) {
168 for (const item of newItems) {
169 _hooks.run("scheduler:itemDue", {
170 rootId,
171 nodeId: item.nodeId,
172 nodeName: item.nodeName,
173 scheduledFor: item.scheduledFor,
174 userId,
175 }).catch(() => {});
176 }
177 }
178
179 // Notifications (persistent)
180 try {
181 const { getExtension } = await import("../loader.js");
182 const notifExt = getExtension("notifications");
183 if (notifExt?.exports?.Notification) {
184 for (const item of newItems) {
185 const isDue = item.overdueSince != null;
186 notifExt.exports.Notification.create({
187 userId,
188 rootId,
189 type: "schedule:due",
190 title: `${item.nodeName} is ${isDue ? "due now" : "overdue"}`,
191 content: `Scheduled for ${item.scheduledFor}`,
192 }).catch(() => {});
193 }
194 }
195 } catch {}
196
197 // Gateway (external channels)
198 try {
199 const { getExtension } = await import("../loader.js");
200 const gw = getExtension("gateway");
201 if (gw?.exports?.dispatchNotifications) {
202 const notifications = newItems.map(item => ({
203 type: "schedule:due",
204 title: `${item.nodeName} is ${item.overdueSince != null ? "due" : "overdue"}`,
205 content: `Scheduled for ${item.scheduledFor}`,
206 rootId,
207 }));
208 gw.exports.dispatchNotifications(rootId, notifications).catch(() => {});
209 }
210 } catch {}
211}
212
213// ── Completion tracking ──
214
215/**
216 * Record a completion when a scheduled node is marked completed.
217 * Writes to metadata.scheduler.completions[] on the node.
218 */
219export async function recordCompletion(node, scheduledFor) {
220 if (!_Node) return;
221
222 const nodeId = String(node._id || node);
223 const doc = node._id ? node : await _Node.findById(nodeId);
224 if (!doc) return;
225
226 const config = await getConfig(null); // maxCompletions is global
227 const existing = _metadata.getExtMeta(doc, "scheduler");
228 const completions = Array.isArray(existing.completions) ? existing.completions : [];
229
230 const scheduledTime = new Date(scheduledFor).getTime();
231 const completedAt = Date.now();
232 const deltaMinutes = Math.round((completedAt - scheduledTime) / 60000);
233
234 completions.push({ completedAt, scheduledFor: scheduledTime, deltaMinutes });
235
236 // Cap at maxCompletions
237 while (completions.length > config.maxCompletions) {
238 completions.shift();
239 }
240
241 await _metadata.setExtMeta(doc, "scheduler", { completions });
242
243 log.verbose("Scheduler", `Recorded completion for "${doc.name}": ${deltaMinutes > 0 ? "+" : ""}${deltaMinutes}min`);
244}
245
246// ── Reliability calculator ──
247
248/**
249 * Calculate reliability metrics from a completions array.
250 */
251export function calculateReliability(completions) {
252 if (!Array.isArray(completions) || completions.length === 0) {
253 return null;
254 }
255
256 const deltas = completions.map(c => c.deltaMinutes).filter(d => typeof d === "number");
257 if (deltas.length === 0) return null;
258
259 const averageDeltaMinutes = Math.round(deltas.reduce((a, b) => a + b, 0) / deltas.length);
260 const onTimeCount = deltas.filter(d => Math.abs(d) < 60).length;
261 const onTimeRate = Math.round((onTimeCount / deltas.length) * 100);
262
263 // Streak: consecutive on-time from most recent
264 let streak = 0;
265 for (let i = deltas.length - 1; i >= 0; i--) {
266 if (Math.abs(deltas[i]) < 60) streak++;
267 else break;
268 }
269
270 return {
271 averageDeltaMinutes,
272 onTimeRate,
273 streak,
274 totalCompletions: completions.length,
275 recentCompletions: completions.slice(-5).map(c => ({
276 completedAt: new Date(c.completedAt).toISOString(),
277 scheduledFor: new Date(c.scheduledFor).toISOString(),
278 deltaMinutes: c.deltaMinutes,
279 })),
280 };
281}
282
283// ── Week view ──
284
285/**
286 * Get timeline for the full week (7 days lookahead).
287 */
288export async function getWeekTimeline(rootId) {
289 if (!_Node || !rootId) return null;
290
291 const now = Date.now();
292 const weekMs = 7 * 24 * 3600000;
293
294 const descendantIds = await getDescendantIds(rootId, { maxResults: 10000 });
295 const allIds = [rootId, ...descendantIds];
296
297 const scheduledNodes = await _Node.find({
298 _id: { $in: allIds },
299 "metadata.schedules.date": { $exists: true, $ne: null },
300 }).select("_id name status metadata").lean();
301
302 const items = [];
303 for (const node of scheduledNodes) {
304 const meta = node.metadata instanceof Map
305 ? Object.fromEntries(node.metadata)
306 : (node.metadata || {});
307
308 const rawSchedule = meta.schedules?.date;
309 if (!rawSchedule) continue;
310
311 const scheduledFor = new Date(rawSchedule).getTime();
312 if (isNaN(scheduledFor)) continue;
313
314 const delta = scheduledFor - now;
315 if (delta > weekMs) continue; // beyond this week
316
317 items.push({
318 nodeId: String(node._id),
319 nodeName: node.name,
320 scheduledFor: new Date(scheduledFor).toISOString(),
321 status: node.status || "active",
322 isPast: delta <= 0,
323 });
324 }
325
326 items.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor));
327 return items;
328}
329
330// ── Cleanup ──
331
332/**
333 * Clear all cached timelines. Called on shutdown.
334 */
335export function clearAll() {
336 timelines.clear();
337 notified.clear();
338}
339
1/**
2 * Scheduler
3 *
4 * The clock that watches the calendar. Syncs to the tree's breathing
5 * rhythm. Every exhale, checks what's due, upcoming, or overdue.
6 * Signals through enrichContext, notifications, and gateway.
7 * Tracks completion patterns over time.
8 */
9
10import log from "../../seed/log.js";
11import { getUserMeta } from "../../seed/tree/userMetadata.js";
12import {
13 configure,
14 scanTree,
15 getCachedTimeline,
16 signalDueItems,
17 recordCompletion,
18 calculateReliability,
19 getWeekTimeline,
20 clearAll,
21} from "./core.js";
22
23export async function init(core) {
24 configure({
25 Node: core.models.Node,
26 hooks: core.hooks,
27 metadata: core.metadata,
28 });
29
30 let fallbackTimer = null;
31 const activeRoots = new Set(); // track trees we've seen for fallback mode
32
33 // ── breath:exhale listener ──
34 // Every exhale, scan the tree for schedule changes.
35 // If breath is not installed, a fallback timer runs instead.
36
37 let breathConnected = false;
38
39 core.hooks.register("breath:exhale", async ({ rootId }) => {
40 breathConnected = true;
41 if (!rootId) return;
42 try {
43 const timeline = await scanTree(rootId);
44 if (timeline) {
45 // Find an owner to send notifications to
46 const root = await core.models.Node.findById(rootId).select("rootOwner").lean();
47 if (root?.rootOwner) {
48 await signalDueItems(rootId, timeline, String(root.rootOwner));
49 }
50 }
51 } catch (err) {
52 log.warn("Scheduler", `Scan failed for ${rootId}: ${err.message}`);
53 }
54 }, "scheduler");
55
56 // Track active trees for fallback mode
57 core.hooks.register("afterNavigate", async ({ rootId }) => {
58 if (rootId) activeRoots.add(String(rootId));
59 }, "scheduler");
60
61 // Fallback timer: if breath extension is not installed, scan every 60s.
62 // Check directly via getExtension instead of waiting for an event.
63 setTimeout(async () => {
64 if (breathConnected) return;
65 try {
66 const { getExtension } = await import("../loader.js");
67 if (getExtension("breath")) return; // breath is installed, just hasn't fired yet
68 } catch {}
69 log.info("Scheduler", "Breath not installed. Starting fallback timer (60s).");
70 fallbackTimer = setInterval(async () => {
71 for (const rootId of activeRoots) {
72 try {
73 const timeline = await scanTree(rootId);
74 if (timeline) {
75 const root = await core.models.Node.findById(rootId).select("rootOwner").lean();
76 if (root?.rootOwner) {
77 await signalDueItems(rootId, timeline, String(root.rootOwner));
78 }
79 }
80 } catch {}
81 }
82 }, 60000);
83 if (fallbackTimer.unref) fallbackTimer.unref();
84 }, 30000);
85
86 // ── afterStatusChange ──
87 // When a scheduled node is completed, record the completion.
88
89 core.hooks.register("afterStatusChange", async ({ node, status }) => {
90 if (status !== "completed") return;
91 if (!node) return;
92
93 const meta = node.metadata instanceof Map
94 ? Object.fromEntries(node.metadata)
95 : (node.metadata || {});
96
97 const schedule = meta.schedules?.date;
98 if (!schedule) return;
99
100 await recordCompletion(node, schedule);
101 }, "scheduler");
102
103 // ── enrichContext ──
104 // Inject timeline and reliability data so the AI knows what's due.
105
106 core.hooks.register("enrichContext", async ({ context, node, meta, userId }) => {
107 if (!node?._id) return;
108
109 // Find the root for this node
110 let rootId = null;
111 if (!node.parent) {
112 rootId = String(node._id);
113 } else {
114 // Walk up via breath's cache or resolve manually
115 try {
116 const { getExtension } = await import("../loader.js");
117 const breathExt = getExtension("breath");
118 if (breathExt?.exports?.getBreathContext) {
119 // breath has a resolveRootId we can use indirectly
120 // but we need to import from breath/core directly
121 }
122 } catch {}
123 // Fallback: check if any cached timeline contains this node
124 // For enrichContext, we typically have the root context anyway
125 // The orchestrator fetches context at a known position in a known tree
126 }
127
128 // If we can't determine rootId, try to get it from meta
129 if (!rootId) {
130 // Most enrichContext calls happen within a tree context where
131 // the orchestrator already knows the rootId. Use a simple parent walk.
132 try {
133 let current = node;
134 for (let depth = 0; depth < 50; depth++) {
135 if (!current.parent) {
136 rootId = String(current._id);
137 break;
138 }
139 current = await core.models.Node.findById(current.parent).select("_id parent").lean();
140 if (!current) break;
141 }
142 } catch {}
143 }
144
145 if (!rootId) return;
146
147 const timeline = getCachedTimeline(rootId);
148 if (!timeline) return;
149
150 // Phase suppression: during attention, skip upcoming (only show due/overdue)
151 let suppressUpcoming = false;
152 if (userId) {
153 try {
154 const User = core.models.User;
155 if (User) {
156 const user = await User.findById(userId).select("metadata").lean();
157 if (user) {
158 const phaseMeta = getUserMeta(user, "phase");
159 if (phaseMeta?.currentPhase === "attention") {
160 // Check config
161 const configNode = await core.models.Node.findOne({
162 parent: rootId,
163 name: ".config",
164 }).select("metadata").lean();
165 const schedulerConfig = configNode?.metadata instanceof Map
166 ? configNode.metadata.get("scheduler")
167 : configNode?.metadata?.scheduler;
168 if (schedulerConfig?.suppressDuringAttention !== false) {
169 suppressUpcoming = true;
170 }
171 }
172 }
173 }
174 } catch {}
175 }
176
177 const schedulerCtx = {};
178 if (timeline.due?.length > 0) schedulerCtx.due = timeline.due;
179 if (timeline.overdue?.length > 0) schedulerCtx.overdue = timeline.overdue;
180 if (!suppressUpcoming && timeline.upcoming?.length > 0) {
181 schedulerCtx.upcoming = timeline.upcoming;
182 }
183
184 if (Object.keys(schedulerCtx).length > 0) {
185 context.scheduler = schedulerCtx;
186 }
187
188 // Node-specific reliability data
189 const schedulerMeta = meta?.scheduler;
190 if (schedulerMeta?.completions?.length > 0) {
191 const reliability = calculateReliability(schedulerMeta.completions);
192 if (reliability) {
193 context.schedulerReliability = reliability;
194 }
195 }
196 }, "scheduler");
197
198 // ── Import router and tools ──
199
200 const { default: router, setMetadata: setRouteMetadata } = await import("./routes.js");
201 setRouteMetadata(core.metadata);
202 const { default: tools, setMetadata: setToolMetadata } = await import("./tools.js");
203 setToolMetadata(core.metadata);
204
205 log.info("Scheduler", "Loaded. The clock watches the calendar.");
206
207 return {
208 router,
209 tools,
210 exports: {
211 scanTree,
212 getCachedTimeline,
213 getWeekTimeline,
214 calculateReliability,
215 },
216 jobs: [
217 {
218 name: "scheduler-scan",
219 start: () => {},
220 stop: () => {
221 if (fallbackTimer) clearInterval(fallbackTimer);
222 clearAll();
223 },
224 },
225 ],
226 };
227}
228
1export default {
2 name: "scheduler",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "The clock that watches the calendar. Reads schedule data from nodes, builds " +
7 "an in-memory timeline, and signals when items are upcoming, due, or overdue. " +
8 "Syncs to the tree's breathing rhythm. Tracks completion patterns over time. " +
9 "The AI sees what's due without being asked.",
10
11 needs: {
12 models: ["Node"],
13 services: ["hooks", "metadata"],
14 },
15
16 optional: {
17 extensions: [
18 "schedules", // reads schedule data from nodes
19 "breath", // syncs to breath cycle
20 "notifications", // persistent reminders
21 "gateway", // push to external channels
22 "intent", // overdue items become intents
23 "purpose", // prioritize by coherence
24 "phase", // suppress during focus
25 "digest", // morning timeline
26 ],
27 },
28
29 provides: {
30 models: {},
31 routes: "./routes.js",
32 tools: true,
33 jobs: true,
34 orchestrator: false,
35 energyActions: {},
36 sessionTypes: {},
37 hooks: {
38 fires: ["scheduler:itemDue"],
39 listens: ["breath:exhale", "afterStatusChange", "enrichContext"],
40 },
41 cli: [
42 {
43 command: "schedule-check",
44 scope: ["tree"],
45 description: "Show due, upcoming, and overdue items",
46 method: "GET",
47 endpoint: "/scheduler/check",
48 },
49 {
50 command: "schedule-reliability",
51 scope: ["tree"],
52 description: "Show completion patterns at this node",
53 method: "GET",
54 endpoint: "/scheduler/reliability",
55 },
56 ],
57 },
58};
59
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import {
5 scanTree,
6 getCachedTimeline,
7 getWeekTimeline,
8 calculateReliability,
9} from "./core.js";
10import Node from "../../seed/models/node.js";
11
12let _metadata = null;
13export function setMetadata(metadata) { _metadata = metadata; }
14
15const router = express.Router();
16
17/**
18 * GET /scheduler/check?rootId=...&week=true
19 * Returns due, upcoming, overdue for a tree.
20 */
21router.get("/scheduler/check", authenticate, async (req, res) => {
22 try {
23 const { rootId, week } = req.query;
24 if (!rootId) return sendError(res, 400, ERR.INVALID_INPUT, "rootId is required");
25
26 if (week === "true") {
27 const items = await getWeekTimeline(rootId);
28 return sendOk(res, { week: items || [] });
29 }
30
31 let timeline = getCachedTimeline(rootId);
32 if (!timeline) {
33 timeline = await scanTree(rootId);
34 }
35 if (!timeline) {
36 return sendOk(res, { due: [], upcoming: [], overdue: [] });
37 }
38
39 sendOk(res, {
40 due: timeline.due,
41 upcoming: timeline.upcoming,
42 overdue: timeline.overdue,
43 });
44 } catch (err) {
45 sendError(res, 500, ERR.INTERNAL, err.message);
46 }
47});
48
49/**
50 * GET /scheduler/timeline?rootId=...
51 * Same as check but with full node details.
52 */
53router.get("/scheduler/timeline", authenticate, async (req, res) => {
54 try {
55 const { rootId } = req.query;
56 if (!rootId) return sendError(res, 400, ERR.INVALID_INPUT, "rootId is required");
57
58 const timeline = await scanTree(rootId);
59 if (!timeline) {
60 return sendOk(res, { due: [], upcoming: [], overdue: [], lastScan: null });
61 }
62
63 sendOk(res, timeline);
64 } catch (err) {
65 sendError(res, 500, ERR.INTERNAL, err.message);
66 }
67});
68
69/**
70 * GET /scheduler/reliability/:nodeId
71 * Returns completion patterns for a specific node.
72 */
73router.get("/scheduler/reliability/:nodeId", authenticate, async (req, res) => {
74 try {
75 const { nodeId } = req.params;
76 const node = await Node.findById(nodeId).select("name metadata").lean();
77 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
78
79 const schedulerMeta = _metadata.getExtMeta(node, "scheduler");
80 if (!schedulerMeta?.completions?.length) {
81 return sendOk(res, {
82 nodeName: node.name,
83 message: "No completion history",
84 totalCompletions: 0,
85 });
86 }
87
88 const reliability = calculateReliability(schedulerMeta.completions);
89 sendOk(res, { nodeName: node.name, ...reliability });
90 } catch (err) {
91 sendError(res, 500, ERR.INTERNAL, err.message);
92 }
93});
94
95export default router;
96
1import { z } from "zod";
2import {
3 scanTree,
4 getCachedTimeline,
5 getWeekTimeline,
6 calculateReliability,
7} from "./core.js";
8import Node from "../../seed/models/node.js";
9
10let _metadata = null;
11export function setMetadata(metadata) { _metadata = metadata; }
12
13export default [
14 {
15 name: "schedule-timeline",
16 description:
17 "Get the schedule timeline for the current tree. Shows due, upcoming, and overdue items. " +
18 "Use this to check what's happening today or this week.",
19 schema: {
20 userId: z.string().describe("Injected by server. Ignore."),
21 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
22 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
23 rootId: z.string().describe("The tree root to check."),
24 window: z
25 .enum(["day", "week"])
26 .optional()
27 .describe("Time window. 'day' (default) shows 24h lookahead. 'week' shows 7 days."),
28 },
29 annotations: {
30 readOnlyHint: true,
31 destructiveHint: false,
32 idempotentHint: true,
33 openWorldHint: false,
34 },
35 handler: async ({ rootId, window: timeWindow }) => {
36 try {
37 if (timeWindow === "week") {
38 const items = await getWeekTimeline(rootId);
39 if (!items || items.length === 0) {
40 return { content: [{ type: "text", text: "No scheduled items this week." }] };
41 }
42 return {
43 content: [{ type: "text", text: JSON.stringify({ week: items }, null, 2) }],
44 };
45 }
46
47 // Default: day view (use cached or fresh scan)
48 let timeline = getCachedTimeline(rootId);
49 if (!timeline) {
50 timeline = await scanTree(rootId);
51 }
52 if (!timeline) {
53 return { content: [{ type: "text", text: "No scheduled items found." }] };
54 }
55
56 const { due, upcoming, overdue } = timeline;
57 if (!due.length && !upcoming.length && !overdue.length) {
58 return { content: [{ type: "text", text: "Nothing due, upcoming, or overdue right now." }] };
59 }
60
61 return {
62 content: [{ type: "text", text: JSON.stringify({ due, upcoming, overdue }, null, 2) }],
63 };
64 } catch (err) {
65 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
66 }
67 },
68 },
69
70 {
71 name: "schedule-reliability",
72 description:
73 "Get completion patterns for a scheduled node. Shows average timing, on-time rate, " +
74 "streak, and recent completions. Use this to understand how consistent someone is " +
75 "with a recurring item.",
76 schema: {
77 userId: z.string().describe("Injected by server. Ignore."),
78 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
79 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
80 nodeId: z.string().describe("The node to check reliability for."),
81 },
82 annotations: {
83 readOnlyHint: true,
84 destructiveHint: false,
85 idempotentHint: true,
86 openWorldHint: false,
87 },
88 handler: async ({ nodeId }) => {
89 try {
90 const node = await Node.findById(nodeId).select("name metadata").lean();
91 if (!node) {
92 return { content: [{ type: "text", text: "Node not found." }] };
93 }
94
95 const schedulerMeta = _metadata.getExtMeta(node, "scheduler");
96 if (!schedulerMeta?.completions?.length) {
97 return {
98 content: [{ type: "text", text: `"${node.name}" has no completion history yet.` }],
99 };
100 }
101
102 const reliability = calculateReliability(schedulerMeta.completions);
103 if (!reliability) {
104 return { content: [{ type: "text", text: "Not enough data to calculate reliability." }] };
105 }
106
107 return {
108 content: [{
109 type: "text",
110 text: JSON.stringify({ nodeName: node.name, ...reliability }, null, 2),
111 }],
112 };
113 } catch (err) {
114 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
115 }
116 },
117 },
118];
119
Loading comments...