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.
Files changed (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,10 +1,9 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { messages } from "../../i18n/index.ts";
4
- import { type OwnerIdentity, ownerWords } from "../../identity.ts";
4
+ import { addresseeWords, type OwnerIdentity } from "../../identity.ts";
5
5
  import type { MemoryStore } from "../../services.ts";
6
6
  import { toolError, toolText } from "../../shared/tool-result.ts";
7
- import type { Speaker } from "../../speakers.ts";
8
7
  import { timeZone, zonedToday } from "../../time.ts";
9
8
  import type { Memory, PromptMemory } from "./owner-memory-store.ts";
10
9
 
@@ -17,8 +16,8 @@ function line(memory: Memory): string {
17
16
  export function ownerMemorySection(
18
17
  memory: PromptMemory,
19
18
  today: string,
20
- /** Whose memory it is, when not the owner's. */
21
- speaker?: Speaker,
19
+ /** The name of whose memory it is, when not the primary owner's. */
20
+ name?: string,
22
21
  ): string {
23
22
  const core =
24
23
  memory.core.length > 0
@@ -27,7 +26,7 @@ export function ownerMemorySection(
27
26
  const events =
28
27
  memory.events.length > 0 ? memory.events.map(line).join("\n") : "(none)";
29
28
  return [
30
- speaker ? `## Memory of ${speaker.name}` : "## Owner memory",
29
+ name === undefined ? "## Owner memory" : `## Memory of ${name}`,
31
30
  `Today in ${messages().zoneName(timeZone())} is ${today}. What is stored right now, including anything you added earlier in this conversation; each appears once.`,
32
31
  `### Core facts\n${core}`,
33
32
  `### Upcoming events\n${events}`,
@@ -35,42 +34,92 @@ export function ownerMemorySection(
35
34
  ].join("\n\n");
36
35
  }
37
36
 
37
+ /** Whose memory a session's turns read, and how its tools name them. */
38
+ export interface MemoryFor {
39
+ /** The primary owner's principal, whose memory keeps 0.8's "Owner memory" heading. */
40
+ ownerId: string;
41
+ /** Whom the tool descriptions address: a private conversation's person, or `THE_SPEAKER`. */
42
+ addressee: OwnerIdentity;
43
+ /** Whether the tools tell the model that each speaker has a memory of their own, as in a shared conversation. */
44
+ describesSpeakers: boolean;
45
+ /** Whose memory the running turn reads and changes, and their name; undefined when it is no one's. */
46
+ whose(): { principalId: string; name: string } | undefined;
47
+ }
48
+
49
+ /** The memory tools, whose results hold one person's memory. */
50
+ export const MEMORY_TOOLS = [
51
+ "memory_add",
52
+ "memory_search",
53
+ "memory_remove",
54
+ ] as const;
55
+
56
+ /** What a memory tool's result records in the session: whose memory it holds. */
57
+ interface PrivateMemory {
58
+ privateTo: string;
59
+ }
60
+
61
+ /** A memory tool's answer from the principal's memory, which records whose it is. */
62
+ function privateText(text: string, principalId: string) {
63
+ const details: PrivateMemory = { privateTo: principalId };
64
+ return { ...toolText(text), details };
65
+ }
66
+
38
67
  /**
39
- * Adds the owner's core facts and upcoming events to every run, and registers
40
- * memory_add, memory_search, and memory_remove. The section goes into appendSystemPrompt
41
- * because claude-bridge forwards only the prompt's option sections to Claude Code, not a
42
- * replaced system prompt.
68
+ * The call's answer, or, when it fails, such as with the store unreachable, its error recording
69
+ * whose memory the call was for: its arguments are what the model wrote from that memory.
70
+ */
71
+ async function ownAnswer<T>(
72
+ principalId: string,
73
+ answer: () => Promise<T>,
74
+ ): Promise<T | (ReturnType<typeof toolError> & { details: PrivateMemory })> {
75
+ try {
76
+ return await answer();
77
+ } catch (error) {
78
+ const details: PrivateMemory = { privateTo: principalId };
79
+ return {
80
+ ...toolError(error instanceof Error ? error.message : String(error)),
81
+ details,
82
+ };
83
+ }
84
+ }
85
+
86
+ /** What the memory tools answer in a turn that reads no one's memory, such as the host's own report. */
87
+ const NO_ONE =
88
+ "This turn reads no one's memory, so there is nothing to remember, search, or forget in it.";
89
+
90
+ /**
91
+ * Adds the core facts and upcoming events of whose memory the turn reads to every run, and
92
+ * registers memory_add, memory_search, and memory_remove over it. The section goes into
93
+ * appendSystemPrompt because claude-bridge forwards only the prompt's option sections to Claude
94
+ * Code, not a replaced system prompt.
43
95
  */
44
96
  export function ownerMemoryExtension(
45
97
  memories: MemoryStore,
46
- /** The owner's Discord id: the speaker whose memory a turn reads when no other speaks. */
47
- ownerId: string,
48
- owner: OwnerIdentity,
49
- /**
50
- * The person the running turn is for, when it may be someone other than the owner. Their
51
- * memory, not the owner's, is what the turn reads and changes; without it every turn is the owner's.
52
- */
53
- speaker?: () => Speaker | undefined,
54
- /** Whether the tools tell the model that each speaker has a memory of their own. */
55
- describesSpeakers = speaker !== undefined,
98
+ memory: MemoryFor,
56
99
  ): ExtensionFactory {
57
- const o = ownerWords(owner);
58
- /** The running turn's other speaker; undefined when the owner's own memory applies. */
59
- const other = (): Speaker | undefined => {
60
- const current = speaker?.();
61
- return current && current.id !== ownerId ? current : undefined;
100
+ const o = addresseeWords(memory.addressee);
101
+ // Whose memory the call reads and changes; its result records them.
102
+ const storeOf = () => {
103
+ const whose = memory.whose();
104
+ return (
105
+ whose && {
106
+ store: memories.forSpeaker(whose.principalId),
107
+ whose: whose.principalId,
108
+ }
109
+ );
62
110
  };
63
- const storeOf = () => memories.forSpeaker(other()?.id ?? ownerId);
64
- const shared = describesSpeakers
111
+ const shared = memory.describesSpeakers
65
112
  ? " Whoever is speaking has a memory of their own, shared by every agent; this reads and changes theirs, not someone else's."
66
113
  : "";
67
114
  return (pi) => {
68
115
  pi.on("before_agent_start", async (event) => {
116
+ const whose = memory.whose();
117
+ if (!whose) return;
69
118
  const today = zonedToday();
70
119
  const section = ownerMemorySection(
71
- await storeOf().forPrompt(today),
120
+ await memories.forSpeaker(whose.principalId).forPrompt(today),
72
121
  today,
73
- other(),
122
+ whose.principalId === memory.ownerId ? undefined : whose.name,
74
123
  );
75
124
  const appended = event.systemPromptOptions.appendSystemPrompt;
76
125
  event.systemPromptOptions.appendSystemPrompt = appended
@@ -98,12 +147,19 @@ export function ownerMemoryExtension(
98
147
  ),
99
148
  }),
100
149
  execute: async (_toolCallId, params) => {
101
- const saved = await storeOf().add(
102
- params.fact,
103
- params.kind,
104
- params.date,
105
- );
106
- return toolText(`Remembered as ${saved.kind}: ${line(saved).slice(2)}`);
150
+ const of = storeOf();
151
+ if (!of) return toolError(NO_ONE);
152
+ return ownAnswer(of.whose, async () => {
153
+ const saved = await of.store.add(
154
+ params.fact,
155
+ params.kind,
156
+ params.date,
157
+ );
158
+ return privateText(
159
+ `Remembered as ${saved.kind}: ${line(saved).slice(2)}`,
160
+ of.whose,
161
+ );
162
+ });
107
163
  },
108
164
  });
109
165
 
@@ -115,14 +171,21 @@ export function ownerMemoryExtension(
115
171
  query: Type.String({ description: "Keywords separated by spaces." }),
116
172
  }),
117
173
  execute: async (_toolCallId, params) => {
118
- const found = await storeOf().search(params.query);
119
- return toolText(
120
- found.length === 0
121
- ? `Nothing remembered matches "${params.query}".`
122
- : found
123
- .map((memory) => `- [${memory.kind}] ${line(memory).slice(2)}`)
124
- .join("\n"),
125
- );
174
+ const of = storeOf();
175
+ if (!of) return toolError(NO_ONE);
176
+ return ownAnswer(of.whose, async () => {
177
+ const found = await of.store.search(params.query);
178
+ return privateText(
179
+ found.length === 0
180
+ ? `Nothing remembered matches "${params.query}".`
181
+ : found
182
+ .map(
183
+ (memory) => `- [${memory.kind}] ${line(memory).slice(2)}`,
184
+ )
185
+ .join("\n"),
186
+ of.whose,
187
+ );
188
+ });
126
189
  },
127
190
  });
128
191
 
@@ -136,15 +199,25 @@ export function ownerMemoryExtension(
136
199
  }),
137
200
  }),
138
201
  execute: async (_toolCallId, params) => {
139
- const removed = await storeOf().remove(params.text);
140
- if (removed.length === 0) {
141
- return toolError(
142
- `No remembered fact contains "${params.text}". Nothing was forgotten.`,
202
+ const of = storeOf();
203
+ if (!of) return toolError(NO_ONE);
204
+ return ownAnswer(of.whose, async () => {
205
+ const removed = await of.store.remove(params.text);
206
+ // The refusal repeats the text, which may be the person's memory, so it records whose too.
207
+ if (removed.length === 0) {
208
+ const details: PrivateMemory = { privateTo: of.whose };
209
+ return {
210
+ ...toolError(
211
+ `No remembered fact contains "${params.text}". Nothing was forgotten.`,
212
+ ),
213
+ details,
214
+ };
215
+ }
216
+ return privateText(
217
+ `Forgot:\n${removed.map((fact) => `- ${fact}`).join("\n")}`,
218
+ of.whose,
143
219
  );
144
- }
145
- return toolText(
146
- `Forgot:\n${removed.map((fact) => `- ${fact}`).join("\n")}`,
147
- );
220
+ });
148
221
  },
149
222
  });
150
223
  };
@@ -1,26 +1,66 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import type { OwnerNotifier } from "../../domain/ports.ts";
4
- import { type OwnerIdentity, ownerWords } from "../../identity.ts";
5
- import { toolText } from "../../shared/tool-result.ts";
3
+ import type { Notifier } from "../../domain/ports.ts";
4
+ import { addresseeWords, type OwnerIdentity } from "../../identity.ts";
5
+ import { toolError, toolText } from "../../shared/tool-result.ts";
6
6
 
7
+ /** What notify needs in one session. */
8
+ export interface SessionNotify {
9
+ /** Sends a person a notice through their own channel; false when none reaches them. */
10
+ notifier: Notifier;
11
+ /**
12
+ * Whom a call notifies, read when it runs: the conversation's person in a private one, the
13
+ * turn's speaker in a shared one; or why nobody is, such as in a turn nobody is named for.
14
+ */
15
+ recipient: () => Promise<{ principalId: string } | { refused: string }>;
16
+ /** How a notice reaches them, completing "Send Ada …", such as "a direct message on Discord". */
17
+ channels: string;
18
+ }
19
+
20
+ /**
21
+ * Registers notify for one session: it sends a notice to the person the conversation serves,
22
+ * through the first direct channel that reaches them, and refuses when none does. Its 0.8 name,
23
+ * `notify_owner`, still names it in a selection and in `toolTiers` until 1.0 (`RENAMED_TOOLS`).
24
+ */
7
25
  export function notifyExtension(
8
- notifier: OwnerNotifier,
9
- owner: OwnerIdentity,
26
+ notify: SessionNotify,
27
+ /** Whom the description names: the private conversation's person, or the speaker. */
28
+ addressee: OwnerIdentity,
29
+ /**
30
+ * Whom it names at each turn, when that may change while the session is open, such as when the
31
+ * host gains or loses an owner; the tool is described anew before a turn whose words differ.
32
+ */
33
+ current?: () => Promise<OwnerIdentity>,
10
34
  ): ExtensionFactory {
11
- const o = ownerWords(owner);
12
- return (pi) => {
13
- pi.registerTool({
14
- name: "notify_owner",
15
- label: "Notify owner",
16
- description: `Send ${o.name} a direct message on Discord. Use only when ${o.he} asks to be notified or reminded by DM; your normal reply already reaches ${o.him}.`,
35
+ const tool = (who: OwnerIdentity) => {
36
+ const o = addresseeWords(who);
37
+ return {
38
+ name: "notify",
39
+ label: "Notify",
40
+ description: `Send ${o.name} ${notify.channels}. Use only when ${o.he} asks to be notified or reminded by DM; your normal reply already reaches ${o.him}.`,
17
41
  parameters: Type.Object({
18
42
  text: Type.String({ description: "The message to send." }),
19
43
  }),
20
- execute: async (_toolCallId, params) => {
21
- await notifier.notifyOwner(params.text);
44
+ execute: async (_toolCallId: string, params: { text: string }) => {
45
+ const recipient = await notify.recipient();
46
+ if ("refused" in recipient) return toolError(recipient.refused);
47
+ if (!(await notify.notifier.notify(recipient.principalId, params.text)))
48
+ return toolError(
49
+ "the person this notice is for has no direct channel on this host to send it to",
50
+ );
22
51
  return toolText("Sent.");
23
52
  },
53
+ };
54
+ };
55
+ return (pi) => {
56
+ let described = tool(addressee);
57
+ pi.registerTool(described);
58
+ if (!current) return;
59
+ pi.on("before_agent_start", async () => {
60
+ const now = tool(await current());
61
+ if (now.description === described.description) return;
62
+ described = now;
63
+ pi.registerTool(now);
24
64
  });
25
65
  };
26
66
  }
@@ -0,0 +1,110 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
3
+ import type { OwnerIdentity } from "../../identity.ts";
4
+ import type { Logger } from "../../log.ts";
5
+ import type {
6
+ DirectChannelProvider,
7
+ DirectChannels,
8
+ } from "../../presence/direct-channels.ts";
9
+ import type { SessionContext } from "../../sessions.ts";
10
+ import { notifyExtension } from "./notify.ts";
11
+
12
+ export interface SessionNotifyDeps {
13
+ directChannels: DirectChannels;
14
+ /** The primary owner: the host's own turns, such as a report's, notify them, as 0.8 did. */
15
+ owner: OwnerIdentity & { id: string };
16
+ /**
17
+ * Whether a notice in a shared conversation can only be the primary owner's: the host has no
18
+ * other owner, and only owners may use notify. Its description then names them, as in 0.8.
19
+ */
20
+ onlyOwnerNotified(): Promise<boolean>;
21
+ logger: Logger;
22
+ }
23
+
24
+ /** "a direct message on Discord", or each provider's label joined with "or". */
25
+ function labels(providers: readonly DirectChannelProvider[]): string {
26
+ return providers.map((provider) => provider.label).join(" or ");
27
+ }
28
+
29
+ /**
30
+ * The notify tool of each session: none on a host without direct channels, and none in a private
31
+ * conversation whose person no direct channel reaches. A notice goes to the conversation's
32
+ * person in a private one, and to the turn's speaker in a shared one.
33
+ */
34
+ export function sessionNotify(
35
+ deps: SessionNotifyDeps,
36
+ ): (session: SessionContext) => ExtensionFactory | null {
37
+ const { directChannels, owner, logger } = deps;
38
+ // Whom a notice is for: the person of a private conversation, the speaker of a shared one; the
39
+ // host's own turns notify the conversation's person, or else the primary owner. Anyone else
40
+ // speaking in someone's private conversation is refused, so their text never reaches its person.
41
+ const recipientOf = (
42
+ session: SessionContext,
43
+ own: string | undefined,
44
+ ): { principalId: string } | { refused: string } => {
45
+ const speaker = session.speaker();
46
+ if (!speaker)
47
+ return {
48
+ refused:
49
+ "a notice is sent only in a turn someone is named for, whose notice it is",
50
+ };
51
+ if (speaker.principalId === SYSTEM_PRINCIPAL)
52
+ return { principalId: own ?? owner.id };
53
+ if (own !== undefined && speaker.principalId !== own)
54
+ return {
55
+ refused:
56
+ "this conversation is private to someone else, so a notice here is not yours to send",
57
+ };
58
+ return { principalId: speaker.principalId };
59
+ };
60
+ // A failed lookup names no one: the speaker's words are true whoever it is.
61
+ const onlyOwner = (session: SessionContext) =>
62
+ deps.onlyOwnerNotified().catch((error: unknown) => {
63
+ logger.warn(
64
+ { channel: session.homeChannel, err: error },
65
+ "could not tell whether the primary owner is the only owner",
66
+ );
67
+ return false;
68
+ });
69
+ return (session) => {
70
+ const providers = directChannels.providers();
71
+ if (providers.length === 0) return null;
72
+ const { conversation } = session;
73
+ const own =
74
+ conversation.visibility === "private"
75
+ ? conversation.principalId
76
+ : undefined;
77
+ return async (pi) => {
78
+ let reaching = providers;
79
+ if (own !== undefined) {
80
+ try {
81
+ const known = await directChannels.known(own);
82
+ if (!known) return;
83
+ reaching = [known];
84
+ } catch (error) {
85
+ // No destination was established; do not advertise an unusable tool.
86
+ logger.warn(
87
+ { channel: session.homeChannel, err: error },
88
+ "could not tell whether a direct channel reaches the conversation's person",
89
+ );
90
+ return;
91
+ }
92
+ }
93
+ // A shared conversation names the primary owner while only they can be notified, which
94
+ // an owner granted or revoked while the session is open changes before its next turn.
95
+ const describe = async () =>
96
+ own === undefined && (await onlyOwner(session))
97
+ ? owner
98
+ : session.addressee;
99
+ await notifyExtension(
100
+ {
101
+ notifier: directChannels,
102
+ recipient: async () => recipientOf(session, own),
103
+ channels: labels(reaching),
104
+ },
105
+ await describe(),
106
+ own === undefined ? describe : undefined,
107
+ )(pi);
108
+ };
109
+ };
110
+ }
@@ -35,6 +35,11 @@ export interface PrecheckContext {
35
35
  /** The due schedule, already moved to its next run (or deleted, when it runs once). */
36
36
  schedule: Schedule;
37
37
  firedAt: Date;
38
+ /**
39
+ * The tier this run is for: the schedule's, capped at what its creator holds now, as its turn
40
+ * would run. A precheck runs only for a creator who may run the turn.
41
+ */
42
+ tier: Tier;
38
43
  /** Aborted when the precheck runs out of time or the host stops; its answer is then ignored. */
39
44
  signal: AbortSignal;
40
45
  }
@@ -61,8 +66,9 @@ export interface PrecheckScope {
61
66
  /** The schedule's background target, such as the owner's. */
62
67
  target: string;
63
68
  /**
64
- * The tier of whoever set the script: its creator's when it runs, the asker's when schedule_list
65
- * describes it; absent when the asker's is unknown. A runner may grant lower tiers less.
69
+ * The tier of whoever set the script: the run's (`PrecheckContext.tier`) when it runs, the
70
+ * asker's when schedule_list describes it; absent when the asker's is unknown. A runner may grant
71
+ * lower tiers less.
66
72
  */
67
73
  tier?: Tier;
68
74
  }
@@ -1,5 +1,6 @@
1
1
  import type { HoldCheck } from "../../holds.ts";
2
2
  import type { Logger } from "../../log.ts";
3
+ import type { Tier } from "../../speakers.ts";
3
4
  import { timeZone, zonedStamp } from "../../time.ts";
4
5
  import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
5
6
  import {
@@ -33,10 +34,12 @@ export class SchedulePrechecks {
33
34
  this.#options = options;
34
35
  }
35
36
 
37
+ /** Runs the schedule's precheck for a run at `tier`, its creator's capped tier now. */
36
38
  async run(
37
39
  schedule: Schedule,
38
40
  name: string,
39
41
  firedAt: Date,
42
+ tier: Tier,
40
43
  ): Promise<PrecheckOutcome> {
41
44
  const { logger } = this.#options;
42
45
  let decision: PrecheckOutcome;
@@ -47,7 +50,7 @@ export class SchedulePrechecks {
47
50
  ? { kind: "failed", error: precheck }
48
51
  : await runPrecheck(
49
52
  precheck,
50
- { schedule, firedAt },
53
+ { schedule, firedAt, tier },
51
54
  {
52
55
  signal: this.#options.signal,
53
56
  settled: (run) => {
@@ -110,6 +110,34 @@ function toSchedule(row: Row): Schedule {
110
110
  };
111
111
  }
112
112
 
113
+ /** Refuses a schedule the table could not run: a precheck and a script both, or a script without its tools. */
114
+ function checked(schedule: NewSchedule): void {
115
+ if (schedule.precheck && schedule.precheckScript)
116
+ throw new ScheduleError(
117
+ "a schedule has a precheck or a precheck script, not both",
118
+ );
119
+ if (schedule.precheckScript && !schedule.precheckTools)
120
+ throw new ScheduleError(
121
+ "a precheck script is saved with the tools it may call",
122
+ );
123
+ }
124
+
125
+ async function insert(sql: SQL, schedule: NewSchedule): Promise<Schedule> {
126
+ checked(schedule);
127
+ const rows: Row[] = await sql`
128
+ INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
129
+ created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools)
130
+ VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
131
+ ${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
132
+ ${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
133
+ ${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
134
+ ${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null})
135
+ RETURNING *`;
136
+ const [row] = rows;
137
+ if (!row) throw new Error("the schedule insert returned no row");
138
+ return toSchedule(row);
139
+ }
140
+
113
141
  /** Scheduled turns, in the database. A one-time schedule's row is deleted once it fires. */
114
142
  export class PgScheduleStore implements ScheduleStore {
115
143
  readonly #sql: SQL;
@@ -176,26 +204,29 @@ export class PgScheduleStore implements ScheduleStore {
176
204
  }
177
205
 
178
206
  async create(schedule: NewSchedule): Promise<Schedule> {
179
- if (schedule.precheck && schedule.precheckScript)
180
- throw new ScheduleError(
181
- "a schedule has a precheck or a precheck script, not both",
182
- );
183
- if (schedule.precheckScript && !schedule.precheckTools)
184
- throw new ScheduleError(
185
- "a precheck script is saved with the tools it may call",
186
- );
187
- const rows: Row[] = await this.#sql`
188
- INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
189
- created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools)
190
- VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
191
- ${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
192
- ${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
193
- ${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
194
- ${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null})
195
- RETURNING *`;
196
- const [row] = rows;
197
- if (!row) throw new Error("the schedule insert returned no row");
198
- return toSchedule(row);
207
+ return insert(this.#sql, schedule);
208
+ }
209
+
210
+ /**
211
+ * Creates a schedule unless its creator already has `limit.max` of its target's schedules,
212
+ * counting those whose creator id is one of `limit.creators`; resolves the count reached instead.
213
+ * The count and the insert hold a lock on the creator, so creates at once by one person in many
214
+ * conversations stay within the limit.
215
+ */
216
+ async createWithin(
217
+ schedule: NewSchedule,
218
+ limit: { creators: readonly string[]; max: number },
219
+ ): Promise<{ created: Schedule } | { reached: number }> {
220
+ checked(schedule);
221
+ return this.#sql.begin(async (tx) => {
222
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`schedules/creator:${schedule.createdById}`}, 0))`;
223
+ const [row]: { count: string | number }[] = await tx`
224
+ SELECT count(*) AS count FROM schedules
225
+ WHERE mode = ${schedule.target} AND created_by_id IN ${tx([...limit.creators])}`;
226
+ const count = Number(row?.count ?? 0);
227
+ if (count >= limit.max) return { reached: count };
228
+ return { created: await insert(tx, schedule) };
229
+ });
199
230
  }
200
231
 
201
232
  async get(id: number): Promise<Schedule | undefined> {