pi-session-mail 0.1.0 → 0.2.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.
- package/CHANGELOG.md +96 -1
- package/README.md +280 -157
- package/package.json +3 -6
- package/src/config.ts +46 -0
- package/{index.ts → src/index.ts} +144 -135
- package/{running.ts → src/running.ts} +118 -10
- package/src/settle.ts +82 -0
- package/src/store.ts +412 -0
- package/{tools.ts → src/tools.ts} +80 -40
- package/store.ts +0 -222
|
@@ -23,10 +23,21 @@
|
|
|
23
23
|
// `pi.events` (below).
|
|
24
24
|
//
|
|
25
25
|
// The hook contracts live in this package's README.
|
|
26
|
-
import { type FSWatcher, watch } from "node:fs";
|
|
26
|
+
import { type FSWatcher, statSync, watch } from "node:fs";
|
|
27
|
+
import { dirname } from "node:path";
|
|
27
28
|
|
|
28
29
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
isRunning,
|
|
32
|
+
label,
|
|
33
|
+
listRunning,
|
|
34
|
+
type RunningRecord,
|
|
35
|
+
removeRecord,
|
|
36
|
+
resolveTo,
|
|
37
|
+
type State,
|
|
38
|
+
writeRecord,
|
|
39
|
+
} from "./running.ts";
|
|
40
|
+
import { type SettleInput, settle } from "./settle.ts";
|
|
30
41
|
import {
|
|
31
42
|
boxPath,
|
|
32
43
|
claim,
|
|
@@ -36,51 +47,45 @@ import {
|
|
|
36
47
|
findSent,
|
|
37
48
|
isAddress,
|
|
38
49
|
listNew,
|
|
50
|
+
pruneClosed,
|
|
39
51
|
readEnvelope,
|
|
40
52
|
type SentCopy,
|
|
41
53
|
send,
|
|
42
54
|
} from "./store.ts";
|
|
43
|
-
import { registerTools, type Tools } from "./tools.ts";
|
|
55
|
+
import { readSettings, registerTools, type Tools } from "./tools.ts";
|
|
44
56
|
|
|
45
57
|
const CUSTOM_TYPE = "mailbox";
|
|
46
58
|
const STATUS_KEY = "mailbox";
|
|
59
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
47
60
|
/** Fallback rescan interval; `fs.watch` on macOS drops and coalesces events. */
|
|
48
61
|
const POLL_MS = 1000;
|
|
49
62
|
/** Inbound bodies are cut at this many UTF-8 bytes. */
|
|
50
63
|
const BODY_CAP = 32 * 1024;
|
|
51
64
|
/** Each request quoted in a reply is cut at this many UTF-8 bytes. */
|
|
52
65
|
const QUOTE_CAP = 2 * 1024;
|
|
53
|
-
/** Reply body when the run ended with no assistant text. */
|
|
54
|
-
const NO_ANSWER = "(The session settled with no answer text.)";
|
|
55
|
-
/** Reply body of a run the user stopped, with any partial answer text after it. */
|
|
56
|
-
const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
|
|
57
|
-
/** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
|
|
58
|
-
const TOOK_OVER =
|
|
59
|
-
"(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
|
|
60
|
-
/** Opening line of the reply body of a run that ended on an error. */
|
|
61
|
-
const ERRORED = (error: string) =>
|
|
62
|
-
`(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
|
|
63
|
-
/** Body of a `failed` reply to requests that never entered the conversation. */
|
|
64
|
-
const UNSEEN =
|
|
65
|
-
"(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
|
|
66
|
-
|
|
67
66
|
/** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
|
|
68
67
|
function cap(text: string, max: number): string | undefined {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
|
|
73
|
-
return bytes.subarray(0, end).toString("utf8");
|
|
68
|
+
// `encodeInto` stops before a character that does not fit, so the cut is on a boundary.
|
|
69
|
+
const { read } = new TextEncoder().encodeInto(text, new Uint8Array(max));
|
|
70
|
+
return read === text.length ? undefined : text.slice(0, read);
|
|
74
71
|
}
|
|
75
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Text from outside this session (an id, a name, a working directory) with every
|
|
75
|
+
* control character (C0, DEL, C1) and the line and paragraph separators
|
|
76
|
+
* U+2028/U+2029 replaced by a space, so it cannot start a line of its own and
|
|
77
|
+
* pass for a `[mailbox]` line.
|
|
78
|
+
*/
|
|
79
|
+
const plain = (text: string): string => text.replace(/[\p{Cc}\u2028\u2029]/gu, " ");
|
|
80
|
+
|
|
76
81
|
/** A reply quotes each request it answers: the id, the copy's path, then the capped body. */
|
|
77
82
|
function quoteRequest(request: SentCopy): string {
|
|
78
83
|
const cut = cap(request.envelope.body, QUOTE_CAP);
|
|
79
84
|
const body =
|
|
80
85
|
cut === undefined
|
|
81
86
|
? request.envelope.body
|
|
82
|
-
: `${cut}\n[mailbox] Request cut at 2 KiB; the full copy is ${request.path}`;
|
|
83
|
-
return `[mailbox] Your request ${request.envelope.id}, quoted from ${request.path}:\n${body
|
|
87
|
+
: `${cut}\n[mailbox] Request cut at 2 KiB; the full copy is ${plain(request.path)}`;
|
|
88
|
+
return `[mailbox] Your request ${plain(request.envelope.id)}, quoted from ${plain(request.path)}:\n${body
|
|
84
89
|
.split("\n")
|
|
85
90
|
.map((line) => `> ${line}`)
|
|
86
91
|
.join("\n")}`;
|
|
@@ -93,10 +98,11 @@ function quoteRequest(request: SentCopy): string {
|
|
|
93
98
|
function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy[]; quotes: string[] } {
|
|
94
99
|
const requests: SentCopy[] = [];
|
|
95
100
|
const quotes: string[] = [];
|
|
101
|
+
const copies = findSent(me, ids);
|
|
96
102
|
for (const id of ids) {
|
|
97
|
-
const copy =
|
|
103
|
+
const copy = copies.get(id);
|
|
98
104
|
if (copy === undefined) {
|
|
99
|
-
quotes.push(`[mailbox] Your request ${id} has no copy in sent/; only its id is known.`);
|
|
105
|
+
quotes.push(`[mailbox] Your request ${plain(id)} has no copy in sent/; only its id is known.`);
|
|
100
106
|
continue;
|
|
101
107
|
}
|
|
102
108
|
requests.push(copy);
|
|
@@ -111,10 +117,9 @@ function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy
|
|
|
111
117
|
* directory. No distrust wording: with it, models refused every peer request
|
|
112
118
|
* as a possible injection (spec Q37). Safety lives outside the model.
|
|
113
119
|
*/
|
|
114
|
-
function senderLine(address: string): { line: string; name: string } {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const name = label(record ?? { address });
|
|
120
|
+
function senderLine(address: string, record: RunningRecord | undefined): { line: string; name: string } {
|
|
121
|
+
const where = record === undefined ? "" : `, working in ${plain(record.cwd)}`;
|
|
122
|
+
const name = plain(label(record ?? { address }));
|
|
118
123
|
return { line: `[mailbox] From ${name} (${address}${where}), another Pi session on this machine.`, name };
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -135,29 +140,30 @@ function inboundText(
|
|
|
135
140
|
path: string,
|
|
136
141
|
quotes: readonly string[],
|
|
137
142
|
opts: { lateAnswer: boolean; byUser: boolean },
|
|
143
|
+
senderRecord: RunningRecord | undefined,
|
|
138
144
|
): string {
|
|
139
145
|
const { lateAnswer, byUser } = opts;
|
|
140
|
-
const sender = senderLine(envelope.from);
|
|
146
|
+
const sender = senderLine(envelope.from, senderRecord);
|
|
147
|
+
const id = plain(envelope.id);
|
|
148
|
+
const replyIds = envelope.in_reply_to.map(plain);
|
|
141
149
|
const header = [sender.line];
|
|
142
150
|
if (envelope.kind === "request") header.push("Your final answer this turn goes back to it automatically.");
|
|
143
151
|
if (envelope.kind === "ask")
|
|
144
152
|
header.push(
|
|
145
|
-
`It is waiting for your answer to its ask ${
|
|
153
|
+
`It is waiting for your answer to its ask ${id}. Answer with session_mail_reply (ask ${id}); otherwise this run's last message is sent as the answer.`,
|
|
146
154
|
);
|
|
147
155
|
if (lateAnswer) header.push("It answers an ask of yours that has stopped waiting, so it arrives as a message.");
|
|
148
156
|
if (envelope.kind === "message")
|
|
149
157
|
header.push(`It expects no answer; if one is wanted, send it with session_mail_send to ${envelope.from}.`);
|
|
150
158
|
if (envelope.in_reply_to.length > 0)
|
|
151
|
-
header.push(
|
|
152
|
-
`It is a reply to your request${envelope.in_reply_to.length > 1 ? "s" : ""} ${envelope.in_reply_to.join(", ")}.`,
|
|
153
|
-
);
|
|
159
|
+
header.push(`It is a reply to your request${envelope.in_reply_to.length > 1 ? "s" : ""} ${replyIds.join(", ")}.`);
|
|
154
160
|
if (byUser)
|
|
155
161
|
header.push("The user made that request, typing it with /mailbox or through an extension such as delegate.");
|
|
156
162
|
if (envelope.in_reply_to.length > 0 && envelope.status !== "done")
|
|
157
|
-
header.push(`Its status is "${envelope.status}", not "done": it is not an answer.`);
|
|
163
|
+
header.push(`Its status is "${plain(String(envelope.status))}", not "done": it is not an answer.`);
|
|
158
164
|
const cut = cap(envelope.body, BODY_CAP);
|
|
159
165
|
const body =
|
|
160
|
-
cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${path}`;
|
|
166
|
+
cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${plain(path)}`;
|
|
161
167
|
return [header.join(" "), ...quotes, body, endLine(sender.name)].join("\n\n");
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -166,8 +172,9 @@ function inboundText(
|
|
|
166
172
|
* synchronous code before `emit` returns; do all work before any `await`.
|
|
167
173
|
*
|
|
168
174
|
* - `message:send`: the caller emits `{ to, body }`; `pi-session-mail` writes a
|
|
169
|
-
* request
|
|
170
|
-
*
|
|
175
|
+
* request, stamped with the run's hop count during a run and 0 when idle,
|
|
176
|
+
* and sets `envelope` (or `error`) on the same object. Neither set means no
|
|
177
|
+
* provider is installed.
|
|
171
178
|
* - `message:inbound`: emitted for every claimed envelope, requests and
|
|
172
179
|
* replies alike, before injection; `requests` holds a reply's request
|
|
173
180
|
* copies from this session's `sent/`, and a listener sets `handled` to show
|
|
@@ -185,22 +192,15 @@ type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMe
|
|
|
185
192
|
|
|
186
193
|
/** The last assistant message in a run's transcript, if any. */
|
|
187
194
|
function lastAssistant(messages: readonly unknown[]): Message | undefined {
|
|
188
|
-
|
|
189
|
-
const message = messages[i] as Message;
|
|
190
|
-
if (message?.role === "assistant") return message;
|
|
191
|
-
}
|
|
192
|
-
return undefined;
|
|
195
|
+
return (messages as Message[]).findLast((message) => message?.role === "assistant");
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
/** The text of the last assistant message that has any; undefined when none has. */
|
|
196
199
|
function lastAssistantText(messages: readonly unknown[]): string | undefined {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (text !== undefined) return text;
|
|
202
|
-
}
|
|
203
|
-
return undefined;
|
|
200
|
+
const message = (messages as Message[]).findLast(
|
|
201
|
+
(candidate) => candidate?.role === "assistant" && assistantText(candidate) !== undefined,
|
|
202
|
+
);
|
|
203
|
+
return message === undefined ? undefined : assistantText(message);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/** The text of an assistant message; undefined when it has none. */
|
|
@@ -227,12 +227,8 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
227
227
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
228
228
|
/** Envelope ids already handed to Pi in this process. */
|
|
229
229
|
const delivered = new Set<string>();
|
|
230
|
-
/** The kind of each envelope this session has claimed, by id, so a reply can say why it is refused. */
|
|
231
|
-
const receivedKinds = new Map<string, Envelope["kind"]>();
|
|
232
230
|
/** Request ids awaiting this session's reply, keyed by sender. In memory only. */
|
|
233
231
|
let owed = new Map<string, string[]>();
|
|
234
|
-
/** Ask ids awaiting this session's answer, keyed by sender; answered apart from requests. */
|
|
235
|
-
let owedAsks = new Map<string, string[]>();
|
|
236
232
|
/** Owed request ids whose message entered the conversation (or a listener took over). */
|
|
237
233
|
let seen = new Set<string>();
|
|
238
234
|
/**
|
|
@@ -254,8 +250,8 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
254
250
|
* steered into it. Mail sent from the turn carries it.
|
|
255
251
|
*/
|
|
256
252
|
let hops = 0;
|
|
257
|
-
/**
|
|
258
|
-
let configWarned =
|
|
253
|
+
/** The problems with `session-mail.json` this session has already warned about. */
|
|
254
|
+
let configWarned = new Set<string>();
|
|
259
255
|
/** What this session's running-session record says. */
|
|
260
256
|
let name: string | undefined;
|
|
261
257
|
let cwd = "";
|
|
@@ -285,6 +281,19 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
285
281
|
|
|
286
282
|
const warn = (message: string) => ctx?.ui.notify(`mailbox: ${message}`, "warning");
|
|
287
283
|
|
|
284
|
+
/** The address folder is left as found; tell the user how to tighten it. */
|
|
285
|
+
function warnIfLoose(me: string) {
|
|
286
|
+
const folder = dirname(boxPath(me, "new"));
|
|
287
|
+
let mode: number;
|
|
288
|
+
try {
|
|
289
|
+
mode = statSync(folder).mode & 0o777;
|
|
290
|
+
} catch {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if ((mode & 0o077) === 0) return;
|
|
294
|
+
warn(`${folder} is mode ${mode.toString(8)}, open to other users; run: chmod 700 ${folder}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
/** Footer entry `✉ N pending · N read · N awaiting`, non-zero parts only; hidden when all are zero. */
|
|
289
298
|
function updateStatus() {
|
|
290
299
|
if (!ctx?.hasUI || address === undefined) return;
|
|
@@ -315,7 +324,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
315
324
|
|
|
316
325
|
function deliver(me: string, envelope: Envelope, path: string) {
|
|
317
326
|
delivered.add(envelope.id);
|
|
318
|
-
|
|
327
|
+
tools?.recordKind(envelope);
|
|
319
328
|
// The answer to a waiting ask is that tool call's result, and nothing else.
|
|
320
329
|
if (tools?.takeAnswer(envelope)) return;
|
|
321
330
|
const { requests, quotes } = requestQuotes(me, envelope.in_reply_to);
|
|
@@ -329,7 +338,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
329
338
|
// Only requests arm an answer: replies are never answered, and messages
|
|
330
339
|
// expect none. A request arms one even when a listener took over its display.
|
|
331
340
|
if (envelope.kind === "request") owed.set(envelope.from, [...(owed.get(envelope.from) ?? []), envelope.id]);
|
|
332
|
-
|
|
341
|
+
tools?.armAsk(envelope);
|
|
333
342
|
// Mail that wakes or steers the session raises its count; a reply shown
|
|
334
343
|
// quietly starts no turn, but one a listener took over may.
|
|
335
344
|
if (wakes || inbound.handled) hops = Math.max(hops, envelope.hops + 1);
|
|
@@ -342,7 +351,13 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
342
351
|
pi.sendMessage(
|
|
343
352
|
{
|
|
344
353
|
customType: CUSTOM_TYPE,
|
|
345
|
-
content: inboundText(
|
|
354
|
+
content: inboundText(
|
|
355
|
+
envelope,
|
|
356
|
+
path,
|
|
357
|
+
quotes,
|
|
358
|
+
{ lateAnswer, byUser },
|
|
359
|
+
listRunning().find((r) => r.address === envelope.from),
|
|
360
|
+
),
|
|
346
361
|
display: true,
|
|
347
362
|
details: { id: envelope.id },
|
|
348
363
|
},
|
|
@@ -382,8 +397,11 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
382
397
|
try {
|
|
383
398
|
if (address === undefined) throw new Error("no active session has a mailbox address");
|
|
384
399
|
if (typeof payload.body !== "string") throw new Error("body must be a string");
|
|
385
|
-
//
|
|
386
|
-
|
|
400
|
+
// During a run the caller is the model acting through an extension, so the
|
|
401
|
+
// request continues the run's chain; idle, it acts for the user and starts a
|
|
402
|
+
// fresh one. It is never refused here: the receiving side enforces the limit.
|
|
403
|
+
const hopsNow = state === "busy" ? hops : 0;
|
|
404
|
+
payload.envelope = send(address, payload.to as string, payload.body, { kind: "request", hops: hopsNow });
|
|
387
405
|
updateStatus();
|
|
388
406
|
} catch (err) {
|
|
389
407
|
payload.error = (err as Error).message;
|
|
@@ -399,11 +417,27 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
399
417
|
payload.scanned = true;
|
|
400
418
|
});
|
|
401
419
|
|
|
420
|
+
/** Warn about a problem with `session-mail.json`, once per session. */
|
|
421
|
+
function configProblem(message: string) {
|
|
422
|
+
if (configWarned.has(message)) return;
|
|
423
|
+
configWarned.add(message);
|
|
424
|
+
warn(message);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Remove closed sessions' folders that are older than `pruneAfterDays`; a failure warns and never throws. */
|
|
428
|
+
function pruneOld(me: string) {
|
|
429
|
+
try {
|
|
430
|
+
const { pruneAfterDays } = readSettings(configProblem);
|
|
431
|
+
pruneClosed({ own: me, isRunning, cutoffMs: Date.now() - pruneAfterDays * DAY_MS });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
warn(`could not prune old mailbox folders: ${(err as Error).message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
402
437
|
pi.on("session_start", async (_event, context) => {
|
|
403
438
|
stop();
|
|
404
439
|
ctx = context;
|
|
405
440
|
owed = new Map();
|
|
406
|
-
owedAsks = new Map();
|
|
407
441
|
seen = new Set();
|
|
408
442
|
held = new Set();
|
|
409
443
|
lastAnswer = undefined;
|
|
@@ -413,15 +447,25 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
413
447
|
hops = 0;
|
|
414
448
|
tools?.abandonAsk();
|
|
415
449
|
waitingOn = "";
|
|
416
|
-
configWarned =
|
|
450
|
+
configWarned = new Set();
|
|
417
451
|
const id = context.sessionManager.getSessionId();
|
|
418
452
|
if (!isAddress(id)) {
|
|
419
453
|
address = undefined;
|
|
454
|
+
tools?.setAddress(undefined);
|
|
420
455
|
warn(`session id ${JSON.stringify(id)} is not a usable address; the mailbox is off`);
|
|
421
456
|
return;
|
|
422
457
|
}
|
|
458
|
+
try {
|
|
459
|
+
ensureBoxes(id);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
address = undefined;
|
|
462
|
+
tools?.setAddress(undefined);
|
|
463
|
+
warn(`mailbox is off: ${(err as Error).message}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
423
466
|
address = id;
|
|
424
|
-
|
|
467
|
+
tools?.setAddress(id);
|
|
468
|
+
warnIfLoose(id);
|
|
425
469
|
name = context.sessionManager.getSessionName?.() || undefined;
|
|
426
470
|
cwd = context.cwd;
|
|
427
471
|
state = context.isIdle?.() === false ? "busy" : "idle";
|
|
@@ -435,6 +479,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
435
479
|
pendingScan = setImmediate(() => {
|
|
436
480
|
pendingScan = undefined;
|
|
437
481
|
scan();
|
|
482
|
+
pruneOld(id);
|
|
438
483
|
});
|
|
439
484
|
pendingScan.unref?.();
|
|
440
485
|
try {
|
|
@@ -499,52 +544,25 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
499
544
|
// queued follow-up after `agent_end` would otherwise get a premature answer.
|
|
500
545
|
pi.on("agent_settled", async () => {
|
|
501
546
|
const me = address;
|
|
502
|
-
// A stopped or errored run notes how it ended, then gives what it had.
|
|
503
|
-
const opening = lastAborted ? STOPPED : lastError !== undefined ? ERRORED(lastError) : undefined;
|
|
504
|
-
const body =
|
|
505
|
-
opening === undefined
|
|
506
|
-
? (lastAnswer ?? NO_ANSWER)
|
|
507
|
-
: lastAnswer === undefined
|
|
508
|
-
? opening
|
|
509
|
-
: `${opening}\n\n${lastAnswer}`;
|
|
510
|
-
const status = lastAborted ? "stopped" : lastError !== undefined ? "failed" : "done";
|
|
511
|
-
// A request held across a stop is answered by the run that completes it, saying the user took over.
|
|
512
|
-
const tookOverBody = `${TOOK_OVER}\n\n${lastAnswer ?? NO_ANSWER}`;
|
|
513
547
|
// Answers carry the settled turn's count; the next turn counts afresh.
|
|
514
548
|
const answerHops = hops;
|
|
515
549
|
hops = 0;
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
550
|
+
const end: SettleInput["end"] = lastAborted
|
|
551
|
+
? { kind: "stopped" }
|
|
552
|
+
: lastError !== undefined
|
|
553
|
+
? { kind: "failed", error: lastError }
|
|
554
|
+
: { kind: "done" };
|
|
555
|
+
const decision = settle({
|
|
556
|
+
requests: owed,
|
|
557
|
+
asks: tools?.takeOwedAsks() ?? new Map(),
|
|
558
|
+
seen,
|
|
559
|
+
held,
|
|
560
|
+
end,
|
|
561
|
+
answer: lastAnswer,
|
|
562
|
+
});
|
|
563
|
+
owed = decision.owed;
|
|
564
|
+
held = decision.held;
|
|
525
565
|
seen = new Set();
|
|
526
|
-
held = new Set();
|
|
527
|
-
const replies: { to: string; ids: string[]; body: string; status: Envelope["status"] }[] = [];
|
|
528
|
-
for (const [to, ids] of requests) {
|
|
529
|
-
const unseen = ids.filter((id) => !read.has(id) && !wasHeld.has(id));
|
|
530
|
-
const keep = ids.filter(
|
|
531
|
-
(id) => !unseen.includes(id) && (lastAborted || (lastError !== undefined && wasHeld.has(id))),
|
|
532
|
-
);
|
|
533
|
-
const answer = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
|
|
534
|
-
if (keep.length > 0) owed.set(to, keep);
|
|
535
|
-
for (const id of keep) held.add(id);
|
|
536
|
-
const plain = answer.filter((id) => !wasHeld.has(id));
|
|
537
|
-
const tookOver = answer.filter((id) => wasHeld.has(id));
|
|
538
|
-
if (plain.length > 0) replies.push({ to, ids: plain, body, status });
|
|
539
|
-
if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
|
|
540
|
-
if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
|
|
541
|
-
}
|
|
542
|
-
for (const [to, ids] of asks) {
|
|
543
|
-
const done = ids.filter((id) => read.has(id));
|
|
544
|
-
const failed = ids.filter((id) => !read.has(id));
|
|
545
|
-
if (done.length > 0) replies.push({ to, ids: done, body, status });
|
|
546
|
-
if (failed.length > 0) replies.push({ to, ids: failed, body: UNSEEN, status: "failed" });
|
|
547
|
-
}
|
|
548
566
|
lastAnswer = undefined;
|
|
549
567
|
lastAborted = false;
|
|
550
568
|
lastError = undefined;
|
|
@@ -552,7 +570,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
552
570
|
state = "idle";
|
|
553
571
|
announce();
|
|
554
572
|
if (me === undefined) return;
|
|
555
|
-
for (const reply of replies) {
|
|
573
|
+
for (const reply of decision.replies) {
|
|
556
574
|
const { to } = reply;
|
|
557
575
|
try {
|
|
558
576
|
// Answers are never refused by the hop limit.
|
|
@@ -575,6 +593,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
575
593
|
}
|
|
576
594
|
}
|
|
577
595
|
address = undefined;
|
|
596
|
+
tools?.setAddress(undefined);
|
|
578
597
|
});
|
|
579
598
|
|
|
580
599
|
tools = registerTools(pi, {
|
|
@@ -583,38 +602,19 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
583
602
|
waitingOn = to;
|
|
584
603
|
announce();
|
|
585
604
|
},
|
|
586
|
-
reply: (id, body) => {
|
|
587
|
-
const me = address;
|
|
588
|
-
if (me === undefined) throw new Error("this session has no mailbox address; nothing was sent");
|
|
589
|
-
const kind = receivedKinds.get(id);
|
|
590
|
-
if (kind === undefined)
|
|
591
|
-
throw new Error(`no mail with id ${JSON.stringify(id)} reached this session; nothing was sent`);
|
|
592
|
-
if (kind !== "ask")
|
|
593
|
-
throw new Error(
|
|
594
|
-
`${id} is a ${kind}, not an ask; nothing was sent. ${kind === "request" ? "A request is answered automatically when this run settles." : kind === "message" ? "A message expects no answer; session_mail_send sends one if it is wanted." : "A reply is never answered."}`,
|
|
595
|
-
);
|
|
596
|
-
const from = [...owedAsks].find(([, ids]) => ids.includes(id))?.[0];
|
|
597
|
-
// An answered ask, by this tool or at settle, is no longer owed.
|
|
598
|
-
if (from === undefined) throw new Error(`ask ${id} is already answered; nothing was sent`);
|
|
599
|
-
// Answers carry the turn's count and are never refused by the hop limit.
|
|
600
|
-
const answer = send(me, from, body, { kind: "reply", hops, inReplyTo: [id], status: "done" });
|
|
601
|
-
const rest = (owedAsks.get(from) ?? []).filter((owedId) => owedId !== id);
|
|
602
|
-
if (rest.length > 0) owedAsks.set(from, rest);
|
|
603
|
-
else owedAsks.delete(from);
|
|
604
|
-
return answer;
|
|
605
|
-
},
|
|
606
605
|
hops: () => hops,
|
|
607
|
-
configProblem
|
|
608
|
-
if (configWarned) return;
|
|
609
|
-
configWarned = true;
|
|
610
|
-
warn(message);
|
|
611
|
-
},
|
|
606
|
+
configProblem,
|
|
612
607
|
});
|
|
613
608
|
|
|
614
609
|
pi.registerCommand("mailbox", {
|
|
615
|
-
description:
|
|
610
|
+
description:
|
|
611
|
+
"Show this session's mailbox address and name, send a request: /mailbox <name or id> <text>, or remove closed sessions' empty mailbox folders: /mailbox prune",
|
|
616
612
|
handler: async (args, context) => {
|
|
617
|
-
const me =
|
|
613
|
+
const me = address;
|
|
614
|
+
if (me === undefined) {
|
|
615
|
+
context.ui.notify("mailbox: this session has no mailbox address; the mailbox is off", "error");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
618
|
const trimmed = args.trim();
|
|
619
619
|
if (trimmed === "") {
|
|
620
620
|
const current = context.sessionManager.getSessionName?.() || undefined;
|
|
@@ -624,6 +624,15 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
624
624
|
);
|
|
625
625
|
return;
|
|
626
626
|
}
|
|
627
|
+
if (trimmed === "prune") {
|
|
628
|
+
try {
|
|
629
|
+
const removed = pruneClosed({ own: me, isRunning });
|
|
630
|
+
context.ui.notify(`Removed ${removed} mailbox folders of closed sessions`, "info");
|
|
631
|
+
} catch (err) {
|
|
632
|
+
context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
627
636
|
const match = /^(\S+)\s+([\s\S]+)$/.exec(trimmed);
|
|
628
637
|
if (!match) {
|
|
629
638
|
context.ui.notify("Usage: /mailbox <name or id> <text>", "error");
|