pi-session-mail 0.2.2 → 0.4.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.
package/src/tools.ts CHANGED
@@ -24,8 +24,6 @@ import { type Envelope, send } from "./store.ts";
24
24
 
25
25
  /** What the tools need from the extension around them. */
26
26
  export type ToolHooks = {
27
- /** Called after a tool wrote mail, so the footer can count it. */
28
- sent: () => void;
29
27
  /** The current turn's hop count, which mail sent now carries. */
30
28
  hops: () => number;
31
29
  /** Report a broken `session-mail.json`; the extension shows one warning per session. */
@@ -118,11 +116,64 @@ function checkHops(hops: number, hooks: ToolHooks): void {
118
116
  );
119
117
  }
120
118
 
121
- /** The one result every tool returns. */
122
- function toolResult(text: string, details: unknown = {}) {
123
- return { content: [{ type: "text" as const, text }], details };
119
+ /**
120
+ * The one result every tool returns. `structuredContent` is what codemode scripts
121
+ * receive; it must match the tool's `outputSchema`, which Pi does not check.
122
+ */
123
+ function toolResult<D, S>(text: string, details: D, structuredContent: S) {
124
+ return { content: [{ type: "text" as const, text }], details, structuredContent };
124
125
  }
125
126
 
127
+ /** Hints for a tool that adds mail and never deletes or overwrites any, on this machine only. */
128
+ const SENDS_MAIL = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
129
+
130
+ const SessionList = Type.Object({
131
+ sessions: Type.Array(
132
+ Type.Object({
133
+ id: Type.String({ description: "The session's full id; pass it as `to`" }),
134
+ name: Type.Optional(Type.String({ description: "The session name; absent when none is set" })),
135
+ cwd: Type.String(),
136
+ state: Type.Union([Type.Literal("idle"), Type.Literal("busy")]),
137
+ waitingOn: Type.Optional(
138
+ Type.String({ description: "The full id of the session this one's ask waits on; absent when none" }),
139
+ ),
140
+ self: Type.Boolean({ description: "True for the calling session" }),
141
+ }),
142
+ ),
143
+ });
144
+
145
+ const SendResult = Type.Object({
146
+ id: Type.String({ description: "The message's id" }),
147
+ to: Type.String({ description: "The recipient's full id" }),
148
+ running: Type.Boolean({ description: "False when the message waits in a closed session's inbox" }),
149
+ });
150
+
151
+ const asked = {
152
+ id: Type.String({ description: "The ask's id" }),
153
+ to: Type.String({ description: "The asked session's full id" }),
154
+ };
155
+
156
+ /** One variant per {@link AskOutcome}, told apart by `outcome`. */
157
+ const AskResult = Type.Union([
158
+ Type.Object({
159
+ ...asked,
160
+ outcome: Type.Literal("answered"),
161
+ status: Type.String({
162
+ description: '"done" for an answer; any other status means the run ended otherwise, and body is not an answer',
163
+ }),
164
+ body: Type.String(),
165
+ }),
166
+ Type.Object({ ...asked, outcome: Type.Literal("timed-out") }),
167
+ Type.Object({ ...asked, outcome: Type.Literal("stopped-running") }),
168
+ Type.Object({ ...asked, outcome: Type.Literal("stopped-by-user") }),
169
+ ]);
170
+
171
+ const ReplyResult = Type.Object({
172
+ id: Type.String({ description: "The reply's id" }),
173
+ ask: Type.String({ description: "The id of the ask it answers" }),
174
+ to: Type.String({ description: "The asker's full id" }),
175
+ });
176
+
126
177
  /** One running session as the list shows it. */
127
178
  function describe(record: RunningRecord, byAddress: ReadonlyMap<string, RunningRecord>, me: string): string {
128
179
  const waiting = record.waitingOn === "" ? "" : `, waiting on ${waitLabel(record.waitingOn, byAddress)}`;
@@ -170,16 +221,31 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
170
221
  description:
171
222
  "List the Pi sessions running on this machine, in every agent root: each one's name (or short id when it has none), full id, working directory, idle or busy state, and whom it is waiting on. The calling session is marked. A session is addressed by its name, its full id, or an id prefix of at least 8 characters.",
172
223
  parameters: Type.Object({}),
224
+ outputSchema: SessionList,
225
+ // Listing deletes the records of sessions whose process is gone, which is housekeeping, not a change.
226
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
173
227
  async execute(_toolCallId: string, _params: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
174
228
  const me = ctx.sessionManager.getSessionId();
175
229
  const running = listRunning();
176
- if (running.length === 0) return toolResult("No Pi sessions are running with a mailbox.", { sessions: [] });
230
+ if (running.length === 0)
231
+ return toolResult("No Pi sessions are running with a mailbox.", { sessions: [] }, { sessions: [] });
177
232
  const byAddress = new Map(running.map((record) => [record.address, record]));
178
233
  const lines = running.map((record) => describe(record, byAddress, me));
179
234
  const count = `${running.length} running session${running.length === 1 ? "" : "s"}:`;
180
- return toolResult([count, ...lines].join("\n"), {
181
- sessions: running.map((record) => ({ ...record, self: record.address === me })),
182
- });
235
+ return toolResult(
236
+ [count, ...lines].join("\n"),
237
+ { sessions: running.map((record) => ({ ...record, self: record.address === me })) },
238
+ {
239
+ sessions: running.map(({ address, name, cwd, state, waitingOn }) => ({
240
+ id: address,
241
+ ...(name === undefined ? {} : { name }),
242
+ cwd,
243
+ state,
244
+ ...(waitingOn === "" ? {} : { waitingOn }),
245
+ self: address === me,
246
+ })),
247
+ },
248
+ );
183
249
  },
184
250
  });
185
251
 
@@ -196,6 +262,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
196
262
  description: "The message text. The recipient sees it labelled as coming from this session.",
197
263
  }),
198
264
  }),
265
+ outputSchema: SendResult,
266
+ annotations: SENDS_MAIL,
199
267
  async execute(
200
268
  _toolCallId: string,
201
269
  params: { to: string; message: string },
@@ -210,13 +278,13 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
210
278
  const hops = hooks.hops();
211
279
  checkHops(hops, hooks);
212
280
  const envelope = send(me, to, body, { kind: "message", hops });
213
- hooks.sent();
214
281
  const target = listRunning().find((record) => record.address === to);
215
282
  const text =
216
283
  target === undefined
217
284
  ? `Message ${envelope.id} left for ${to}, which is not running: it waits in that session's inbox and is delivered when the session starts or resumes.`
218
285
  : `Sent message ${envelope.id} to ${label(target)} (${to}). It expects no answer; any answer arrives as a message.`;
219
- return toolResult(text, { id: envelope.id, to, running: target !== undefined });
286
+ const result = { id: envelope.id, to, running: target !== undefined };
287
+ return toolResult(text, result, result);
220
288
  },
221
289
  });
222
290
 
@@ -234,6 +302,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
234
302
  "The question. The recipient sees it labelled as coming from this session, with this session waiting.",
235
303
  }),
236
304
  }),
305
+ outputSchema: AskResult,
306
+ annotations: SENDS_MAIL,
237
307
  async execute(
238
308
  _toolCallId: string,
239
309
  params: { to: string; message: string },
@@ -262,7 +332,6 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
262
332
  const hops = hooks.hops();
263
333
  checkHops(hops, hooks);
264
334
  const ask = send(me, to, body, { kind: "ask", hops });
265
- hooks.sent();
266
335
  const outcome = await new Promise<AskOutcome>((resolve) => {
267
336
  const finish = (result: AskOutcome) => {
268
337
  if (waiting?.id !== ask.id) return;
@@ -283,7 +352,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
283
352
  if (signal?.aborted) onAbort();
284
353
  else signal?.addEventListener("abort", onAbort, { once: true });
285
354
  });
286
- return toolResult(askText(ask, target, outcome), { id: ask.id, to, ...outcome });
355
+ const result = { id: ask.id, to, ...outcome };
356
+ return toolResult(askText(ask, target, outcome), result, result);
287
357
  },
288
358
  });
289
359
 
@@ -296,6 +366,9 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
296
366
  ask: Type.String({ description: "The ask id, as given in the ask's label." }),
297
367
  message: Type.String({ description: "The answer." }),
298
368
  }),
369
+ outputSchema: ReplyResult,
370
+ // A repeated reply to the same ask is refused and sends nothing.
371
+ annotations: { ...SENDS_MAIL, idempotentHint: true },
299
372
  async execute(
300
373
  _toolCallId: string,
301
374
  params: { ask: string; message: string },
@@ -307,14 +380,14 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
307
380
  const body = typeof params.message === "string" ? params.message.trim() : "";
308
381
  if (body === "") throw new Error("the answer is empty; nothing was sent");
309
382
  const answer = reply(id, body);
310
- hooks.sent();
311
383
  const sender = listRunning().find((record) => record.address === answer.to);
312
384
  const who = sender === undefined ? answer.to : `${label(sender)} (${answer.to})`;
313
- return toolResult(`Answered ask ${id} from ${who}; this run's last message will not be sent for it.`, {
314
- id: answer.id,
315
- ask: id,
316
- to: answer.to,
317
- });
385
+ const result = { id: answer.id, ask: id, to: answer.to };
386
+ return toolResult(
387
+ `Answered ask ${id} from ${who}; this run's last message will not be sent for it.`,
388
+ result,
389
+ result,
390
+ );
318
391
  },
319
392
  });
320
393