pi-roundtable 0.9.12 → 0.10.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 (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +146 -21
  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/team-turns.ts +11 -4
  10. package/src/core/builtin/agent-server.ts +6 -0
  11. package/src/core/config/access.ts +23 -3
  12. package/src/core/config/adapters.ts +11 -0
  13. package/src/core/config/schema.ts +14 -0
  14. package/src/core/define-roundtable.ts +1 -0
  15. package/src/core/define.ts +44 -6
  16. package/src/core/discord/card-audience.ts +104 -7
  17. package/src/core/discord/discord-owners.ts +19 -13
  18. package/src/core/discord/owner-card-types.ts +73 -0
  19. package/src/core/discord/owner-cards.ts +56 -77
  20. package/src/core/domain/conversation.ts +7 -0
  21. package/src/core/domain/ports.ts +4 -1
  22. package/src/core/holds.ts +60 -0
  23. package/src/core/i18n/cards.ts +3 -0
  24. package/src/core/identity/access-policy.ts +65 -2
  25. package/src/core/identity/caching-principal-store.ts +2 -2
  26. package/src/core/identity/identity-links.ts +96 -0
  27. package/src/core/identity/identity-plugin.ts +10 -3
  28. package/src/core/identity/identity-schema.ts +3 -1
  29. package/src/core/identity/identity-service.ts +5 -3
  30. package/src/core/identity/principal-store.ts +59 -142
  31. package/src/core/identity/principal-tables.ts +10 -2
  32. package/src/core/identity/principal-types.ts +146 -0
  33. package/src/core/interactions/prompts.ts +47 -3
  34. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  35. package/src/core/routing/conversation-turns.ts +4 -1
  36. package/src/core/runtime/conversation-sessions.ts +8 -0
  37. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  38. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  39. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  40. package/src/core/runtime/extensions/held-calls.ts +91 -0
  41. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  42. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  43. package/src/core/runtime/prompt-slot.ts +10 -2
  44. package/src/core/runtime/session-factory.ts +1 -0
  45. package/src/core/services.ts +11 -0
  46. package/src/core/sessions.ts +7 -0
  47. package/src/core/testing/surface-contract-support.ts +194 -0
  48. package/src/core/testing/surface-contract.ts +54 -120
  49. package/src/core/tool-turn.ts +5 -1
  50. package/src/index.ts +7 -0
@@ -0,0 +1,146 @@
1
+ import type { Pronouns } from "../config/config.ts";
2
+ import type { Tier } from "../speakers.ts";
3
+
4
+ export type { Pronouns };
5
+
6
+ /** A person the host serves, whichever surface they reach it through. */
7
+ export interface Principal {
8
+ /** Opaque: a speaker id carried over from 0.8, or `p_<ulid>` for one made since. */
9
+ id: string;
10
+ displayName: string;
11
+ pronouns?: Pronouns;
12
+ disabled: boolean;
13
+ }
14
+
15
+ /** A principal as stored, with when it was made and when it was last seen. */
16
+ export interface PrincipalRecord extends Principal {
17
+ locale?: string;
18
+ timeZone?: string;
19
+ createdAt: Date;
20
+ lastSeenAt?: Date;
21
+ /** The tier it was last seen at; it only ever lowers what a background turn may do, never grants. */
22
+ lastTier?: Tier;
23
+ /** Whether the person of its 0.8 speaker id may still claim it: only one the backfill made, until any identity is linked to it. */
24
+ claimable: boolean;
25
+ }
26
+
27
+ /** An identity on one provider: `discord` and a user id, `oidc:<issuer>` and a subject, `token` and a name. */
28
+ export interface IdentityRef {
29
+ provider: string;
30
+ subject: string;
31
+ }
32
+
33
+ /**
34
+ * How an identity came to be linked: by the configuration, a plugin declaring it
35
+ * (`RoundtablePlugin.identities`), the CLI, admission on first contact, a 0.8 speaker id claimed,
36
+ * or the person themself through `IDENTITY_LINKS` (`self`).
37
+ */
38
+ export type LinkSource =
39
+ | "config"
40
+ | "plugin"
41
+ | "cli"
42
+ | "jit"
43
+ | "legacy"
44
+ | "self";
45
+
46
+ export interface IdentityLink extends IdentityRef {
47
+ principalId: string;
48
+ source: LinkSource;
49
+ linkedAt: Date;
50
+ }
51
+
52
+ /** Where a role came from. The configuration's are synced at every boot; a CLI grant stays until the CLI revokes it. */
53
+ export type RoleSource = "config" | "cli";
54
+
55
+ export interface RoleGrant {
56
+ role: Tier;
57
+ source: RoleSource;
58
+ grantedAt: Date;
59
+ }
60
+
61
+ /** A principal holding a role, and where the role came from. */
62
+ export interface RoleHolder {
63
+ principalId: string;
64
+ source: RoleSource;
65
+ }
66
+
67
+ export interface NewPrincipal {
68
+ /** The id to keep, for a principal carried over from 0.8; otherwise a new `p_` id is made. */
69
+ id?: string;
70
+ displayName: string;
71
+ pronouns?: Pronouns;
72
+ }
73
+
74
+ /** The principals, their linked identities, and their lasting roles. */
75
+ export interface PrincipalStore {
76
+ get(id: string): Promise<PrincipalRecord | undefined>;
77
+ list(): Promise<PrincipalRecord[]>;
78
+ /** Refuses an id already taken, and the system principal's. */
79
+ create(input: NewPrincipal): Promise<PrincipalRecord>;
80
+ /** Changes the name or the pronouns; `pronouns: null` clears them. */
81
+ update(
82
+ id: string,
83
+ change: { displayName?: string; pronouns?: Pronouns | null },
84
+ ): Promise<PrincipalRecord | undefined>;
85
+ /**
86
+ * Links the identity to the principal; linking it again to the same one changes nothing, but
87
+ * that the configuration linking it makes the link the configuration's, and a plugin linking it
88
+ * makes one the CLI, a first contact, or a claim made the plugin's; to another one is refused. A
89
+ * principal with any identity linked is no longer claimable, even after the identity is
90
+ * unlinked, except by a plugin's link: a plugin's credential is not the person's own identity,
91
+ * so it leaves their 0.8 claim to them.
92
+ */
93
+ link(
94
+ principalId: string,
95
+ identity: IdentityRef,
96
+ source: LinkSource,
97
+ ): Promise<IdentityLink>;
98
+ /**
99
+ * The identity's link, claiming the principal for it when the identity is still unlinked and
100
+ * the principal claimable and not an owner; undefined when it may not be claimed. A principal
101
+ * is claimed at most once. A claim names the principal `displayName`, when given, while its
102
+ * name is still its id, as the backfill names someone it knows no name of; a real name stays.
103
+ */
104
+ claim(
105
+ principalId: string,
106
+ identity: IdentityRef,
107
+ displayName?: string,
108
+ ): Promise<IdentityLink | undefined>;
109
+ /**
110
+ * The identity's link, making a new `p_` principal for it, of `id` when given, when it is still
111
+ * unlinked; however many contacts admit it at once, one principal is made.
112
+ */
113
+ admit(
114
+ identity: IdentityRef,
115
+ displayName: string,
116
+ id?: string,
117
+ ): Promise<IdentityLink>;
118
+ /** Whether there was a link to remove; with `source`, only a link made by it is removed. */
119
+ unlink(
120
+ provider: string,
121
+ subject: string,
122
+ source?: LinkSource,
123
+ ): Promise<boolean>;
124
+ identity(
125
+ provider: string,
126
+ subject: string,
127
+ ): Promise<IdentityLink | undefined>;
128
+ identitiesOf(principalId: string): Promise<IdentityLink[]>;
129
+ /** Every link made by `source`, such as the configuration's. */
130
+ linksFrom(source: LinkSource): Promise<IdentityLink[]>;
131
+ /** Grants a lasting role. A CLI grant is kept as the CLI's even when the configuration grants the same role. */
132
+ grant(principalId: string, role: Tier, source: RoleSource): Promise<void>;
133
+ /** Revokes the role, or only the grant from `source`; whether there was one to revoke. */
134
+ revoke(
135
+ principalId: string,
136
+ role: Tier,
137
+ source?: RoleSource,
138
+ ): Promise<boolean>;
139
+ rolesOf(principalId: string): Promise<RoleGrant[]>;
140
+ /** Every principal holding the role. */
141
+ holders(role: Tier): Promise<RoleHolder[]>;
142
+ disable(id: string): Promise<void>;
143
+ enable(id: string): Promise<void>;
144
+ /** Records that the principal was seen at `at` (default now), at this tier, or null when refused. */
145
+ touch(id: string, tier: Tier | null, at?: Date): Promise<void>;
146
+ }
@@ -37,9 +37,51 @@ export interface ApprovalDetails {
37
37
  files?: ApprovalFile[];
38
38
  }
39
39
 
40
- /** An answer given on a prompt after its turn stopped waiting; `by` names who gave it. */
40
+ /**
41
+ * Who approved a held call: the principal, the identity they answered with as `Speaker.id`, and
42
+ * the name that identity goes by. A surface reports it when it knows it; a held call's `run`
43
+ * reads it as `ToolTurn.approvedBy`.
44
+ */
45
+ export interface ApprovedBy {
46
+ principalId: string;
47
+ speakerId: string;
48
+ name: string;
49
+ /**
50
+ * True when they approved as a core owner. A surface sets it when the approver is one; a call
51
+ * that named approvers is released only by an approver it names or by an `owner`, so an
52
+ * approval without it, from anyone else, is not accepted.
53
+ */
54
+ owner?: true;
55
+ }
56
+
57
+ /**
58
+ * What a held call's approval is asked with besides its text: who may approve it, and where to
59
+ * hear who did.
60
+ */
61
+ export interface ApprovalOptions {
62
+ /**
63
+ * The principal ids that may approve this call instead of the scope's speaker. Given in a
64
+ * conversation that escalates to the owners, the speaker may not approve it unless named, and
65
+ * each named principal needs the call's tier when they answer; the owners may still approve it.
66
+ * An empty list leaves the owners alone. In a private conversation, whose person is the only one
67
+ * who answers, the person may approve it only when named or an owner; otherwise it comes back
68
+ * `unavailable` at once. A surface must report who approved through `approvedBy`: in a shared
69
+ * conversation an approval it does not name is not accepted for a call that names approvers.
70
+ */
71
+ approvers?: readonly string[];
72
+ /**
73
+ * Called with who approved the call, before the approval resolves `approved`; not called for a
74
+ * refusal, or by a surface that does not know who answered.
75
+ */
76
+ approvedBy?(by: ApprovedBy): void;
77
+ }
78
+
79
+ /**
80
+ * An answer given on a prompt after its turn stopped waiting; `by` names who gave it. An approval
81
+ * names the approver too, where the surface knows them.
82
+ */
41
83
  export type LateAnswer = { by: string } & (
42
- | { kind: "approval"; approved: boolean }
84
+ | { kind: "approval"; approved: boolean; approvedBy?: ApprovedBy }
43
85
  | { kind: "question"; answer: OwnerAnswer }
44
86
  );
45
87
 
@@ -119,7 +161,8 @@ export interface Prompts {
119
161
  * tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
120
162
  * prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
121
163
  * `pending` comes only with a `wait`. `details` is the approval as data beside `message`, its
122
- * text; a surface that shows text only ignores it.
164
+ * text; a surface that shows text only ignores it. `options` says who else may approve it and
165
+ * hears who did; see `ApprovalOptions`.
123
166
  */
124
167
  confirm(
125
168
  title: string,
@@ -128,6 +171,7 @@ export interface Prompts {
128
171
  minTier?: Tier,
129
172
  wait?: PromptWait,
130
173
  details?: ApprovalDetails,
174
+ options?: ApprovalOptions,
131
175
  ): Promise<Approval>;
132
176
  /**
133
177
  * Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
@@ -292,30 +292,61 @@ export function precheckScriptTools(
292
292
  },
293
293
  ): PrecheckTool[] {
294
294
  const tools = new Map<string, PrecheckTool>();
295
- for (const call of readPrecheckScript(script).calls) {
296
- const name = options.toolName(call.server, call.tool);
297
- const context = options.context ?? {};
298
- let held: string | undefined;
299
- if (call.args) held = options.holds(name, call.args, context);
300
- else {
301
- held = options.holds(name, {}, context);
302
- const rule =
303
- held === undefined ? options.holds.mayHold?.(name) : undefined;
304
- if (rule !== undefined) held = messages().precheckToolMayHold(name, rule);
305
- }
295
+ for (const call of judgedScriptCalls(script, options)) {
306
296
  const key = `${call.server}\u0000${call.tool}`;
307
297
  const known = tools.get(key);
308
298
  if (!known)
309
299
  tools.set(key, {
310
300
  server: call.server,
311
301
  tool: call.tool,
312
- ...(held ? { held } : {}),
302
+ ...(call.held ? { held: call.held } : {}),
313
303
  });
314
- else if (held && !known.held) known.held = held;
304
+ else if (call.held && !known.held) known.held = call.held;
315
305
  }
316
306
  return [...tools.values()];
317
307
  }
318
308
 
309
+ /** A call in a precheck script, judged: the name its rules know it by, its input, and what they say it would do. */
310
+ interface JudgedScriptCall {
311
+ server: string;
312
+ tool: string;
313
+ name: string;
314
+ /** The arguments written out in the script; empty when it computes them. */
315
+ input: Record<string, unknown>;
316
+ /** Whether the script writes its arguments out, so `input` is what the call will be made with. */
317
+ literal: boolean;
318
+ held?: string;
319
+ }
320
+
321
+ /** Each call of the script, judged by the host's hold rules, one entry per call. */
322
+ function judgedScriptCalls(
323
+ script: unknown,
324
+ options: {
325
+ toolName: (server: string, tool: string) => string;
326
+ holds: HoldCheck;
327
+ context?: HoldContext;
328
+ },
329
+ ): JudgedScriptCall[] {
330
+ const context = options.context ?? {};
331
+ return readPrecheckScript(script).calls.map((call) => {
332
+ const name = options.toolName(call.server, call.tool);
333
+ const input = call.args ?? {};
334
+ let held = options.holds(name, input, context);
335
+ if (!call.args && held === undefined) {
336
+ const rule = options.holds.mayHold?.(name);
337
+ if (rule !== undefined) held = messages().precheckToolMayHold(name, rule);
338
+ }
339
+ return {
340
+ server: call.server,
341
+ tool: call.tool,
342
+ name,
343
+ input,
344
+ literal: call.args !== undefined,
345
+ ...(held ? { held } : {}),
346
+ };
347
+ });
348
+ }
349
+
319
350
  const SCHEDULE_TOOLS = new Set(["schedule_create", "schedule_update"]);
320
351
 
321
352
  /**
@@ -330,12 +361,17 @@ export function precheckScriptHoldRule(options: {
330
361
  tiers?: () => ToolTiers;
331
362
  }): HoldRule {
332
363
  let judging = false;
333
- /** The held tools a script being saved calls, by the names the hold rules know them by. */
334
- const heldCalls = (
364
+ /** The held calls a script being saved makes, each with the name the hold rules know its tool by and its input. */
365
+ const scriptCalls = (
335
366
  tool: string,
336
367
  input: Record<string, unknown>,
337
368
  context: HoldContext,
338
- ): { name: string; held: string }[] => {
369
+ ): {
370
+ name: string;
371
+ input: Record<string, unknown>;
372
+ literal: boolean;
373
+ held: string;
374
+ }[] => {
339
375
  const script = input.precheck_script;
340
376
  if (!SCHEDULE_TOOLS.has(tool) || typeof script !== "string" || judging)
341
377
  return [];
@@ -343,13 +379,20 @@ export function precheckScriptHoldRule(options: {
343
379
  if (!runner) return [];
344
380
  judging = true;
345
381
  try {
346
- return precheckScriptTools(script, {
382
+ return judgedScriptCalls(script, {
347
383
  toolName: (server, name) => runner.toolName(server, name),
348
384
  holds: options.holds(),
349
385
  context,
350
- }).flatMap((t) =>
351
- t.held
352
- ? [{ name: runner.toolName(t.server, t.tool), held: t.held }]
386
+ }).flatMap((call) =>
387
+ call.held
388
+ ? [
389
+ {
390
+ name: call.name,
391
+ input: call.input,
392
+ literal: call.literal,
393
+ held: call.held,
394
+ },
395
+ ]
353
396
  : [],
354
397
  );
355
398
  } catch {
@@ -359,6 +402,19 @@ export function precheckScriptHoldRule(options: {
359
402
  judging = false;
360
403
  }
361
404
  };
405
+ /** The held tools a script being saved calls, once each, by the names the hold rules know them by. */
406
+ const heldCalls = (
407
+ tool: string,
408
+ input: Record<string, unknown>,
409
+ context: HoldContext,
410
+ ): { name: string; held: string }[] => {
411
+ const seen = new Set<string>();
412
+ return scriptCalls(tool, input, context).filter((call) => {
413
+ if (seen.has(call.name)) return false;
414
+ seen.add(call.name);
415
+ return true;
416
+ });
417
+ };
362
418
  return {
363
419
  name: "precheck-scripts",
364
420
  describe(tool, input, context) {
@@ -383,5 +439,44 @@ export function precheckScriptHoldRule(options: {
383
439
  undefined,
384
440
  );
385
441
  },
442
+ mayName: (tool) => SCHEDULE_TOOLS.has(tool),
443
+ // Whoever approves must also be named for each held call the script makes: the principals
444
+ // every call names, and, where a call's approvers cannot be read, the owners alone.
445
+ approversInTurn(tool, input, context, turn) {
446
+ const calls = scriptCalls(tool, input, context);
447
+ const holds = options.holds();
448
+ const lookup = holds.approversInTurn;
449
+ if (calls.length === 0 || !lookup) return undefined;
450
+ return Promise.all(
451
+ calls.map(async (call) => {
452
+ try {
453
+ // A script's computed arguments are not known now, so its call is judged by none.
454
+ const named = await lookup.call(
455
+ holds,
456
+ call.name,
457
+ call.input,
458
+ context,
459
+ turn,
460
+ );
461
+ // A rule that answers by the arguments may name approvers once they are known:
462
+ // until then only the owners can stand for them.
463
+ return named === undefined &&
464
+ !call.literal &&
465
+ holds.mayName?.(call.name) !== undefined
466
+ ? []
467
+ : named;
468
+ } catch {
469
+ return [];
470
+ }
471
+ }),
472
+ ).then((lists) => {
473
+ const named = lists.filter(
474
+ (list): list is readonly string[] => list !== undefined,
475
+ );
476
+ const [first, ...rest] = named;
477
+ if (!first) return undefined;
478
+ return first.filter((id) => rest.every((list) => list.includes(id)));
479
+ });
480
+ },
386
481
  };
387
482
  }
@@ -49,7 +49,10 @@ export interface ConversationTurnInput {
49
49
  steerable?: boolean;
50
50
  /** The turn may ask the owner on the surface's prompts, such as approvals of held actions. */
51
51
  interactive?: boolean;
52
- /** The owner confirmed the channel's held actions, so they may run in this turn. */
52
+ /**
53
+ * The speaker confirmed the channel's held actions, so they may run in this turn, except those
54
+ * that named approvers the speaker is neither among nor an owner of, which stay held.
55
+ */
53
56
  confirmed?: boolean;
54
57
  /**
55
58
  * Posts the turn's outcome in place of the default reply (the text, or a failure or stopped
@@ -109,6 +109,14 @@ export class ConversationSessions {
109
109
  this.#tiers,
110
110
  // Owner sessions have no shell workspace but still send files from their own directory.
111
111
  agents ? agents.workDir : this.#factory.cwd(undefined),
112
+ undefined,
113
+ // A disabled principal cannot approve, so it is not named as one who can.
114
+ async (id) => {
115
+ const principal = await this.#options.principalOf?.(id);
116
+ return principal && !principal.disabled
117
+ ? principal.displayName
118
+ : undefined;
119
+ },
112
120
  );
113
121
  this.#gates.set(key, gate);
114
122
  this.#restored.delete(key);
@@ -0,0 +1,50 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ToolTurn } from "../../define.ts";
3
+ import type { PromptSlot } from "../prompt-slot.ts";
4
+ import type { ConfirmationGate, HoldTurn } from "./confirmation-gate.ts";
5
+
6
+ /**
7
+ * Without a slot, or with an empty one, every call that needs confirmation is held. `turnOf` builds
8
+ * the turn a hold that looks things up receives; without it, such a hold is not asked in a turn.
9
+ */
10
+ export function confirmationGateExtension(
11
+ gate: ConfirmationGate,
12
+ slot?: PromptSlot,
13
+ turnOf?: (signal: AbortSignal) => ToolTurn,
14
+ ): ExtensionFactory {
15
+ return (pi) => {
16
+ /** Calls a card's approval released, until their results are back. */
17
+ const approved = new Set<string>();
18
+ pi.on("tool_result", (event) => {
19
+ gate.finish(event.toolCallId);
20
+ if (!approved.delete(event.toolCallId)) return undefined;
21
+ const outcome = event.isError
22
+ ? "It was approved on its card and ran, but it failed:"
23
+ : "It was approved on its card and ran; its result follows.";
24
+ return {
25
+ content: [{ type: "text", text: outcome }, ...event.content],
26
+ };
27
+ });
28
+ pi.on("tool_call", async (event, ctx) => {
29
+ const prompts = slot?.prompts;
30
+ let ask: Parameters<ConfirmationGate["review"]>[2];
31
+ if (prompts && slot) {
32
+ ask = { prompts, asker: slot.asker };
33
+ if (ctx.signal) ask.signal = ctx.signal;
34
+ }
35
+ const turn: HoldTurn | undefined = turnOf
36
+ ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
37
+ : undefined;
38
+ const { reason, approvedOnCard, approvedBy, refused } = await gate.decide(
39
+ event.toolName,
40
+ event.input as Record<string, unknown>,
41
+ ask,
42
+ turn,
43
+ );
44
+ if (approvedOnCard) approved.add(event.toolCallId);
45
+ if (approvedBy && !reason) gate.grant(event.toolCallId, approvedBy);
46
+ if (refused) slot?.refuse(event.toolCallId, refused);
47
+ return reason ? { block: true, reason } : undefined;
48
+ });
49
+ };
50
+ }