EXTENSION for TreeOS
scheduler
The clock that watches the calendar. Reads schedule data from nodes, builds an in-memory timeline, and signals when items are upcoming, due, or overdue. Syncs to the tree's breathing rhythm. Tracks completion patterns over time. The AI sees what's due without being asked.
v1.0.1 by TreeOS Site 0 downloads 5 files 841 lines 25.0 KB published 38d ago
treeos ext install scheduler
View changelog

Manifest

Provides

  • routes
  • tools
  • jobs
  • 2 CLI commands
  • 1 custom hooks

Requires

  • services: hooks, metadata
  • models: Node

Optional

  • extensions: schedules, breath, notifications, gateway, intent, purpose, phase, digest
SHA256: 6a10ab5040446439c2673f72e45c63bfb4bbbf6e86613e90dd9bce8f3ae3dfdf

CLI Commands

CommandMethodDescription
schedule-checkGETShow due, upcoming, and overdue items
schedule-reliabilityGETShow completion patterns at this node

Hooks

Listens To

  • breath:exhale
  • afterStatusChange
  • enrichContext

Fires

HookDataDescription
scheduler:itemDue

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star scheduler

Comments

Loading comments...

Post comments from the CLI: treeos ext comment scheduler "your comment"
Max 3 comments per extension. One star and one flag per user.