pi-roundtable 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.10] - 2026-10-03
9
+
10
+ ### Added
11
+
12
+ - Kit: the core's compaction tiers for a host that builds its own Pi session, such as a sandbox worker: `CompactionTiers`, `compactionEngine`, `SOFT_COMPACT_TOKENS`, `HARD_COMPACT_TOKENS`, `COMPACT_HEADROOM_TOKENS`, and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`.
13
+ - Sandbox: Pi worker sessions compact with those tiers, through an optional host compactor (`compaction` on `PiSandboxRuntime`) at 300,000 tokens and Pi's summary past 500,000, and the host logs each compaction and fallback per channel.
14
+
15
+ ### Changed
16
+
17
+ - Sandbox: a failed Pi turn keeps its cause and is logged, the broker logs upstream failures, a timed-out worker's last log lines are logged before its container is removed, and Pi containers log to journald tagged `sandbox/<channel>` by default instead of local files (the Docker daemon must have journald).
18
+
19
+ ## [0.7.9] - 2026-10-03
20
+
21
+ ### Added
22
+
23
+ - Schedule prechecks: host code registers a named check with `PRECHECKS` (`register`, as `register({ name, description, timeoutMs?, run })`, provided by the new built-in `prechecks` plugin), and a schedule may name one in the new `precheck` parameter of `schedule_create` and `schedule_update` (`null` removes it). When the schedule falls due, the scheduler takes it as before and then runs the check: `{ wake: false, note? }` skips the turn and posts the note as the bot's own small message, `{ wake: true, context }` runs the turn with the context under a "Precheck found" heading, and a throw or a timeout (`PRECHECK_TIMEOUT_MS`, 60 seconds by default) runs it with the error. `schedule_list` and `/<root> schedule list` show a schedule's precheck and the last outcome, and `schedule_list` names the registered prechecks with their descriptions. New types: `Precheck`, `PrecheckContext`, `PrecheckResult`, `PrecheckRegistry`, `PrecheckFinding`. `Schedule`, `NewSchedule`, and `ScheduleChange` gain an optional `precheck`; `BackgroundTurns.runScheduled` takes the `PrecheckFinding` as an optional third argument. The `schedules` table gains a nullable `precheck` column through the new `schedules-precheck` migration.
24
+ - `pi-roundtable/testing`: `fakePrecheck` (`fakePrecheck(name, answer, options?)`, a precheck that answers as the test says and records its calls) and `fakePrechecks` (a real in-memory registry with the given prechecks registered), with the types `FakePrecheck` and `FakePrecheckAnswer`.
25
+
8
26
  ## [0.7.8] - 2026-10-03
9
27
 
10
28
  ### Fixed
package/docs/plugins.md CHANGED
@@ -10,7 +10,7 @@ Each example has a test next to it that runs it without Discord or PostgreSQL, e
10
10
  A plugin is an object with a name and a `setup` function.
11
11
  `setup` returns the parts the plugin adds to the bot: tools agents can call, text added to their prompt, agents to create, handlers for events, long-lived services, slash commands, HTTP routes, and so on.
12
12
  The bot itself is assembled from plugins too.
13
- The core ships built-in plugins for its memory and schedule stores, Discord connection, agent server, notifications, delegation, and schedules.
13
+ The core ships built-in plugins for its memory and schedule stores, schedule prechecks, Discord connection, agent server, notifications, delegation, and schedules.
14
14
  You can switch off three [addons](#addons-memory-skills-and-discord-administration): memory, skills, and Discord administration.
15
15
  Your plugins are added after them and can read or replace the built-ins' [keyed services](#services-what-plugins-provide-to-each-other).
16
16
 
@@ -61,10 +61,13 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
61
61
  Ordinary 48 MP images and thin 9000×1 images remain admitted.
62
62
  This helper is not a downloader or filesystem validator.
63
63
  - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
64
+ - Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
65
+ It scans only the first `max * 4` characters (at least 4,096), in linear time, so pass it git, gh or provider error text before showing that text to a user.
64
66
  - Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
65
67
  - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
66
68
  - Mirroring a built-in tool in a worker that cannot reach the host: `SCHEDULE_TOOLS`, `scheduleToolSpecs({ locale, timeZone })`, `isScheduleTool`, `callScheduleTool`, `DELEGATE_TOOL` and `DELEGATE_TOOL_SPEC`.
67
69
  The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
70
+ - Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass)` to hold a compaction extension back past the ceiling, `latest()`), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`.
68
71
  - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
69
72
  - Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
70
73
 
@@ -164,6 +167,7 @@ The built-in plugins provide these, from the main entry:
164
167
  | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on, `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
165
168
  | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
166
169
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
170
+ | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list` |
167
171
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(id)` gives that speaker's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
168
172
  | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
169
173
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
@@ -740,6 +744,62 @@ export function heartbeat(everyMs: number, beat: () => Promise<void> | void) {
740
744
  ```
741
745
  <!-- /example -->
742
746
 
747
+ #### Prechecks: wake a schedule only when it has work
748
+
749
+ A schedule whose answer is "all normal" on most days still costs a model turn each time it fires.
750
+ A precheck is host code that runs first and decides whether the turn runs at all.
751
+ Register it with `services.get(PRECHECKS).register({ name, description, timeoutMs?, run })` during setup; the agent attaches it to a schedule by name with the `precheck` parameter of `schedule_create` or `schedule_update` (`null` removes it), and `schedule_list` shows the registered names with their descriptions.
752
+ The model only picks a name: it never supplies code or a command, so attaching a precheck grants nothing beyond what the schedule already has, and the same tier rules apply.
753
+
754
+ When a schedule with a precheck falls due, the scheduler takes it first (moves it to its next run, or deletes a one-time schedule) exactly as before, so a slow precheck never fires it twice, and then calls `run({ schedule, firedAt, signal })`:
755
+
756
+ - `{ wake: false, note? }` skips the turn. A `note` is posted in the schedule's channel as the bot's own small message, which starts no turn. The last status reads `skipped by precheck (note)`.
757
+ - `{ wake: true, context }` runs the turn; its text carries `context` under a `### Precheck found (<name>):` heading after the prompt. The last status reads `woken by precheck; ran`.
758
+ - A throw, a wrong answer, a name no longer registered, or running past `timeoutMs` (default `PRECHECK_TIMEOUT_MS`, 60 seconds; `signal` is aborted then) runs the turn with the error under `### Precheck failed: <name>`, so the agent can look into it. The error is logged, and the last status reads `precheck failed (…), woke; ran`.
759
+
760
+ A name is lower-case letters, digits, `.`, `_`, and `-`; registering one twice throws a `PluginError`.
761
+ A `BackgroundTurns` of your own receives what the precheck found as `runScheduled`'s third argument, a `PrecheckFinding`.
762
+
763
+ <!-- example: examples/prechecks.ts -->
764
+ ```ts
765
+ import { definePlugin, PRECHECKS } from "pi-roundtable";
766
+
767
+ /** Last night's reading and its usual level, from wherever the host keeps them. */
768
+ export interface RecoveryReading {
769
+ hrv: number;
770
+ baseline: number;
771
+ }
772
+
773
+ /**
774
+ * A precheck a daily schedule can name: the host reads the numbers first and wakes the agent
775
+ * only when they are off, so an ordinary morning costs no model turn.
776
+ */
777
+ export function recoveryPrecheck(read: () => Promise<RecoveryReading>) {
778
+ return definePlugin({
779
+ name: "recovery-precheck",
780
+ setup: ({ services }) => {
781
+ services.get(PRECHECKS).register({
782
+ name: "health.recovery",
783
+ description:
784
+ "Reads last night's HRV; wakes you when it is a fifth or more under its baseline.",
785
+ timeoutMs: 15_000,
786
+ run: async () => {
787
+ const { hrv, baseline } = await read();
788
+ if (hrv >= baseline * 0.8)
789
+ return { wake: false, note: `HRV ${hrv} ms, as usual.` };
790
+ return {
791
+ wake: true,
792
+ context: `HRV ${hrv} ms against a baseline of ${baseline} ms.`,
793
+ };
794
+ },
795
+ });
796
+ return {};
797
+ },
798
+ });
799
+ }
800
+ ```
801
+ <!-- /example -->
802
+
743
803
  ### `migrations` and `context.database()`: tables of your own
744
804
 
745
805
  Declare `migrations` on the plugin object.
@@ -2074,6 +2134,7 @@ Call `useTestLocale()` after a test changes the process-wide locale or time zone
2074
2134
  `recordingLogger()` is a logger that keeps what it is asked to write: its `lines` hold each call's `level`, `fields` (those of a `child` included), and `message`, for a test of what the code logs.
2075
2135
  Use `partial<Port>({ ... })` to stand in for a port your code takes as an argument.
2076
2136
  It provides the members you give it and throws an error naming any missing member you read, so the test needs no `as unknown as Port` cast.
2137
+ `fakePrecheck(name, answer, { description?, timeoutMs? })` is a precheck that answers `answer` (a `PrecheckResult`, an `Error` it throws, or a function of its context) and records each context in `calls`; `fakePrechecks(...prechecks)` is a real in-memory `PrecheckRegistry` with them registered, which a test gives a plugin as `servicePair(PRECHECKS, registry)` and reads back with `registry.get(name)`.
2077
2138
  `fakeDiscord({ ownerId?, rootCommand? })` is the `DISCORD` service for a plugin that adds slash commands: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`.
2078
2139
  Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
2079
2140
 
@@ -2439,6 +2500,13 @@ Import from the entries listed below; source area files are internal.
2439
2500
  | `RuntimeDeps` | `pi-roundtable` | type |
2440
2501
  | `RuntimeFactory` | `pi-roundtable` | type |
2441
2502
  | `SCHEDULES` | `pi-roundtable` | value |
2503
+ | `PRECHECKS` | `pi-roundtable` | value |
2504
+ | `PRECHECK_TIMEOUT_MS` | `pi-roundtable` | value |
2505
+ | `Precheck` | `pi-roundtable` | type |
2506
+ | `PrecheckContext` | `pi-roundtable` | type |
2507
+ | `PrecheckFinding` | `pi-roundtable` | type |
2508
+ | `PrecheckRegistry` | `pi-roundtable` | type |
2509
+ | `PrecheckResult` | `pi-roundtable` | type |
2442
2510
  | `SKILLS` | `pi-roundtable` | value |
2443
2511
  | `Schedule` | `pi-roundtable` | type |
2444
2512
  | `ScheduleChange` | `pi-roundtable` | type |
@@ -2513,6 +2581,10 @@ Import from the entries listed below; source area files are internal.
2513
2581
  | `describeDb` | `pi-roundtable/testing` | value |
2514
2582
  | `eagerText` | `pi-roundtable/testing` | value |
2515
2583
  | `fakeDiscord` | `pi-roundtable/testing` | value |
2584
+ | `fakePrecheck` | `pi-roundtable/testing` | value |
2585
+ | `fakePrechecks` | `pi-roundtable/testing` | value |
2586
+ | `FakePrecheck` | `pi-roundtable/testing` | type |
2587
+ | `FakePrecheckAnswer` | `pi-roundtable/testing` | type |
2516
2588
  | `fakeThreads` | `pi-roundtable/testing` | value |
2517
2589
  | `openTestStore` | `pi-roundtable/testing` | value |
2518
2590
  | `servicePair` | `pi-roundtable/testing` | value |
@@ -2594,6 +2666,14 @@ Import from the entries listed below; source area files are internal.
2594
2666
  | `formatModelRef` | `pi-roundtable/kit` | value |
2595
2667
  | `headline` | `pi-roundtable/kit` | value |
2596
2668
  | `holdChain` | `pi-roundtable/kit` | value |
2669
+ | `COMPACT_HEADROOM_TOKENS` | `pi-roundtable/kit` | value |
2670
+ | `CompactionTiers` | `pi-roundtable/kit` | value |
2671
+ | `compactionEngine` | `pi-roundtable/kit` | value |
2672
+ | `HARD_COMPACT_TOKENS` | `pi-roundtable/kit` | value |
2673
+ | `SOFT_COMPACT_TOKENS` | `pi-roundtable/kit` | value |
2674
+ | `CompactionEngine` | `pi-roundtable/kit` | type |
2675
+ | `CompactionHistory` | `pi-roundtable/kit` | type |
2676
+ | `LatestCompaction` | `pi-roundtable/kit` | type |
2597
2677
  | `isScheduleTool` | `pi-roundtable/kit` | value |
2598
2678
  | `lastAssistant` | `pi-roundtable/kit` | value |
2599
2679
  | `mcpAdapterExtension` | `pi-roundtable/kit` | value |
@@ -0,0 +1,30 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PRECHECKS, type Schedule } from "pi-roundtable";
3
+ import { fakePrechecks, servicePair, testPlugin } from "pi-roundtable/testing";
4
+ import { recoveryPrecheck } from "./prechecks.ts";
5
+
6
+ test("the precheck stays quiet on a normal reading and wakes the agent on a low one", async () => {
7
+ const prechecks = fakePrechecks();
8
+ let reading = { hrv: 52, baseline: 55 };
9
+ const harness = await testPlugin(
10
+ recoveryPrecheck(async () => reading),
11
+ { services: [servicePair(PRECHECKS, prechecks)] },
12
+ );
13
+ const precheck = prechecks.get("health.recovery");
14
+ if (!precheck) throw new Error("the plugin registered no precheck");
15
+ const context = {
16
+ schedule: { id: 1, title: "recovery" } as Schedule,
17
+ firedAt: new Date(),
18
+ signal: new AbortController().signal,
19
+ };
20
+ expect(await precheck.run(context)).toEqual({
21
+ wake: false,
22
+ note: "HRV 52 ms, as usual.",
23
+ });
24
+ reading = { hrv: 31, baseline: 55 };
25
+ expect(await precheck.run(context)).toEqual({
26
+ wake: true,
27
+ context: "HRV 31 ms against a baseline of 55 ms.",
28
+ });
29
+ await harness.stop();
30
+ });
@@ -0,0 +1,35 @@
1
+ import { definePlugin, PRECHECKS } from "pi-roundtable";
2
+
3
+ /** Last night's reading and its usual level, from wherever the host keeps them. */
4
+ export interface RecoveryReading {
5
+ hrv: number;
6
+ baseline: number;
7
+ }
8
+
9
+ /**
10
+ * A precheck a daily schedule can name: the host reads the numbers first and wakes the agent
11
+ * only when they are off, so an ordinary morning costs no model turn.
12
+ */
13
+ export function recoveryPrecheck(read: () => Promise<RecoveryReading>) {
14
+ return definePlugin({
15
+ name: "recovery-precheck",
16
+ setup: ({ services }) => {
17
+ services.get(PRECHECKS).register({
18
+ name: "health.recovery",
19
+ description:
20
+ "Reads last night's HRV; wakes you when it is a fifth or more under its baseline.",
21
+ timeoutMs: 15_000,
22
+ run: async () => {
23
+ const { hrv, baseline } = await read();
24
+ if (hrv >= baseline * 0.8)
25
+ return { wake: false, note: `HRV ${hrv} ms, as usual.` };
26
+ return {
27
+ wake: true,
28
+ context: `HRV ${hrv} ms against a baseline of ${baseline} ms.`,
29
+ };
30
+ },
31
+ });
32
+ return {};
33
+ },
34
+ });
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
3
  import type { SurfacePort } from "../contract/surface.ts";
4
4
  import { scheduleCommands } from "../discord/schedule-commands.ts";
5
5
  import type { ChannelKey } from "../domain/conversation.ts";
6
+ import { messages } from "../i18n/index.ts";
6
7
  import type { OwnerIdentity } from "../identity.ts";
7
8
  import type { ModelRef, ThinkingLevel } from "../models.ts";
8
9
  import { ConversationBackgroundTurns } from "../modules/background/background-turns.ts";
@@ -20,6 +21,7 @@ import {
20
21
  AGENTS,
21
22
  BACKGROUND_TURNS,
22
23
  DELEGATION,
24
+ PRECHECKS,
23
25
  SCHEDULES,
24
26
  } from "../services.ts";
25
27
  import type { SessionContext } from "../sessions.ts";
@@ -77,6 +79,8 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
77
79
  provides: [BACKGROUND_TURNS, DELEGATION],
78
80
  setup: ({ conversations, services, logger, surfaces }) => {
79
81
  const schedules = services.get(SCHEDULES);
82
+ // Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
83
+ const prechecks = services.find(PRECHECKS);
80
84
  const discord = services.get(DISCORD);
81
85
  const { connection } = discord;
82
86
  // Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
@@ -121,6 +125,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
121
125
  store: schedules,
122
126
  owner: { id: owner.id, name: owner.name },
123
127
  channelFor: ownerChannelFor,
128
+ ...(prechecks ? { prechecks } : {}),
124
129
  },
125
130
  session.homeChannel,
126
131
  served(session),
@@ -155,11 +160,24 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
155
160
  export function schedulerPlugin(): RoundtablePlugin {
156
161
  return {
157
162
  name: "schedules",
158
- setup: ({ conversations, services, env, logger }) => {
163
+ setup: ({ conversations, services, env, logger, surfaces }) => {
159
164
  const schedules = services.get(SCHEDULES);
165
+ const prechecks = services.find(PRECHECKS);
160
166
  const scheduler = new Scheduler({
161
167
  store: schedules,
162
168
  runner: services.get(BACKGROUND_TURNS),
169
+ ...(prechecks ? { prechecks } : {}),
170
+ // The bot's own message: the surface drops it, so it starts no turn.
171
+ notify: (schedule, note) =>
172
+ surfaces.sendReply(schedule.channel, {
173
+ chunks: [
174
+ messages().schedulePrecheckNote(
175
+ schedule.id,
176
+ schedule.title,
177
+ note,
178
+ ),
179
+ ],
180
+ }),
163
181
  logger,
164
182
  });
165
183
  // Without Discord there are no schedule commands; the scheduler still fires.
@@ -1,9 +1,10 @@
1
1
  import type { OwnerIdentity } from "../identity.ts";
2
2
  import { ownerMemoryExtension } from "../modules/memory/owner-memory.ts";
3
3
  import { PgMemoryStore } from "../modules/memory/owner-memory-store.ts";
4
+ import { memoryPrecheckRegistry } from "../modules/schedules/prechecks.ts";
4
5
  import { PgScheduleStore } from "../modules/schedules/schedule-store.ts";
5
6
  import type { RoundtablePlugin } from "../plugin.ts";
6
- import { MEMORY, SCHEDULES } from "../services.ts";
7
+ import { MEMORY, PRECHECKS, SCHEDULES } from "../services.ts";
7
8
  import { THE_SPEAKER, type Tier } from "../speakers.ts";
8
9
  import { fixed } from "./session-tool.ts";
9
10
 
@@ -56,7 +57,7 @@ export function memoryPlugin(options: MemoryOptions): RoundtablePlugin {
56
57
  export function scheduleStorePlugin(): RoundtablePlugin {
57
58
  return {
58
59
  name: "schedule-store",
59
- migrations: [PgScheduleStore.migration],
60
+ migrations: PgScheduleStore.migrations(),
60
61
  provides: [SCHEDULES],
61
62
  setup: async ({ database, services }) => {
62
63
  services.provide(SCHEDULES, await PgScheduleStore.attach(database()));
@@ -64,3 +65,18 @@ export function scheduleStorePlugin(): RoundtablePlugin {
64
65
  },
65
66
  };
66
67
  }
68
+
69
+ /**
70
+ * The registry of the host's prechecks, provided as `PRECHECKS`; the plugins after it register
71
+ * theirs during setup. Apart from the store, so a plugin that replaces the store need not provide it.
72
+ */
73
+ export function precheckPlugin(): RoundtablePlugin {
74
+ return {
75
+ name: "prechecks",
76
+ provides: [PRECHECKS],
77
+ setup: ({ services }) => {
78
+ services.provide(PRECHECKS, memoryPrecheckRegistry());
79
+ return {};
80
+ },
81
+ };
82
+ }
@@ -8,7 +8,11 @@ import { discordAdminPlugin } from "./builtin/discord-admin.ts";
8
8
  import { modulesPlugin, schedulerPlugin } from "./builtin/modules.ts";
9
9
  import { seedsPlugin } from "./builtin/seeds.ts";
10
10
  import { skillsPlugin } from "./builtin/skills.ts";
11
- import { memoryPlugin, scheduleStorePlugin } from "./builtin/stores.ts";
11
+ import {
12
+ memoryPlugin,
13
+ precheckPlugin,
14
+ scheduleStorePlugin,
15
+ } from "./builtin/stores.ts";
12
16
  import { type RoundtableConfig, resolveConfig } from "./config/config.ts";
13
17
  import { ConfigError } from "./domain/errors.ts";
14
18
  import { JudgeError } from "./errors.ts";
@@ -163,6 +167,7 @@ export async function defineRoundtable(
163
167
  plugins: [
164
168
  ...(config.memory ? [memoryPlugin({ owner })] : []),
165
169
  scheduleStorePlugin(),
170
+ precheckPlugin(),
166
171
  discordPlugin({
167
172
  token: discord.token,
168
173
  ownerId: owner.id,
@@ -76,6 +76,9 @@ function section(schedule: Schedule, label: TargetLabel): string {
76
76
  unix(schedule.nextRun),
77
77
  ),
78
78
  text.scheduleSetBy(plain(schedule.createdByName), last),
79
+ ...(schedule.precheck
80
+ ? [text.schedulePrecheck(plain(schedule.precheck))]
81
+ : []),
79
82
  `-# ${plain(prompt)}`,
80
83
  ].join("\n");
81
84
  }
@@ -48,6 +48,9 @@ export function schedulesEn(ctx: CatalogContext) {
48
48
  `${recurrence}; next run <t:${unix}:f>`,
49
49
  scheduleSetBy: (name: string, lastRun: string) =>
50
50
  `Set by ${name}${lastRun}`,
51
+ schedulePrecheck: (name: string) => `Checked first by precheck ${name}`,
52
+ schedulePrecheckNote: (id: number, title: string, note: string) =>
53
+ `-# Schedule #${id} ${title}, skipped by its precheck: ${note.replace(/\n+/g, "\n-# ")}`,
51
54
  scheduleChoice: (id: number, title: string, recurrence: string) =>
52
55
  `#${id} ${title} (${recurrence})`,
53
56
  scheduleTitle: "Schedules",
@@ -87,6 +90,9 @@ export function schedulesZhTW(
87
90
  `${recurrence};下次 <t:${unix}:f>`,
88
91
  scheduleSetBy: (name: string, lastRun: string) =>
89
92
  `由 ${name} 設定${lastRun}`,
93
+ schedulePrecheck: (name: string) => `先由預檢 ${name} 判斷`,
94
+ schedulePrecheckNote: (id: number, title: string, note: string) =>
95
+ `-# 排程 #${id} ${title} 經預檢略過:${note.replace(/\n+/g, "\n-# ")}`,
90
96
  scheduleChoice: (id: number, title: string, recurrence: string) =>
91
97
  `#${id} ${title}(${recurrence})`,
92
98
  scheduleTitle: "排程",
@@ -12,6 +12,7 @@ import {
12
12
  type DelegationOutcome,
13
13
  delegatedTurnText,
14
14
  } from "../delegation/delegator.ts";
15
+ import type { PrecheckFinding } from "../schedules/prechecks.ts";
15
16
  import type { Schedule } from "../schedules/schedule-store.ts";
16
17
  import { scheduledTurnText } from "../schedules/schedule-tools.ts";
17
18
 
@@ -33,15 +34,19 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
33
34
  this.#options = options;
34
35
  }
35
36
 
36
- /** A due schedule's turn, run as its creator's. */
37
- runScheduled(schedule: Schedule, firedAt: Date): Promise<ScheduledOutcome> {
37
+ /** A due schedule's turn, run as its creator's; with what its precheck found, when it has one. */
38
+ runScheduled(
39
+ schedule: Schedule,
40
+ firedAt: Date,
41
+ finding?: PrecheckFinding,
42
+ ): Promise<ScheduledOutcome> {
38
43
  return this.#options.conversations.background({
39
44
  channel: schedule.channel,
40
45
  target: schedule.target,
41
46
  author: { id: schedule.createdById, name: schedule.createdByName },
42
47
  tier: schedule.createdTier,
43
48
  turnId: `schedule-${schedule.id}-${firedAt.getTime()}`,
44
- text: scheduledTurnText(schedule, firedAt),
49
+ text: scheduledTurnText(schedule, firedAt, finding),
45
50
  });
46
51
  }
47
52
 
@@ -0,0 +1,156 @@
1
+ import { PluginError } from "../../errors.ts";
2
+ import type { Schedule } from "./schedule-store.ts";
3
+
4
+ /** How long a precheck may run before it counts as failed, unless it sets its own `timeoutMs`. */
5
+ export const PRECHECK_TIMEOUT_MS = 60_000;
6
+
7
+ /** A precheck name: lower-case letters, digits, `.`, `_`, and `-`, starting with a letter or digit. */
8
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
9
+
10
+ /** What a precheck decides about a due schedule. */
11
+ export type PrecheckResult =
12
+ | {
13
+ /** Skip the turn. A note is posted in the schedule's channel as the bot's own small message, which starts no turn. */
14
+ wake: false;
15
+ note?: string;
16
+ }
17
+ | {
18
+ /** Start the scheduled turn; its text carries `context` under a "Precheck found" heading. */
19
+ wake: true;
20
+ context: string;
21
+ };
22
+
23
+ /** What a precheck is given when its schedule falls due. */
24
+ export interface PrecheckContext {
25
+ /** The due schedule, already moved to its next run (or deleted, when it runs once). */
26
+ schedule: Schedule;
27
+ firedAt: Date;
28
+ /** Aborted when the precheck runs out of time; its answer is then ignored. */
29
+ signal: AbortSignal;
30
+ }
31
+
32
+ /**
33
+ * A cheap check the host runs before a schedule's turn, so the agent is woken only when there is
34
+ * something to do. Host code registers it by name; the model can only attach a registered name to
35
+ * a schedule, never supply code or a command.
36
+ */
37
+ export interface Precheck {
38
+ /** Unique on the host, such as `"health.recovery"`: lower-case letters, digits, `.`, `_`, `-`. */
39
+ name: string;
40
+ /** What it checks and when it wakes the agent, shown to the model by schedule_list. */
41
+ description: string;
42
+ /** How long it may run; default 60 seconds. A timeout counts as a throw. */
43
+ timeoutMs?: number;
44
+ /** Decides whether the turn runs. A throw starts the turn with the error, so the agent can look into it. */
45
+ run(context: PrecheckContext): PrecheckResult | Promise<PrecheckResult>;
46
+ }
47
+
48
+ /** The host's prechecks, by name. Provided as `PRECHECKS` by the `prechecks` plugin. */
49
+ export interface PrecheckRegistry {
50
+ /** Adds a precheck; throws PluginError for a bad or repeated name, an empty description, or a bad timeout. */
51
+ register(precheck: Precheck): void;
52
+ get(name: string): Precheck | undefined;
53
+ /** Every registered precheck, by name. */
54
+ list(): readonly Precheck[];
55
+ }
56
+
57
+ /** What came of a precheck that let the turn run, carried into the turn's text. */
58
+ export type PrecheckFinding =
59
+ | { precheck: string; context: string }
60
+ | { precheck: string; error: string };
61
+
62
+ /** The decision of one precheck run, with a throw, a timeout, or a bad answer as `failed`. */
63
+ export type PrecheckOutcome =
64
+ | { kind: "skip"; note?: string }
65
+ | { kind: "wake"; context: string }
66
+ | { kind: "failed"; error: string };
67
+
68
+ /**
69
+ * Prechecks kept in memory, as the host registers them while its plugins set up. Its methods
70
+ * close over the map instead of private fields, so a test may hand it over through a proxy.
71
+ */
72
+ export function memoryPrecheckRegistry(): PrecheckRegistry {
73
+ const prechecks = new Map<string, Precheck>();
74
+ return {
75
+ register(precheck) {
76
+ const { name, description, timeoutMs, run } = precheck ?? {};
77
+ if (typeof name !== "string" || !NAME.test(name))
78
+ throw new PluginError(
79
+ `a precheck needs a name of lower-case letters, digits, ".", "_", and "-", such as "health.recovery"; got ${JSON.stringify(name)}.`,
80
+ );
81
+ if (prechecks.has(name))
82
+ throw new PluginError(
83
+ `precheck ${name} is already registered; give each precheck its own name.`,
84
+ );
85
+ if (typeof description !== "string" || !description.trim())
86
+ throw new PluginError(
87
+ `precheck ${name} needs a description: what it checks and when it wakes the agent.`,
88
+ );
89
+ if (
90
+ timeoutMs !== undefined &&
91
+ !(Number.isInteger(timeoutMs) && timeoutMs > 0)
92
+ )
93
+ throw new PluginError(
94
+ `precheck ${name}: timeoutMs must be a positive whole number of milliseconds; got ${String(timeoutMs)}.`,
95
+ );
96
+ if (typeof run !== "function")
97
+ throw new PluginError(`precheck ${name} needs a run function.`);
98
+ prechecks.set(name, Object.freeze({ ...precheck }));
99
+ },
100
+ get: (name) => prechecks.get(name),
101
+ list: () =>
102
+ [...prechecks.values()].sort((a, b) => a.name.localeCompare(b.name)),
103
+ };
104
+ }
105
+
106
+ function errorText(error: unknown): string {
107
+ return error instanceof Error ? error.message || error.name : String(error);
108
+ }
109
+
110
+ /** Checks a precheck's answer, so a wrong shape fails like a throw instead of waking silently. */
111
+ function decided(result: unknown): PrecheckOutcome {
112
+ if (result && typeof result === "object" && "wake" in result) {
113
+ const answer = result as Record<string, unknown>;
114
+ if (answer.wake === false) {
115
+ const note = typeof answer.note === "string" ? answer.note.trim() : "";
116
+ return note ? { kind: "skip", note } : { kind: "skip" };
117
+ }
118
+ if (answer.wake === true && typeof answer.context === "string")
119
+ return { kind: "wake", context: answer.context };
120
+ }
121
+ return {
122
+ kind: "failed",
123
+ error: `it answered ${JSON.stringify(result)}, not { wake: false, note? } or { wake: true, context }`,
124
+ };
125
+ }
126
+
127
+ /** Runs one precheck within its timeout; never rejects. */
128
+ export async function runPrecheck(
129
+ precheck: Precheck,
130
+ context: Omit<PrecheckContext, "signal">,
131
+ ): Promise<PrecheckOutcome> {
132
+ const timeoutMs = precheck.timeoutMs ?? PRECHECK_TIMEOUT_MS;
133
+ const abort = new AbortController();
134
+ let timer: ReturnType<typeof setTimeout> | undefined;
135
+ const timeout = new Promise<PrecheckOutcome>((resolve) => {
136
+ timer = setTimeout(() => {
137
+ abort.abort();
138
+ resolve({
139
+ kind: "failed",
140
+ error: `it did not answer within ${timeoutMs / 1000} seconds`,
141
+ });
142
+ }, timeoutMs);
143
+ });
144
+ const answer = (async (): Promise<PrecheckOutcome> => {
145
+ try {
146
+ return decided(await precheck.run({ ...context, signal: abort.signal }));
147
+ } catch (error) {
148
+ return { kind: "failed", error: `it threw: ${errorText(error)}` };
149
+ }
150
+ })();
151
+ try {
152
+ return await Promise.race([answer, timeout]);
153
+ } finally {
154
+ clearTimeout(timer);
155
+ }
156
+ }
@@ -19,6 +19,8 @@ export interface Schedule {
19
19
  /** The creator's tier when it was set; the schedule runs at it and lower tiers may not change it. */
20
20
  createdTier: Tier;
21
21
  createdAt: Date;
22
+ /** The name of the precheck the host runs before its turn; absent, the turn always runs. */
23
+ precheck?: string;
22
24
  lastRun?: Date;
23
25
  lastStatus?: string;
24
26
  }
@@ -33,6 +35,8 @@ export interface NewSchedule {
33
35
  createdById: string;
34
36
  createdByName: string;
35
37
  createdTier: Tier;
38
+ /** A registered precheck's name, run before each turn. */
39
+ precheck?: string;
36
40
  }
37
41
 
38
42
  export interface ScheduleChange {
@@ -40,6 +44,8 @@ export interface ScheduleChange {
40
44
  prompt?: string;
41
45
  recurrence?: Recurrence;
42
46
  nextRun?: Date;
47
+ /** A registered precheck's name to run before each turn; null removes the schedule's precheck. */
48
+ precheck?: string | null;
43
49
  }
44
50
 
45
51
  interface Row {
@@ -55,6 +61,7 @@ interface Row {
55
61
  created_by_name: string;
56
62
  created_tier: Tier;
57
63
  created_at: Date;
64
+ precheck: string | null;
58
65
  last_run: Date | null;
59
66
  last_status: string | null;
60
67
  }
@@ -73,6 +80,7 @@ function toSchedule(row: Row): Schedule {
73
80
  createdByName: row.created_by_name,
74
81
  createdTier: row.created_tier,
75
82
  createdAt: row.created_at,
83
+ ...(row.precheck ? { precheck: row.precheck } : {}),
76
84
  ...(row.last_run ? { lastRun: row.last_run } : {}),
77
85
  ...(row.last_status ? { lastStatus: row.last_status } : {}),
78
86
  };
@@ -112,6 +120,19 @@ export class PgScheduleStore implements ScheduleStore {
112
120
  },
113
121
  };
114
122
 
123
+ /** The table, then the precheck each schedule may name; schedules made before prechecks have none. */
124
+ static migrations(): Migration[] {
125
+ return [
126
+ PgScheduleStore.migration,
127
+ {
128
+ name: "schedules-precheck",
129
+ up: async (sql) => {
130
+ await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck text`;
131
+ },
132
+ },
133
+ ];
134
+ }
135
+
115
136
  /** The store over the host's migrated pool. */
116
137
  static async attach(sql: SQL): Promise<PgScheduleStore> {
117
138
  return new PgScheduleStore(sql);
@@ -120,10 +141,11 @@ export class PgScheduleStore implements ScheduleStore {
120
141
  async create(schedule: NewSchedule): Promise<Schedule> {
121
142
  const rows: Row[] = await this.#sql`
122
143
  INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
123
- created_by_id, created_by_name, created_tier)
144
+ created_by_id, created_by_name, created_tier, precheck)
124
145
  VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
125
146
  ${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
126
- ${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier})
147
+ ${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
148
+ ${schedule.precheck ?? null})
127
149
  RETURNING *`;
128
150
  const [row] = rows;
129
151
  if (!row) throw new Error("the schedule insert returned no row");
@@ -161,7 +183,8 @@ export class PgScheduleStore implements ScheduleStore {
161
183
  title = ${change.title ?? current.title},
162
184
  prompt = ${change.prompt ?? current.prompt},
163
185
  recurrence = ${JSON.stringify(change.recurrence ?? current.recurrence)},
164
- next_run = ${change.nextRun ?? current.nextRun}
186
+ next_run = ${change.nextRun ?? current.nextRun},
187
+ precheck = ${change.precheck === undefined ? (current.precheck ?? null) : change.precheck}
165
188
  WHERE id = ${id}
166
189
  RETURNING *`;
167
190
  return rows[0] ? toSchedule(rows[0]) : undefined;
@@ -6,6 +6,7 @@ import type { ScheduleStore } from "../../services.ts";
6
6
  import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
7
7
  import { type Tier, tierAtLeast } from "../../speakers.ts";
8
8
  import { timeZone, zonedStamp } from "../../time.ts";
9
+ import type { PrecheckFinding, PrecheckRegistry } from "./prechecks.ts";
9
10
  import {
10
11
  describeRecurrence,
11
12
  nextRun,
@@ -30,6 +31,8 @@ export interface ScheduleToolContext {
30
31
  /** Who asked, recorded on created schedules; a scheduled run speaks for its creator. */
31
32
  author: { id: string; name: string; tier?: Tier };
32
33
  now: Date;
34
+ /** The host's prechecks a schedule may name; without them, none can be attached. */
35
+ prechecks?: Pick<PrecheckRegistry, "get" | "list">;
33
36
  }
34
37
 
35
38
  /** The limits of the context's target; a target without them may not schedule. */
@@ -64,6 +67,30 @@ function id(input: Input): number {
64
67
  return value;
65
68
  }
66
69
 
70
+ /** A registered precheck's name; an unknown one is refused with the names there are. */
71
+ function precheckName(ctx: ScheduleToolContext, value: unknown): string {
72
+ const names = (ctx.prechecks?.list() ?? []).map((p) => p.name);
73
+ if (typeof value !== "string" || !value.trim())
74
+ throw new ScheduleError(
75
+ `precheck must be the name of a registered precheck${names.length ? `: ${names.join(", ")}` : "; this host registers none"}`,
76
+ );
77
+ const name = value.trim();
78
+ if (!ctx.prechecks?.get(name))
79
+ throw new ScheduleError(
80
+ names.length
81
+ ? `there is no precheck "${name}"; the registered ones are ${names.join(", ")}`
82
+ : `there is no precheck "${name}"; this host registers none`,
83
+ );
84
+ return name;
85
+ }
86
+
87
+ /** The prechecks a schedule may name, for schedule_list; empty when the host registers none. */
88
+ function precheckCatalog(ctx: ScheduleToolContext): string {
89
+ const all = ctx.prechecks?.list() ?? [];
90
+ if (all.length === 0) return "";
91
+ return `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
92
+ }
93
+
67
94
  function hasTiming(input: Input): boolean {
68
95
  return [
69
96
  "in_minutes",
@@ -94,10 +121,11 @@ function line(schedule: Schedule): string {
94
121
  schedule.prompt.length > LIST_PROMPT_PREVIEW
95
122
  ? `${schedule.prompt.slice(0, LIST_PROMPT_PREVIEW)}…`
96
123
  : schedule.prompt;
124
+ const precheck = schedule.precheck ? `; precheck ${schedule.precheck}` : "";
97
125
  const last = schedule.lastRun
98
126
  ? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
99
127
  : "";
100
- return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}; set by ${schedule.createdByName}${last}\n ${prompt.replace(/\n/g, " ")}`;
128
+ return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}; set by ${schedule.createdByName}${precheck}${last}\n ${prompt.replace(/\n/g, " ")}`;
101
129
  }
102
130
 
103
131
  async function own(ctx: ScheduleToolContext, input: Input): Promise<Schedule> {
@@ -131,6 +159,10 @@ export async function callScheduleTool(
131
159
  const title = text(input, "title", TITLE_CHARS);
132
160
  const prompt = text(input, "prompt", limits.promptChars);
133
161
  const [recurrence, next] = timing(ctx, input);
162
+ const precheck =
163
+ input.precheck === undefined || input.precheck === null
164
+ ? undefined
165
+ : precheckName(ctx, input.precheck);
134
166
  const existing = await ctx.store.forChannel(ctx.channel);
135
167
  if (existing.length >= limits.perChannel)
136
168
  throw new ScheduleError(
@@ -146,8 +178,10 @@ export async function callScheduleTool(
146
178
  createdById: ctx.author.id,
147
179
  createdByName: ctx.author.name,
148
180
  createdTier: ctx.author.tier ?? "owner",
181
+ ...(precheck ? { precheck } : {}),
149
182
  });
150
- return `Scheduled #${created.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}.`;
183
+ const checked = precheck ? `; precheck ${precheck} runs first` : "";
184
+ return `Scheduled #${created.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
151
185
  }
152
186
  case "schedule_list": {
153
187
  if (input.id !== undefined) {
@@ -155,9 +189,11 @@ export async function callScheduleTool(
155
189
  return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}`;
156
190
  }
157
191
  const all = await ctx.store.forChannel(ctx.channel);
158
- return all.length === 0
159
- ? "This channel has no schedules."
160
- : `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map(line).join("\n")}`;
192
+ const listed =
193
+ all.length === 0
194
+ ? "This channel has no schedules."
195
+ : `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map(line).join("\n")}`;
196
+ return `${listed}${precheckCatalog(ctx)}`;
161
197
  }
162
198
  case "schedule_update": {
163
199
  const schedule = changeable(ctx, await own(ctx, input));
@@ -171,11 +207,20 @@ export async function callScheduleTool(
171
207
  change.recurrence = recurrence;
172
208
  change.nextRun = next;
173
209
  }
210
+ // null removes the precheck; a name must be registered.
211
+ if (input.precheck !== undefined)
212
+ change.precheck =
213
+ input.precheck === null ? null : precheckName(ctx, input.precheck);
174
214
  if (Object.keys(change).length === 0)
175
- throw new ScheduleError("give a title, prompt, or timing to change");
215
+ throw new ScheduleError(
216
+ "give a title, prompt, timing, or precheck to change",
217
+ );
176
218
  const updated = await ctx.store.update(ctx.channel, schedule.id, change);
177
219
  if (!updated) throw new ScheduleError(`schedule #${schedule.id} is gone`);
178
- return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}.`;
220
+ const checked = updated.precheck
221
+ ? `; precheck ${updated.precheck} runs first`
222
+ : "";
223
+ return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
179
224
  }
180
225
  case "schedule_cancel": {
181
226
  const schedule = changeable(ctx, await own(ctx, input));
@@ -187,12 +232,28 @@ export async function callScheduleTool(
187
232
  }
188
233
  }
189
234
 
190
- /** What a scheduled run receives as its message. */
191
- export function scheduledTurnText(schedule: Schedule, firedAt: Date): string {
235
+ /** What the schedule's precheck found, or how it failed, under its own heading after the task. */
236
+ function findingText(finding: PrecheckFinding): string[] {
237
+ return "error" in finding
238
+ ? [
239
+ "",
240
+ `### Precheck failed: ${finding.precheck}`,
241
+ `The precheck that decides whether this task needs you failed, so you were woken anyway: ${finding.error}. Check what it watches yourself, and say so if it needs fixing.`,
242
+ ]
243
+ : ["", `### Precheck found (${finding.precheck}):`, finding.context];
244
+ }
245
+
246
+ /** What a scheduled run receives as its message, with what its precheck found when it has one. */
247
+ export function scheduledTurnText(
248
+ schedule: Schedule,
249
+ firedAt: Date,
250
+ finding?: PrecheckFinding,
251
+ ): string {
192
252
  return [
193
253
  `## Scheduled task #${schedule.id}: ${schedule.title}`,
194
254
  `${schedule.createdByName} set this schedule (${describeRecurrence(schedule.recurrence)}). It is due now, ${zonedStamp(firedAt)} ${messages().zoneTime(timeZone())}. Nobody wrote a new message: carry out the task below and write what you would post in this channel. If the task keeps a record for later runs, update it with schedule_update on #${schedule.id}.`,
195
255
  "",
196
256
  schedule.prompt,
257
+ ...(finding ? findingText(finding) : []),
197
258
  ].join("\n");
198
259
  }
@@ -1,14 +1,24 @@
1
1
  import type { ScheduledOutcome } from "../../contract/channels.ts";
2
2
  import type { Logger } from "../../log.ts";
3
3
  import type { ScheduleStore } from "../../services.ts";
4
+ import {
5
+ type PrecheckFinding,
6
+ type PrecheckOutcome,
7
+ type PrecheckRegistry,
8
+ runPrecheck,
9
+ } from "./prechecks.ts";
4
10
  import { nextRun } from "./recurrence.ts";
5
11
  import type { Schedule } from "./schedule-store.ts";
6
12
 
7
13
  export type { ScheduledOutcome };
8
14
 
9
15
  export interface ScheduledRunner {
10
- /** Runs one due schedule in its channel and posts the answer there; never rejects. */
11
- runScheduled(schedule: Schedule, firedAt: Date): Promise<ScheduledOutcome>;
16
+ /** Runs one due schedule in its channel and posts the answer there, with what its precheck found; never rejects. */
17
+ runScheduled(
18
+ schedule: Schedule,
19
+ firedAt: Date,
20
+ finding?: PrecheckFinding,
21
+ ): Promise<ScheduledOutcome>;
12
22
  }
13
23
 
14
24
  /** A run found this late, for example after the service was down, is skipped instead of run. */
@@ -17,18 +27,24 @@ export const LATE_LIMIT_MS = 12 * 3_600_000;
17
27
  export interface SchedulerOptions {
18
28
  store: Pick<ScheduleStore, "due" | "claim" | "recordStatus">;
19
29
  runner: ScheduledRunner;
30
+ /** The host's prechecks; without them, a schedule that names one runs with that as its error. */
31
+ prechecks?: Pick<PrecheckRegistry, "get">;
32
+ /** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
33
+ notify?: (schedule: Schedule, note: string) => Promise<void>;
20
34
  logger: Logger;
21
35
  intervalMs?: number;
22
36
  /** Replaceable in tests. */
23
37
  now?: () => Date;
24
38
  }
25
39
 
40
+ const STATUS_CHARS = 300;
41
+
26
42
  function statusText(outcome: ScheduledOutcome): string {
27
43
  switch (outcome.status) {
28
44
  case "ran":
29
45
  return "ran";
30
46
  case "failed":
31
- return `failed: ${outcome.error}`.slice(0, 300);
47
+ return `failed: ${outcome.error}`;
32
48
  case "skipped":
33
49
  return `skipped: ${outcome.reason}`;
34
50
  default:
@@ -36,6 +52,14 @@ function statusText(outcome: ScheduledOutcome): string {
36
52
  }
37
53
  }
38
54
 
55
+ /** How the precheck let the turn run, before the turn's own outcome. */
56
+ function precheckPrefix(finding: PrecheckFinding | undefined): string {
57
+ if (!finding) return "";
58
+ return "error" in finding
59
+ ? `precheck failed (${finding.error}), woke; `
60
+ : "woken by precheck; ";
61
+ }
62
+
39
63
  /**
40
64
  * Checks for due schedules on an interval. Each due schedule is claimed first, moved to its
41
65
  * next run or deleted, so a slow or failing run never fires twice; missed runs are not caught up.
@@ -106,7 +130,23 @@ export class Scheduler {
106
130
  }
107
131
 
108
132
  async #fire(schedule: Schedule, firedAt: Date): Promise<void> {
109
- const { store, runner, logger } = this.#options;
133
+ const { runner, logger } = this.#options;
134
+ let finding: PrecheckFinding | undefined;
135
+ if (schedule.precheck) {
136
+ const decision = await this.#precheck(
137
+ schedule,
138
+ schedule.precheck,
139
+ firedAt,
140
+ );
141
+ if (decision.kind === "skip") {
142
+ await this.#skip(schedule, decision.note);
143
+ return;
144
+ }
145
+ finding =
146
+ decision.kind === "wake"
147
+ ? { precheck: schedule.precheck, context: decision.context }
148
+ : { precheck: schedule.precheck, error: decision.error };
149
+ }
110
150
  logger.info(
111
151
  {
112
152
  schedule: schedule.id,
@@ -115,10 +155,72 @@ export class Scheduler {
115
155
  },
116
156
  "schedule firing",
117
157
  );
118
- const outcome = await runner.runScheduled(schedule, firedAt);
158
+ const outcome = await runner.runScheduled(schedule, firedAt, finding);
119
159
  logger.info({ schedule: schedule.id, outcome }, "schedule finished");
160
+ await this.#record(
161
+ schedule,
162
+ `${precheckPrefix(finding)}${statusText(outcome)}`,
163
+ );
164
+ }
165
+
166
+ /** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
167
+ async #precheck(
168
+ schedule: Schedule,
169
+ name: string,
170
+ firedAt: Date,
171
+ ): Promise<PrecheckOutcome> {
172
+ const { prechecks, logger } = this.#options;
173
+ let decision: PrecheckOutcome;
174
+ try {
175
+ const precheck = prechecks?.get(name);
176
+ decision = precheck
177
+ ? await runPrecheck(precheck, { schedule, firedAt })
178
+ : { kind: "failed", error: "no precheck of that name is registered" };
179
+ } catch (error) {
180
+ decision = {
181
+ kind: "failed",
182
+ error: `the precheck could not be looked up: ${error instanceof Error ? error.message : String(error)}`,
183
+ };
184
+ }
185
+ // A warning, not an error: the woken turn carries the error already, and an error line would
186
+ // wake the ops agent's report turn for the same failure.
187
+ if (decision.kind === "failed")
188
+ logger.warn(
189
+ { schedule: schedule.id, precheck: name, error: decision.error },
190
+ "precheck failed; the scheduled turn runs with the error",
191
+ );
192
+ else
193
+ logger.info(
194
+ {
195
+ schedule: schedule.id,
196
+ precheck: name,
197
+ wake: decision.kind === "wake",
198
+ },
199
+ "precheck decided",
200
+ );
201
+ return decision;
202
+ }
203
+
204
+ /** Records a run the precheck skipped and posts its note; a note that cannot be posted is logged. */
205
+ async #skip(schedule: Schedule, note: string | undefined): Promise<void> {
206
+ const { notify, logger } = this.#options;
207
+ if (note && notify)
208
+ await notify(schedule, note).catch((error: unknown) =>
209
+ logger.warn(
210
+ { schedule: schedule.id, err: error },
211
+ "precheck note not posted",
212
+ ),
213
+ );
214
+ await this.#record(
215
+ schedule,
216
+ note ? `skipped by precheck (${note})` : "skipped by precheck",
217
+ );
218
+ }
219
+
220
+ async #record(schedule: Schedule, status: string): Promise<void> {
221
+ const { store, logger } = this.#options;
120
222
  await store
121
- .recordStatus(schedule.id, statusText(outcome))
223
+ .recordStatus(schedule.id, status.slice(0, STATUS_CHARS))
122
224
  .catch((error: unknown) =>
123
225
  logger.error(
124
226
  { schedule: schedule.id, err: error },
@@ -16,6 +16,7 @@ import {
16
16
  } from "../../shared/schedule-tools.ts";
17
17
  import type { Speaker } from "../../speakers.ts";
18
18
  import { timeZone } from "../../time.ts";
19
+ import type { PrecheckRegistry } from "./prechecks.ts";
19
20
  import { callScheduleTool } from "./schedule-tools.ts";
20
21
 
21
22
  export interface OwnerSchedules {
@@ -26,6 +27,8 @@ export interface OwnerSchedules {
26
27
  * messages for a conversation no chat surface carries, where a run could not be posted.
27
28
  */
28
29
  channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
30
+ /** The host's prechecks a schedule may name; without them, none can be attached. */
31
+ prechecks?: Pick<PrecheckRegistry, "get" | "list">;
29
32
  }
30
33
 
31
34
  /** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
@@ -58,7 +61,7 @@ export function schedulesExtension(
58
61
  /** The person the running turn is for; their schedules run at their tier. */
59
62
  speaker: () => Speaker | undefined = () => undefined,
60
63
  ): ExtensionFactory {
61
- const { store, owner, channelFor } = schedules;
64
+ const { store, owner, channelFor, prechecks } = schedules;
62
65
  const defs = scheduleToolSpecs({
63
66
  locale: activeLocale(),
64
67
  timeZone: timeZone(),
@@ -79,6 +82,7 @@ export function schedulesExtension(
79
82
  target: OWNER_TARGET,
80
83
  author: speaker() ?? owner,
81
84
  now: new Date(),
85
+ ...(prechecks ? { prechecks } : {}),
82
86
  },
83
87
  spec.name,
84
88
  input,
@@ -16,6 +16,10 @@ import type {
16
16
  MemoryKind,
17
17
  PromptMemory,
18
18
  } from "./modules/memory/owner-memory-store.ts";
19
+ import type {
20
+ PrecheckFinding,
21
+ PrecheckRegistry,
22
+ } from "./modules/schedules/prechecks.ts";
19
23
  import type {
20
24
  NewSchedule,
21
25
  Schedule,
@@ -38,6 +42,13 @@ export const AGENTS: ServiceKey<AgentServer> =
38
42
  export const SCHEDULES: ServiceKey<ScheduleStore> = serviceKey<ScheduleStore>(
39
43
  "roundtable.schedules",
40
44
  );
45
+ /**
46
+ * The host's named prechecks, which a schedule may run before its turn to decide whether the
47
+ * agent is woken at all. Provided by the `prechecks` plugin; register yours during setup:
48
+ * `services.get(PRECHECKS).register({ name, description, run })`.
49
+ */
50
+ export const PRECHECKS: ServiceKey<PrecheckRegistry> =
51
+ serviceKey<PrecheckRegistry>("roundtable.prechecks");
41
52
  /** Turns nobody wrote: a due schedule's, a delegated task's report, a logged error's. Provided by the modules plugin. */
42
53
  export const BACKGROUND_TURNS: ServiceKey<BackgroundTurns> =
43
54
  serviceKey<BackgroundTurns>("roundtable.background-turns");
@@ -177,8 +188,12 @@ export interface ScheduleStore {
177
188
 
178
189
  /** Turns nobody wrote, each answered in its channel by the claim that owns it. */
179
190
  export interface BackgroundTurns {
180
- /** A due schedule's turn, run as its creator's. */
181
- runScheduled(schedule: Schedule, firedAt: Date): Promise<ScheduledOutcome>;
191
+ /** A due schedule's turn, run as its creator's; with what its precheck found, when it has one. */
192
+ runScheduled(
193
+ schedule: Schedule,
194
+ firedAt: Date,
195
+ finding?: PrecheckFinding,
196
+ ): Promise<ScheduledOutcome>;
182
197
  /** A delegated task's report, answered in its channel under the same rules as a schedule. */
183
198
  runDelegated(job: DelegationJob, result: DelegationOutcome): Promise<void>;
184
199
  /** The process's own logged error, reported to an agent in its channel as a report turn. */
@@ -91,12 +91,18 @@ export function scheduleToolSpecs(
91
91
  description: "What to do when it runs, self-contained.",
92
92
  }),
93
93
  ...timing(zone),
94
+ precheck: Type.Optional(
95
+ Type.String({
96
+ description:
97
+ "The name of a precheck the host runs first, from schedule_list; you are woken only when it finds something.",
98
+ }),
99
+ ),
94
100
  }),
95
101
  },
96
102
  {
97
103
  name: "schedule_list",
98
104
  label: "List schedules",
99
- description: `List this channel's schedules with their next run, and the current ${zone}. Give id to read one schedule's full prompt.`,
105
+ description: `List this channel's schedules with their next run, and the current ${zone}, and the prechecks a schedule may attach. Give id to read one schedule's full prompt.`,
100
106
  parameters: Type.Object({
101
107
  id: Type.Optional(Type.Integer({ description: "Schedule id." })),
102
108
  }),
@@ -105,7 +111,7 @@ export function scheduleToolSpecs(
105
111
  name: "schedule_update",
106
112
  label: "Update schedule",
107
113
  description:
108
- "Change one of this channel's schedules: its title, its prompt, or its timing (timing fields replace the old timing). A run can use this to keep its own prompt current, such as adding what it already reported.",
114
+ "Change one of this channel's schedules: its title, its prompt, its timing (timing fields replace the old timing), or its precheck. A run can use this to keep its own prompt current, such as adding what it already reported.",
109
115
  parameters: Type.Object({
110
116
  id: Type.Integer({ description: "Schedule id." }),
111
117
  title: Type.Optional(Type.String()),
@@ -113,6 +119,12 @@ export function scheduleToolSpecs(
113
119
  Type.String({ description: "The whole new prompt." }),
114
120
  ),
115
121
  ...timing(zone),
122
+ precheck: Type.Optional(
123
+ Type.Union([Type.String(), Type.Null()], {
124
+ description:
125
+ "A precheck's name from schedule_list to run first, or null to remove the schedule's precheck.",
126
+ }),
127
+ ),
116
128
  }),
117
129
  },
118
130
  {
@@ -0,0 +1,56 @@
1
+ import {
2
+ memoryPrecheckRegistry,
3
+ type Precheck,
4
+ type PrecheckContext,
5
+ type PrecheckRegistry,
6
+ type PrecheckResult,
7
+ } from "../modules/schedules/prechecks.ts";
8
+
9
+ /** What a fake precheck answers: a result, an Error it throws, or a function of its context. */
10
+ export type FakePrecheckAnswer =
11
+ | PrecheckResult
12
+ | Error
13
+ | ((context: PrecheckContext) => PrecheckResult | Promise<PrecheckResult>);
14
+
15
+ /** A precheck for tests, which records every context it ran with. */
16
+ export interface FakePrecheck extends Precheck {
17
+ readonly calls: PrecheckContext[];
18
+ }
19
+
20
+ /**
21
+ * A precheck that answers as the test says, for registering where host code would:
22
+ * `services.get(PRECHECKS).register(fakePrecheck("health.recovery", { wake: false }))`, or into
23
+ * `fakePrechecks(...)`. An Error answer is thrown, as a failing check would.
24
+ */
25
+ export function fakePrecheck(
26
+ name: string,
27
+ answer: FakePrecheckAnswer,
28
+ options: { description?: string; timeoutMs?: number } = {},
29
+ ): FakePrecheck {
30
+ const calls: PrecheckContext[] = [];
31
+ return {
32
+ name,
33
+ description: options.description ?? `A test precheck, ${name}.`,
34
+ ...(options.timeoutMs === undefined
35
+ ? {}
36
+ : { timeoutMs: options.timeoutMs }),
37
+ calls,
38
+ run: async (context) => {
39
+ calls.push(context);
40
+ if (answer instanceof Error) throw answer;
41
+ return typeof answer === "function" ? answer(context) : answer;
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * A real in-memory precheck registry with the given prechecks registered, for a test that builds
48
+ * a scheduler or schedule tools itself. It refuses what the host's refuses.
49
+ */
50
+ export function fakePrechecks(
51
+ ...prechecks: readonly Precheck[]
52
+ ): PrecheckRegistry {
53
+ const registry = memoryPrecheckRegistry();
54
+ for (const precheck of prechecks) registry.register(precheck);
55
+ return registry;
56
+ }
package/src/index.ts CHANGED
@@ -126,6 +126,14 @@ export type {
126
126
  PromptMemory,
127
127
  } from "./core/modules/memory/owner-memory-store.ts";
128
128
  export { MEMORY_KINDS } from "./core/modules/memory/owner-memory-store.ts";
129
+ export type {
130
+ Precheck,
131
+ PrecheckContext,
132
+ PrecheckFinding,
133
+ PrecheckRegistry,
134
+ PrecheckResult,
135
+ } from "./core/modules/schedules/prechecks.ts";
136
+ export { PRECHECK_TIMEOUT_MS } from "./core/modules/schedules/prechecks.ts";
129
137
  export type {
130
138
  Recurrence,
131
139
  Weekday,
@@ -187,6 +195,7 @@ export {
187
195
  BACKGROUND_TURNS,
188
196
  DELEGATION,
189
197
  MEMORY,
198
+ PRECHECKS,
190
199
  SCHEDULES,
191
200
  SKILLS,
192
201
  } from "./core/services.ts";
@@ -0,0 +1,14 @@
1
+ // The core's compaction tiers, for a host that builds its own Pi session (a sandbox worker, say).
2
+
3
+ export type {
4
+ CompactionEngine,
5
+ CompactionHistory,
6
+ LatestCompaction,
7
+ } from "../core/runtime/compaction-tiers.ts";
8
+ export {
9
+ COMPACT_HEADROOM_TOKENS,
10
+ CompactionTiers,
11
+ compactionEngine,
12
+ HARD_COMPACT_TOKENS,
13
+ SOFT_COMPACT_TOKENS,
14
+ } from "../core/runtime/compaction-tiers.ts";
package/src/kit/index.ts CHANGED
@@ -14,6 +14,18 @@ export {
14
14
  withAttachmentsBlock,
15
15
  withReference,
16
16
  } from "./channels.ts";
17
+ export type {
18
+ CompactionEngine,
19
+ CompactionHistory,
20
+ LatestCompaction,
21
+ } from "./compaction.ts";
22
+ export {
23
+ COMPACT_HEADROOM_TOKENS,
24
+ CompactionTiers,
25
+ compactionEngine,
26
+ HARD_COMPACT_TOKENS,
27
+ SOFT_COMPACT_TOKENS,
28
+ } from "./compaction.ts";
17
29
  export { scrubDiagnostic } from "./diagnostics.ts";
18
30
  export type {
19
31
  ChoiceAnswer,
package/src/testing.ts CHANGED
@@ -83,6 +83,11 @@ export type { TestLocale } from "./core/testing/locale.ts";
83
83
  export { useTestLocale } from "./core/testing/locale.ts";
84
84
  export { OWNER_SPEAKER } from "./core/testing/owner.ts";
85
85
  export { partial } from "./core/testing/partial.ts";
86
+ export type {
87
+ FakePrecheck,
88
+ FakePrecheckAnswer,
89
+ } from "./core/testing/prechecks.ts";
90
+ export { fakePrecheck, fakePrechecks } from "./core/testing/prechecks.ts";
86
91
  export type {
87
92
  RecordedLog,
88
93
  RecordingLogger,