pi-roundtable 0.7.19 → 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.
Files changed (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. 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
- /** Who asked, recorded on created schedules; a scheduled run speaks for its creator. */
40
- author: { id: string; name: string; tier?: Tier };
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
- ...(ctx.author.tier ? { tier: ctx.author.tier } : {}),
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 (!schedule || schedule.channel !== ctx.channel)
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 (ctx.author.tier && !tierAtLeast(ctx.author.tier, schedule.createdTier))
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 created = await ctx.store.create({
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.id,
383
+ createdById: ctx.author.principalId,
278
384
  createdByName: ctx.author.name,
279
- createdTier: ctx.author.tier ?? "owner",
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 #${created.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
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 = await ctx.store.forChannel(ctx.channel);
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 { ScheduledOutcome } from "../../contract/channels.ts";
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
- const decision = await this.#prechecks.run(schedule, name, firedAt);
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 { OWNER_TARGET } from "../../agents/agent-claim.ts";
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 the owner's direct
28
- * messages for a conversation no chat surface carries, where a run could not be posted.
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 owner conversation; its runs speak for the owner. With
58
- * `agents`, schedule_list can also read another agent's schedules.
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 { store, owner, channelFor, prechecks, holds } = schedules;
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: OWNER_TARGET,
88
- author: speaker() ?? owner,
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 ${identity.name}'s Discord direct messages; their runs are posted there.)`;
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
  });
@@ -19,20 +19,28 @@ const OWN_KEYS = new Set(["level", "time", "pid", "hostname", "msg", "err"]);
19
19
  const HEAD_KEYS = ["app", "plugin", "module"];
20
20
  const APP_FRAME = /((?:src|shared|worker)\/[^\s():]+\.ts):(\d+)/;
21
21
 
22
- /** Where the reports go, known once the Discord surface and the agent team are up. */
22
+ /** Who investigates the errors: an agent, in its channel, or the claim of one conversation. */
23
+ export type ErrorReportDestination =
24
+ | { agent: string }
25
+ | { conversation: ChannelKey };
26
+
27
+ /** How the reports are delivered, known once the surfaces, and for an agent the team, are up. */
23
28
  export interface ErrorReportDelivery {
24
- /** The agent's channel while it is active; undefined for an archived or unknown agent. */
25
- channelOf(agent: string): ChannelKey | undefined;
26
- /** The visible message in the agent's channel. */
29
+ /**
30
+ * The agent's channel while it is active; undefined for an archived or unknown agent. Asked
31
+ * only for an agent's reports.
32
+ */
33
+ channelOf?(agent: string): ChannelKey | undefined;
34
+ /** The visible message in the channel. */
27
35
  post(channel: ChannelKey, text: string): Promise<void>;
28
- /** The agent's report turn; resolves when it has run. */
36
+ /** The report turn; resolves when it has run. */
29
37
  turn(channel: ChannelKey, text: string): Promise<ScheduledOutcome>;
30
38
  logger: Logger;
31
39
  }
32
40
 
33
41
  export interface ErrorReporterOptions {
34
- /** The agent that investigates the errors, reported in its channel. */
35
- opsAgent: string;
42
+ /** Who investigates the errors. */
43
+ destination: ErrorReportDestination;
36
44
  /** The name the reports give the process, such as "Roundtable". */
37
45
  app: string;
38
46
  ignored?: readonly RegExp[];
@@ -43,14 +51,14 @@ export interface ErrorReporterOptions {
43
51
  }
44
52
 
45
53
  /**
46
- * The process's own `error` and `fatal` log entries, each reported to the ops agent as a visible
47
- * message and a report turn in its channel. The same error goes at most once an hour, and at
54
+ * The process's own `error` and `fatal` log entries, each reported to the ops agent or the ops
55
+ * conversation as a visible message and a report turn in its channel. The same error goes at most once an hour, and at
48
56
  * most five reports an hour overall; the rest are counted and summed up in one report when the
49
57
  * hour allows. Errors from the reporting itself are only logged; errors logged elsewhere while
50
58
  * an error-report turn runs are counted and summed up once it ends.
51
59
  */
52
60
  export class ErrorReporter {
53
- readonly #opsAgent: string;
61
+ readonly destination: ErrorReportDestination;
54
62
  readonly #app: string;
55
63
  readonly #ignored: readonly RegExp[];
56
64
  readonly #now: () => number;
@@ -68,7 +76,7 @@ export class ErrorReporter {
68
76
  #summaryDue = false;
69
77
 
70
78
  constructor(options: ErrorReporterOptions) {
71
- this.#opsAgent = options.opsAgent;
79
+ this.destination = options.destination;
72
80
  this.#app = options.app;
73
81
  this.#ignored = options.ignored ?? IGNORED_ERRORS;
74
82
  this.#now = options.now ?? Date.now;
@@ -177,7 +185,11 @@ export class ErrorReporter {
177
185
  const delivery = this.#delivery;
178
186
  if (!delivery) return Promise.resolve();
179
187
  return this.#reporting.run(true, async () => {
180
- const channel = delivery.channelOf(this.#opsAgent);
188
+ const { destination } = this;
189
+ const channel =
190
+ "conversation" in destination
191
+ ? destination.conversation
192
+ : delivery.channelOf?.(destination.agent);
181
193
  if (!channel) return;
182
194
  try {
183
195
  await delivery.post(channel, text);
@@ -11,11 +11,16 @@ import type { ServiceKey, Services } from "./contract/services.ts";
11
11
  import type { ChatSurface, SurfacePort } from "./contract/surface.ts";
12
12
  import type { Migration } from "./db/migrations.ts";
13
13
  import type { ToolContribution } from "./define.ts";
14
+ import type { TurnProgress } from "./domain/progress.ts";
14
15
  import { PluginError } from "./errors.ts";
15
16
  import type { HoldCheck, HoldRule } from "./holds.ts";
16
17
  import type { HttpRoute } from "./http/listeners.ts";
17
18
  import type { Locale } from "./i18n/index.ts";
18
19
  import type { Logger } from "./log.ts";
20
+ import type {
21
+ DirectChannelProvider,
22
+ DirectChannels,
23
+ } from "./presence/direct-channels.ts";
19
24
  import type { ConversationTurns } from "./routing/conversation-turns.ts";
20
25
  import type {
21
26
  AgentTurnScope,
@@ -75,6 +80,11 @@ export interface TurnEndEvent extends TurnEvent {
75
80
  result: "ok" | "failed" | "stopped";
76
81
  }
77
82
 
83
+ /** What a running turn wrote or ran, as a handler hears of it, between its start and its end. */
84
+ export interface TurnProgressEvent extends TurnEvent {
85
+ progress: TurnProgress;
86
+ }
87
+
78
88
  /** What a plugin may react to; a handler that throws is logged and never stops the others. */
79
89
  export interface EventHandlers {
80
90
  /**
@@ -87,6 +97,12 @@ export interface EventHandlers {
87
97
  turnStarted?(turn: TurnEvent): Promise<void> | void;
88
98
  /** A turn that began ended, however it ended. */
89
99
  turnEnded?(turn: TurnEndEvent): Promise<void> | void;
100
+ /**
101
+ * A turn run through `context.turns` wrote text or ran a tool, as it goes: text joined over a
102
+ * short interval, and each tool's name with a short preview of its arguments. Turns on a runtime
103
+ * without live progress report none.
104
+ */
105
+ turnProgress?(event: TurnProgressEvent): Promise<void> | void;
90
106
  /** The team changed: an agent or group was created, edited, arranged, archived, or started over. */
91
107
  changed?(): Promise<void> | void;
92
108
  /** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
@@ -97,6 +113,8 @@ export interface EventHandlers {
97
113
  export interface EventSink {
98
114
  turnStarted(turn: TurnEvent): void;
99
115
  turnEnded(turn: TurnEndEvent): void;
116
+ /** Optional, so a sink of your own written before it still fits. */
117
+ turnProgress?(event: TurnProgressEvent): void;
100
118
  changed(): void;
101
119
  }
102
120
 
@@ -127,6 +145,12 @@ export interface Persona {
127
145
  * is in the host's language.
128
146
  */
129
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";
130
154
  }
131
155
 
132
156
  /** What a plugin adds to the process; a plugin that adds nothing is refused. */
@@ -165,6 +189,13 @@ export interface Contribution {
165
189
  * schedule or delegated task names one, and a turn for a target nobody contributes is skipped.
166
190
  */
167
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[];
168
199
  /** Lines the agent server's dashboard shows under its title, such as links, in contribution order. */
169
200
  dashboard?: readonly string[];
170
201
  /** Tools the plugin adds, each with the lowest tier that may use it; built with `defineTool`. */
@@ -199,6 +230,7 @@ export const CONTRIBUTION_KEYS = [
199
230
  "surfaces",
200
231
  "personas",
201
232
  "backgroundTargets",
233
+ "directChannels",
202
234
  "dashboard",
203
235
  "tools",
204
236
  "toolTiers",
@@ -221,6 +253,8 @@ export interface LinkedSessions {
221
253
  prompt: readonly PromptSection[];
222
254
  /** The prompt of a conversation kind, from the plugin that contributes its persona; undefined when none does. */
223
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";
224
258
  /** Every tool name plugins require at startup, each once. */
225
259
  requiredTools: readonly string[];
226
260
  /** The tools plugins defined for agents, by name. */
@@ -255,6 +289,8 @@ export interface PluginContext {
255
289
  conversations: ConversationPort;
256
290
  /** Every contributed chat surface, chosen by a channel's prefix; calls during setup throw NotLinkedError. */
257
291
  surfaces: SurfacePort;
292
+ /** Every plugin's direct channels, which reach a person on their own; calls during setup throw NotLinkedError. */
293
+ directChannels: DirectChannels;
258
294
  /** Runs conversation turns of any kind over the runtime and the surfaces; calls during setup throw NotLinkedError. */
259
295
  turns: ConversationTurns;
260
296
  /** The host's one connection pool, migrated before any setup; throws PluginError without a database. */
@@ -276,10 +312,36 @@ export interface PluginContext {
276
312
  apiKey(provider: string): Promise<string | undefined>;
277
313
  }
278
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
+
279
330
  export interface RoundtablePlugin {
280
331
  name: string;
281
332
  /** The plugin's tables; the host runs every plugin's, in registration order, before any setup. */
282
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[];
283
345
  /** The provider slots the plugin fills, resolved before any setup; two plugins cannot fill one slot. */
284
346
  providers?: Partial<Providers>;
285
347
  /**