{"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../../src/core/scheduler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,MAAM,WAAW,aAAa;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AA4BD,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAsB7D;AAQD,MAAM,WAAW,oBAAoB;IACpC,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,qFAAqF;IACrF,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC;IACvB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,aAAa;IACzB,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,KAAK,CAA6C;IAC1D,OAAO,CAAC,QAAQ,CAAC,IAAI,CACoD;IAEzE,YAAY,IAAI,EAAE,oBAAoB,EAGrC;IAED;8DAC0D;IAC1D,IAAI,IAAI,IAAI,CAeX;IAED,OAAO,CAAC,OAAO;IASf,8CAA8C;IAC9C,KAAK,IAAI,IAAI,CAIZ;IAED,IAAI,IAAI,IAAI,CAKX;IAED,MAAM,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,aAAa,CAWlF;IAED,IAAI,IAAI,aAAa,EAAE,CAEtB;IAED,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAM1B;IAED,KAAK,IAAI,IAAI,CAIZ;IAED,gFAAgF;IAChF,IAAI,CAAC,GAAG,EAAE,IAAI,GAAG,IAAI,CAsBpB;CACD","sourcesContent":["/**\n * Task scheduler — cron-driven recurring/one-shot prompts.\n *\n * Backs the `/loop` command and the Cron* tools. Tasks are matched against a\n * standard 5-field cron expression (minute hour day-of-month month day-of-week)\n * in local time and fired by re-submitting their prompt as a user message.\n * Firing is idle-gated so a task never interrupts an in-flight turn; a task due\n * while the agent is busy is picked up on a later tick within the same minute.\n *\n * Recurring tasks persist; one-shot tasks delete themselves after firing.\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nexport interface ScheduledTask {\n\tid: string;\n\t/** 5-field cron expression in local time. */\n\tcron: string;\n\t/** Prompt re-submitted on each fire. */\n\tprompt: string;\n\t/** Recurring (fire on every match) vs one-shot (fire once, then delete). */\n\trecurring: boolean;\n\tcreatedAt: number;\n\t/** Minute-bucket key of the last fire, to avoid double-firing within a minute. */\n\tlastRunMinute?: number;\n}\n\n/** Parse one cron field into a predicate over its numeric value. */\nfunction parseField(field: string, min: number, max: number): (value: number) => boolean {\n\tif (field === \"*\") return () => true;\n\n\tconst allowed = new Set<number>();\n\tfor (const part of field.split(\",\")) {\n\t\t// step: */n or a-b/n or a/n\n\t\tconst [range, stepStr] = part.split(\"/\");\n\t\tconst step = stepStr ? Number.parseInt(stepStr, 10) : 1;\n\t\tif (!Number.isFinite(step) || step < 1) continue;\n\n\t\tlet lo = min;\n\t\tlet hi = max;\n\t\tif (range && range !== \"*\") {\n\t\t\tconst [a, b] = range.split(\"-\");\n\t\t\tlo = Number.parseInt(a, 10);\n\t\t\thi = b !== undefined ? Number.parseInt(b, 10) : lo;\n\t\t\tif (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;\n\t\t}\n\t\tfor (let v = lo; v <= hi; v += step) {\n\t\t\tif (v >= min && v <= max) allowed.add(v);\n\t\t}\n\t}\n\treturn (value: number) => allowed.has(value);\n}\n\n/** True when `date` matches the 5-field cron expression. Invalid expressions never match. */\nexport function matchesCron(expr: string, date: Date): boolean {\n\tconst fields = expr.trim().split(/\\s+/);\n\tif (fields.length !== 5) return false;\n\n\tconst [m, h, dom, mon, dow] = fields;\n\tconst minute = parseField(m, 0, 59)(date.getMinutes());\n\tconst hour = parseField(h, 0, 23)(date.getHours());\n\tconst month = parseField(mon, 1, 12)(date.getMonth() + 1);\n\t// day-of-week: cron allows 0 or 7 for Sunday; normalize 7→0.\n\tconst dowVal = date.getDay();\n\tconst domField = parseField(dom, 1, 31);\n\tconst dowField = parseField(dow.replace(/7/g, \"0\"), 0, 6);\n\n\tif (!(minute && hour && month)) return false;\n\n\t// Standard cron semantics: when both DOM and DOW are restricted, match either.\n\tconst domRestricted = dom !== \"*\";\n\tconst dowRestricted = dow !== \"*\";\n\tif (domRestricted && dowRestricted) {\n\t\treturn domField(date.getDate()) || dowField(dowVal);\n\t}\n\treturn domField(date.getDate()) && dowField(dowVal);\n}\n\nlet idCounter = 0;\nfunction newId(): string {\n\tidCounter += 1;\n\treturn `task_${Date.now().toString(36)}${idCounter.toString(36)}`;\n}\n\nexport interface TaskSchedulerOptions {\n\t/** Path to the durable JSON store (written on every mutation). */\n\tstorePath: string;\n\t/**\n\t * Legacy store path read only when {@link storePath} does not yet exist, so\n\t * tasks scheduled under an older location migrate forward on first persist.\n\t */\n\tlegacyStorePath?: string;\n\t/** Submit a due task's prompt (e.g. via sendUserMessage). */\n\tfire: (prompt: string) => void;\n\t/** Whether the agent is idle; tasks only fire when true. Defaults to always-idle. */\n\tisIdle?: () => boolean;\n\t/** Tick interval in ms. Defaults to 30s. */\n\tintervalMs?: number;\n}\n\nexport class TaskScheduler {\n\tprivate tasks: ScheduledTask[] = [];\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\tprivate readonly opts: Required<Pick<TaskSchedulerOptions, \"storePath\" | \"fire\">> &\n\t\tPick<TaskSchedulerOptions, \"isIdle\" | \"intervalMs\" | \"legacyStorePath\">;\n\n\tconstructor(opts: TaskSchedulerOptions) {\n\t\tthis.opts = opts;\n\t\tthis.load();\n\t}\n\n\t/** Load persisted tasks from disk (best-effort). Falls back to the legacy\n\t *  store path when the primary one does not exist yet. */\n\tload(): void {\n\t\tconst source =\n\t\t\texistsSync(this.opts.storePath) || !this.opts.legacyStorePath\n\t\t\t\t? this.opts.storePath\n\t\t\t\t: existsSync(this.opts.legacyStorePath)\n\t\t\t\t\t? this.opts.legacyStorePath\n\t\t\t\t\t: this.opts.storePath;\n\t\ttry {\n\t\t\tif (existsSync(source)) {\n\t\t\t\tconst parsed = JSON.parse(readFileSync(source, \"utf8\")) as { tasks?: ScheduledTask[] };\n\t\t\t\tthis.tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];\n\t\t\t}\n\t\t} catch {\n\t\t\tthis.tasks = [];\n\t\t}\n\t}\n\n\tprivate persist(): void {\n\t\ttry {\n\t\t\tmkdirSync(dirname(this.opts.storePath), { recursive: true });\n\t\t\twriteFileSync(this.opts.storePath, `${JSON.stringify({ tasks: this.tasks }, null, 2)}\\n`, \"utf8\");\n\t\t} catch {\n\t\t\t// best-effort persistence\n\t\t}\n\t}\n\n\t/** Begin the tick loop. Safe to call once. */\n\tstart(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => this.tick(new Date()), this.opts.intervalMs ?? 30_000);\n\t\tthis.timer.unref?.();\n\t}\n\n\tstop(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\tcreate(input: { cron: string; prompt: string; recurring?: boolean }): ScheduledTask {\n\t\tconst task: ScheduledTask = {\n\t\t\tid: newId(),\n\t\t\tcron: input.cron,\n\t\t\tprompt: input.prompt,\n\t\t\trecurring: input.recurring ?? true,\n\t\t\tcreatedAt: Date.now(),\n\t\t};\n\t\tthis.tasks.push(task);\n\t\tthis.persist();\n\t\treturn task;\n\t}\n\n\tlist(): ScheduledTask[] {\n\t\treturn [...this.tasks];\n\t}\n\n\tdelete(id: string): boolean {\n\t\tconst before = this.tasks.length;\n\t\tthis.tasks = this.tasks.filter((t) => t.id !== id);\n\t\tconst removed = this.tasks.length < before;\n\t\tif (removed) this.persist();\n\t\treturn removed;\n\t}\n\n\tclear(): void {\n\t\tif (this.tasks.length === 0) return;\n\t\tthis.tasks = [];\n\t\tthis.persist();\n\t}\n\n\t/** Evaluate all tasks against `now` and fire those that are due (when idle). */\n\ttick(now: Date): void {\n\t\tconst idle = this.opts.isIdle ? this.opts.isIdle() : true;\n\t\tif (!idle) return;\n\n\t\tconst minuteKey = Math.floor(now.getTime() / 60_000);\n\t\tlet mutated = false;\n\t\tconst toDelete: string[] = [];\n\n\t\tfor (const task of this.tasks) {\n\t\t\tif (task.lastRunMinute === minuteKey) continue;\n\t\t\tif (!matchesCron(task.cron, now)) continue;\n\n\t\t\ttask.lastRunMinute = minuteKey;\n\t\t\tmutated = true;\n\t\t\tthis.opts.fire(task.prompt);\n\t\t\tif (!task.recurring) toDelete.push(task.id);\n\t\t}\n\n\t\tif (toDelete.length > 0) {\n\t\t\tthis.tasks = this.tasks.filter((t) => !toDelete.includes(t.id));\n\t\t}\n\t\tif (mutated || toDelete.length > 0) this.persist();\n\t}\n}\n"]}