@inetafrica/open-claudia 3.5.16 → 3.5.18

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.
Files changed (46) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +4 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +26 -0
  7. package/channels/kazee/adapter.js +130 -21
  8. package/channels/telegram/adapter.js +4 -2
  9. package/core/actions.js +8 -0
  10. package/core/config.js +22 -1
  11. package/core/cost-report.js +3 -0
  12. package/core/cron-schedule.js +59 -0
  13. package/core/dream.js +141 -34
  14. package/core/guardrail.js +5 -0
  15. package/core/handlers.js +78 -17
  16. package/core/lessons.js +11 -5
  17. package/core/limit-pickup.js +313 -0
  18. package/core/loopback.js +29 -0
  19. package/core/providers/claude-events.js +79 -0
  20. package/core/providers/codex.js +48 -0
  21. package/core/providers/events.js +4 -1
  22. package/core/relationship.js +1 -1
  23. package/core/router.js +16 -1
  24. package/core/run-context.js +3 -0
  25. package/core/runner.js +440 -14
  26. package/core/safe-filename.js +47 -0
  27. package/core/scheduler.js +170 -38
  28. package/core/state.js +91 -0
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +508 -0
  32. package/core/usage-limits.js +321 -0
  33. package/core/utility-agent.js +17 -1
  34. package/package.json +4 -4
  35. package/test-fixtures/fake-agent-cli.js +26 -2
  36. package/test-guardrail.js +19 -0
  37. package/test-kazee-context-media.js +1 -1
  38. package/test-provider-compaction.js +66 -1
  39. package/test-provider-dream.js +3 -1
  40. package/test-provider-events.js +106 -0
  41. package/test-provider-language.js +16 -1
  42. package/test-provider-pack-review.js +3 -1
  43. package/test-provider-scheduler.js +72 -1
  44. package/test-thread-lanes.js +6 -1
  45. package/test-usage-accounting.js +4 -0
  46. package/web.js +104 -51
package/.env.example CHANGED
@@ -70,6 +70,16 @@ USAGE_ALERT_RATE_MULTIPLIER=1.75
70
70
  USAGE_ALERT_BASELINE_TURNS=20
71
71
  USAGE_ALERT_MIN_BASELINE_TURNS=6
72
72
  USAGE_ALERT_COOLDOWN_MS=1800000
73
+ # Plan limits (Claude Pro/Max, the ChatGPT plan behind Codex). Warn in the chat
74
+ # when a window's use passes these percentages, once per window (off = never).
75
+ LIMIT_WARN_PERCENTS=80,95
76
+ # A message a usage limit stopped is picked up when the limit resets, unless
77
+ # the person writes again first: on (default) | off.
78
+ LIMIT_PICKUP=on
79
+ # Seconds after the reset before it runs (default 120); a reset further out
80
+ # than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited for.
81
+ LIMIT_PICKUP_DELAY_S=120
82
+ LIMIT_PICKUP_MAX_WAIT_H=192
73
83
  MEMORY_RECALL_MAX_CHARS=9000
74
84
  # Default recall engine when a chat hasn't picked one with /engine: classic | discoverer
75
85
  RECALL_ENGINE=classic
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.18
4
+
5
+ Usage limits (Sumeet, 7 Oct 15:00 EAT: "track usage and notify the user that they are going to hit their limit, if the limit is hit can the bot auto schedule to continue when the limit replenishes"; 15:14 EAT: "if the user sends it a message it cancels and if the session limit message comes up again it will just rearm").
6
+
7
+ - **A message a usage limit stopped is picked up when the limit resets.** When Claude's 5-hour or weekly limit (or Codex's plan limit) stops a chat turn, the bot says which limit and when it resets ("⏳ Claude's weekly limit is used up, so I stopped here. I'll pick your message back up on Fri 9 Oct at 08:02, when it resets. If you write to me before then, I'll answer that instead and drop this.") and runs the message again 2 minutes after the reset (`LIMIT_PICKUP_DELAY_S`), in the same conversation, as the person who sent it, with "check what you already did; never repeat a send, push, deploy or write". One pick-up per person per conversation, kept in `limit-pickups.json` so it survives a restart. The person writing again in that conversation drops it (also when the guard holds that message), as do `/stop`, `/new` and `/end`; another person in a group does not. A run that hits a limit again arms it again with the same message, at most 5 times. Resets further out than `LIMIT_PICKUP_MAX_WAIT_H` (192 h) are not waited for. `LIMIT_PICKUP=off` only says when the limit resets. An unasked group reply that hits a limit posts nothing.
8
+ - **The limit notice is never the reply.** Claude Code words a plan limit "You've hit your limit / session limit / weekly limit · resets …", which the old checks missed: the bot posted "Claude Code run failed: You've hit your session limit · resets 11:30am (Africa/Nairobi)", or the bare notice as if it were the answer (the tickets bot to a staff member, 7 Oct). The limit is now read from Claude Code's `rate_limit_event` (status `rejected`, the reset time) and its synthetic `rate_limit` message, which never streams as answer text; Codex's "You've hit your usage limit … try again at …" was already caught. The reset time comes from the event, else Codex's rollout, else the notice's own words.
9
+ - **Warnings at 80% and 95%.** Claude Code reports each window's use on every run (`rate_limit_event.unifiedWindows`, a 0-1 fraction per 5-hour and weekly window); Codex in its rollout (its windows are named by length: on a Pro account the main one is weekly). After the reply that takes a window past 80% or 95%, the chat gets "⚠️ Claude: 82% of the 5-hour limit is used. It resets at 17:20." Once per window per percentage (`LIMIT_WARN_PERCENTS`, `off` for none); never to an outside contact, on a call or for an unasked group reply. Claude Code's own `allowed_warning` status is a pace warning (25% early in the week) and is not used.
10
+ - **/usage shows the plan limits** ("Claude plan limits: 5-hour 57% (resets 17:20) · weekly 26% (resets Wed 14 Oct, 08:00)") and the waiting pick-up in this conversation.
11
+ - **Tests:** `test-usage-limits.js`, `test-limit-pickup.js` (new); `test-provider-events.js`; the fake agent CLI gains a `usage-limit` scenario and `FAKE_AGENT_RATE_LIMIT_INFO`.
12
+
13
+ Token diet for every bot (Sumeet, 8 Oct 08:19 EAT: "Let's roll it out to the fleet if we already seeing savings?"). On the owner's bot the 3.5.17 canary cut a typical reply from $8.72 to $4.03, and the cost per reply with compactions included by 48%, over 82 replies.
14
+
15
+ - **The token diet is on by default.** Every conversation compacts at 200k, and before a cold resume over 120k, as listed conversations did in 3.5.17. `TOKEN_DIET_CONVERSATIONS=off` turns it off; a list of conversation keys still limits it to those.
16
+ - **The compaction summary no longer dies on a tool call.** Claude runs it with `--max-turns 1`, and its prompt never said not to use tools: a model that checked a fact first used up the one turn and left no brief (4 of 24 compactions on 7-8 Oct, $35 for nothing). The prompt now says so, and a threshold compaction that does not complete logs why.
17
+ - **A scheduled job's own conversation gets the same cap.** A watch, cron or wakeup resumes the conversation it was made in; once its thread had moved on, nothing compacted that conversation (the Kazee Bugs monitor's grew to 894k, $55 a wake). It is now compacted like its thread, before a cold run and after a run past the threshold, without touching the thread's live session. The usage ledger marks these compactions `scope: "job"`.
18
+ - **Jobs follow their compactions.** The completed-compactions index keeps only the last 20, which the token diet fills in under a day, so jobs lost their way and resumed old, huge conversations (the PBX thread's wakeup and watch: 790k, $86 for two runs). The scheduler now also reads the lineage in the session history, and a recurring job remembers the session it last ran on.
19
+ - **Tests:** `test-token-diet.js` (the default; job conversations end to end through the scheduler), `test-provider-compaction.js` (branch commit, the no-tools summary), `test-provider-scheduler.js` (history lineage, a job keeps its session); `test-usage-accounting.js` pins the classic thresholds with the diet off.
20
+
21
+ ## 3.5.17
22
+
23
+ One combined release (Sumeet, 7 Oct 09:21 EAT): Release 1 below, the 7 Oct security fixes, and the dashboard and provider fixes found on 6 Oct. What people will notice: Codex bots start answering in groups when a message concerns them (their checks failed before), a file over 200 MB sent to a bot is refused, and a /dashboard link opens a Continue page first.
24
+
25
+ Release 1, "reliable and lean" (Sumeet, 7 Oct: token diet approved 06:12 EAT, task-cleanup plan sections 2, 5 and 6 at 09:45, resuming interrupted work at 10:20). Fewer tokens re-read on every step, scheduled work that actually runs, a dream that reaches the owner, and work that survives a restart. The compaction changes are a canary: off until conversations are listed.
26
+
27
+ - **Each conversation sees its own tasks.** A new task records the conversation it was made in (the thread, or the main timeline; a plan's subtasks follow the plan). The first turn of a session no longer gets the whole channel's task tree (90-123k characters on the owner's Kazee chat, where every thread shares one list): it gets this conversation's open tasks in full (at most 12, 6,000 characters), one line about the rest of the chat (count and the 5 most recent titles) and the pointer to `open-claudia task list`, at most 7,500 characters in all. Older tasks with no conversation count as "elsewhere". `task list` is unchanged.
28
+ - **Team list and slash commands once per session.** They rode every turn (about 4.5k characters); now they come with the first turn of a session in each conversation (again after a restart, a new conversation or a compaction). Who is speaking and the formatting notes still come every turn.
29
+ - **Token diet canary for compaction.** `TOKEN_DIET_CONVERSATIONS` (conversation keys as the usage ledger writes them, or `*`; empty = off) makes listed conversations compact at `TOKEN_DIET_COMPACT_TOKENS` (200k) instead of 380k, and compact before a turn that resumes them after the prompt cache expired (idle over `TOKEN_DIET_COLD_MINUTES`, 60) when their context is over `TOKEN_DIET_COLD_MIN_TOKENS` (120k); the turn then runs on the fresh session. `/compactwindow` still wins. Every compaction now leaves a usage-ledger record (`kind: "compaction"`, `compactReason`: threshold, cold-resume or manual, the context it replaced and what it cost), so before and after can be measured per conversation; the cost report and the dashboard keep counting turns only.
30
+ - **The dream reports every task list.** The cold-task report read only the old Telegram list; it now covers every task list on the bot (task commands are owner-only, so they are all the owner's), at most 8 lines, each naming its list.
31
+ - **Task archive, with a reason.** `open-claudia task archive <id> --reason "done: <proof>" | "replaced: <by what>" | "dropped: <who decided>" | "handed: <to whom>"` moves a task and its subtasks out of the list without losing them; `task restore <id>` brings them back with the same ids; `task archived [--limit N] [--search <text>]` lists them. Nothing archives by age, and `task done` is unchanged.
32
+ - **Late crons still run.** node-cron skipped any run that started more than a second late, and the bot is often 2-3 seconds late at the top of a minute: the 04:00 dream ran on 7 of 14 nights and the 07:30 daily cron missed 6 and 7 Oct. Crons now tolerate `OC_CRON_LATE_TOLERANCE_MS` (10 min) and the dream `DREAM_LATE_TOLERANCE_MS` (3 h).
33
+ - **The dream catches up.** If it has not run for more than 26 hours, it runs once now (checked at boot and hourly), never beside a running dream and at most once per 26 hours.
34
+ - **The dream summary reaches the owner's main channel.** `DREAM_SUMMARY_TO` (`<adapter>:<channelId>`) if set, else the owner's primary channel from the people list, else Telegram as before. A Kazee-only owner never got it.
35
+ - **A failing recurring job keeps its schedule.** One occurrence that failed five times used to disable a cron for good. Now that occurrence is recorded and skipped and the job runs again at its next time; one-shot wake-ups still give up.
36
+ - **The owner hears when a job dies.** A wake-up or watch that gives up, or a cron occurrence that failed for good, posts one short message in its own conversation (or to the owner if that channel is gone): which job, the error code, and how to re-arm it.
37
+ - **"No response requested." is not an answer.** A wake-up, cron or watch whose whole reply is that placeholder (Claude Code writes it into a session cut short by a restart) is re-run once, in the same session, with "Act on the prompt above now. Your first output must be a tool call or a real reply."
38
+ - **New lessons reach running conversations.** A lesson learned after a conversation started is added to its next turn, once, under "New standing rules since this conversation started:".
39
+ - **Interrupted work picks up after a restart** (Sumeet, 7 Oct: "a way for interrupted threads and work to get resumed automatically"). When a chat turn starts, the bot notes it in a small journal file and removes the note when the turn ends, is stopped or fails. After a restart the bot reads what is left: a turn cut off less than 2 hours ago (`RESUME_MAX_AGE_MIN`) continues in the same conversation with "Picking up where I was cut off (restart at HH:MM)." and an instruction to check what was already done and never repeat a send, push, deploy or write (also when it was the conversation's first turn, or its session is lost: the work may be half done, so it is never replayed as the plain message); an older one is offered back with Continue / Drop buttons for a day; one that was already cut off twice is not run again and the owner is told. Messages that were waiting behind it run afterwards, in order. `RESUME_INTERRUPTED=off` turns this off; safe mode never resumes anything. Scheduled jobs keep their own retry and are never run twice.
40
+ - **/stop no longer loses waiting messages.** Each message it drops comes back with a "Run it" button for a day.
41
+ - **Planned restarts wait for running work.** On a restart, upgrade or rollout the bot stops starting new turns (a message sent then runs right after the restart) and gives running turns time to finish: up to `DRAIN_TIMEOUT_S` (60 s) on /restart, /upgrade and /downgrade, `DRAIN_SIGTERM_S` (6 s) on a rollout or launchd stop, whose sender kills the bot soon after (bot pods allow 15 s); the bot saves its state before it waits. Anything still running picks up after the restart.
42
+ - **A pack is not re-sent for a metadata change.** A memory pack already in the session is injected again only when what it shows changed, not when its "applied on" stamp or parent bookkeeping moves.
43
+ - **Security: a sender's file name stays inside the files folder.** Kazee and Telegram saved a document under the sender's own file name, so a name like `../auth.json` could overwrite the owner list. One helper (`core/safe-filename.js`) keeps the last path segment, drops control and odd characters, refuses hidden or empty names, caps the length and checks the result stays in the folder. A document the guard holds for the owner keeps its file for the approval.
44
+ - **Security: Kazee media downloads are fenced and capped at 200 MB.** The adapter fetched any link a message carried, with no size limit. Now `KAZEE_MEDIA_HOSTS`, when set, is the exact list of hosts allowed; when unset, any public host is allowed but private, loopback and link-local addresses are refused (checked on every lookup answer, so the address cannot change in between); Kazee's own host is always allowed. Files stream to a `.part` file and stop at 200 MB (Kazee Chat's own limit) or 60 s idle; a failed or oversized download is deleted.
45
+ - **Security: the dashboard escapes everything it shows.** Pending users' names, cron labels, session titles, logs, the soul text, config values and usage rows went into the page as HTML, so a Telegram name or cron label carrying markup ran as script in the owner's dashboard.
46
+ - **Security: a bad `/otp/` link answers 400.** A malformed link threw inside the request handler and messaged the owner each time, no sign-in needed. Bad links get a short 400 page; any handler failure answers 500 and is logged.
47
+ - **Fix: /dashboard links survive Kazee Chat's link previews.** Kazee Chat fetches every link in a message to build a preview, which used up the one-time sign-in link before anyone tapped it ("Link expired or already used", 6 Oct). The link now opens a Continue page; only pressing it (a POST) uses the link.
48
+ - **Fix: the Users tab works.** Approve, deny and remove on the bot's dashboard and Agent Space's Users tab answered 404, because the sign-in routes above them took every `/api/auth/*` path.
49
+ - **Fix: a conversation whose saved history is gone starts fresh.** Claude Code answers "No conversation found with session ID" when the session file is missing, and every turn of that conversation failed until someone sent /new (chat-spaces-bot, 6 Oct). The stale session is retired and the turn runs fresh.
50
+ - **Fix: helper agents' notes stay out of the chat.** Text streamed by a helper agent (the Agent/Task tool) was posted as the bot's reply (a security audit's working notes reached the owner, 6 Oct); it now only counts toward spend.
51
+ - **Fix: Codex bots' background checks pass OpenAI's strict schema rules.** Every utility schema broke strict mode (400 invalid_json_schema), so on Codex bots passive group replies, pack review, dedup, the dream, recall filtering and bot hand-off checks all failed. They work now, which is why Codex bots start answering in groups.
52
+ - **Fix: reassurances are never standing rules.** The owner-rule detector took "Never mind, I found it." as a never-directive and saved it, and offered "Don't worry so much about that." as a rule. Sentences opening with don't worry, do not worry or never mind no longer form a rule.
53
+ - **node-cron ^4.6.0.** 4.2.1 has no option to tolerate a late start, which the late-cron fix relies on (its fallback for old versions stays).
54
+ - **Tests:** `test-task-injection.js`, `test-speaker-extras.js`, `test-token-diet.js`, `test-dream-tasks.js`, `test-task-archive.js`, `test-cron-tolerance.js`, `test-dream-schedule.js`, `test-job-failures.js`, `test-scheduled-placeholder.js`, `test-lessons-since.js`, `test-pack-reinject.js`, `test-turn-journal.js` (new); `test-thread-lanes.js`, `test-loopback-owner-gate.js` and `test-provider-scheduler.js` updated. The fake agent CLI gains a `[[placeholder]]` prompt marker. Security and fix tests: `test-safe-filename.js`, `test-media-filename-paths.js`, `test-router-document-guard.js`, `test-web-dashboard-escape.js`, `test-web-bad-requests.js`, `test-kazee-download-limit.js`, `test-web-auth-routes.js`, `test-web-otp-link-preview.js`, `test-codex-output-schema.js` (new); `test-kazee-context-media.js`, `test-provider-refusal-recovery.js`, `test-provider-events.js`, `test-provider-dream.js`, `test-provider-pack-review.js`, `test-guardrail.js` extended.
55
+
3
56
  ## 3.5.16
4
57
 
5
58
  Watch a bot's browser live (Sumeet, 6 Oct: "can that show the browser like a see my computer view?", then "yeh go ahead"). Also carries 3.5.14 and 3.5.15, which never reached the bots: the 3.5.15 build kept failing on a timing-sensitive test, fixed here. Canary on the sales and tickets bots, then the fleet.
package/README.md CHANGED
@@ -439,6 +439,7 @@ All stored in `~/.open-claudia/`:
439
439
  | `TELEGRAM_CHAT_ID` | Yes* | Comma-separated authorized chat IDs |
440
440
  | `CHANNELS` | No | Channels to start: `telegram`, `kazee`, or `telegram,kazee` (default `telegram`) |
441
441
  | `KAZEE_URL` / `KAZEE_BOT_TOKEN` / `KAZEE_BOT_USER_ID` / `KAZEE_OWNER_USER_ID` | Kazee only | Kazee Chat connection |
442
+ | `KAZEE_MEDIA_HOSTS` | No | Comma-separated hosts (or URLs) Kazee serves file links from. When set, files are fetched only from these and the `KAZEE_URL` host. When unset, any public host works but private, loopback and link-local addresses are refused. Files stop at 200 MB either way |
442
443
  | `WORKSPACE` | Yes | Path to your projects directory |
443
444
  | `CLAUDE_PATH` / `CODEX_PATH` | No | Optional paths to provider CLIs (auto-detected if in PATH) |
444
445
  | `DEFAULT_PROVIDER` | No | Foreground default (`claude` or `codex`); blank deterministically chooses the first compatible provider |
@@ -452,6 +453,9 @@ All stored in `~/.open-claudia/`:
452
453
  | `USAGE_ALERT_CONTEXT_TOKENS` | No | With alerts on: alert when one completed turn's context tokens exceed this ceiling (default `120000`, `off` disables) |
453
454
  | `USAGE_ALERT_RATE_MULTIPLIER` | No | With alerts on: alert when the latest context-token rate exceeds the recent baseline by this multiple (default `1.75`, `off` disables) |
454
455
  | `USAGE_ALERT_BASELINE_TURNS` / `USAGE_ALERT_MIN_BASELINE_TURNS` / `USAGE_ALERT_COOLDOWN_MS` | No | Tune token-rate baseline size, minimum sample size, and alert cooldown |
456
+ | `LIMIT_WARN_PERCENTS` | No | Plan-limit warnings in the chat: comma-separated percentages of a 5-hour or weekly window (default `80,95`, `off` disables). Each is said once per window, after the reply that crossed it; never to an outside contact |
457
+ | `LIMIT_PICKUP` | No | `on` (default) or `off`. A message a Claude or Codex usage limit stopped is picked up when the limit resets; the person writing again in that conversation, `/stop`, `/new` or `/end` drops it, and a new limit hit arms it again |
458
+ | `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before the pick-up runs (default `120`); resets further out than this many hours (default `192`) are not waited for |
455
459
  | `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
456
460
  | `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
457
461
  | `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
package/bin/cli.js CHANGED
@@ -648,7 +648,7 @@ Background work (only inside an active bot-spawned task):
648
648
  open-claudia watch-list List watches on this channel + master switch state
649
649
  open-claudia watch-remove <id> Remove a watch
650
650
  open-claudia watch-on | watch-off Global master switch for all watches (owner)
651
- open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
651
+ open-claudia task add|list|start|done|remove|archive|restore Per-channel todo (survives restarts)
652
652
  open-claudia agent "<prompt>" [--role X] [--provider active|claude|codex] Spawn a provider-isolated sub-agent
653
653
 
654
654
  Cluster control (self-management of this bot's own deployment via the broker):
package/bin/task.js CHANGED
@@ -17,6 +17,10 @@ Per-channel todo list with optional plan/subtask hierarchy.
17
17
  # a plan won't complete while any subtask is still open
18
18
  open-claudia task remove <id> # removing a plan removes its subtasks
19
19
  open-claudia task clear-completed # prune any leftover completed entries
20
+ open-claudia task archive <id> --reason "<how it closed>" # move a plan/task (and its subtasks) out of the list
21
+ reason (required): "done: <proof>" | "replaced: <by what>" | "dropped: <who decided>" | "handed: <to whom>"
22
+ open-claudia task restore <id> # bring an archived task back, same id
23
+ open-claudia task archived [--limit N] [--search <text>] # archived tasks, most recent first
20
24
 
21
25
  Use plans for any work with 3+ distinct steps. Mark subtasks in_progress
22
26
  as you start them and done as you finish, so a resumed turn or a new
@@ -166,6 +170,50 @@ async function runClearCompleted() {
166
170
  } catch (e) { console.error(e.message); process.exit(1); }
167
171
  }
168
172
 
173
+ async function runArchive(args) {
174
+ const reason = takeFlag(args, "reason");
175
+ const id = args[0];
176
+ if (!id || !reason) {
177
+ console.error('Usage: task archive <id> --reason "done: <proof>" | "replaced: <by what>" | "dropped: <who decided>" | "handed: <to whom>"');
178
+ process.exit(2);
179
+ }
180
+ try {
181
+ const res = await postJson("task-archive", { id, reason });
182
+ if (!res.ok) { console.error(`Not found: ${id}`); process.exit(1); }
183
+ const extra = res.alsoArchived ? ` (and ${res.alsoArchived} subtask${res.alsoArchived === 1 ? "" : "s"})` : "";
184
+ console.log(`Archived ${id}${extra}. Restore with: open-claudia task restore ${id}`);
185
+ process.exit(0);
186
+ } catch (e) { console.error(e.message); process.exit(1); }
187
+ }
188
+
189
+ async function runRestore(args) {
190
+ const id = args[0];
191
+ if (!id) { console.error("Usage: task restore <id>"); process.exit(2); }
192
+ try {
193
+ const res = await postJson("task-restore", { id });
194
+ if (!res.ok) { console.error(`Not in the archive: ${id}`); process.exit(1); }
195
+ const extra = res.alsoRestored ? ` (and ${res.alsoRestored} subtask${res.alsoRestored === 1 ? "" : "s"})` : "";
196
+ console.log(`Restored ${id}${extra}.`);
197
+ process.exit(0);
198
+ } catch (e) { console.error(e.message); process.exit(1); }
199
+ }
200
+
201
+ async function runArchived(args) {
202
+ const limit = takeFlag(args, "limit");
203
+ const search = takeFlag(args, "search");
204
+ try {
205
+ const res = await postJson("task-archived", { ...(limit ? { limit: Number(limit) } : {}), ...(search ? { search } : {}) });
206
+ const list = res.tasks || [];
207
+ if (list.length === 0) { console.log("No archived tasks."); process.exit(0); }
208
+ for (const t of list) {
209
+ const when = t.archivedAt ? new Date(t.archivedAt).toISOString().slice(0, 10) : "?";
210
+ console.log(`${statusMark(t)} ${t.content} (${t.id}) · archived ${when} · ${t.closedReason || "?"}: ${t.reason || ""}`);
211
+ (t.children || []).forEach((c) => console.log(` ${statusMark(c)} ${c.content} (${c.id})`));
212
+ }
213
+ process.exit(0);
214
+ } catch (e) { console.error(e.message); process.exit(1); }
215
+ }
216
+
169
217
  async function run(args) {
170
218
  const sub = args[0];
171
219
  const rest = args.slice(1);
@@ -180,6 +228,9 @@ async function run(args) {
180
228
  case "pending": return runUpdate(rest, "pending");
181
229
  case "remove": case "rm": return runRemove(rest);
182
230
  case "clear-completed": case "clear": return runClearCompleted();
231
+ case "archive": return runArchive(rest);
232
+ case "restore": return runRestore(rest);
233
+ case "archived": return runArchived(rest);
183
234
  default:
184
235
  console.error(`Unknown task subcommand: ${sub}`);
185
236
  console.log(HELP);
package/bot.js CHANGED
@@ -123,6 +123,16 @@ function persist() {
123
123
  async function gracefulShutdown(signal) {
124
124
  console.log(`Received ${signal}, shutting down gracefully...`);
125
125
  try { stopScheduler(); } catch (e) {}
126
+ // Save first: whoever sent SIGTERM kills the process soon after (bot pods
127
+ // allow 15 s, launchd 20 s), and the drain below must not cost the save.
128
+ persist();
129
+ // Planned restart (rollout, launchd stop): no new turns; running ones get
130
+ // DRAIN_SIGTERM_S (6) to finish, the rest stay journaled and resume after
131
+ // boot (core/turn-journal).
132
+ try {
133
+ const journal = require("./core/turn-journal");
134
+ await journal.drain({ timeoutMs: journal.sigtermDrainTimeoutMs() });
135
+ } catch (e) { require("./core/turn-journal").markShuttingDown(); }
126
136
  try { loopback.stop(); } catch (e) {}
127
137
  const sideCleanup = sideChatCoordinator.cancelAll().catch(() => 0);
128
138
  const memoryCleanup = require("./core/pack-review")
@@ -362,6 +372,22 @@ async function safeModeBoot() {
362
372
  }
363
373
  }
364
374
 
375
+ // Turns cut short by the last restart resume (or are offered back), and
376
+ // messages that were queued behind them are restored in order.
377
+ try {
378
+ const resumed = await require("./core/turn-journal").resumeInterrupted(adapters);
379
+ if (resumed.resumed.length || resumed.asked.length || resumed.refused.length || resumed.restored) {
380
+ console.log(`Interrupted turns: ${resumed.resumed.length} resumed, ${resumed.asked.length} offered, ${resumed.refused.length} not resumed, ${resumed.restored} queued restored`);
381
+ }
382
+ } catch (e) { console.error("interrupted-turn resume failed:", e.message); }
383
+
384
+ // Messages a usage limit stopped are picked up when the limit resets
385
+ // (core/limit-pickup.js): a timer for each one still waiting.
386
+ try {
387
+ const waiting = require("./core/limit-pickup").start(adapters);
388
+ if (waiting.length) console.log(`Limit pick-ups waiting: ${waiting.length}`);
389
+ } catch (e) { console.error("limit pick-up start failed:", e.message); }
390
+
365
391
  // Kazee Cards: snoozed owner decisions are asked again when due.
366
392
  try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
367
393
 
@@ -8,10 +8,13 @@ const path = require("path");
8
8
  const https = require("https");
9
9
  const http = require("http");
10
10
  const crypto = require("crypto");
11
+ const dns = require("dns");
12
+ const net = require("net");
11
13
  const { pipeline } = require("stream/promises");
12
14
  const { Transform } = require("stream");
13
15
  const { URL } = require("url");
14
16
  const { TEMP_DIR, FILES_DIR } = require("../../core/config");
17
+ const { safeJoin } = require("../../core/safe-filename");
15
18
  const { canonicalForChannel } = require("../../core/identity");
16
19
  const { inlineKeyboardToPortable } = require("../types");
17
20
  const { createKazeeSocket } = require("./socket");
@@ -129,14 +132,72 @@ const MIME_EXT = {
129
132
  "application/zip": ".zip",
130
133
  };
131
134
 
135
+ // Inbound media never goes past 200 MB. With KAZEE_MEDIA_HOSTS set (comma-
136
+ // separated hosts or URLs), files are fetched only from those hosts and Kazee
137
+ // itself (KAZEE_URL). Without it, Kazee's presigned links on its storage host
138
+ // keep working: any public host is allowed, but never a private, loopback or
139
+ // link-local address (checked when connecting), so a crafted link cannot
140
+ // point the bot at internal services.
141
+ const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
142
+ const DOWNLOAD_IDLE_MS = 60 * 1000;
143
+
144
+ function mediaHostList(mediaHosts) {
145
+ return (Array.isArray(mediaHosts) ? mediaHosts : String(mediaHosts || "").split(","))
146
+ .map((raw) => String(raw || "").trim())
147
+ .filter(Boolean);
148
+ }
149
+
150
+ function mediaHostSet(url, mediaHosts) {
151
+ const hosts = new Set();
152
+ for (const entry of [url, ...mediaHostList(mediaHosts)]) {
153
+ try { hosts.add((entry.includes("://") ? new URL(entry).host : entry).toLowerCase()); } catch (_) {}
154
+ }
155
+ return hosts;
156
+ }
157
+
158
+ const NON_PUBLIC = new net.BlockList();
159
+ for (const [address, prefix] of [
160
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
161
+ ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
162
+ ]) NON_PUBLIC.addSubnet(address, prefix, "ipv4");
163
+ for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) {
164
+ NON_PUBLIC.addSubnet(address, prefix, "ipv6");
165
+ }
166
+
167
+ function isPublicAddress(address) {
168
+ const family = net.isIP(String(address || ""));
169
+ if (!family) return false;
170
+ const mapped = family === 6 && /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
171
+ if (mapped) return !NON_PUBLIC.check(mapped[1], "ipv4");
172
+ return !NON_PUBLIC.check(address, family === 6 ? "ipv6" : "ipv4");
173
+ }
174
+
175
+ // dns.lookup for http(s).get that refuses names resolving to a non-public
176
+ // address. It runs when connecting, so the answer cannot change in between.
177
+ function publicOnlyLookup(hostname, options, callback) {
178
+ dns.lookup(hostname, options, (err, address, family) => {
179
+ if (err) return callback(err);
180
+ const answers = Array.isArray(address) ? address : [{ address, family }];
181
+ if (answers.some((a) => !isPublicAddress(a.address))) {
182
+ console.error(`Kazee download refused: ${hostname} resolves to a non-public address`);
183
+ return callback(Object.assign(new Error(`${hostname} resolves to a non-public address`), { code: "EMEDIAHOST" }));
184
+ }
185
+ callback(null, address, family);
186
+ });
187
+ }
188
+
132
189
  class KazeeAdapter {
133
- constructor({ id = "kazee", url, token, ownerUserId, botUserId, passiveGate = null, spawnUtilityAgent = null }) {
190
+ constructor({ id = "kazee", url, token, ownerUserId, botUserId, mediaHosts = "", passiveGate = null, spawnUtilityAgent = null }) {
134
191
  this.id = id;
135
192
  this.type = "kazee";
136
193
  // KAZEE_URL is the root host (socket.io lives at /). REST routes are
137
194
  // mounted under /api on chat-central; we prepend that internally.
138
195
  this.url = url.replace(/\/$/, "");
139
196
  this.apiBase = this.url + "/api";
197
+ this._mediaHosts = mediaHostSet(this.url, mediaHosts);
198
+ this._mediaHostsListed = mediaHostList(mediaHosts).length > 0;
199
+ try { this._kazeeHost = new URL(this.url).host.toLowerCase(); } catch (_) { this._kazeeHost = ""; }
200
+ this._maxDownloadBytes = MAX_DOWNLOAD_BYTES;
140
201
  this.token = token;
141
202
  this.ownerUserId = ownerUserId || "";
142
203
  this.botUserId = botUserId || null;
@@ -1593,16 +1654,48 @@ class KazeeAdapter {
1593
1654
  const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
1594
1655
  const keepName = media.type === "document" && media.fileName;
1595
1656
  const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
1657
+ const generated = `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
1596
1658
  const localPath = keepName
1597
- ? path.join(baseDir, media.fileName + nameSuffix)
1598
- : path.join(baseDir, `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`);
1659
+ ? safeJoin(baseDir, media.fileName + nameSuffix, generated)
1660
+ : path.join(baseDir, generated);
1599
1661
  await this._downloadUrl(url, localPath);
1600
1662
  return localPath;
1601
1663
  }
1602
1664
 
1665
+ // The parsed URL when it may be fetched; otherwise logs why and throws.
1666
+ _checkedMediaUrl(raw) {
1667
+ let url = null;
1668
+ try { url = new URL(String(raw)); } catch (_) {}
1669
+ if (!url || !["https:", "http:"].includes(url.protocol) || url.username || url.password) {
1670
+ console.error("Kazee download refused: not an http(s) media URL");
1671
+ throw new Error("Invalid media URL");
1672
+ }
1673
+ const host = url.host.toLowerCase();
1674
+ if (this._mediaHostsListed) {
1675
+ if (!this._mediaHosts.has(host)) {
1676
+ console.error(`Kazee download refused: ${host} is not a Kazee media host (allowed: KAZEE_URL's host and KAZEE_MEDIA_HOSTS)`);
1677
+ throw new Error(`${host} is not a Kazee media host`);
1678
+ }
1679
+ } else if (host !== this._kazeeHost) {
1680
+ // Names are checked when connecting (publicOnlyLookup); IP literals never look up.
1681
+ const literal = url.hostname.replace(/^\[|\]$/g, "");
1682
+ if (net.isIP(literal) && !isPublicAddress(literal)) {
1683
+ console.error(`Kazee download refused: ${host} is a non-public address`);
1684
+ throw new Error(`${host} is a non-public address`);
1685
+ }
1686
+ }
1687
+ return url;
1688
+ }
1689
+
1690
+ // Request options for a checked media URL: without a KAZEE_MEDIA_HOSTS list,
1691
+ // anything but Kazee's own host must resolve to public addresses only.
1692
+ _mediaRequestOptions(url) {
1693
+ if (this._mediaHostsListed || url.host.toLowerCase() === this._kazeeHost) return {};
1694
+ return { lookup: publicOnlyLookup };
1695
+ }
1696
+
1603
1697
  async _downloadContextMedia(media) {
1604
- const url = new URL(media.fileId);
1605
- if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid media URL");
1698
+ const url = this._checkedMediaUrl(media.fileId);
1606
1699
  const candidateExt = path.extname(media.fileName || url.pathname).toLowerCase();
1607
1700
  const ext = /^\.[a-z0-9]{1,8}$/.test(candidateExt) ? candidateExt : MIME_EXT[media.mimeType] || ".bin";
1608
1701
  const key = crypto.createHash("sha256").update(media.contextKey).digest("hex");
@@ -1619,7 +1712,7 @@ class KazeeAdapter {
1619
1712
  const timer = setTimeout(() => req?.destroy(new Error("Media download timed out")), Math.min(media.timeoutMs || 8000, 8000));
1620
1713
  try {
1621
1714
  const res = await new Promise((resolve, reject) => {
1622
- req = (url.protocol === "https:" ? https : http).get(url, resolve);
1715
+ req = (url.protocol === "https:" ? https : http).get(url, this._mediaRequestOptions(url), resolve);
1623
1716
  req.on("error", reject);
1624
1717
  });
1625
1718
  if (res.statusCode !== 200 || Number(res.headers["content-length"]) > maxBytes) {
@@ -1741,21 +1834,37 @@ class KazeeAdapter {
1741
1834
  });
1742
1835
  }
1743
1836
 
1744
- _downloadUrl(url, localPath) {
1745
- return new Promise((resolve, reject) => {
1746
- const lib = url.startsWith("https:") ? https : http;
1747
- const out = fs.createWriteStream(localPath);
1748
- lib.get(url, (res) => {
1749
- if (res.statusCode && res.statusCode >= 400) {
1750
- out.destroy();
1751
- reject(new Error(`Download ${url} → ${res.statusCode}`));
1752
- return;
1753
- }
1754
- res.pipe(out);
1755
- out.on("finish", () => { out.close(); resolve(); });
1756
- }).on("error", reject);
1757
- });
1837
+ // Streams to a .part file next to localPath, counting bytes; over the cap
1838
+ // (declared or actual) or idle too long, it aborts and deletes the part.
1839
+ async _downloadUrl(rawUrl, localPath) {
1840
+ const url = this._checkedMediaUrl(rawUrl);
1841
+ const maxBytes = this._maxDownloadBytes;
1842
+ const tooBig = () => new Error(`Kazee media exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB size limit`);
1843
+ const partial = `${localPath}.${crypto.randomUUID()}.part`;
1844
+ let req;
1845
+ try {
1846
+ const res = await new Promise((resolve, reject) => {
1847
+ req = (url.protocol === "https:" ? https : http).get(url, this._mediaRequestOptions(url), resolve);
1848
+ req.on("error", reject);
1849
+ req.setTimeout(DOWNLOAD_IDLE_MS, () => req.destroy(new Error("Kazee download stalled")));
1850
+ });
1851
+ if (res.statusCode < 200 || res.statusCode >= 300) {
1852
+ res.resume();
1853
+ throw new Error(`Download from ${url.host} → ${res.statusCode}`);
1854
+ }
1855
+ if (Number(res.headers["content-length"]) > maxBytes) { res.destroy(); throw tooBig(); }
1856
+ let bytes = 0;
1857
+ await pipeline(res, new Transform({ transform(chunk, enc, done) {
1858
+ bytes += chunk.length;
1859
+ done(bytes > maxBytes ? tooBig() : null, chunk);
1860
+ } }), fs.createWriteStream(partial, { flags: "wx" }));
1861
+ fs.renameSync(partial, localPath);
1862
+ } catch (e) {
1863
+ req?.destroy();
1864
+ try { fs.unlinkSync(partial); } catch (_) {}
1865
+ throw e;
1866
+ }
1758
1867
  }
1759
1868
  }
1760
1869
 
1761
- module.exports = { KazeeAdapter };
1870
+ module.exports = { KazeeAdapter, isPublicAddress, publicOnlyLookup };
@@ -8,6 +8,7 @@ const path = require("path");
8
8
  const https = require("https");
9
9
  const TelegramBot = require("node-telegram-bot-api");
10
10
  const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
11
+ const { safeJoin } = require("../../core/safe-filename");
11
12
  const { canonicalForChannel } = require("../../core/identity");
12
13
  const { portableToInlineKeyboard } = require("../types");
13
14
  const { telegramHtml, htmlToPlain, splitMessage } = require("./format");
@@ -701,9 +702,10 @@ class TelegramAdapter {
701
702
  : media.type === "video" ? ".mp4"
702
703
  : ".bin");
703
704
  const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
705
+ const generated = `tg-${Date.now()}${ext}`;
704
706
  const localPath = media.type === "document" && media.fileName
705
- ? path.join(baseDir, media.fileName)
706
- : path.join(baseDir, `tg-${Date.now()}${ext}`);
707
+ ? safeJoin(baseDir, media.fileName, generated)
708
+ : path.join(baseDir, generated);
707
709
  await new Promise((resolve, reject) => {
708
710
  const out = fs.createWriteStream(localPath);
709
711
  https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
package/core/actions.js CHANGED
@@ -910,6 +910,14 @@ async function handleAction(envelope) {
910
910
  `\nVerification settles identity only; permissions still come from mandates and rules.`
911
911
  );
912
912
  }
913
+ // Interrupted-turn offers (core/turn-journal): Continue / Drop a turn cut
914
+ // off by a restart, or run a message /stop dropped.
915
+ if (d.startsWith("rsm:")) {
916
+ return require("./turn-journal").handleResumeAction(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
917
+ }
918
+ if (d.startsWith("rq:")) {
919
+ return require("./turn-journal").handleRunDropped(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
920
+ }
913
921
  if (d.startsWith("grd:")) {
914
922
  // Guardrail capture buttons (Track C). Owner-only: standing rules layer
915
923
  // above lessons and are never editable by an external speaker.
package/core/config.js CHANGED
@@ -126,6 +126,25 @@ const CLAUDE_PATH = configuredProviderPath("claude");
126
126
  const CODEX_PATH = config.CODEX_PATH || null;
127
127
  const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
128
128
  const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
129
+ // Token diet, on for every conversation by default since 3.5.18 (the 3.5.17
130
+ // canary halved the cost of a reply on the owner's bot). TOKEN_DIET_CONVERSATIONS
131
+ // is "*" (all), a comma-separated list of conversation keys as the usage
132
+ // ledger's `project` field writes them (e.g. kazee:<room>#thread:<root>), or
133
+ // "off". A listed conversation compacts at TOKEN_DIET_COMPACT_TOKENS instead of
134
+ // AUTO_COMPACT_TOKENS, and is compacted before a turn that resumes it after
135
+ // TOKEN_DIET_COLD_MINUTES idle (the prompt cache has expired, so the whole
136
+ // context would be re-written) when its context exceeds TOKEN_DIET_COLD_MIN_TOKENS.
137
+ const TOKEN_DIET_SETTING = String(config.TOKEN_DIET_CONVERSATIONS || process.env.TOKEN_DIET_CONVERSATIONS || "*").trim();
138
+ const TOKEN_DIET_CONVERSATIONS = /^(off|none)$/i.test(TOKEN_DIET_SETTING)
139
+ ? []
140
+ : TOKEN_DIET_SETTING.split(",").map((s) => s.trim()).filter(Boolean);
141
+ const TOKEN_DIET_COMPACT_TOKENS = parseInt(config.TOKEN_DIET_COMPACT_TOKENS || process.env.TOKEN_DIET_COMPACT_TOKENS || "200000", 10);
142
+ const TOKEN_DIET_COLD_MINUTES = parseInt(config.TOKEN_DIET_COLD_MINUTES || process.env.TOKEN_DIET_COLD_MINUTES || "60", 10);
143
+ const TOKEN_DIET_COLD_MIN_TOKENS = parseInt(config.TOKEN_DIET_COLD_MIN_TOKENS || process.env.TOKEN_DIET_COLD_MIN_TOKENS || "120000", 10);
144
+ // How late a cron occurrence may start and still run (node-cron otherwise drops
145
+ // a run more than 1 s late). Daily/user crons: 10 min; the nightly dream: 3 h.
146
+ const CRON_LATE_TOLERANCE_MS = parseInt(config.OC_CRON_LATE_TOLERANCE_MS || process.env.OC_CRON_LATE_TOLERANCE_MS || "600000", 10);
147
+ const DREAM_LATE_TOLERANCE_MS = parseInt(config.DREAM_LATE_TOLERANCE_MS || process.env.DREAM_LATE_TOLERANCE_MS || "10800000", 10);
129
148
  const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
130
149
  // Unified multi-channel identity. Default ON: the owner's Telegram, Kazee and
131
150
  // voice channels resolve to one canonical person, sharing one state, session,
@@ -243,7 +262,7 @@ function loadChannels() {
243
262
  channels.push({
244
263
  id,
245
264
  type: "kazee",
246
- opts: { url, token, ownerUserId, botUserId },
265
+ opts: { url, token, ownerUserId, botUserId, mediaHosts: config.KAZEE_MEDIA_HOSTS || "" },
247
266
  });
248
267
  } else if (type === "spaces") {
249
268
  // Conversational plane for Kazee Spaces. Bot authenticates with its own
@@ -374,6 +393,8 @@ module.exports = {
374
393
  resolvedCodexPath,
375
394
  AUTO_COMPACT_TOKENS,
376
395
  MIN_COMPACT_INTERVAL_MS,
396
+ TOKEN_DIET_CONVERSATIONS, TOKEN_DIET_COMPACT_TOKENS, TOKEN_DIET_COLD_MINUTES, TOKEN_DIET_COLD_MIN_TOKENS,
397
+ CRON_LATE_TOLERANCE_MS, DREAM_LATE_TOLERANCE_MS,
377
398
  PROJECT_TRANSCRIPTS,
378
399
  UNIFIED_IDENTITY,
379
400
  TRANSCRIPT_MAX_ENTRY_CHARS,
@@ -38,6 +38,9 @@ function aggregate(records) {
38
38
  byProject: new Map(),
39
39
  };
40
40
  for (const r of records) {
41
+ // Compaction records (kind "compaction") are not turns; their own cost
42
+ // sits in compactionUsage.
43
+ if (r.kind === "compaction") continue;
41
44
  agg.turns += 1;
42
45
  const cost = Number(r.costUsd) || 0;
43
46
  agg.costUsd += cost;
@@ -0,0 +1,59 @@
1
+ // Cron scheduling that tolerates a late start.
2
+ //
3
+ // node-cron drops an occurrence whose heartbeat fires late: 4.6.0 skips any run
4
+ // more than DEFAULT_MISSED_EXECUTION_TOLERANCE (1000 ms) late, and 4.2.1 (the
5
+ // version package-lock pins) misses it outright, because the late second no
6
+ // longer matches the expression's seconds field. The bot is often 2-3 s late
7
+ // at the top of a minute, so the 04:00 dream ran on 7 of 14 nights and daily
8
+ // crons were skipped. 4.6+ takes a `missedExecutionTolerance` option; for
9
+ // versions without it, a missed execution is run here, once, when the latest
10
+ // slot it belongs to is still within the tolerance (the task's own
11
+ // timeMatcher finds that slot; an occurrence never runs twice).
12
+
13
+ const cron = require("node-cron");
14
+
15
+ function scheduleTolerant(expression, callback, opts = {}) {
16
+ const cronLib = opts.cron || cron;
17
+ const now = opts.now || Date.now;
18
+ const toleranceMs = Number.isFinite(opts.toleranceMs) && opts.toleranceMs >= 0 ? opts.toleranceMs : 0;
19
+ const label = opts.label || expression;
20
+ const noOverlap = opts.noOverlap === true;
21
+ let lastSlot = -Infinity;
22
+ let running = 0;
23
+
24
+ const invoke = async (slot, context) => {
25
+ if (slot <= lastSlot) return undefined; // this occurrence already ran
26
+ if (noOverlap && running > 0) return undefined;
27
+ lastSlot = slot;
28
+ running += 1;
29
+ try { return await callback(context); }
30
+ finally { running -= 1; }
31
+ };
32
+
33
+ const options = { missedExecutionTolerance: toleranceMs };
34
+ if (noOverlap) options.noOverlap = true;
35
+ const task = cronLib.schedule(expression, (context) => {
36
+ const date = context && context.date instanceof Date ? context.date : new Date(now());
37
+ // The slot is the matched time, whole seconds.
38
+ return invoke(Math.floor(date.getTime() / 1000) * 1000, context);
39
+ }, options);
40
+
41
+ const matcher = task && task.timeMatcher && typeof task.timeMatcher.match === "function" ? task.timeMatcher : null;
42
+ if (task && typeof task.on === "function" && matcher && toleranceMs > 0) {
43
+ const stepMs = String(expression).trim().split(/\s+/).length >= 6 ? 1000 : 60000;
44
+ task.on("execution:missed", () => {
45
+ const at = now();
46
+ let slot = Math.floor(at / stepMs) * stepMs;
47
+ for (; slot >= at - toleranceMs; slot -= stepMs) {
48
+ if (matcher.match(new Date(slot))) break;
49
+ }
50
+ if (slot < at - toleranceMs || slot <= lastSlot) return;
51
+ console.log(`[cron] ${label}: running the ${new Date(slot).toISOString()} occurrence ${Math.round((at - slot) / 1000)}s late (tolerance ${Math.round(toleranceMs / 1000)}s)`);
52
+ Promise.resolve(invoke(slot, { date: new Date(slot), late: true }))
53
+ .catch((error) => console.error(`[cron] ${label}: late run failed: ${error?.message || error}`));
54
+ });
55
+ }
56
+ return task;
57
+ }
58
+
59
+ module.exports = { scheduleTolerant };