pi-roundtable 0.1.0
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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { SQL } from "bun";
|
|
10
|
+
import { ScheduleError } from "../../domain/errors.ts";
|
|
11
|
+
import { messages } from "../../i18n/index.ts";
|
|
12
|
+
import { silentLogger } from "../../log.ts";
|
|
13
|
+
import {
|
|
14
|
+
describeDb,
|
|
15
|
+
openTestStore,
|
|
16
|
+
type TestStore,
|
|
17
|
+
testDatabaseUrl,
|
|
18
|
+
} from "../../testing/database.ts";
|
|
19
|
+
import { useTestLocale } from "../../testing/locale.ts";
|
|
20
|
+
import { setTimeZone, zonedStamp } from "../../time.ts";
|
|
21
|
+
import { describeRecurrence, nextRun, parseRecurrence } from "./recurrence.ts";
|
|
22
|
+
import { type Schedule, ScheduleStore } from "./schedule-store.ts";
|
|
23
|
+
import {
|
|
24
|
+
callScheduleTool,
|
|
25
|
+
SCHEDULE_LIMITS,
|
|
26
|
+
type ScheduleToolContext,
|
|
27
|
+
scheduledTurnText,
|
|
28
|
+
} from "./schedule-tools.ts";
|
|
29
|
+
import { type ScheduledOutcome, Scheduler } from "./scheduler.ts";
|
|
30
|
+
|
|
31
|
+
const taipei = (stamp: string) =>
|
|
32
|
+
new Date(`${stamp.replace(" ", "T")}:00+08:00`);
|
|
33
|
+
|
|
34
|
+
// The schedules below are written in Taipei time; the suite's other files keep the neutral zone.
|
|
35
|
+
beforeAll(() => setTimeZone("Asia/Taipei"));
|
|
36
|
+
afterAll(useTestLocale);
|
|
37
|
+
|
|
38
|
+
describe("recurrence", () => {
|
|
39
|
+
const now = taipei("2026-09-26 23:30");
|
|
40
|
+
|
|
41
|
+
test("a one-time run is the given Taipei time, and none once it has passed", () => {
|
|
42
|
+
const once = parseRecurrence({ at: "2026-09-27 09:00" }, now);
|
|
43
|
+
expect(nextRun(once, now)).toEqual(taipei("2026-09-27 09:00"));
|
|
44
|
+
expect(nextRun(once, taipei("2026-09-27 09:00"))).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("in_minutes is one run that many minutes ahead, rounded up to the minute", () => {
|
|
48
|
+
const at = new Date(taipei("2026-09-26 23:58").getTime() + 30_000);
|
|
49
|
+
const relative = parseRecurrence({ in_minutes: 2 }, at);
|
|
50
|
+
expect(relative).toEqual({
|
|
51
|
+
kind: "once",
|
|
52
|
+
date: "2026-09-27",
|
|
53
|
+
time: "00:01",
|
|
54
|
+
});
|
|
55
|
+
expect(() =>
|
|
56
|
+
parseRecurrence({ in_minutes: 2, at: "2026-09-27 09:00" }, at),
|
|
57
|
+
).toThrow(ScheduleError);
|
|
58
|
+
expect(() => parseRecurrence({ in_minutes: 0 }, at)).toThrow(ScheduleError);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("every n days counts from the start date and never returns the current instant", () => {
|
|
62
|
+
const every = parseRecurrence(
|
|
63
|
+
{ time: "09:00", every_days: 3, start_date: "2026-09-16" },
|
|
64
|
+
now,
|
|
65
|
+
);
|
|
66
|
+
expect(nextRun(every, now)).toEqual(taipei("2026-09-28 09:00"));
|
|
67
|
+
expect(nextRun(every, taipei("2026-09-28 09:00"))).toEqual(
|
|
68
|
+
taipei("2026-10-01 09:00"),
|
|
69
|
+
);
|
|
70
|
+
expect(describeRecurrence(every)).toBe(
|
|
71
|
+
messages().scheduleEveryDays(3, "09:00", "2026-09-16"),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("daily defaults to starting today, so a time still ahead runs today", () => {
|
|
76
|
+
const daily = parseRecurrence({ time: "23:45" }, now);
|
|
77
|
+
expect(nextRun(daily, now)).toEqual(taipei("2026-09-26 23:45"));
|
|
78
|
+
expect(describeRecurrence(daily)).toBe(messages().scheduleDaily("23:45"));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("weekly runs on the listed weekdays", () => {
|
|
82
|
+
// 2026-09-26 is a Saturday.
|
|
83
|
+
const weekly = parseRecurrence(
|
|
84
|
+
{ time: "08:00", weekdays: ["wed", "mon"] },
|
|
85
|
+
now,
|
|
86
|
+
);
|
|
87
|
+
expect(nextRun(weekly, now)).toEqual(taipei("2026-09-28 08:00"));
|
|
88
|
+
expect(nextRun(weekly, taipei("2026-09-28 08:00"))).toEqual(
|
|
89
|
+
taipei("2026-09-30 08:00"),
|
|
90
|
+
);
|
|
91
|
+
expect(describeRecurrence(weekly)).toBe(
|
|
92
|
+
messages().scheduleWeekly(
|
|
93
|
+
[messages().scheduleWeekday("mon"), messages().scheduleWeekday("wed")],
|
|
94
|
+
"08:00",
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("bad timing is refused with a message the model can act on", () => {
|
|
100
|
+
const cases = [
|
|
101
|
+
{},
|
|
102
|
+
{ at: "2026-02-30 09:00" },
|
|
103
|
+
{ at: "tomorrow" },
|
|
104
|
+
{ at: "2026-09-27 09:00", time: "09:00" },
|
|
105
|
+
{ time: "25:00" },
|
|
106
|
+
{ time: "09:00", every_days: 0 },
|
|
107
|
+
{ time: "09:00", weekdays: ["someday"] },
|
|
108
|
+
{ time: "09:00", weekdays: ["mon"], every_days: 2 },
|
|
109
|
+
];
|
|
110
|
+
for (const input of cases)
|
|
111
|
+
expect(() => parseRecurrence(input, now)).toThrow(ScheduleError);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
|
|
116
|
+
describeDb("PostgreSQL", () => {
|
|
117
|
+
let store: TestStore<ScheduleStore>;
|
|
118
|
+
|
|
119
|
+
beforeAll(async () => {
|
|
120
|
+
const admin = new SQL(testDatabaseUrl);
|
|
121
|
+
await admin`DROP TABLE IF EXISTS schedules`;
|
|
122
|
+
await admin.close();
|
|
123
|
+
store = await openTestStore(ScheduleStore);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await store.close();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
const admin = new SQL(testDatabaseUrl);
|
|
132
|
+
await admin`TRUNCATE schedules`;
|
|
133
|
+
await admin.close();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const now = taipei("2026-09-26 23:30");
|
|
137
|
+
const ctx = (
|
|
138
|
+
over: Partial<ScheduleToolContext> = {},
|
|
139
|
+
): ScheduleToolContext => ({
|
|
140
|
+
store,
|
|
141
|
+
channel: "discord:a",
|
|
142
|
+
mode: "party",
|
|
143
|
+
author: { id: "u1", name: "Sam" },
|
|
144
|
+
now,
|
|
145
|
+
...over,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("schedule tools", () => {
|
|
149
|
+
test("a schedule keeps its creator's tier, and a lower tier cannot change it", async () => {
|
|
150
|
+
const admin = { id: "u2", name: "Ada", tier: "admin" } as const;
|
|
151
|
+
const member = { id: "u3", name: "Max", tier: "member" } as const;
|
|
152
|
+
await callScheduleTool(ctx({ author: admin }), "schedule_create", {
|
|
153
|
+
title: "patrol",
|
|
154
|
+
prompt: "check the disk",
|
|
155
|
+
time: "09:00",
|
|
156
|
+
every_days: 1,
|
|
157
|
+
});
|
|
158
|
+
const [schedule] = await store.forChannel("discord:a");
|
|
159
|
+
const id = schedule?.id ?? 0;
|
|
160
|
+
expect(schedule?.createdTier).toBe("admin");
|
|
161
|
+
for (const tool of ["schedule_update", "schedule_cancel"] as const)
|
|
162
|
+
await expect(
|
|
163
|
+
callScheduleTool(ctx({ author: member }), tool, {
|
|
164
|
+
id,
|
|
165
|
+
title: "renamed",
|
|
166
|
+
}),
|
|
167
|
+
).rejects.toThrow(/higher tier/);
|
|
168
|
+
await callScheduleTool(ctx({ author: admin }), "schedule_update", {
|
|
169
|
+
id,
|
|
170
|
+
title: "renamed",
|
|
171
|
+
});
|
|
172
|
+
expect((await store.get(id))?.title).toBe("renamed");
|
|
173
|
+
// The owner's own schedules default to the owner's tier.
|
|
174
|
+
await callScheduleTool(ctx(), "schedule_create", {
|
|
175
|
+
title: "morning report",
|
|
176
|
+
prompt: "report",
|
|
177
|
+
time: "10:00",
|
|
178
|
+
every_days: 1,
|
|
179
|
+
});
|
|
180
|
+
const owners = await store.forChannel("discord:a");
|
|
181
|
+
expect(owners.map((x) => x.createdTier).sort()).toEqual([
|
|
182
|
+
"admin",
|
|
183
|
+
"owner",
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("create, list, update, and cancel act on the channel's schedules", async () => {
|
|
188
|
+
const created = await callScheduleTool(ctx(), "schedule_create", {
|
|
189
|
+
title: "jobs",
|
|
190
|
+
prompt: "find pastry shop openings",
|
|
191
|
+
time: "09:00",
|
|
192
|
+
every_days: 3,
|
|
193
|
+
start_date: "2026-09-16",
|
|
194
|
+
});
|
|
195
|
+
expect(created).toContain("first run 2026-09-28 09:00");
|
|
196
|
+
const [schedule] = await store.forChannel("discord:a");
|
|
197
|
+
expect(schedule?.createdByName).toBe("Sam");
|
|
198
|
+
expect(schedule?.mode).toBe("party");
|
|
199
|
+
|
|
200
|
+
const id = schedule?.id ?? 0;
|
|
201
|
+
expect(await callScheduleTool(ctx(), "schedule_list", {})).toContain(
|
|
202
|
+
`#${id} jobs`,
|
|
203
|
+
);
|
|
204
|
+
await callScheduleTool(ctx(), "schedule_update", {
|
|
205
|
+
id,
|
|
206
|
+
prompt: "find pastry shop openings; already seen: Acme",
|
|
207
|
+
});
|
|
208
|
+
expect(await callScheduleTool(ctx(), "schedule_list", { id })).toContain(
|
|
209
|
+
"already seen: Acme",
|
|
210
|
+
);
|
|
211
|
+
// Changing only the prompt keeps the timing.
|
|
212
|
+
expect((await store.get(id))?.nextRun).toEqual(
|
|
213
|
+
taipei("2026-09-28 09:00"),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await callScheduleTool(ctx(), "schedule_update", { id, time: "20:00" });
|
|
217
|
+
expect((await store.get(id))?.nextRun).toEqual(
|
|
218
|
+
taipei("2026-09-27 20:00"),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(
|
|
222
|
+
callScheduleTool(ctx({ channel: "discord:b" }), "schedule_cancel", {
|
|
223
|
+
id,
|
|
224
|
+
}),
|
|
225
|
+
).rejects.toThrow(ScheduleError);
|
|
226
|
+
await callScheduleTool(ctx(), "schedule_cancel", { id });
|
|
227
|
+
expect(await store.forChannel("discord:a")).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a party channel is limited in count, prompt size, and distance", async () => {
|
|
231
|
+
const { perChannel, promptChars } = SCHEDULE_LIMITS.party;
|
|
232
|
+
for (let i = 0; i < perChannel; i += 1)
|
|
233
|
+
await callScheduleTool(ctx(), "schedule_create", {
|
|
234
|
+
title: `t${i}`,
|
|
235
|
+
prompt: "p",
|
|
236
|
+
time: "10:00",
|
|
237
|
+
});
|
|
238
|
+
expect(
|
|
239
|
+
callScheduleTool(ctx(), "schedule_create", {
|
|
240
|
+
title: "one more",
|
|
241
|
+
prompt: "p",
|
|
242
|
+
time: "10:00",
|
|
243
|
+
}),
|
|
244
|
+
).rejects.toThrow("cancel one first");
|
|
245
|
+
expect(
|
|
246
|
+
callScheduleTool(ctx({ channel: "discord:b" }), "schedule_create", {
|
|
247
|
+
title: "long",
|
|
248
|
+
prompt: "x".repeat(promptChars + 1),
|
|
249
|
+
time: "10:00",
|
|
250
|
+
}),
|
|
251
|
+
).rejects.toThrow(`within ${promptChars}`);
|
|
252
|
+
expect(
|
|
253
|
+
callScheduleTool(ctx({ channel: "discord:b" }), "schedule_create", {
|
|
254
|
+
title: "far",
|
|
255
|
+
prompt: "p",
|
|
256
|
+
at: "2027-09-26 10:00",
|
|
257
|
+
}),
|
|
258
|
+
).rejects.toThrow("within 90 days");
|
|
259
|
+
expect(
|
|
260
|
+
callScheduleTool(ctx({ channel: "discord:b" }), "schedule_create", {
|
|
261
|
+
title: "past",
|
|
262
|
+
prompt: "p",
|
|
263
|
+
at: "2026-09-26 10:00",
|
|
264
|
+
}),
|
|
265
|
+
).rejects.toThrow("already passed");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("the scheduled message names the schedule and carries its prompt", async () => {
|
|
269
|
+
await callScheduleTool(ctx(), "schedule_create", {
|
|
270
|
+
title: "drink water",
|
|
271
|
+
prompt: "remind Sam to drink water",
|
|
272
|
+
at: "2026-09-27 09:00",
|
|
273
|
+
});
|
|
274
|
+
const [schedule] = await store.forChannel("discord:a");
|
|
275
|
+
if (!schedule) throw new Error("missing schedule");
|
|
276
|
+
const text = scheduledTurnText(schedule, taipei("2026-09-27 09:00"));
|
|
277
|
+
expect(text).toContain(`#${schedule.id}: drink water`);
|
|
278
|
+
expect(text).toContain("2026-09-27 09:00");
|
|
279
|
+
expect(text.endsWith("remind Sam to drink water")).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("scheduler", () => {
|
|
284
|
+
function scheduler(
|
|
285
|
+
clock: { now: Date },
|
|
286
|
+
fired: Schedule[],
|
|
287
|
+
outcome: ScheduledOutcome,
|
|
288
|
+
) {
|
|
289
|
+
return new Scheduler({
|
|
290
|
+
store,
|
|
291
|
+
runner: {
|
|
292
|
+
runScheduled: async (schedule) => {
|
|
293
|
+
fired.push(schedule);
|
|
294
|
+
return outcome;
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
logger: silentLogger(),
|
|
298
|
+
now: () => clock.now,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
test("a due one-time schedule fires once and is deleted", async () => {
|
|
303
|
+
await callScheduleTool(ctx(), "schedule_create", {
|
|
304
|
+
title: "once",
|
|
305
|
+
prompt: "p",
|
|
306
|
+
at: "2026-09-27 09:00",
|
|
307
|
+
});
|
|
308
|
+
const clock = { now: taipei("2026-09-27 08:59") };
|
|
309
|
+
const fired: Schedule[] = [];
|
|
310
|
+
const s = scheduler(clock, fired, { status: "ran" });
|
|
311
|
+
await s.tick();
|
|
312
|
+
expect(fired).toHaveLength(0);
|
|
313
|
+
clock.now = taipei("2026-09-27 09:00");
|
|
314
|
+
await s.tick();
|
|
315
|
+
await s.tick();
|
|
316
|
+
await s.idle();
|
|
317
|
+
expect(fired.map((f) => f.title)).toEqual(["once"]);
|
|
318
|
+
expect(await store.forChannel("discord:a")).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("a repeating schedule moves to its next run and keeps the outcome", async () => {
|
|
322
|
+
await callScheduleTool(ctx(), "schedule_create", {
|
|
323
|
+
title: "daily",
|
|
324
|
+
prompt: "p",
|
|
325
|
+
time: "09:00",
|
|
326
|
+
});
|
|
327
|
+
const clock = { now: taipei("2026-09-27 09:00") };
|
|
328
|
+
const fired: Schedule[] = [];
|
|
329
|
+
const s = scheduler(clock, fired, {
|
|
330
|
+
status: "skipped",
|
|
331
|
+
reason: "party mode is off",
|
|
332
|
+
});
|
|
333
|
+
await s.tick();
|
|
334
|
+
await s.idle();
|
|
335
|
+
const [after] = await store.forChannel("discord:a");
|
|
336
|
+
expect(fired).toHaveLength(1);
|
|
337
|
+
expect(after && zonedStamp(after.nextRun)).toBe("2026-09-28 09:00");
|
|
338
|
+
expect(after?.lastStatus).toBe("skipped: party mode is off");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("a run found long after it was due is skipped, not caught up", async () => {
|
|
342
|
+
await callScheduleTool(ctx(), "schedule_create", {
|
|
343
|
+
title: "daily",
|
|
344
|
+
prompt: "p",
|
|
345
|
+
time: "09:00",
|
|
346
|
+
});
|
|
347
|
+
const clock = { now: taipei("2026-09-29 12:00") };
|
|
348
|
+
const fired: Schedule[] = [];
|
|
349
|
+
const s = scheduler(clock, fired, { status: "ran" });
|
|
350
|
+
await s.tick();
|
|
351
|
+
await s.idle();
|
|
352
|
+
const [after] = await store.forChannel("discord:a");
|
|
353
|
+
expect(fired).toHaveLength(0);
|
|
354
|
+
expect(after && zonedStamp(after.nextRun)).toBe("2026-09-30 09:00");
|
|
355
|
+
expect(after?.lastStatus).toBe("skipped: missed while offline");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ScheduledOutcome } from "../../contract/channels.ts";
|
|
2
|
+
import type { Logger } from "../../log.ts";
|
|
3
|
+
import { nextRun } from "./recurrence.ts";
|
|
4
|
+
import type { Schedule, ScheduleStore } from "./schedule-store.ts";
|
|
5
|
+
|
|
6
|
+
export type { ScheduledOutcome };
|
|
7
|
+
|
|
8
|
+
export interface ScheduledRunner {
|
|
9
|
+
/** Runs one due schedule in its channel and posts the answer there; never rejects. */
|
|
10
|
+
runScheduled(schedule: Schedule, firedAt: Date): Promise<ScheduledOutcome>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A run found this late, for example after the service was down, is skipped instead of run. */
|
|
14
|
+
export const LATE_LIMIT_MS = 12 * 3_600_000;
|
|
15
|
+
|
|
16
|
+
export interface SchedulerOptions {
|
|
17
|
+
store: Pick<ScheduleStore, "due" | "claim" | "recordStatus">;
|
|
18
|
+
runner: ScheduledRunner;
|
|
19
|
+
logger: Logger;
|
|
20
|
+
intervalMs?: number;
|
|
21
|
+
/** Replaceable in tests. */
|
|
22
|
+
now?: () => Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function statusText(outcome: ScheduledOutcome): string {
|
|
26
|
+
switch (outcome.status) {
|
|
27
|
+
case "ran":
|
|
28
|
+
return "ran";
|
|
29
|
+
case "failed":
|
|
30
|
+
return `failed: ${outcome.error}`.slice(0, 300);
|
|
31
|
+
case "skipped":
|
|
32
|
+
return `skipped: ${outcome.reason}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks for due schedules on an interval. Each due schedule is claimed first, moved to its
|
|
38
|
+
* next run or deleted, so a slow or failing run never fires twice; missed runs are not caught up.
|
|
39
|
+
*/
|
|
40
|
+
export class Scheduler {
|
|
41
|
+
readonly #options: SchedulerOptions;
|
|
42
|
+
readonly #running = new Set<Promise<void>>();
|
|
43
|
+
#timer: ReturnType<typeof setInterval> | undefined;
|
|
44
|
+
#ticking = false;
|
|
45
|
+
|
|
46
|
+
constructor(options: SchedulerOptions) {
|
|
47
|
+
this.#options = options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
start(): void {
|
|
51
|
+
this.#timer = setInterval(
|
|
52
|
+
() => void this.tick(),
|
|
53
|
+
this.#options.intervalMs ?? 30_000,
|
|
54
|
+
);
|
|
55
|
+
void this.tick();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop(): void {
|
|
59
|
+
clearInterval(this.#timer);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Claims every due schedule and starts its run; runs continue after tick resolves. */
|
|
63
|
+
async tick(): Promise<void> {
|
|
64
|
+
if (this.#ticking) return;
|
|
65
|
+
this.#ticking = true;
|
|
66
|
+
const { store, logger } = this.#options;
|
|
67
|
+
try {
|
|
68
|
+
const now = this.#options.now?.() ?? new Date();
|
|
69
|
+
for (const schedule of await store.due(now)) {
|
|
70
|
+
const claimed = await store.claim(
|
|
71
|
+
schedule,
|
|
72
|
+
nextRun(schedule.recurrence, now),
|
|
73
|
+
now,
|
|
74
|
+
);
|
|
75
|
+
if (!claimed) continue;
|
|
76
|
+
if (now.getTime() - schedule.nextRun.getTime() > LATE_LIMIT_MS) {
|
|
77
|
+
logger.warn(
|
|
78
|
+
{ schedule: schedule.id, due: schedule.nextRun },
|
|
79
|
+
"schedule missed its run",
|
|
80
|
+
);
|
|
81
|
+
await store.recordStatus(
|
|
82
|
+
schedule.id,
|
|
83
|
+
"skipped: missed while offline",
|
|
84
|
+
);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const run = this.#fire(schedule, now).finally(() =>
|
|
88
|
+
this.#running.delete(run),
|
|
89
|
+
);
|
|
90
|
+
this.#running.add(run);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
logger.error({ err: error }, "schedule check failed");
|
|
94
|
+
} finally {
|
|
95
|
+
this.#ticking = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolves once every started run has finished. */
|
|
100
|
+
async idle(): Promise<void> {
|
|
101
|
+
await Promise.all([...this.#running]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async #fire(schedule: Schedule, firedAt: Date): Promise<void> {
|
|
105
|
+
const { store, runner, logger } = this.#options;
|
|
106
|
+
logger.info(
|
|
107
|
+
{ schedule: schedule.id, channel: schedule.channel, mode: schedule.mode },
|
|
108
|
+
"schedule firing",
|
|
109
|
+
);
|
|
110
|
+
const outcome = await runner.runScheduled(schedule, firedAt);
|
|
111
|
+
logger.info({ schedule: schedule.id, outcome }, "schedule finished");
|
|
112
|
+
await store
|
|
113
|
+
.recordStatus(schedule.id, statusText(outcome))
|
|
114
|
+
.catch((error: unknown) =>
|
|
115
|
+
logger.error(
|
|
116
|
+
{ schedule: schedule.id, err: error },
|
|
117
|
+
"schedule status not saved",
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
4
|
+
import { ScheduleError } from "../../domain/errors.ts";
|
|
5
|
+
import type { OwnerIdentity } from "../../identity.ts";
|
|
6
|
+
import {
|
|
7
|
+
type ToolInput,
|
|
8
|
+
textToolsExtension,
|
|
9
|
+
} from "../../runtime/text-tools.ts";
|
|
10
|
+
import {
|
|
11
|
+
type ScheduleToolSpec,
|
|
12
|
+
scheduleToolSpecs,
|
|
13
|
+
} from "../../shared/schedule-tools.ts";
|
|
14
|
+
import type { Speaker } from "../../speakers.ts";
|
|
15
|
+
import type { ScheduleStore } from "./schedule-store.ts";
|
|
16
|
+
import { callScheduleTool } from "./schedule-tools.ts";
|
|
17
|
+
|
|
18
|
+
export interface OwnerSchedules {
|
|
19
|
+
store: ScheduleStore;
|
|
20
|
+
owner: { id: string; name: string };
|
|
21
|
+
/**
|
|
22
|
+
* The chat channel a conversation's schedules belong to: itself, or the owner's direct
|
|
23
|
+
* messages for a conversation no chat surface carries, where a run could not be posted.
|
|
24
|
+
*/
|
|
25
|
+
channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
|
|
29
|
+
export type AgentChannelLookup = (agent: string) => ChannelKey;
|
|
30
|
+
|
|
31
|
+
/** schedule_list for agents: an optional agent name reads that agent's schedules instead. */
|
|
32
|
+
function withAgentOption(spec: ScheduleToolSpec): ScheduleToolSpec {
|
|
33
|
+
if (spec.name !== "schedule_list") return spec;
|
|
34
|
+
return {
|
|
35
|
+
...spec,
|
|
36
|
+
description: `${spec.description} Give agent to read another agent's schedules instead; you can change only your own, so ask that agent to change its.`,
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
id: Type.Optional(Type.Integer({ description: "Schedule id." })),
|
|
39
|
+
agent: Type.Optional(
|
|
40
|
+
Type.String({ description: "Another agent's name, from agent_list." }),
|
|
41
|
+
),
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Registers the schedule tools for one owner conversation; its runs speak for the owner. With
|
|
48
|
+
* `agents`, schedule_list can also read another agent's schedules.
|
|
49
|
+
*/
|
|
50
|
+
export function schedulesExtension(
|
|
51
|
+
schedules: OwnerSchedules,
|
|
52
|
+
channel: ChannelKey,
|
|
53
|
+
identity: OwnerIdentity,
|
|
54
|
+
agents?: AgentChannelLookup,
|
|
55
|
+
/** The person the running turn is for; their schedules run at their tier. */
|
|
56
|
+
speaker: () => Speaker | undefined = () => undefined,
|
|
57
|
+
): ExtensionFactory {
|
|
58
|
+
const { store, owner, channelFor } = schedules;
|
|
59
|
+
const defs = scheduleToolSpecs().map((base) => {
|
|
60
|
+
const spec = agents ? withAgentOption(base) : base;
|
|
61
|
+
return {
|
|
62
|
+
...spec,
|
|
63
|
+
run: async (input: ToolInput) => {
|
|
64
|
+
const peer =
|
|
65
|
+
agents && typeof input.agent === "string" && input.agent
|
|
66
|
+
? agents(input.agent)
|
|
67
|
+
: undefined;
|
|
68
|
+
const target = peer ?? (await channelFor(channel));
|
|
69
|
+
const answer = await callScheduleTool(
|
|
70
|
+
{
|
|
71
|
+
store,
|
|
72
|
+
channel: target,
|
|
73
|
+
mode: "owner",
|
|
74
|
+
author: speaker() ?? owner,
|
|
75
|
+
now: new Date(),
|
|
76
|
+
},
|
|
77
|
+
spec.name,
|
|
78
|
+
input,
|
|
79
|
+
);
|
|
80
|
+
if (peer) return `Agent ${String(input.agent)}'s channel:\n${answer}`;
|
|
81
|
+
if (target === channel) return answer;
|
|
82
|
+
return `${answer}\n(These are the schedules of ${identity.name}'s Discord direct messages; their runs are posted there.)`;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
return textToolsExtension(defs, ScheduleError);
|
|
87
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AgentError } from "../../domain/errors.ts";
|
|
2
|
+
|
|
3
|
+
export function checkRepoName(repo: string): void {
|
|
4
|
+
if (
|
|
5
|
+
!/^[\w.-]+\/[\w.-]+$/.test(repo) ||
|
|
6
|
+
repo.split("/").some((p) => /^\.+$/.test(p))
|
|
7
|
+
)
|
|
8
|
+
throw new AgentError(
|
|
9
|
+
`"${repo}" is not a repository: write it as <owner>/<repo>, as repo_list shows.`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { migrate } from "../../db/migrations.ts";
|
|
4
|
+
import {
|
|
5
|
+
describeDb,
|
|
6
|
+
migratedPool,
|
|
7
|
+
TEST_GUILD,
|
|
8
|
+
testDatabaseUrl,
|
|
9
|
+
} from "../../testing/database.ts";
|
|
10
|
+
import { LEGACY_WRITTEN_KIND, SkillStore } from "./skill-store.ts";
|
|
11
|
+
|
|
12
|
+
// Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
|
|
13
|
+
describeDb("the written skill kind", () => {
|
|
14
|
+
let sql: SQL;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
const admin = new SQL(testDatabaseUrl);
|
|
18
|
+
for (const table of ["skills", "skill_groups", "agent_skills"])
|
|
19
|
+
await admin.unsafe(`DROP TABLE IF EXISTS ${table}`);
|
|
20
|
+
await admin.close();
|
|
21
|
+
// The tables as they were when an agent-written skill was stored under the legacy kind.
|
|
22
|
+
sql = await migratedPool(SkillStore.migration);
|
|
23
|
+
await sql`INSERT INTO skills (name, kind, description, body)
|
|
24
|
+
VALUES ('notes', ${LEGACY_WRITTEN_KIND}, 'Take notes.', 'Body.')`;
|
|
25
|
+
await sql`INSERT INTO skills (name, kind, repo, path)
|
|
26
|
+
VALUES ('linked-one', 'linked', 'owner/repo', 'skills/linked-one')`;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await sql.close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const kinds = async () =>
|
|
34
|
+
(await sql`SELECT name, kind FROM skills ORDER BY name`) as {
|
|
35
|
+
name: string;
|
|
36
|
+
kind: string;
|
|
37
|
+
}[];
|
|
38
|
+
|
|
39
|
+
test("a legacy row becomes written, a linked row is left alone, and the store reads it", async () => {
|
|
40
|
+
await migrate(sql, SkillStore.migrations(TEST_GUILD));
|
|
41
|
+
expect(await kinds()).toEqual([
|
|
42
|
+
{ name: "linked-one", kind: "linked" },
|
|
43
|
+
{ name: "notes", kind: "written" },
|
|
44
|
+
]);
|
|
45
|
+
const store = await SkillStore.attach(sql, TEST_GUILD);
|
|
46
|
+
expect(store.skill("notes")?.kind).toBe("written");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("running the migration again changes nothing", async () => {
|
|
50
|
+
await migrate(sql, SkillStore.migrations(TEST_GUILD));
|
|
51
|
+
const before = await kinds();
|
|
52
|
+
await migrate(sql, SkillStore.migrations(TEST_GUILD));
|
|
53
|
+
expect(await kinds()).toEqual(before);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative as relativePath, resolve } from "node:path";
|
|
3
|
+
import { AgentError } from "../../domain/errors.ts";
|
|
4
|
+
import { checkRepoName } from "./repo-name.ts";
|
|
5
|
+
import { checkSkillName, readSkillFile } from "./skill-rules.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The skills a link would register: the one at a repository path, or every child folder with a
|
|
9
|
+
* SKILL.md (behavior 15). Throws one error listing every problem, so nothing is half linked.
|
|
10
|
+
*/
|
|
11
|
+
export function planLinkedSkills(
|
|
12
|
+
reposDir: string,
|
|
13
|
+
repo: string,
|
|
14
|
+
path: string,
|
|
15
|
+
checkFreeName: (name: string) => void,
|
|
16
|
+
): { name: string; repo: string; path: string }[] {
|
|
17
|
+
checkRepoName(repo);
|
|
18
|
+
const repoDir = join(reposDir, repo);
|
|
19
|
+
if (!existsSync(join(repoDir, ".git")))
|
|
20
|
+
throw new AgentError(
|
|
21
|
+
`${repo} is not a managed repository; add it with repo_add first.`,
|
|
22
|
+
);
|
|
23
|
+
const relative = relativePath(repoDir, resolve(repoDir, path.trim()));
|
|
24
|
+
if (relative.startsWith("..") || isAbsolute(relative))
|
|
25
|
+
throw new AgentError(`The path must stay inside ${repo}.`);
|
|
26
|
+
const dir = join(repoDir, relative);
|
|
27
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
28
|
+
throw new AgentError(`${repo} has no folder ${relative || "."}.`);
|
|
29
|
+
const folders = existsSync(join(dir, "SKILL.md"))
|
|
30
|
+
? [relative]
|
|
31
|
+
: readdirSync(dir, { withFileTypes: true })
|
|
32
|
+
.filter(
|
|
33
|
+
(entry) =>
|
|
34
|
+
entry.isDirectory() &&
|
|
35
|
+
existsSync(join(dir, entry.name, "SKILL.md")),
|
|
36
|
+
)
|
|
37
|
+
.map((entry) => (relative ? `${relative}/${entry.name}` : entry.name))
|
|
38
|
+
.sort();
|
|
39
|
+
if (folders.length === 0)
|
|
40
|
+
throw new AgentError(
|
|
41
|
+
`${repo}:${relative || "."} has no SKILL.md, nor any folder with one.`,
|
|
42
|
+
);
|
|
43
|
+
const entries: { name: string; repo: string; path: string }[] = [];
|
|
44
|
+
const problems: string[] = [];
|
|
45
|
+
for (const folder of folders) {
|
|
46
|
+
const read = readSkillFile(join(repoDir, folder, "SKILL.md"));
|
|
47
|
+
if ("error" in read) {
|
|
48
|
+
problems.push(`${folder}: ${read.error}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
checkSkillName(read.name);
|
|
53
|
+
checkFreeName(read.name);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
problems.push(`${folder}: ${(error as Error).message}`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
entries.push({ name: read.name, repo, path: folder });
|
|
59
|
+
}
|
|
60
|
+
const names = entries.map((e) => e.name);
|
|
61
|
+
const twice = names.filter((name, i) => names.indexOf(name) !== i);
|
|
62
|
+
if (twice.length > 0)
|
|
63
|
+
problems.push(`names used twice: ${[...new Set(twice)].join(", ")}`);
|
|
64
|
+
if (problems.length > 0)
|
|
65
|
+
throw new AgentError(
|
|
66
|
+
`Nothing was linked:\n${problems.map((p) => `- ${p}`).join("\n")}`,
|
|
67
|
+
);
|
|
68
|
+
return entries;
|
|
69
|
+
}
|