pi-roundtable 0.8.0 → 0.9.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 +102 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +606 -78
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +140 -85
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +41 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +135 -134
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +50 -5
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +47 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -3,6 +3,7 @@ import type { ChannelKey } from "../../domain/conversation.ts";
|
|
|
3
3
|
import { ScheduleError } from "../../domain/errors.ts";
|
|
4
4
|
import type { HoldCheck } from "../../holds.ts";
|
|
5
5
|
import { messages } from "../../i18n/index.ts";
|
|
6
|
+
import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
|
|
6
7
|
import type { ScheduleStore } from "../../services.ts";
|
|
7
8
|
import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
|
|
8
9
|
import { type Tier, tierAtLeast } from "../../speakers.ts";
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
type Recurrence,
|
|
21
22
|
type RecurrenceInput,
|
|
22
23
|
} from "./recurrence.ts";
|
|
23
|
-
import type { Schedule } from "./schedule-store.ts";
|
|
24
|
+
import type { NewSchedule, Schedule } from "./schedule-store.ts";
|
|
24
25
|
|
|
25
26
|
const TITLE_CHARS = 80;
|
|
26
27
|
const LIST_PROMPT_PREVIEW = 200;
|
|
@@ -29,16 +30,35 @@ const DESCRIBE_TIMEOUT_MS = 10_000;
|
|
|
29
30
|
const DAY_MS = 86_400_000;
|
|
30
31
|
|
|
31
32
|
export interface ScheduleToolContext {
|
|
33
|
+
/**
|
|
34
|
+
* The schedules; `all` and `createWithin` are needed only where the target counts each person's
|
|
35
|
+
* (`perPrincipal`).
|
|
36
|
+
*/
|
|
32
37
|
store: Pick<
|
|
33
38
|
ScheduleStore,
|
|
34
39
|
"create" | "get" | "forChannel" | "update" | "remove"
|
|
35
|
-
|
|
40
|
+
> &
|
|
41
|
+
Partial<Pick<ScheduleStore, "all" | "createWithin">>;
|
|
36
42
|
channel: ChannelKey;
|
|
37
43
|
/** Whose schedules these are; its `schedules` limits apply, and without them nothing is scheduled. */
|
|
38
44
|
target: BackgroundTarget;
|
|
39
|
-
/**
|
|
40
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Who asked: their principal, recorded as a created schedule's creator, the name it lists, and
|
|
47
|
+
* the tier its runs ask for; a scheduled run speaks for its creator. The host's own turns,
|
|
48
|
+
* whose principal is the system's, create none.
|
|
49
|
+
*/
|
|
50
|
+
author: { principalId: string; id: string; name: string; tier: Tier };
|
|
41
51
|
now: Date;
|
|
52
|
+
/**
|
|
53
|
+
* Whose the conversation is: in a `private` one the asker lists, reads, changes, and cancels
|
|
54
|
+
* only their own schedules; in a `shared` one, the default, every schedule of the channel.
|
|
55
|
+
*/
|
|
56
|
+
visibility?: "private" | "shared";
|
|
57
|
+
/**
|
|
58
|
+
* The principal a schedule's creator id stands for, such as the primary owner for 0.8's
|
|
59
|
+
* `remote-mcp`; without it, or when it knows none, the id is the principal's.
|
|
60
|
+
*/
|
|
61
|
+
principalOf?: (createdById: string) => Promise<string | undefined>;
|
|
42
62
|
/**
|
|
43
63
|
* The host's prechecks a schedule may name, and the runner of the scripts it may carry instead;
|
|
44
64
|
* without them, none can be attached.
|
|
@@ -160,7 +180,7 @@ async function precheckCatalog(ctx: ScheduleToolContext): Promise<string> {
|
|
|
160
180
|
runner.describe({
|
|
161
181
|
channel: ctx.channel,
|
|
162
182
|
target: ctx.target.name,
|
|
163
|
-
|
|
183
|
+
tier: ctx.author.tier,
|
|
164
184
|
}),
|
|
165
185
|
)
|
|
166
186
|
.then(
|
|
@@ -221,24 +241,109 @@ function line(schedule: Schedule): string {
|
|
|
221
241
|
return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}; set by ${schedule.createdByName}${precheck}${last}\n ${prompt.replace(/\n/g, " ")}`;
|
|
222
242
|
}
|
|
223
243
|
|
|
244
|
+
/** The principal who created a schedule. */
|
|
245
|
+
async function creatorOf(
|
|
246
|
+
ctx: ScheduleToolContext,
|
|
247
|
+
schedule: Schedule,
|
|
248
|
+
): Promise<string> {
|
|
249
|
+
return (
|
|
250
|
+
(await ctx.principalOf?.(schedule.createdById)) ?? schedule.createdById
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Whether the asker sees a schedule of the channel: in a private conversation only their own. */
|
|
255
|
+
async function visible(
|
|
256
|
+
ctx: ScheduleToolContext,
|
|
257
|
+
schedule: Schedule,
|
|
258
|
+
): Promise<boolean> {
|
|
259
|
+
return (
|
|
260
|
+
ctx.visibility !== "private" ||
|
|
261
|
+
(await creatorOf(ctx, schedule)) === ctx.author.principalId
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
224
265
|
async function own(ctx: ScheduleToolContext, input: Input): Promise<Schedule> {
|
|
225
266
|
const schedule = await ctx.store.get(id(input));
|
|
226
|
-
if (
|
|
267
|
+
if (
|
|
268
|
+
!schedule ||
|
|
269
|
+
schedule.channel !== ctx.channel ||
|
|
270
|
+
!(await visible(ctx, schedule))
|
|
271
|
+
)
|
|
227
272
|
throw new ScheduleError(
|
|
228
273
|
`this channel has no schedule #${String(input.id)}`,
|
|
229
274
|
);
|
|
230
275
|
return schedule;
|
|
231
276
|
}
|
|
232
277
|
|
|
278
|
+
/**
|
|
279
|
+
* The creator ids that stand for the asker among the target's schedules, for a target that counts
|
|
280
|
+
* each person's (`perPrincipal`): their principal's and any older id read as theirs, such as 0.8's
|
|
281
|
+
* `remote-mcp` for the primary owner. Undefined for a target that counts per conversation only.
|
|
282
|
+
*/
|
|
283
|
+
async function perPrincipalCreators(
|
|
284
|
+
ctx: ScheduleToolContext,
|
|
285
|
+
): Promise<string[] | undefined> {
|
|
286
|
+
const { perPrincipal } = limitsOf(ctx);
|
|
287
|
+
if (perPrincipal === undefined) return undefined;
|
|
288
|
+
if (!ctx.store.all || !ctx.store.createWithin)
|
|
289
|
+
throw new ScheduleError(
|
|
290
|
+
`${ctx.target.name} schedules are counted per person, and this host's schedule store cannot count them`,
|
|
291
|
+
);
|
|
292
|
+
const creators = new Set([ctx.author.principalId]);
|
|
293
|
+
for (const schedule of await ctx.store.all())
|
|
294
|
+
if (
|
|
295
|
+
schedule.target === ctx.target.name &&
|
|
296
|
+
!creators.has(schedule.createdById) &&
|
|
297
|
+
(await creatorOf(ctx, schedule)) === ctx.author.principalId
|
|
298
|
+
)
|
|
299
|
+
creators.add(schedule.createdById);
|
|
300
|
+
return [...creators];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Stores a new schedule, within the target's limit of one person's across all their conversations
|
|
305
|
+
* where it has one: counted and stored under a lock on the person, so creates at once stay within it.
|
|
306
|
+
*/
|
|
307
|
+
async function created(
|
|
308
|
+
ctx: ScheduleToolContext,
|
|
309
|
+
schedule: NewSchedule,
|
|
310
|
+
): Promise<Schedule> {
|
|
311
|
+
const creators = await perPrincipalCreators(ctx);
|
|
312
|
+
const { perPrincipal } = limitsOf(ctx);
|
|
313
|
+
if (!creators || perPrincipal === undefined || !ctx.store.createWithin)
|
|
314
|
+
return ctx.store.create(schedule);
|
|
315
|
+
const outcome = await ctx.store.createWithin(schedule, {
|
|
316
|
+
creators,
|
|
317
|
+
max: perPrincipal,
|
|
318
|
+
});
|
|
319
|
+
if ("reached" in outcome)
|
|
320
|
+
throw new ScheduleError(
|
|
321
|
+
`you already have ${outcome.reached} schedules, the most one person may have here; cancel one first`,
|
|
322
|
+
);
|
|
323
|
+
return outcome.created;
|
|
324
|
+
}
|
|
325
|
+
|
|
233
326
|
/** A schedule runs at its creator's tier, so someone of a lower tier may not rewrite it. */
|
|
234
327
|
function changeable(ctx: ScheduleToolContext, schedule: Schedule): Schedule {
|
|
235
|
-
if (
|
|
328
|
+
if (!tierAtLeast(ctx.author.tier, schedule.createdTier))
|
|
236
329
|
throw new ScheduleError(
|
|
237
330
|
`schedule #${schedule.id} was set by a higher tier; only that tier or above can change it`,
|
|
238
331
|
);
|
|
239
332
|
return schedule;
|
|
240
333
|
}
|
|
241
334
|
|
|
335
|
+
/**
|
|
336
|
+
* The host's own turns, such as a logged error's or an outside webhook's report, read text no one
|
|
337
|
+
* vouches for, so they leave no schedule behind them that runs later as someone's: they may only
|
|
338
|
+
* list schedules.
|
|
339
|
+
*/
|
|
340
|
+
function notTheHost(ctx: ScheduleToolContext, refusal: string): void {
|
|
341
|
+
if (ctx.author.principalId === SYSTEM_PRINCIPAL)
|
|
342
|
+
throw new ScheduleError(
|
|
343
|
+
`the host's own turns, such as a report's, ${refusal}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
242
347
|
/** Runs one schedule tool against the channel's schedules and returns the answer for the model. */
|
|
243
348
|
export async function callScheduleTool(
|
|
244
349
|
ctx: ScheduleToolContext,
|
|
@@ -249,6 +354,7 @@ export async function callScheduleTool(
|
|
|
249
354
|
const limits = limitsOf(ctx);
|
|
250
355
|
switch (name) {
|
|
251
356
|
case "schedule_create": {
|
|
357
|
+
notTheHost(ctx, "set up no schedules; ask the owner to set it up");
|
|
252
358
|
const title = text(input, "title", TITLE_CHARS);
|
|
253
359
|
const prompt = text(input, "prompt", limits.promptChars);
|
|
254
360
|
const [recurrence, next] = timing(ctx, input);
|
|
@@ -267,16 +373,16 @@ export async function callScheduleTool(
|
|
|
267
373
|
throw new ScheduleError(
|
|
268
374
|
`this channel already has ${existing.length} schedules, the most it may have; cancel one first`,
|
|
269
375
|
);
|
|
270
|
-
const
|
|
376
|
+
const made = await created(ctx, {
|
|
271
377
|
channel: ctx.channel,
|
|
272
378
|
target: ctx.target.name,
|
|
273
379
|
title,
|
|
274
380
|
prompt,
|
|
275
381
|
recurrence,
|
|
276
382
|
nextRun: next,
|
|
277
|
-
createdById: ctx.author.
|
|
383
|
+
createdById: ctx.author.principalId,
|
|
278
384
|
createdByName: ctx.author.name,
|
|
279
|
-
createdTier: ctx.author.tier
|
|
385
|
+
createdTier: ctx.author.tier,
|
|
280
386
|
...(precheck ? { precheck } : {}),
|
|
281
387
|
...(script
|
|
282
388
|
? { precheckScript: script.script, precheckTools: script.tools }
|
|
@@ -287,7 +393,7 @@ export async function callScheduleTool(
|
|
|
287
393
|
: script
|
|
288
394
|
? "; its precheck script runs first"
|
|
289
395
|
: "";
|
|
290
|
-
return `Scheduled #${
|
|
396
|
+
return `Scheduled #${made.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
|
|
291
397
|
}
|
|
292
398
|
case "schedule_list": {
|
|
293
399
|
if (input.id !== undefined) {
|
|
@@ -297,7 +403,9 @@ export async function callScheduleTool(
|
|
|
297
403
|
: "";
|
|
298
404
|
return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}${script}`;
|
|
299
405
|
}
|
|
300
|
-
const all =
|
|
406
|
+
const all: Schedule[] = [];
|
|
407
|
+
for (const schedule of await ctx.store.forChannel(ctx.channel))
|
|
408
|
+
if (await visible(ctx, schedule)) all.push(schedule);
|
|
301
409
|
const listed =
|
|
302
410
|
all.length === 0
|
|
303
411
|
? "This channel has no schedules."
|
|
@@ -305,6 +413,7 @@ export async function callScheduleTool(
|
|
|
305
413
|
return `${listed}${await precheckCatalog(ctx)}`;
|
|
306
414
|
}
|
|
307
415
|
case "schedule_update": {
|
|
416
|
+
notTheHost(ctx, "change no schedules; ask the owner to change it");
|
|
308
417
|
const schedule = changeable(ctx, await own(ctx, input));
|
|
309
418
|
const change: Parameters<ScheduleToolContext["store"]["update"]>[2] = {};
|
|
310
419
|
if (input.title !== undefined)
|
|
@@ -349,6 +458,7 @@ export async function callScheduleTool(
|
|
|
349
458
|
return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
|
|
350
459
|
}
|
|
351
460
|
case "schedule_cancel": {
|
|
461
|
+
notTheHost(ctx, "cancel no schedules; ask the owner to cancel it");
|
|
352
462
|
const schedule = changeable(ctx, await own(ctx, input));
|
|
353
463
|
await ctx.store.remove(schedule.id, ctx.channel);
|
|
354
464
|
return `Cancelled #${schedule.id} "${schedule.title}".`;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BackgroundRunsAs,
|
|
3
|
+
ScheduledOutcome,
|
|
4
|
+
} from "../../contract/channels.ts";
|
|
2
5
|
import type { HoldCheck } from "../../holds.ts";
|
|
3
6
|
import type { Logger } from "../../log.ts";
|
|
4
7
|
import type { ScheduleStore } from "../../services.ts";
|
|
@@ -20,6 +23,11 @@ export interface ScheduledRunner {
|
|
|
20
23
|
firedAt: Date,
|
|
21
24
|
finding?: PrecheckFinding,
|
|
22
25
|
): Promise<ScheduledOutcome>;
|
|
26
|
+
/**
|
|
27
|
+
* Who the schedule's turn would run as now, asked before its precheck: a precheck runs only for
|
|
28
|
+
* a creator who may run the turn, at the tier it would run at. Throws when that cannot be told.
|
|
29
|
+
*/
|
|
30
|
+
runsAs(schedule: Schedule): Promise<BackgroundRunsAs>;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
/** How long stop waits for running prechecks to clean up after aborting them. */
|
|
@@ -169,7 +177,29 @@ export class Scheduler {
|
|
|
169
177
|
schedule.precheck ??
|
|
170
178
|
(schedule.precheckScript ? SCRIPT_PRECHECK : undefined);
|
|
171
179
|
if (name) {
|
|
172
|
-
|
|
180
|
+
// A creator who may not run the turn now, such as one disabled, runs no precheck either.
|
|
181
|
+
const runs = await this.#runsAs(schedule);
|
|
182
|
+
if (this.#stopping.signal.aborted) {
|
|
183
|
+
await this.#record(
|
|
184
|
+
schedule,
|
|
185
|
+
"skipped: the host stopped before its run",
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if ("skipped" in runs) {
|
|
190
|
+
logger.info(
|
|
191
|
+
{ schedule: schedule.id, reason: runs.skipped },
|
|
192
|
+
"schedule skipped before its precheck",
|
|
193
|
+
);
|
|
194
|
+
await this.#record(schedule, `skipped: ${runs.skipped}`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const decision = await this.#prechecks.run(
|
|
198
|
+
schedule,
|
|
199
|
+
name,
|
|
200
|
+
firedAt,
|
|
201
|
+
runs.speaker.tier,
|
|
202
|
+
);
|
|
173
203
|
if (this.#stopping.signal.aborted) {
|
|
174
204
|
await this.#record(
|
|
175
205
|
schedule,
|
|
@@ -202,6 +232,17 @@ export class Scheduler {
|
|
|
202
232
|
);
|
|
203
233
|
}
|
|
204
234
|
|
|
235
|
+
/** Who the schedule runs as; a failure to tell is a reason to skip it, as its turn would not run. */
|
|
236
|
+
async #runsAs(schedule: Schedule): Promise<BackgroundRunsAs> {
|
|
237
|
+
try {
|
|
238
|
+
return await this.#options.runner.runsAs(schedule);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return {
|
|
241
|
+
skipped: `whom it runs as could not be checked (${error instanceof Error ? error.message : String(error)})`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
205
246
|
/** Records a run the precheck skipped and posts its note; a note that cannot be posted is logged. */
|
|
206
247
|
async #skip(schedule: Schedule, note: string | undefined): Promise<void> {
|
|
207
248
|
const { notify, logger } = this.#options;
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
3
|
+
import type { BackgroundTarget } from "../../contract/channels.ts";
|
|
4
4
|
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
5
5
|
import { ScheduleError } from "../../domain/errors.ts";
|
|
6
6
|
import type { HoldCheck } from "../../holds.ts";
|
|
7
7
|
import { activeLocale } from "../../i18n/index.ts";
|
|
8
|
-
import type { OwnerIdentity } from "../../identity.ts";
|
|
9
8
|
import {
|
|
10
9
|
type ToolInput,
|
|
11
10
|
textToolsExtension,
|
|
@@ -17,22 +16,32 @@ import {
|
|
|
17
16
|
} from "../../shared/schedule-tools.ts";
|
|
18
17
|
import type { Speaker } from "../../speakers.ts";
|
|
19
18
|
import { timeZone } from "../../time.ts";
|
|
19
|
+
import { PERSONAL_TARGET } from "../background/personal-target.ts";
|
|
20
20
|
import type { PrecheckRegistry } from "./prechecks.ts";
|
|
21
21
|
import { callScheduleTool } from "./schedule-tools.ts";
|
|
22
22
|
|
|
23
23
|
export interface OwnerSchedules {
|
|
24
24
|
store: ScheduleStore;
|
|
25
|
-
owner: { id: string; name: string };
|
|
26
25
|
/**
|
|
27
|
-
* The chat channel a conversation's schedules belong to: itself, or
|
|
28
|
-
*
|
|
26
|
+
* The chat channel a conversation's schedules belong to: itself, or, for a conversation no chat
|
|
27
|
+
* surface carries, where a run could not be posted, the asker's direct messages; asked with the
|
|
28
|
+
* asker's principal, and throws ScheduleError when their runs could go nowhere.
|
|
29
29
|
*/
|
|
30
|
-
channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
|
|
30
|
+
channelFor: (channel: ChannelKey, principalId: string) => Promise<ChannelKey>;
|
|
31
31
|
/** The host's prechecks a schedule may name, and the runner of scripts it may carry; without them, none can be attached. */
|
|
32
32
|
prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
|
|
33
33
|
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
34
34
|
/** The host's hold rules, which mark a saved script's approved tools. */
|
|
35
35
|
holds?: () => HoldCheck;
|
|
36
|
+
/**
|
|
37
|
+
* Whose the conversation is, read when a tool runs: in a private one the speaker sees only
|
|
38
|
+
* their own schedules. Without it, every schedule of the channel, as in a shared one.
|
|
39
|
+
*/
|
|
40
|
+
visibility?: () => Promise<"private" | "shared">;
|
|
41
|
+
/** The principal a schedule's creator id stands for, such as the primary owner for 0.8's `remote-mcp`. */
|
|
42
|
+
principalOf?: (createdById: string) => Promise<string | undefined>;
|
|
43
|
+
/** The target the schedules are made for, with its limits; default `PERSONAL_TARGET`. */
|
|
44
|
+
target?: BackgroundTarget;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
|
|
@@ -54,18 +63,26 @@ function withAgentOption(spec: ScheduleToolSpec): ScheduleToolSpec {
|
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
/**
|
|
57
|
-
* Registers the schedule tools for one
|
|
58
|
-
*
|
|
66
|
+
* Registers the schedule tools for one conversation; a schedule's runs speak for the person whose
|
|
67
|
+
* turn set it up, and in a turn nobody is named for the tools refuse. With `agents`,
|
|
68
|
+
* schedule_list can also read another agent's schedules.
|
|
59
69
|
*/
|
|
60
70
|
export function schedulesExtension(
|
|
61
71
|
schedules: OwnerSchedules,
|
|
62
72
|
channel: ChannelKey,
|
|
63
|
-
identity: OwnerIdentity,
|
|
64
73
|
agents?: AgentChannelLookup,
|
|
65
74
|
/** The person the running turn is for; their schedules run at their tier. */
|
|
66
75
|
speaker: () => Speaker | undefined = () => undefined,
|
|
67
76
|
): ExtensionFactory {
|
|
68
|
-
const {
|
|
77
|
+
const {
|
|
78
|
+
store,
|
|
79
|
+
channelFor,
|
|
80
|
+
prechecks,
|
|
81
|
+
holds,
|
|
82
|
+
visibility,
|
|
83
|
+
principalOf,
|
|
84
|
+
target: kept = PERSONAL_TARGET,
|
|
85
|
+
} = schedules;
|
|
69
86
|
const defs = scheduleToolSpecs({
|
|
70
87
|
locale: activeLocale(),
|
|
71
88
|
timeZone: timeZone(),
|
|
@@ -75,27 +92,39 @@ export function schedulesExtension(
|
|
|
75
92
|
return {
|
|
76
93
|
...spec,
|
|
77
94
|
run: async (input: ToolInput) => {
|
|
95
|
+
const author = speaker();
|
|
96
|
+
if (!author)
|
|
97
|
+
throw new ScheduleError(
|
|
98
|
+
"schedules are kept only in a turn someone is named for, whose schedules they are",
|
|
99
|
+
);
|
|
78
100
|
const peer =
|
|
79
101
|
agents && typeof input.agent === "string" && input.agent
|
|
80
102
|
? agents(input.agent)
|
|
81
103
|
: undefined;
|
|
82
|
-
const target = peer ?? (await channelFor(channel));
|
|
104
|
+
const target = peer ?? (await channelFor(channel, author.principalId));
|
|
83
105
|
const answer = await callScheduleTool(
|
|
84
106
|
{
|
|
85
107
|
store,
|
|
86
108
|
channel: target,
|
|
87
|
-
target:
|
|
88
|
-
author:
|
|
109
|
+
target: kept,
|
|
110
|
+
author: {
|
|
111
|
+
principalId: author.principalId,
|
|
112
|
+
id: author.id,
|
|
113
|
+
name: author.name,
|
|
114
|
+
tier: author.tier,
|
|
115
|
+
},
|
|
89
116
|
now: new Date(),
|
|
90
117
|
...(prechecks ? { prechecks } : {}),
|
|
91
118
|
...(holds ? { holds } : {}),
|
|
119
|
+
...(visibility ? { visibility: await visibility() } : {}),
|
|
120
|
+
...(principalOf ? { principalOf } : {}),
|
|
92
121
|
},
|
|
93
122
|
spec.name,
|
|
94
123
|
input,
|
|
95
124
|
);
|
|
96
125
|
if (peer) return `Agent ${String(input.agent)}'s channel:\n${answer}`;
|
|
97
126
|
if (target === channel) return answer;
|
|
98
|
-
return `${answer}\n(These are the schedules of ${
|
|
127
|
+
return `${answer}\n(These are the schedules of ${author.name}'s direct messages; their runs are posted there.)`;
|
|
99
128
|
},
|
|
100
129
|
};
|
|
101
130
|
});
|
package/src/core/plugin.ts
CHANGED
|
@@ -17,6 +17,10 @@ import type { HoldCheck, HoldRule } from "./holds.ts";
|
|
|
17
17
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
18
18
|
import type { Locale } from "./i18n/index.ts";
|
|
19
19
|
import type { Logger } from "./log.ts";
|
|
20
|
+
import type {
|
|
21
|
+
DirectChannelProvider,
|
|
22
|
+
DirectChannels,
|
|
23
|
+
} from "./presence/direct-channels.ts";
|
|
20
24
|
import type { ConversationTurns } from "./routing/conversation-turns.ts";
|
|
21
25
|
import type {
|
|
22
26
|
AgentTurnScope,
|
|
@@ -141,6 +145,12 @@ export interface Persona {
|
|
|
141
145
|
* is in the host's language.
|
|
142
146
|
*/
|
|
143
147
|
prompt(): string;
|
|
148
|
+
/**
|
|
149
|
+
* Whose memory its conversations read: `"speaker"` (the default), the conversation's person in a
|
|
150
|
+
* private one and each turn's speaker in a shared one, or `"none"`: no memory tools and no
|
|
151
|
+
* memory in the prompt.
|
|
152
|
+
*/
|
|
153
|
+
memory?: "speaker" | "none";
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
/** What a plugin adds to the process; a plugin that adds nothing is refused. */
|
|
@@ -179,6 +189,13 @@ export interface Contribution {
|
|
|
179
189
|
* schedule or delegated task names one, and a turn for a target nobody contributes is skipped.
|
|
180
190
|
*/
|
|
181
191
|
backgroundTargets?: readonly BackgroundTarget[];
|
|
192
|
+
/**
|
|
193
|
+
* Ways to reach a person on their own, such as a chat network's direct messages, one per name
|
|
194
|
+
* across every plugin. The host reaches a person through the first, in contribution order,
|
|
195
|
+
* that reaches them: `notify` sends there, and a conversation no chat surface carries keeps its
|
|
196
|
+
* schedules and delegated reports in the creator's.
|
|
197
|
+
*/
|
|
198
|
+
directChannels?: readonly DirectChannelProvider[];
|
|
182
199
|
/** Lines the agent server's dashboard shows under its title, such as links, in contribution order. */
|
|
183
200
|
dashboard?: readonly string[];
|
|
184
201
|
/** Tools the plugin adds, each with the lowest tier that may use it; built with `defineTool`. */
|
|
@@ -213,6 +230,7 @@ export const CONTRIBUTION_KEYS = [
|
|
|
213
230
|
"surfaces",
|
|
214
231
|
"personas",
|
|
215
232
|
"backgroundTargets",
|
|
233
|
+
"directChannels",
|
|
216
234
|
"dashboard",
|
|
217
235
|
"tools",
|
|
218
236
|
"toolTiers",
|
|
@@ -235,6 +253,8 @@ export interface LinkedSessions {
|
|
|
235
253
|
prompt: readonly PromptSection[];
|
|
236
254
|
/** The prompt of a conversation kind, from the plugin that contributes its persona; undefined when none does. */
|
|
237
255
|
persona(kind: string): string | undefined;
|
|
256
|
+
/** Whose memory conversations of the kind read, as its persona declares; "speaker" when it declares none. */
|
|
257
|
+
personaMemory?(kind: string): "speaker" | "none";
|
|
238
258
|
/** Every tool name plugins require at startup, each once. */
|
|
239
259
|
requiredTools: readonly string[];
|
|
240
260
|
/** The tools plugins defined for agents, by name. */
|
|
@@ -269,6 +289,8 @@ export interface PluginContext {
|
|
|
269
289
|
conversations: ConversationPort;
|
|
270
290
|
/** Every contributed chat surface, chosen by a channel's prefix; calls during setup throw NotLinkedError. */
|
|
271
291
|
surfaces: SurfacePort;
|
|
292
|
+
/** Every plugin's direct channels, which reach a person on their own; calls during setup throw NotLinkedError. */
|
|
293
|
+
directChannels: DirectChannels;
|
|
272
294
|
/** Runs conversation turns of any kind over the runtime and the surfaces; calls during setup throw NotLinkedError. */
|
|
273
295
|
turns: ConversationTurns;
|
|
274
296
|
/** The host's one connection pool, migrated before any setup; throws PluginError without a database. */
|
|
@@ -290,10 +312,36 @@ export interface PluginContext {
|
|
|
290
312
|
apiKey(provider: string): Promise<string | undefined>;
|
|
291
313
|
}
|
|
292
314
|
|
|
315
|
+
/**
|
|
316
|
+
* An identity a plugin's own credential stands for, such as the bearer token of an endpoint it
|
|
317
|
+
* serves, and the principal it speaks as.
|
|
318
|
+
*/
|
|
319
|
+
export interface PluginIdentity {
|
|
320
|
+
/**
|
|
321
|
+
* Written `token:<subject>`, such as `token:remote-mcp`. Only the `token` provider is accepted:
|
|
322
|
+
* a surface's identity, such as `discord:` or `oidc:`, is a person's own account, linked by
|
|
323
|
+
* `access.owners` or the CLI, and any other provider stops the boot with a ConfigError.
|
|
324
|
+
*/
|
|
325
|
+
identity: string;
|
|
326
|
+
/** The id of the principal it stands for; the primary owner, the first of `access.owners`, when absent. */
|
|
327
|
+
principal?: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
293
330
|
export interface RoundtablePlugin {
|
|
294
331
|
name: string;
|
|
295
332
|
/** The plugin's tables; the host runs every plugin's, in registration order, before any setup. */
|
|
296
333
|
migrations?: readonly Migration[];
|
|
334
|
+
/**
|
|
335
|
+
* Identities the plugin's own credentials stand for, read before any setup. At every boot the
|
|
336
|
+
* identity plugin links each to its principal as the plugin's (`roundtable principal list` shows
|
|
337
|
+
* them as `plugin`), moves one bound to another principal than at the last boot, and unlinks one
|
|
338
|
+
* no plugin declares any more. An identity of a provider other than `token`, one linked to
|
|
339
|
+
* someone else, another plugin's, a principal that does not exist, or the system principal stops
|
|
340
|
+
* the boot with a ConfigError. A declared
|
|
341
|
+
* identity is never admitted or claimed at a first contact, and `IDENTITY.principalOf` reads
|
|
342
|
+
* whom it stands for.
|
|
343
|
+
*/
|
|
344
|
+
identities?: readonly PluginIdentity[];
|
|
297
345
|
/** The provider slots the plugin fills, resolved before any setup; two plugins cannot fill one slot. */
|
|
298
346
|
providers?: Partial<Providers>;
|
|
299
347
|
/**
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { SurfacePort } from "../contract/surface.ts";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
import type { Notifier } from "../domain/ports.ts";
|
|
4
|
+
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A way to reach a person on their own, such as a chat network's direct messages or a web inbox.
|
|
8
|
+
* A plugin contributes it as `directChannels`; the host reaches a person through the first one,
|
|
9
|
+
* in contribution order, that reaches them.
|
|
10
|
+
*/
|
|
11
|
+
export interface DirectChannelProvider {
|
|
12
|
+
/** Unique across every plugin, such as `"discord"`. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* How the `notify` tool names a message sent through it, completing "Send Ada …": such as
|
|
16
|
+
* `"a direct message on Discord"`.
|
|
17
|
+
*/
|
|
18
|
+
label: string;
|
|
19
|
+
/**
|
|
20
|
+
* The person's own conversation here, where a notice to them is posted and where their
|
|
21
|
+
* schedules and delegated reports from a conversation no chat surface carries run; undefined
|
|
22
|
+
* when they have none here. It may reach the network and throw when that fails.
|
|
23
|
+
*/
|
|
24
|
+
reaches(principalId: string): Promise<ChannelKey | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the person has a conversation here, from the host's own records such as their linked
|
|
27
|
+
* identities, without reaching the network. A session reads it when it decides whether to offer
|
|
28
|
+
* a tool that sends or runs there, so a network outage then does not take the tool away; the
|
|
29
|
+
* conversation itself is reached when the tool runs. Without it the host asks `reaches`.
|
|
30
|
+
*/
|
|
31
|
+
knows?(principalId: string): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Sends the person a notice here; without it the host posts the text in the channel `reaches`
|
|
34
|
+
* names, through the surface that serves it.
|
|
35
|
+
*/
|
|
36
|
+
deliver?(principalId: string, text: string): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where a person was reached: the provider, and their conversation on it. */
|
|
40
|
+
export interface DirectReach {
|
|
41
|
+
provider: DirectChannelProvider;
|
|
42
|
+
channel: ChannelKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every plugin's direct channels, as plugins and the core use them. Each call before the host
|
|
47
|
+
* links its plugins throws NotLinkedError, as for `conversations`.
|
|
48
|
+
*/
|
|
49
|
+
export interface DirectChannels extends Notifier {
|
|
50
|
+
/** Every contributed provider, in contribution order; empty when no plugin contributes one. */
|
|
51
|
+
providers(): readonly DirectChannelProvider[];
|
|
52
|
+
/** The person's own conversation on the first provider that reaches them; undefined when none does. */
|
|
53
|
+
reach(principalId: string): Promise<DirectReach | undefined>;
|
|
54
|
+
/**
|
|
55
|
+
* The first provider that knows the person, as `DirectChannelProvider.knows` tells without the
|
|
56
|
+
* network (or `reaches`, for a provider without it); undefined when none does.
|
|
57
|
+
*/
|
|
58
|
+
known(principalId: string): Promise<DirectChannelProvider | undefined>;
|
|
59
|
+
/**
|
|
60
|
+
* Sends the person `text` through the first provider that reaches them; false when none does.
|
|
61
|
+
* A provider's failure, such as a network error, rejects.
|
|
62
|
+
*/
|
|
63
|
+
notify(principalId: string, text: string): Promise<boolean>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The direct channels over the providers the host linked, posting through its surfaces where a provider does not deliver itself. */
|
|
67
|
+
export function directChannelsPort(
|
|
68
|
+
providers: () => readonly DirectChannelProvider[],
|
|
69
|
+
surfaces: Pick<SurfacePort, "sendReply">,
|
|
70
|
+
): DirectChannels {
|
|
71
|
+
const reach = async (
|
|
72
|
+
principalId: string,
|
|
73
|
+
): Promise<DirectReach | undefined> => {
|
|
74
|
+
for (const provider of providers()) {
|
|
75
|
+
const channel = await provider.reaches(principalId);
|
|
76
|
+
if (channel !== undefined) return { provider, channel };
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
providers: () => providers(),
|
|
82
|
+
reach,
|
|
83
|
+
known: async (principalId) => {
|
|
84
|
+
for (const provider of providers()) {
|
|
85
|
+
const knows = provider.knows
|
|
86
|
+
? await provider.knows(principalId)
|
|
87
|
+
: (await provider.reaches(principalId)) !== undefined;
|
|
88
|
+
if (knows) return provider;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
},
|
|
92
|
+
notify: async (principalId, text) => {
|
|
93
|
+
const reached = await reach(principalId);
|
|
94
|
+
if (!reached) return false;
|
|
95
|
+
if (reached.provider.deliver)
|
|
96
|
+
await reached.provider.deliver(principalId, text);
|
|
97
|
+
else
|
|
98
|
+
await surfaces.sendReply(reached.channel, {
|
|
99
|
+
chunks: splitReply(text),
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|