pi-roundtable 0.9.11 → 0.9.13

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 (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +151 -23
  4. package/examples/fake-surface.test.ts +137 -0
  5. package/examples/fake-surface.ts +71 -14
  6. package/package.json +1 -1
  7. package/src/cli/checks/identity.ts +73 -5
  8. package/src/core/agents/agent-claim.ts +12 -0
  9. package/src/core/agents/agent-prompt.ts +3 -1
  10. package/src/core/agents/agent-team.ts +1 -0
  11. package/src/core/agents/team-turns.ts +11 -4
  12. package/src/core/builtin/agent-server.ts +6 -0
  13. package/src/core/config/access.ts +23 -3
  14. package/src/core/config/adapters.ts +11 -0
  15. package/src/core/config/schema.ts +14 -0
  16. package/src/core/contract/providers.ts +5 -1
  17. package/src/core/define-roundtable.ts +1 -0
  18. package/src/core/define.ts +44 -6
  19. package/src/core/discord/card-audience.ts +104 -7
  20. package/src/core/discord/discord-owners.ts +19 -13
  21. package/src/core/discord/owner-card-types.ts +73 -0
  22. package/src/core/discord/owner-cards.ts +56 -77
  23. package/src/core/domain/conversation.ts +7 -0
  24. package/src/core/domain/ports.ts +4 -1
  25. package/src/core/holds.ts +60 -0
  26. package/src/core/i18n/cards.ts +3 -0
  27. package/src/core/identity/access-policy.ts +65 -2
  28. package/src/core/identity/caching-principal-store.ts +2 -2
  29. package/src/core/identity/identity-links.ts +96 -0
  30. package/src/core/identity/identity-plugin.ts +10 -3
  31. package/src/core/identity/identity-schema.ts +3 -1
  32. package/src/core/identity/identity-service.ts +5 -3
  33. package/src/core/identity/principal-store.ts +59 -142
  34. package/src/core/identity/principal-tables.ts +10 -2
  35. package/src/core/identity/principal-types.ts +146 -0
  36. package/src/core/interactions/prompts.ts +47 -3
  37. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  38. package/src/core/routing/conversation-turns.ts +4 -1
  39. package/src/core/runtime/conversation-sessions.ts +8 -0
  40. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  41. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  42. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  43. package/src/core/runtime/extensions/held-calls.ts +91 -0
  44. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  45. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  46. package/src/core/runtime/prompt-slot.ts +10 -2
  47. package/src/core/runtime/runtime-plugin.ts +3 -0
  48. package/src/core/runtime/runtime-types.ts +8 -0
  49. package/src/core/runtime/session-factory.ts +22 -0
  50. package/src/core/services.ts +11 -0
  51. package/src/core/sessions.ts +7 -0
  52. package/src/core/shared/generate-image-tool.ts +173 -0
  53. package/src/core/testing/surface-contract-support.ts +194 -0
  54. package/src/core/testing/surface-contract.ts +54 -120
  55. package/src/core/tool-turn.ts +5 -1
  56. package/src/index.ts +7 -0
@@ -0,0 +1,133 @@
1
+ import type {
2
+ HeldCall,
3
+ PendingConfirmation,
4
+ } from "../../domain/conversation.ts";
5
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
6
+ import type { ApprovedBy } from "../../interactions/prompts.ts";
7
+ import type { Speaker } from "../../speakers.ts";
8
+ import { mayApproveNamed } from "./held-calls.ts";
9
+
10
+ /**
11
+ * The speaker of a turn as the gate records them; `name` is what an approval by their message is
12
+ * attributed to, and `tier` tells whether they approve as an owner.
13
+ */
14
+ export type SpeakerRef = Pick<Speaker, "id" | "principalId"> & {
15
+ name?: string;
16
+ tier?: Speaker["tier"];
17
+ };
18
+
19
+ /** A call an approval released, and who gave it, when that is known. */
20
+ export interface Release {
21
+ call: HeldCall;
22
+ by?: ApprovedBy;
23
+ }
24
+
25
+ /** What a call needs of the gate: nothing, a hold, or a release that spends an approval. */
26
+ export type Needing = { held: HeldCall } | { released: ApprovedBy | undefined };
27
+
28
+ /** The text for a held call that the speaker's own message confirms. */
29
+ export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
30
+ const o = ownerWords(addressee);
31
+ return `Held for ${o.name}'s confirmation: this would ${call.action}. Do not retry it in this turn. Tell ${o.him} exactly what will happen and ask ${o.him} to confirm; once ${o.he} agrees in ${o.his} next message, make the same call with the same input and it runs.`;
32
+ }
33
+
34
+ /**
35
+ * For a call that names approvers the speaker is neither among nor an owner of: the text that
36
+ * tells the model the truth about it, from the names of those who may approve; undefined when
37
+ * the speaker may.
38
+ */
39
+ export function unlessNamed(
40
+ call: HeldCall,
41
+ speaker: SpeakerRef | undefined,
42
+ addressee: OwnerIdentity,
43
+ isPrivate: boolean,
44
+ ): ((names: string[], missing: boolean) => string) | undefined {
45
+ if (!call.approverIds || mayApproveNamed(speaker, call)) return undefined;
46
+ const o = ownerWords(addressee);
47
+ const anyone = (call.approverIds?.length ?? 0) > 0;
48
+ return (names, missing) => {
49
+ // Names that could not be read still leave the people the tool named.
50
+ const named =
51
+ names.length === 0 && anyone
52
+ ? ["the people named for it"]
53
+ : missing
54
+ ? [...names, "others named for it"]
55
+ : names;
56
+ const who = orList([...named, "an owner"]);
57
+ const approval = `Only ${who} can approve it.`;
58
+ if (isPrivate)
59
+ return `Held, and ${o.name} cannot approve it here: this would ${call.action}. ${approval} A private conversation can only be answered by its person, so it stays held and nothing ${o.he} says here releases it. Do not retry it, and do not ask ${o.him} to confirm it or to say yes. Tell ${o.him} plainly that it was not done and needs ${who}.`;
60
+ return `Held, and ${o.name} cannot approve it: this would ${call.action}. ${approval} Do not retry it in this turn, and do not ask ${o.name} to confirm it. Tell ${o.him} plainly that it was not done, and ask ${who} to confirm in this channel; once one of them agrees in a message, make the same call with the same input and it runs.`;
61
+ };
62
+ }
63
+
64
+ /** The names of the principals, as far as they are known; ids are never shown. `missing` says some could not be read. */
65
+ export async function namesOf(
66
+ nameOf: ((principalId: string) => Promise<string | undefined>) | undefined,
67
+ principalIds: readonly string[],
68
+ ): Promise<{ names: string[]; missing: boolean }> {
69
+ if (!nameOf) return { names: [], missing: principalIds.length > 0 };
70
+ const names = await Promise.all(
71
+ [...new Set(principalIds)].map(async (id) => {
72
+ try {
73
+ return await nameOf(id);
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }),
78
+ );
79
+ return {
80
+ names: [...new Set(names.filter((name): name is string => !!name))],
81
+ missing: names.some((name) => !name),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The pending hold with `call` added: a partial release kept calls that name approvers, which
87
+ * belong to no speaker, so what this turn holds is its speaker's from now.
88
+ */
89
+ export function remembered(
90
+ current: PendingConfirmation | undefined,
91
+ kept: PendingConfirmation | undefined,
92
+ selectionId: string,
93
+ speaker: SpeakerRef | undefined,
94
+ call: HeldCall,
95
+ ): PendingConfirmation {
96
+ // A partial release kept calls that name approvers, which belong to no speaker: what this turn
97
+ // holds is its speaker's, from now.
98
+ let pending = current;
99
+ if (pending && pending === kept) {
100
+ pending = { ...pending, calls: [...pending.calls] };
101
+ pending.selectionId = selectionId;
102
+ pending.heldAt = new Date();
103
+ delete pending.speakerId;
104
+ delete pending.principalId;
105
+ if (speaker) {
106
+ pending.speakerId = speaker.id;
107
+ pending.principalId = speaker.principalId;
108
+ }
109
+ }
110
+ pending ??= {
111
+ selectionId,
112
+ heldAt: new Date(),
113
+ calls: [],
114
+ ...(speaker
115
+ ? {
116
+ speakerId: speaker.id,
117
+ principalId: speaker.principalId,
118
+ }
119
+ : {}),
120
+ };
121
+ if (
122
+ !pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
123
+ )
124
+ pending.calls.push(call);
125
+ return pending;
126
+ }
127
+
128
+ /** "a", "a or b", "a, b or c". */
129
+ function orList(items: readonly string[]): string {
130
+ const last = items.at(-1);
131
+ if (items.length < 2 || last === undefined) return items.join("");
132
+ return `${items.slice(0, -1).join(", ")} or ${last}`;
133
+ }
@@ -0,0 +1,91 @@
1
+ import type {
2
+ HeldCall,
3
+ PendingConfirmation,
4
+ } from "../../domain/conversation.ts";
5
+ import type { OwnerIdentity } from "../../identity.ts";
6
+ import type { ApprovedBy } from "../../interactions/prompts.ts";
7
+ import type { Speaker } from "../../speakers.ts";
8
+
9
+ /** Held actions older than this cannot be approved any more. */
10
+ export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
11
+
12
+ /** The held actions when they can still be approved at `now`; expired ones count as none. */
13
+ export function unexpired(
14
+ pending: PendingConfirmation | undefined,
15
+ now = new Date(),
16
+ ): PendingConfirmation | undefined {
17
+ if (!pending) return undefined;
18
+ return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
19
+ ? undefined
20
+ : pending;
21
+ }
22
+
23
+ /** JSON with object keys sorted at every level, so equal inputs compare equal. */
24
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
25
+ export function canonicalJson(value: unknown): string {
26
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
27
+ return JSON.stringify(value, (_key, v: unknown) =>
28
+ isPlainObject(v) ? withSortedKeys(v) : v,
29
+ );
30
+ }
31
+
32
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
33
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
34
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
35
+ }
36
+
37
+ /** The same object with its keys in code-unit order. */
38
+ function withSortedKeys(
39
+ value: Record<string, unknown>,
40
+ ): Record<string, unknown> {
41
+ return Object.fromEntries(
42
+ Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b)),
43
+ );
44
+ }
45
+
46
+ function byCodeUnit(a: string, b: string): number {
47
+ if (a < b) return -1;
48
+ return a > b ? 1 : 0;
49
+ }
50
+
51
+ /** The owner's approving message, preceded by the held calls it releases, so the model makes them again. */
52
+ export function confirmedTurnText(
53
+ pending: PendingConfirmation,
54
+ text: string,
55
+ owner: OwnerIdentity,
56
+ ): string {
57
+ const calls = pending.calls
58
+ .map((call) => `- ${call.action}: ${call.tool} ${call.input}`)
59
+ .join("\n");
60
+ return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
61
+ }
62
+
63
+ /**
64
+ * Whether a call's `approverIds` leave the person free to approve it: always, for a call that names
65
+ * none, since whoever asked decides who the person is; for one that does, only an owner or a named
66
+ * principal. Nobody known, such as a turn without a speaker, may approve a call that names approvers.
67
+ */
68
+ export function mayApproveNamed(
69
+ person:
70
+ | (Pick<Speaker, "principalId"> & { tier?: Speaker["tier"] })
71
+ | undefined,
72
+ call: Pick<HeldCall, "approverIds">,
73
+ ): boolean {
74
+ if (!call.approverIds) return true;
75
+ return (
76
+ person !== undefined &&
77
+ (person.tier === "owner" || call.approverIds.includes(person.principalId))
78
+ );
79
+ }
80
+
81
+ /** Whether an approval reported for a call is one its `approverIds` accept: from a named principal or an owner. */
82
+ export function namedApprover(
83
+ call: Pick<HeldCall, "approverIds">,
84
+ by: ApprovedBy | undefined,
85
+ ): boolean {
86
+ if (!call.approverIds) return true;
87
+ return (
88
+ by !== undefined &&
89
+ (by.owner === true || call.approverIds.includes(by.principalId))
90
+ );
91
+ }
@@ -0,0 +1,108 @@
1
+ import type { ToolTurn } from "../../define.ts";
2
+ import { type HoldCheck, type HoldContext, isPromise } from "../../holds.ts";
3
+
4
+ /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
5
+ export interface HoldTurn {
6
+ /** Aborts when the turn stops; a call being described then opens no card. */
7
+ signal?: AbortSignal;
8
+ /** The turn a hold receives, with `signal` as the signal that ends its wait. */
9
+ toolTurn(signal: AbortSignal): ToolTurn;
10
+ }
11
+
12
+ /** The answer of a description wait the turn's stop cut short. */
13
+ export const STOPPED = Symbol("stopped");
14
+
15
+ /** What waiting for a call's description comes to: its text, or the turn stopped first. */
16
+ export type Described = string | undefined | typeof STOPPED;
17
+
18
+ /** A held call's description with who may approve it instead of the speaker; see `HoldRule.approversInTurn`. */
19
+ export type Judged =
20
+ | { action: string; approvers?: readonly string[] }
21
+ | undefined
22
+ | typeof STOPPED;
23
+
24
+ /**
25
+ * What a lookup that answers later comes to: its answer; `fallback` when it rejects or outlasts
26
+ * `timeoutMs`; `STOPPED` when the turn stops first. Whatever the lookup is still doing is
27
+ * aborted when the wait ends.
28
+ */
29
+ export function awaited<T>(
30
+ answer: Promise<T>,
31
+ fallback: T,
32
+ lookup: AbortController,
33
+ stop: AbortSignal | undefined,
34
+ timeoutMs: number,
35
+ ): Promise<T | typeof STOPPED> {
36
+ return new Promise((resolve) => {
37
+ const onStop = () => end(STOPPED);
38
+ const timer = setTimeout(() => end(fallback), timeoutMs);
39
+ let ended = false;
40
+ const end = (found: T | typeof STOPPED) => {
41
+ if (ended) return;
42
+ ended = true;
43
+ clearTimeout(timer);
44
+ stop?.removeEventListener("abort", onStop);
45
+ lookup.abort();
46
+ resolve(found);
47
+ };
48
+ // Handled first, so a rejection after the wait ended is never left unhandled.
49
+ answer.then(end, () => end(fallback));
50
+ if (stop?.aborted) return end(STOPPED);
51
+ stop?.addEventListener("abort", onStop, { once: true });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * A description joined by the approvers the holds name for a call they hold, looked up as the
57
+ * turn with the same wait as the description; a lookup that rejects or outlasts the timeout names
58
+ * none, so only the owners approve the call. A call no rule holds asks no one.
59
+ */
60
+ export function joinApprovers(
61
+ described: Described | Promise<Described>,
62
+ from: { holds: HoldCheck; context: HoldContext; timeoutMs: number },
63
+ tool: string,
64
+ input: Record<string, unknown>,
65
+ turn: HoldTurn,
66
+ ): Judged | Promise<Judged> {
67
+ const lookup = from.holds.approversInTurn;
68
+ if (!lookup) return withoutApprovers(described);
69
+ const join = (action: Described): Judged | Promise<Judged> => {
70
+ if (action === STOPPED || action === undefined) return action;
71
+ const controller = new AbortController();
72
+ if (turn.signal?.aborted) controller.abort();
73
+ let named:
74
+ | readonly string[]
75
+ | undefined
76
+ | Promise<readonly string[] | undefined>;
77
+ try {
78
+ named = lookup.call(
79
+ from.holds,
80
+ tool,
81
+ input,
82
+ from.context,
83
+ turn.toolTurn(controller.signal),
84
+ );
85
+ } catch {
86
+ named = [];
87
+ }
88
+ if (!isPromise(named)) {
89
+ controller.abort();
90
+ return named ? { action, approvers: named } : { action };
91
+ }
92
+ return awaited(named, [], controller, turn.signal, from.timeoutMs).then(
93
+ (approvers) => {
94
+ if (approvers === STOPPED) return STOPPED;
95
+ return approvers ? { action, approvers } : { action };
96
+ },
97
+ );
98
+ };
99
+ return isPromise(described) ? described.then(join) : join(described);
100
+ }
101
+
102
+ function withoutApprovers(
103
+ described: Described | Promise<Described>,
104
+ ): Judged | Promise<Judged> {
105
+ const wrap = (action: Described): Judged =>
106
+ action === STOPPED || action === undefined ? action : { action };
107
+ return isPromise(described) ? described.then(wrap) : wrap(described);
108
+ }
@@ -160,7 +160,10 @@ export class PiAgentRuntime implements AgentRuntime {
160
160
  );
161
161
  if (session.thinkingLevel !== level) session.setThinkingLevel(level);
162
162
  const gate = await this.#sessions.gate(key, request.agent !== undefined);
163
- const pending = gate.pending();
163
+ // What this turn's message may release: a held call that named approvers is theirs alone.
164
+ const pending = request.confirmed
165
+ ? gate.releasable(request.speaker)
166
+ : gate.pending();
164
167
  const registered = new Set(session.getAllTools().map((tool) => tool.name));
165
168
  // The speaker's tier limits the tools.
166
169
  const { tier } = request.speaker;
@@ -216,8 +219,9 @@ export class PiAgentRuntime implements AgentRuntime {
216
219
  request.confirmed === true,
217
220
  who,
218
221
  request.speaker,
222
+ conversation.visibility === "private",
219
223
  );
220
- if (request.confirmed && pending) {
224
+ if (request.confirmed && pending && pending.calls.length > 0) {
221
225
  logger.info(
222
226
  {
223
227
  channel: request.channel,
@@ -287,7 +291,7 @@ export class PiAgentRuntime implements AgentRuntime {
287
291
  try {
288
292
  const attachments = request.attachments ?? NO_ATTACHMENTS;
289
293
  const text =
290
- request.confirmed && pending
294
+ request.confirmed && pending && pending.calls.length > 0
291
295
  ? confirmedTurnText(pending, request.text, who)
292
296
  : request.text;
293
297
  const prompted = running.run(() =>
@@ -61,9 +61,17 @@ export class PromptSlot {
61
61
  const prompts = this.#prompts;
62
62
  if (!prompts) return undefined;
63
63
  return {
64
- confirm: (title, message, signal, minTier, wait, details) =>
64
+ confirm: (title, message, signal, minTier, wait, details, options) =>
65
65
  this.#waiting(() =>
66
- prompts.confirm(title, message, signal, minTier, wait, details),
66
+ prompts.confirm(
67
+ title,
68
+ message,
69
+ signal,
70
+ minTier,
71
+ wait,
72
+ details,
73
+ options,
74
+ ),
67
75
  ),
68
76
  ask: (title, question: OwnerQuestion, signal, wait) =>
69
77
  this.#waiting(() => prompts.ask(title, question, signal, wait)),
@@ -168,6 +168,9 @@ export function runtimePlugin(
168
168
  }),
169
169
  confirmations: heldActions,
170
170
  toolTiers: context.toolTiers,
171
+ ...(providers.filled.has("images")
172
+ ? { images: providers.images }
173
+ : {}),
171
174
  stopping: () => context.queue.closed,
172
175
  get agents() {
173
176
  return agents();
@@ -4,6 +4,7 @@ import type {
4
4
  ExtensionFactory,
5
5
  ModelRuntime,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import type { ImageDrawer } from "../contract/providers.ts";
7
8
  import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
8
9
  import type { ChannelKey } from "../domain/conversation.ts";
9
10
  import type { InterimTextMode } from "../domain/interim.ts";
@@ -97,6 +98,8 @@ export interface PiAgentRuntimeOptions {
97
98
  interimPrimaryChars?: number;
98
99
  /** How long a new session waits for its MCP tools to register. */
99
100
  mcpConnectTimeoutMs?: number;
101
+ /** The host's image provider, when one fills the slot; sessions then offer `generate_image`. */
102
+ images?: ImageDrawer;
100
103
  /** Defaults to true: bridge shared turns refuse raw private exchanges inaccessible to the reader. */
101
104
  memory?: boolean;
102
105
  }
@@ -113,6 +116,8 @@ export interface CoreExtensions {
113
116
  readAttachment: ExtensionFactory;
114
117
  /** `attach_file`, for a session with a workspace. */
115
118
  attachFile?: ExtensionFactory;
119
+ /** `generate_image`, for a host with an image provider. */
120
+ generateImage?: ExtensionFactory;
116
121
  confirmationGate: ExtensionFactory;
117
122
  askUser: ExtensionFactory;
118
123
  selfCompactGuard: ExtensionFactory;
@@ -144,6 +149,9 @@ export function sessionExtensions(
144
149
  ...(core.attachFile
145
150
  ? [{ name: "attach-file", factory: core.attachFile }]
146
151
  : []),
152
+ ...(core.generateImage
153
+ ? [{ name: "generate-image", factory: core.generateImage }]
154
+ : []),
147
155
  { name: "confirmation-gate", factory: core.confirmationGate },
148
156
  { name: "ask-user", factory: core.askUser },
149
157
  { name: "self-compact-guard", factory: core.selfCompactGuard },
@@ -10,6 +10,7 @@ import {
10
10
  type ToolDefinition,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import { channelSegment } from "../attachments/attachment-dir.ts";
13
+ import type { ImageDrawer } from "../contract/providers.ts";
13
14
  import type { ClientPort } from "../domain/client.ts";
14
15
  import type { ChannelKey } from "../domain/conversation.ts";
15
16
  import { ConfigError } from "../domain/errors.ts";
@@ -28,8 +29,13 @@ import {
28
29
  ATTACH_FILE_TOOL,
29
30
  attachFileExtension,
30
31
  } from "../shared/attach-file-tool.ts";
32
+ import {
33
+ GENERATE_IMAGE_TOOL,
34
+ generateImageExtension,
35
+ } from "../shared/generate-image-tool.ts";
31
36
  import { packageDir } from "../shared/package-dir.ts";
32
37
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
38
+ import type { WorkspaceRoots } from "../shared/workspace-files.ts";
33
39
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
34
40
  import { currentToolNames } from "../tool-tiers.ts";
35
41
  import { toolTurn } from "../tool-turn.ts";
@@ -56,6 +62,7 @@ import type { PromptSlot } from "./prompt-slot.ts";
56
62
  import type {
57
63
  ChannelSession,
58
64
  CompactionEnd,
65
+ CoreExtensions,
59
66
  PiAgentRuntimeOptions,
60
67
  } from "./runtime-types.ts";
61
68
  import {
@@ -142,6 +149,8 @@ export class SessionFactory {
142
149
  ASK_USER_TOOL,
143
150
  // Registered only by a session with a workspace.
144
151
  ATTACH_FILE_TOOL,
152
+ // Registered only with an image provider.
153
+ GENERATE_IMAGE_TOOL,
145
154
  ...groups
146
155
  .filter((group) => selection.groups.includes(group.name))
147
156
  .flatMap((group) => group.tools),
@@ -361,6 +370,7 @@ export class SessionFactory {
361
370
  },
362
371
  speaker: () => this.#deps.speaker(turnKey),
363
372
  origin: () => this.#deps.origin(turnKey),
373
+ approvedBy: (toolCallId) => gate.approvedBy(toolCallId),
364
374
  client: () => this.#deps.client(turnKey),
365
375
  runTask: (task) =>
366
376
  this.#deps.runTask({ turn: turnKey, home: channel }, task),
@@ -381,6 +391,10 @@ export class SessionFactory {
381
391
  ...(context.workspace
382
392
  ? { attachFile: attachFileExtension(context.workspace) }
383
393
  : {}),
394
+ ...imageTools(
395
+ this.#options.images,
396
+ context.workspace ?? { workspace: cwd },
397
+ ),
384
398
  confirmationGate: confirmationGateExtension(gate, slot, (signal) =>
385
399
  toolTurn(context, signal),
386
400
  ),
@@ -442,6 +456,14 @@ export class SessionFactory {
442
456
  }
443
457
  }
444
458
 
459
+ /** `generate_image` for a session of a host with an image provider; nothing without one. */
460
+ export function imageTools(
461
+ images: ImageDrawer | undefined,
462
+ roots: WorkspaceRoots,
463
+ ): Pick<CoreExtensions, "generateImage"> {
464
+ return images ? { generateImage: generateImageExtension(images, roots) } : {};
465
+ }
466
+
445
467
  /** Pi's bash tool with TMPDIR set to `scratchDir`, so `mktemp` and tools write there. */
446
468
  export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
447
469
  // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
@@ -10,6 +10,7 @@ import { type ServiceKey, serviceKey } from "./contract/services.ts";
10
10
  import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
11
11
  import type { PendingConfirmation } from "./domain/conversation.ts";
12
12
  import type { HttpRoute } from "./http/listeners.ts";
13
+ import type { IdentityLinks } from "./identity/identity-links.ts";
13
14
  import type { IdentityService } from "./identity/identity-service.ts";
14
15
  import type { ThinkingSetting } from "./models.ts";
15
16
  import type {
@@ -46,6 +47,16 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
46
47
  */
47
48
  export const IDENTITY: ServiceKey<IdentityService> =
48
49
  serviceKey<IdentityService>("roundtable.identity");
50
+ /**
51
+ * Links a person's own account to their principal, which `IDENTITY` cannot. Provided by the
52
+ * `identity` plugin only when the configuration lists providers in `access.selfLink`; read it with
53
+ * `find` when your plugin works without it, and with `get` when it cannot.
54
+ */
55
+ export const IDENTITY_LINKS: ServiceKey<IdentityLinks> =
56
+ serviceKey<IdentityLinks>("roundtable.identity-links", {
57
+ absent:
58
+ 'Self-linking is switched off. List the providers people may link in the configuration, such as access: { selfLink: { providers: ["discord"] } }.',
59
+ });
49
60
  /**
50
61
  * The runtime every conversation turn runs on: the agent server's, and each turn run through
51
62
  * `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
@@ -3,6 +3,7 @@ import type { ClientPort } from "./domain/client.ts";
3
3
  import { PluginError } from "./errors.ts";
4
4
  import type { Principal } from "./identity/principal-store.ts";
5
5
  import type { OwnerIdentity } from "./identity.ts";
6
+ import type { ApprovedBy } from "./interactions/prompts.ts";
6
7
  import type { Speaker } from "./speakers.ts";
7
8
 
8
9
  /** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
@@ -99,6 +100,12 @@ export interface SessionContext {
99
100
  * method, which reads as undefined.
100
101
  */
101
102
  origin?(): string | undefined;
103
+ /**
104
+ * Who approved the held call whose tool call has this id, while it runs; undefined for a call
105
+ * that ran without a hold or whose approver the surface did not name. A context built before
106
+ * this method has none, which reads as undefined.
107
+ */
108
+ approvedBy?(toolCallId: string): ApprovedBy | undefined;
102
109
  /**
103
110
  * What the client that started the running turn can do for its tools, as `TurnRequest.client`
104
111
  * says; undefined between turns and for a turn whose transport gives none. A context built