palmier 1.0.12 → 1.1.4

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.
package/README.md CHANGED
@@ -57,6 +57,7 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
57
57
  | `notify` | Send a push notification to the user's device |
58
58
  | `request-input` | Request input from the user (blocks until response; aborting the prompt aborts the task) |
59
59
  | `request-confirmation` | Request confirmation from the user (blocks until response; aborting aborts the task) |
60
+ | `request-choice` | Ask the user a question with a fixed set of options, e.g. Yes/No, and return the one picked (blocks until response; aborting aborts the task) |
60
61
  | `fill-password` | Fill a saved password or PIN into the active browser session and submit the form, prompting the user if unknown (the secret is never revealed to the agent) |
61
62
  | `device-geolocation` | Get GPS location of the user's mobile device |
62
63
  | `read-contacts` | Read the contact list from the user's device |
package/dist/mcp-tools.js CHANGED
@@ -89,7 +89,9 @@ const requestInputTool = {
89
89
  });
90
90
  const response = await pendingPromise;
91
91
  const aborted = response.length === 1 && response[0] === "aborted";
92
- recordUserInputToRun(ctx, questions, response, aborted);
92
+ recordUserResponseToRun(ctx, aborted
93
+ ? "Aborted"
94
+ : questions.map((q, i) => `**${q}**\n\n${response[i] ?? ""}`).join("\n\n"));
93
95
  if (aborted) {
94
96
  await ctx.publishEvent("_input", {
95
97
  event_type: "input-resolved", host_id: ctx.config.hostId,
@@ -112,7 +114,7 @@ const requestInputTool = {
112
114
  * agent called via the REST endpoint with taskId); silently skips for pure
113
115
  * MCP-protocol sessions where sessionId is a random UUID.
114
116
  */
115
- function recordUserInputToRun(ctx, questions, response, aborted) {
117
+ function recordUserResponseToRun(ctx, content) {
116
118
  const taskId = ctx.sessionId;
117
119
  if (!taskId)
118
120
  return;
@@ -123,9 +125,6 @@ function recordUserInputToRun(ctx, questions, response, aborted) {
123
125
  const runId = entries[0]?.run_id;
124
126
  if (!runId)
125
127
  return;
126
- const content = aborted
127
- ? "Aborted"
128
- : questions.map((q, i) => `**${q}**\n\n${response[i] ?? ""}`).join("\n\n");
129
128
  try {
130
129
  spliceUserMessage(taskDir, runId, {
131
130
  role: "user",
@@ -189,6 +188,67 @@ const requestConfirmationTool = {
189
188
  return { confirmed };
190
189
  },
191
190
  };
191
+ const requestChoiceTool = {
192
+ name: "request-choice",
193
+ description: [
194
+ "Ask the user a question with a fixed set of answers and let them pick one (e.g. Yes/No).",
195
+ "The request blocks until the user picks an option or aborts the task.",
196
+ 'Response: `{"choice": "<option>"}` with the selected option, or `{"aborted": true}` if the user aborts.',
197
+ "Use this instead of request-input whenever the possible answers are known in advance.",
198
+ ],
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ question: { type: "string", description: "The question to ask" },
203
+ options: {
204
+ type: "array",
205
+ items: { type: "string" },
206
+ description: 'Unique option labels, in display order (e.g. ["Yes", "No"])',
207
+ minItems: 2,
208
+ },
209
+ },
210
+ required: ["question", "options"],
211
+ },
212
+ async handler(args, ctx) {
213
+ const { question, options } = args;
214
+ if (!question)
215
+ throw new ToolError("question is required", 400);
216
+ if (!Array.isArray(options) || options.length < 2 || options.some((o) => typeof o !== "string" || !o.trim())) {
217
+ throw new ToolError("options must contain at least 2 non-empty strings", 400);
218
+ }
219
+ if (new Set(options).size !== options.length)
220
+ throw new ToolError("options must be unique", 400);
221
+ const pendingPromise = registerPending(ctx.sessionId, "choice", options, {
222
+ session_id: ctx.sessionId,
223
+ session_name: ctx.agentName,
224
+ description: question,
225
+ });
226
+ await ctx.publishEvent("_choice", {
227
+ event_type: "choice-request",
228
+ host_id: ctx.config.hostId,
229
+ session_id: ctx.sessionId,
230
+ session_name: ctx.agentName,
231
+ description: question,
232
+ options,
233
+ });
234
+ // Clients answer with the option index so no label can collide with the "aborted" sentinel.
235
+ const response = await pendingPromise;
236
+ const choice = /^\d+$/.test(response[0] ?? "") ? options[Number(response[0])] : undefined;
237
+ const aborted = choice === undefined;
238
+ recordUserResponseToRun(ctx, aborted ? "Aborted" : `**${question}**\n\n${choice}`);
239
+ await ctx.publishEvent("_choice", {
240
+ event_type: "choice-resolved",
241
+ host_id: ctx.config.hostId,
242
+ session_id: ctx.sessionId,
243
+ status: aborted ? "aborted" : "chosen",
244
+ });
245
+ if (aborted) {
246
+ await abortSessionTask(ctx);
247
+ return { aborted: true };
248
+ }
249
+ return { choice };
250
+ },
251
+ };
192
252
  const fillPasswordTool = {
193
253
  name: "fill-password",
194
254
  description: [
@@ -760,7 +820,7 @@ const sendEmailTool = {
760
820
  return result;
761
821
  },
762
822
  };
763
- export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
823
+ export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, requestChoiceTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
764
824
  export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
765
825
  const deviceNotificationsResource = {
766
826
  uri: "notifications://device",
@@ -3,7 +3,7 @@ export interface PendingRequestMeta {
3
3
  /** Doubles as task_id for permission-type entries (the key the task uses). */
4
4
  session_id?: string;
5
5
  /** Human-readable label for whoever opened the prompt — agent name for
6
- * confirm/input, task name for permission. */
6
+ * confirm/input/choice, task name for permission. */
7
7
  session_name?: string;
8
8
  description?: string;
9
9
  input_questions?: string[];
@@ -11,15 +11,15 @@ export interface PendingRequestMeta {
11
11
  secret?: boolean;
12
12
  }
13
13
  export interface PendingRequest {
14
- type: "confirmation" | "permission" | "input";
14
+ type: "confirmation" | "permission" | "input" | "choice";
15
15
  resolve: (value: string[]) => void;
16
- /** Permission list (for 'permission') or input descriptions (for 'input'). */
16
+ /** Permission list (for 'permission'), input descriptions (for 'input'), or option labels (for 'choice'). */
17
17
  params?: RequiredPermission[] | string[];
18
18
  /** Display context for PWAs that connect while this request is already open. */
19
19
  meta?: PendingRequestMeta;
20
20
  }
21
21
  /**
22
- * Key is sessionId for confirmation/input, taskId for permission. Only one
22
+ * Key is sessionId for confirmation/input/choice, taskId for permission. Only one
23
23
  * pending request per key at a time. `meta` is surfaced via host.info so a
24
24
  * freshly-connected PWA can render the modal without replaying events.
25
25
  */
@@ -1,6 +1,6 @@
1
1
  const pending = new Map();
2
2
  /**
3
- * Key is sessionId for confirmation/input, taskId for permission. Only one
3
+ * Key is sessionId for confirmation/input/choice, taskId for permission. Only one
4
4
  * pending request per key at a time. `meta` is surfaced via host.info so a
5
5
  * freshly-connected PWA can render the modal without replaying events.
6
6
  */