pi-roundtable 0.14.1 → 0.15.1

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 (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/plugins.md +46 -11
  3. package/examples/fake-surface.ts +9 -5
  4. package/package.json +4 -4
  5. package/src/core/define.ts +15 -0
  6. package/src/core/discord/agent-discord.ts +26 -6
  7. package/src/core/discord/owner-card-rows.ts +44 -1
  8. package/src/core/discord/owner-card-types.ts +3 -2
  9. package/src/core/discord/owner-cards.ts +57 -58
  10. package/src/core/domain/client.ts +7 -0
  11. package/src/core/domain/progress.ts +8 -2
  12. package/src/core/holds.ts +18 -2
  13. package/src/core/i18n/cards.ts +4 -2
  14. package/src/core/interactions/prompts.ts +43 -1
  15. package/src/core/runtime/conversation-sessions.ts +16 -0
  16. package/src/core/runtime/extensions/approval-card.ts +24 -36
  17. package/src/core/runtime/extensions/approval-fields.ts +212 -0
  18. package/src/core/runtime/extensions/card-ask.ts +74 -0
  19. package/src/core/runtime/extensions/card-text.ts +27 -0
  20. package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
  21. package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
  22. package/src/core/runtime/extensions/gate-refusals.ts +45 -2
  23. package/src/core/runtime/extensions/grants.ts +30 -0
  24. package/src/core/runtime/extensions/held-calls.ts +8 -0
  25. package/src/core/runtime/extensions/hold-describer.ts +99 -0
  26. package/src/core/runtime/extensions/hold-lookups.ts +24 -7
  27. package/src/core/runtime/extensions/late-answer.ts +36 -0
  28. package/src/core/runtime/extensions/withdrawals.ts +95 -0
  29. package/src/core/runtime/session-factory.ts +1 -0
  30. package/src/core/sessions.ts +6 -0
  31. package/src/core/testing/surface-contract.ts +71 -1
  32. package/src/core/tool-turn.ts +3 -0
  33. package/src/index.ts +1 -1
@@ -0,0 +1,212 @@
1
+ import { messages } from "../../i18n/index.ts";
2
+ import { countPoints, firstPoints } from "./card-text.ts";
3
+
4
+ /** The longest label a card shows for a field, in code points. */
5
+ const LABEL_CHARS = 40;
6
+
7
+ /** A one-line string longer than this goes in a block of its own, as does any with a line break. */
8
+ const INLINE_CHARS = 100;
9
+
10
+ /** How many hidden labels a card lists. */
11
+ const HIDDEN_LABELS = 20;
12
+
13
+ /** One argument of a held call as a card shows it. */
14
+ export interface CardField {
15
+ label: string;
16
+ /** An argument the schema does not list: its name is shown in code format, so it reads as a name the tool never declared. */
17
+ extra?: true;
18
+ // pi-lens-ignore: no-unknown-parameters — a tool's argument is untyped model JSON
19
+ value: unknown;
20
+ }
21
+
22
+ /** What the fields of a card may take up; see `fieldList`. */
23
+ export interface FieldLimits {
24
+ /** The fewest code points of a long string kept when the fields do not fit whole. */
25
+ valueChars: number;
26
+ /** The most characters the field list takes up. */
27
+ totalChars: number;
28
+ }
29
+
30
+ // pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
33
+ }
34
+
35
+ /**
36
+ * The arguments of a call in the order its tool's parameter schema lists them, each labelled by
37
+ * its property's `title` or else its name; absent arguments are left out, and an argument the
38
+ * schema does not list follows the listed ones under its own name in code format. Undefined when the schema
39
+ * lists no properties, or the call has no arguments to show.
40
+ */
41
+ export function schemaFields(
42
+ input: Record<string, unknown> | undefined,
43
+ // pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
44
+ schema: unknown,
45
+ ): CardField[] | undefined {
46
+ if (!input || !isRecord(schema) || !isRecord(schema.properties))
47
+ return undefined;
48
+ const properties = schema.properties;
49
+ const fields: CardField[] = [];
50
+ // Own properties only: a name like `toString` or `constructor` is not the prototype's.
51
+ for (const [name, property] of Object.entries(properties)) {
52
+ if (!Object.hasOwn(input, name)) continue;
53
+ const value = input[name];
54
+ if (value === undefined) continue;
55
+ const title =
56
+ isRecord(property) && typeof property.title === "string"
57
+ ? property.title.trim()
58
+ : "";
59
+ fields.push({ label: title || name, value });
60
+ }
61
+ for (const [name, value] of Object.entries(input))
62
+ if (value !== undefined && !Object.hasOwn(properties, name))
63
+ fields.push({ label: name, value, extra: true });
64
+ return fields.length > 0 ? fields : undefined;
65
+ }
66
+
67
+ /**
68
+ * The fields as the lines of a card, within `limits.totalChars`: a long string is cut, the
69
+ * longest first and never below `limits.valueChars`, and marked `… [N chars]` with its length in
70
+ * code points. If that is not enough the last fields are dropped and their labels listed, so a
71
+ * card never hides that it shows less than the call carries.
72
+ */
73
+ export function fieldList(fields: CardField[], limits: FieldLimits): string {
74
+ const render = (limit: number, among: CardField[] = fields) =>
75
+ among.map((field) => fieldLines(field, limit)).join("\n");
76
+ const whole = render(Number.POSITIVE_INFINITY);
77
+ if (whole.length <= limits.totalChars) return whole;
78
+ let limit = limits.valueChars;
79
+ if (render(limit).length <= limits.totalChars) {
80
+ // The largest limit that still fits: the longest values give up characters first.
81
+ let low = limit;
82
+ let high = Math.max(low, longestValue(fields) - 1);
83
+ while (low < high) {
84
+ const mid = Math.ceil((low + high) / 2);
85
+ if (render(mid).length <= limits.totalChars) low = mid;
86
+ else high = mid - 1;
87
+ }
88
+ limit = low;
89
+ return render(limit);
90
+ }
91
+ // At least the first field stays, cut as far as the limit allows.
92
+ const shown: CardField[] = [];
93
+ let size = 0;
94
+ for (const field of fields) {
95
+ const lines = fieldLines(field, limit);
96
+ if (shown.length > 0 && size + lines.length + 1 > limits.totalChars) break;
97
+ shown.push(field);
98
+ size += lines.length + 1;
99
+ }
100
+ const hidden = fields.slice(shown.length);
101
+ const labels = hidden.slice(0, HIDDEN_LABELS).map(labelText);
102
+ if (hidden.length > HIDDEN_LABELS) labels.push("…");
103
+ return `${render(limit, shown)}\n${messages().cardHiddenKeys(labels)}`;
104
+ }
105
+
106
+ /** The code points in the longest string a field carries. */
107
+ function longestValue(fields: CardField[]): number {
108
+ let longest = 0;
109
+ for (const field of fields) {
110
+ const text = textOf(field.value);
111
+ if (text !== undefined) longest = Math.max(longest, countPoints(text));
112
+ }
113
+ return longest;
114
+ }
115
+
116
+ /** The line-break characters a model can put in a string besides `\n`, made `\n` before a value is laid out. */
117
+ const LINE_BREAKS = /\r\n?|\u2028|\u2029|\u0085/g;
118
+
119
+ const lines = (text: string): string => text.replace(LINE_BREAKS, "\n");
120
+
121
+ /** Whether a string shows nothing: empty, or only white space and zero-width characters. */
122
+ const isInvisible = (text: string): boolean =>
123
+ /^[\s\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]*$/.test(
124
+ text,
125
+ );
126
+
127
+ /** `text` written with every character that is not printable ASCII escaped, so nothing in it is invisible. */
128
+ function visible(text: string): string {
129
+ return JSON.stringify(text).replace(
130
+ /[^\x20-\x7e]/g,
131
+ (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The text a field's value is shown as, or undefined for a number, a boolean or null, which are
137
+ * never cut: a string as it is, an array of such scalars joined, anything else as compact JSON.
138
+ */
139
+ // pi-lens-ignore: no-unknown-parameters — a tool's argument is untyped model JSON
140
+ function textOf(value: unknown): string | undefined {
141
+ if (typeof value === "string") return lines(value);
142
+ if (value === null || typeof value === "number" || typeof value === "boolean")
143
+ return undefined;
144
+ if (Array.isArray(value) && value.every(isScalarText))
145
+ return value.map((item) => String(item)).join(", ");
146
+ return JSON.stringify(value);
147
+ }
148
+
149
+ /** Whether an array item reads unambiguously in a comma-joined line: not a string that is empty, invisible, has a comma and space, or a line break. */
150
+ // pi-lens-ignore: no-unknown-parameters — a tool's argument is untyped model JSON
151
+ function isScalarText(item: unknown): boolean {
152
+ if (typeof item === "string")
153
+ return (
154
+ !isInvisible(item) &&
155
+ !item.includes(", ") &&
156
+ lines(item) === item &&
157
+ !item.includes("\n")
158
+ );
159
+ return item === null || typeof item === "number" || typeof item === "boolean";
160
+ }
161
+
162
+ /** The label of a field as a card shows it: its title or name made safe, or a name the schema did not list in code format. */
163
+ function labelText(field: CardField): string {
164
+ const label = firstPoints(lines(field.label), LABEL_CHARS);
165
+ if (field.extra)
166
+ return `\`${label.replace(/\s*\n\s*/g, " ").replaceAll("`", "'")}\``;
167
+ return inline(label);
168
+ }
169
+
170
+ const labelOf = (field: CardField): string => `**${labelText(field)}**`;
171
+
172
+ /** One field as the lines of a card; strings over `limit` code points are cut and marked. */
173
+ function fieldLines(field: CardField, limit: number): string {
174
+ const label = labelOf(field);
175
+ const text = textOf(field.value);
176
+ if (text === undefined) return `${label}: ${String(field.value)}`;
177
+ const points = countPoints(text);
178
+ const cut = points > limit;
179
+ const shown = cut ? firstPoints(text, limit) : text;
180
+ const note = cut ? `… [${points} chars]` : "";
181
+ // A string that shows nothing is written out, so the person sees it is there.
182
+ if (typeof field.value === "string" && isInvisible(shown))
183
+ return `${label}: \`${visible(shown)}\`${note}`;
184
+ const structured = typeof field.value !== "string" && !isScalarArray(field);
185
+ if (structured)
186
+ return shown.length + note.length <= INLINE_CHARS && !shown.includes("`")
187
+ ? `${label}: \`${shown}\`${note}`
188
+ : `${label}:\n${fenced(`${shown}${note}`, "json")}`;
189
+ return shown.includes("\n") || points > INLINE_CHARS
190
+ ? `${label}:\n${fenced(`${shown}${note}`)}`
191
+ : `${label}: ${inline(shown)}${note}`;
192
+ }
193
+
194
+ function isScalarArray(field: CardField): boolean {
195
+ return Array.isArray(field.value) && field.value.every(isScalarText);
196
+ }
197
+
198
+ /** `text` in a fenced block, which keeps its line breaks and in which nothing is markdown. */
199
+ function fenced(text: string, language = ""): string {
200
+ return `\`\`\`${language}\n${text.replaceAll("```", "`\u200b``")}\n\`\`\``;
201
+ }
202
+
203
+ /**
204
+ * `text` made safe to put inside a line of Discord markdown: nothing in it formats, links or
205
+ * mentions anyone, and a line break becomes a space. An `@` stays, so an address reads as written.
206
+ */
207
+ function inline(text: string): string {
208
+ return text
209
+ .replace(/\s*\n\s*/g, " ")
210
+ .replace(/[\\*_~`|[\]<>]/g, "\\$&")
211
+ .replace(/@(?=everyone|here)/g, "@\u200b");
212
+ }
@@ -0,0 +1,74 @@
1
+ import type {
2
+ Approval,
3
+ ApprovalDetails,
4
+ ApprovalOptions,
5
+ ApprovedBy,
6
+ Prompts,
7
+ PromptWait,
8
+ } from "../../interactions/prompts.ts";
9
+ import type { Tier } from "../../speakers.ts";
10
+ import type { Watch } from "./withdrawals.ts";
11
+
12
+ /** What a held call's card is opened with. */
13
+ export interface CardAsk {
14
+ title: string;
15
+ message: string;
16
+ signal: AbortSignal | undefined;
17
+ minTier: Tier | undefined;
18
+ wait: PromptWait;
19
+ details: ApprovalDetails;
20
+ approvers: readonly string[] | undefined;
21
+ /** The call's watch, when its plugin may withdraw it. */
22
+ watch: Watch | undefined;
23
+ }
24
+
25
+ /** How a card came out: its answer, or `withdrawn` when the plugin took the call back first, and who approved it on which card. */
26
+ export interface CardAnswer {
27
+ answer: Approval | "withdrawn";
28
+ approvedBy?: ApprovedBy;
29
+ approvedPrompt?: string;
30
+ }
31
+
32
+ /**
33
+ * Opens the card and waits for the first of its answer and the plugin's withdrawal of the call;
34
+ * the other changes nothing. A card that fails answers nothing, so its watch ends and the
35
+ * failure goes on.
36
+ */
37
+ export async function askOnCard(
38
+ prompts: Prompts,
39
+ ask: CardAsk,
40
+ ): Promise<CardAnswer> {
41
+ const found: Omit<CardAnswer, "answer"> = {};
42
+ const options: ApprovalOptions = {
43
+ ...(ask.approvers ? { approvers: ask.approvers } : {}),
44
+ approvedBy: (by) => {
45
+ found.approvedBy = by;
46
+ },
47
+ approvedPrompt: (prompt) => {
48
+ found.approvedPrompt = prompt;
49
+ },
50
+ ...(ask.watch ? { supersede: ask.watch.signal } : {}),
51
+ };
52
+ try {
53
+ const asking = prompts.confirm(
54
+ ask.title,
55
+ ask.message,
56
+ ask.signal,
57
+ ask.minTier,
58
+ ask.wait,
59
+ ask.details,
60
+ options,
61
+ );
62
+ // What the surface reports is read after the answer, never before it.
63
+ if (!ask.watch) {
64
+ const answer = await asking;
65
+ return { ...found, answer };
66
+ }
67
+ asking.catch(() => undefined);
68
+ const answer = await Promise.race([asking, ask.watch.whenWithdrawn]);
69
+ return { ...found, answer };
70
+ } catch (error) {
71
+ ask.watch?.end();
72
+ throw error;
73
+ }
74
+ }
@@ -0,0 +1,27 @@
1
+ /** How many code points `text` has. */
2
+ export function countPoints(text: string): number {
3
+ let points = 0;
4
+ for (let i = 0; i < text.length; i++) {
5
+ const code = text.charCodeAt(i);
6
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) i++;
7
+ points++;
8
+ }
9
+ return points;
10
+ }
11
+
12
+ /** The first `count` code points of `text`. */
13
+ export function firstPoints(text: string, count: number): string {
14
+ let end = 0;
15
+ for (let points = 0; points < count && end < text.length; points++) {
16
+ const code = text.charCodeAt(end);
17
+ end += code >= 0xd800 && code <= 0xdbff && end + 1 < text.length ? 2 : 1;
18
+ }
19
+ return text.slice(0, end);
20
+ }
21
+
22
+ /** The first `max` characters of `text`, backing off a cut that would split a surrogate pair. */
23
+ export function prefix(text: string, max: number): string {
24
+ if (text.length <= max) return text;
25
+ const code = text.charCodeAt(max - 1);
26
+ return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
27
+ }
@@ -26,6 +26,9 @@ export function confirmationGateExtension(
26
26
  };
27
27
  });
28
28
  pi.on("tool_call", async (event, ctx) => {
29
+ // A tool call id the model reused must not inherit an earlier call's approval.
30
+ gate.finish(event.toolCallId);
31
+ approved.delete(event.toolCallId);
29
32
  const prompts = slot?.prompts;
30
33
  let ask: Parameters<ConfirmationGate["review"]>[2];
31
34
  if (prompts && slot) {
@@ -35,14 +38,22 @@ export function confirmationGateExtension(
35
38
  const turn: HoldTurn | undefined = turnOf
36
39
  ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
37
40
  : 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
- );
41
+ const { reason, approvedOnCard, approvedBy, approvedPrompt, refused } =
42
+ await gate.decide(
43
+ event.toolName,
44
+ event.input as Record<string, unknown>,
45
+ ask,
46
+ turn,
47
+ // A host that gives no tool list shows the call as JSON.
48
+ pi.getAllTools?.().find((tool) => tool.name === event.toolName)
49
+ ?.parameters,
50
+ );
44
51
  if (approvedOnCard) approved.add(event.toolCallId);
45
- if (approvedBy && !reason) gate.grant(event.toolCallId, approvedBy);
52
+ if (!reason && (approvedBy || approvedPrompt))
53
+ gate.grant(event.toolCallId, {
54
+ ...(approvedBy ? { by: approvedBy } : {}),
55
+ ...(approvedPrompt ? { prompt: approvedPrompt } : {}),
56
+ });
46
57
  if (refused) slot?.refuse(event.toolCallId, refused);
47
58
  return reason ? { block: true, reason } : undefined;
48
59
  });