pi-session-mail 0.5.0 → 0.6.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/CHANGELOG.md +54 -1
- package/README.md +170 -104
- package/package.json +1 -1
- package/src/config.ts +14 -17
- package/src/index.ts +66 -70
- package/src/settle.ts +15 -48
- package/src/store.ts +5 -13
- package/src/tools.ts +12 -32
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,58 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-10-31
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The `message:reply` hook: an extension that took over a request answers it
|
|
14
|
+
by emitting `{ to, inReplyTo, body, status }`. `pi-session-mail` writes a
|
|
15
|
+
reply from the current session (the caller cannot set `from`) with that
|
|
16
|
+
body and status exactly as given, at once, without waiting for the run to
|
|
17
|
+
settle or using the model's answer, and sets `envelope` or `error` on the
|
|
18
|
+
payload before `emit` returns, as `message:send` does. `status` is
|
|
19
|
+
required and is `done`, `stopped` or `failed`; `inReplyTo` is a non-empty
|
|
20
|
+
list of at most 50 non-empty ids, checked for shape only and not looked up
|
|
21
|
+
in the mailbox; `body` is any string; `to` is checked as `message:send`
|
|
22
|
+
checks it. There is no duplicate guard: every valid call writes a reply.
|
|
23
|
+
With no provider installed, neither field is set. The reply travels like any
|
|
24
|
+
other: it waits in a closed sender's inbox, and the sender gets it through
|
|
25
|
+
`message:inbound` with its request copies. Asks are still answered with
|
|
26
|
+
`session_mail_reply` or at settle. The README documents the hook with a
|
|
27
|
+
runnable request-to-reply example. It is how an extension answers a request
|
|
28
|
+
now that requests get no automatic answer (see the second **Breaking** entry
|
|
29
|
+
under Removed).
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
|
|
33
|
+
- **Breaking:** the hop limit is gone entirely. There is no per-turn hop
|
|
34
|
+
count, `hops` is no longer written to new envelopes or part of the
|
|
35
|
+
supported envelope, `session_mail_send` and `session_mail_ask` are never
|
|
36
|
+
refused because of hops, and the `hopLimit` setting in `session-mail.json`
|
|
37
|
+
has no effect (no migration; `pruneAfterDays` is unchanged). No other loop
|
|
38
|
+
guard replaces it: sessions can keep waking each other, which is accepted.
|
|
39
|
+
An old stored envelope with a `hops` property is still read and delivered;
|
|
40
|
+
the property is ignored. The test that pinned the old behaviour changed with
|
|
41
|
+
it.
|
|
42
|
+
- **Breaking:** a request (mail another extension sends through
|
|
43
|
+
`message:send`) no longer gets an automatic answer. The receiving session
|
|
44
|
+
sends no reply when the run settles, whether a `message:inbound` listener
|
|
45
|
+
took the request over or not, and whether the run completed, stopped or
|
|
46
|
+
failed or never read the request; a request is no longer held across a stop
|
|
47
|
+
and answered "took over" later. The extension that consumes a request now
|
|
48
|
+
owns its reply. A request is still stored, delivered and woken on as before,
|
|
49
|
+
keeps its `sent/` copy and shows in `/mailbox`. Its label no longer says the
|
|
50
|
+
final answer goes back automatically, and `session_mail_reply` now refuses a
|
|
51
|
+
request saying the extension that consumes it answers it. Asks are
|
|
52
|
+
unchanged. pi-squire's `delegate` relied on the automatic answer, so this
|
|
53
|
+
release and pi-squire must be upgraded together: the matching pi-squire
|
|
54
|
+
release sends its replies with `message:reply`. There is no version check,
|
|
55
|
+
capability negotiation or fallback answering, and mixing this release with
|
|
56
|
+
another pi-squire release is unsupported, with no particular failure
|
|
57
|
+
promised. The tests that pinned automatic request answers
|
|
58
|
+
changed with it: they now pin that no answer is sent, and the reply-delivery
|
|
59
|
+
tests write their replies directly.
|
|
60
|
+
|
|
9
61
|
## [0.5.0] - 2026-10-23
|
|
10
62
|
|
|
11
63
|
### Changed
|
|
@@ -188,7 +240,8 @@ author's dotfiles; the changes below are against that copy.
|
|
|
188
240
|
- CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
|
|
189
241
|
published file list.
|
|
190
242
|
|
|
191
|
-
[Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.
|
|
243
|
+
[Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.6.0...HEAD
|
|
244
|
+
[0.6.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.5.0...v0.6.0
|
|
192
245
|
[0.5.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.4.0...v0.5.0
|
|
193
246
|
[0.4.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...v0.4.0
|
|
194
247
|
[0.3.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.2...v0.3.0
|
package/README.md
CHANGED
|
@@ -5,8 +5,10 @@ A file mailbox between Pi sessions on the same machine.
|
|
|
5
5
|
Each Pi session gets an address and an inbox. You send a message to another
|
|
6
6
|
running session with `/mailbox:send`, and the model lists, asks, answers and
|
|
7
7
|
sends through four `session_mail_*` tools. Mail waits on disk until the
|
|
8
|
-
recipient starts or resumes
|
|
9
|
-
|
|
8
|
+
recipient starts or resumes. An ask's answer goes back to its sender
|
|
9
|
+
automatically; a request another extension sends through `message:send` gets
|
|
10
|
+
no automatic answer, because the extension that consumes it owns its reply and
|
|
11
|
+
sends it through `message:reply`.
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
@@ -60,9 +62,9 @@ and then typing the text:
|
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
The sender sees `Sent <id> to <address>`. A message wakes the recipient's model
|
|
63
|
-
and expects no answer. The model sends
|
|
64
|
-
`session_mail_*` tools, and
|
|
65
|
-
|
|
65
|
+
and expects no answer. The model sends asks, messages and answers through the
|
|
66
|
+
`session_mail_*` tools, and an ask's last answer comes back to the sender as a
|
|
67
|
+
reply when that run settles.
|
|
66
68
|
|
|
67
69
|
The footer shows `📬 N unread` while mail has reached this session but not yet
|
|
68
70
|
entered its conversation, such as mail queued behind a busy run; it is hidden
|
|
@@ -95,18 +97,18 @@ quotes each request it answers, capped at 2 KiB each with the `sent/` copy's
|
|
|
95
97
|
path, or the request's id alone when this session has no copy.
|
|
96
98
|
|
|
97
99
|
When the recipient's agent settles, its last answer goes back to each sender as
|
|
98
|
-
one reply listing
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
one reply listing that sender's asks not already answered with
|
|
101
|
+
`session_mail_reply`. The status is `done` normally, `stopped` when the user
|
|
102
|
+
stopped the run (with the partial text), or `failed` when the run ended on an
|
|
103
|
+
error (with the error and the partial text). An ask the session was stopped
|
|
104
|
+
before reading gets a `failed` reply instead. The answer to an ask its sender
|
|
105
|
+
is still waiting on becomes that sender's `session_mail_ask` result.
|
|
106
|
+
|
|
107
|
+
A request gets no automatic answer: not when its run completes, stops or
|
|
108
|
+
fails, not when the session never read it, and not when a `message:inbound`
|
|
109
|
+
listener took it over. The extension that consumes it owns its reply, and
|
|
110
|
+
sends it with the [`message:reply`](#messagereply) hook. Replies
|
|
111
|
+
and messages are never answered.
|
|
110
112
|
|
|
111
113
|
## Running sessions
|
|
112
114
|
|
|
@@ -131,9 +133,9 @@ The model gets four tools:
|
|
|
131
133
|
| Tool | Parameters | What it does |
|
|
132
134
|
| --- | --- | --- |
|
|
133
135
|
| `session_mail_list` | none | Lists every running session, in every agent directory: its name (or short id, the first 8 characters of its id, when it has no name), full id, working directory, idle or busy state and whom it is waiting on, and marks the calling session. |
|
|
134
|
-
| `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`). In a TUI session without `PI_UNATTENDED=1` it returns at once with outcome `background`, sets no `waitingOn`, and the answer arrives as a message. Everywhere else (print, JSON and RPC modes, and a TUI marked unattended, such as a pi-squire delegate) it waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session,
|
|
135
|
-
| `session_mail_reply` | `ask`, `message` | Answers one open ask this session received, at once, with status `done
|
|
136
|
-
| `session_mail_send` | `to`, `message` | Writes a message (`kind: "message"`) and returns its id. To a running session it is delivered at once; to a closed session, by full id, it waits in that session's inbox and the result says so. Refused when `to` does not resolve, is ambiguous or is this session, when the text is empty
|
|
136
|
+
| `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`). In a TUI session without `PI_UNATTENDED=1` it returns at once with outcome `background`, sets no `waitingOn`, and the answer arrives as a message. Everywhere else (print, JSON and RPC modes, and a TUI marked unattended, such as a pi-squire delegate) it waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, and when `to` does not resolve, is ambiguous or is this session. |
|
|
137
|
+
| `session_mail_reply` | `ask`, `message` | Answers one open ask this session received, at once, with status `done`, without ending the run; the asker's `session_mail_ask` returns with it. The answer at settle then leaves that ask out, while the sender's other asks are still answered. Refused, sending nothing, for an ask already answered (by this tool or at settle), for a request (the extension that consumes it answers it), a message or a reply, for an id that never reached this session, and for an empty text. |
|
|
138
|
+
| `session_mail_send` | `to`, `message` | Writes a message (`kind: "message"`) and returns its id. To a running session it is delivered at once; to a closed session, by full id, it waits in that session's inbox and the result says so. Refused when `to` does not resolve, is ambiguous or is this session, and when the text is empty. |
|
|
137
139
|
|
|
138
140
|
## Addressing
|
|
139
141
|
|
|
@@ -155,10 +157,10 @@ A `to` given to a tool resolves as follows:
|
|
|
155
157
|
|
|
156
158
|
| Kind | Written by | Delivered as | Answered |
|
|
157
159
|
| --- | --- | --- | --- |
|
|
158
|
-
| `request` | `message:send` | `triggerTurn`, `deliverAs: "steer"` |
|
|
159
|
-
| `ask` | `session_mail_ask` | `triggerTurn`, `deliverAs: "steer"` | yes, when the run settles |
|
|
160
|
+
| `request` | `message:send` | `triggerTurn`, `deliverAs: "steer"` | never automatically; the extension that consumes it replies |
|
|
161
|
+
| `ask` | `session_mail_ask` | `triggerTurn`, `deliverAs: "steer"` | yes, with `session_mail_reply` or when the run settles |
|
|
160
162
|
| `message` | `session_mail_send`, `/mailbox:send` | `triggerTurn`, `deliverAs: "steer"` | never |
|
|
161
|
-
| `reply` | the answering session | `deliverAs: "followUp"`, no turn; see below for answers to asks | never |
|
|
163
|
+
| `reply` | the answering session: `session_mail_reply` or settle for an ask, `message:reply` for a request | `deliverAs: "followUp"`, no turn; see below for answers to asks | never |
|
|
162
164
|
|
|
163
165
|
Mail for the model wakes an idle session, and reaches a busy one at its next
|
|
164
166
|
gap between tool calls rather than after the run. It is never held back while
|
|
@@ -172,8 +174,8 @@ ask that is not waiting (a background ask, or one that timed out, that the
|
|
|
172
174
|
user stopped, or whose session restarted) is delivered like a message: it
|
|
173
175
|
starts a turn, and its label says the ask is not waiting for it. An
|
|
174
176
|
unattended session waits rather than asking in the background because ending
|
|
175
|
-
its turn ends its work:
|
|
176
|
-
|
|
177
|
+
its turn ends its work: print mode exits, and an unattended TUI has no person
|
|
178
|
+
to read a late answer.
|
|
177
179
|
|
|
178
180
|
Every injected message opens with a neutral label: `[mailbox] From <name>
|
|
179
181
|
(<full id>, working in <cwd>), another Pi session on this machine.` The short
|
|
@@ -182,7 +184,7 @@ sender is not running. The label carries no distrust wording. Safety stays
|
|
|
182
184
|
outside the model: an owner-only mail root on one machine. The rest of the
|
|
183
185
|
label depends on the kind:
|
|
184
186
|
|
|
185
|
-
- A request's label
|
|
187
|
+
- A request's label is the sender line alone; it promises no answer.
|
|
186
188
|
- An ask's label says its sender is waiting, gives the ask id and
|
|
187
189
|
`session_mail_reply` as the way to answer, and says this run's last message
|
|
188
190
|
is sent as the answer otherwise.
|
|
@@ -207,29 +209,22 @@ user message, and the Anthropic API joins it with the next typed prompt into
|
|
|
207
209
|
one turn. A quiet reply starts no turn, so the user's next prompt lands right
|
|
208
210
|
after it; without the end line, models read that prompt as part of the mail.
|
|
209
211
|
|
|
210
|
-
Mail counts as read when it enters the conversation (`message_end`).
|
|
211
|
-
an abort dropped before that is answered `failed`.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
means the user took over, not that the request is finished. Its sender sees no
|
|
215
|
-
reply yet. It stays owed through further stopped runs and through runs that end
|
|
216
|
-
on an error, and the next run that completes answers it `done`. That reply's
|
|
217
|
-
body opens with `(The user stopped an earlier run partway and took over; the
|
|
218
|
-
answer that follows is from the run that completed after that.)`, then that
|
|
219
|
-
run's answer; the stopped runs' partial text is not included. Asks are never
|
|
220
|
-
held, because their sender is blocked waiting: a stopped run answers them
|
|
221
|
-
`stopped` at once. A held request lives in memory only, so a session that shuts
|
|
222
|
-
down while holding one never answers it.
|
|
212
|
+
Mail counts as read when it enters the conversation (`message_end`). An ask
|
|
213
|
+
an abort dropped before that is answered `failed`. Asks are never held across a
|
|
214
|
+
stop, because their sender is blocked waiting: a stopped run answers them
|
|
215
|
+
`stopped` at once.
|
|
223
216
|
|
|
224
217
|
## Envelopes
|
|
225
218
|
|
|
226
|
-
Every envelope is a JSON file with `id`, `from`, `to`, `kind`,
|
|
219
|
+
Every envelope is a JSON file with `id`, `from`, `to`, `kind`,
|
|
227
220
|
`in_reply_to`, `status`, `ts` and `body`. `kind` is one of:
|
|
228
221
|
|
|
229
|
-
- `request`,
|
|
222
|
+
- `request`, mail another extension sends through `message:send`; the
|
|
223
|
+
extension that consumes it in the recipient answers it, if anything does.
|
|
230
224
|
- `reply`, an answer that names what it answers in `in_reply_to`.
|
|
231
225
|
- `message`, plain mail that expects no answer.
|
|
232
|
-
- `ask`, a question whose sender waits for the answer, answered
|
|
226
|
+
- `ask`, a question whose sender waits for the answer, answered with
|
|
227
|
+
`session_mail_reply` or when the recipient settles.
|
|
233
228
|
|
|
234
229
|
A `kind` this version does not know is read as `message`: the envelope is
|
|
235
230
|
delivered, wakes the session and expects no answer. The original value is not
|
|
@@ -240,42 +235,30 @@ An envelope whose `in_reply_to` names more than 50 requests (50 is allowed) is
|
|
|
240
235
|
set aside with a warning, like any other envelope that cannot be read: it stays in `cur/`
|
|
241
236
|
and is not delivered.
|
|
242
237
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
is
|
|
247
|
-
|
|
238
|
+
An envelope written before `kind` existed is read as a reply when `in_reply_to`
|
|
239
|
+
is non-empty and as a request otherwise.
|
|
240
|
+
|
|
241
|
+
There is no hop count or other limit on chains of mail: sessions can keep waking
|
|
242
|
+
each other. An old envelope that still carries a `hops` property is read and
|
|
243
|
+
delivered like any other, and the property is ignored.
|
|
248
244
|
|
|
249
|
-
A reply's `status`
|
|
250
|
-
|
|
245
|
+
Requests, asks and messages carry no status. A reply's `status` is one of
|
|
246
|
+
`done`, `stopped` and `failed`.
|
|
247
|
+
|
|
248
|
+
The answer to an ask, from `session_mail_reply` (always `done`) or at settle,
|
|
249
|
+
uses the status to say how the run ended, not whether the job succeeded, and
|
|
250
|
+
`pi-session-mail` writes the body:
|
|
251
251
|
|
|
252
252
|
| `status` | Meaning |
|
|
253
253
|
| --- | --- |
|
|
254
|
-
| `done` | the run settled; the body is its answer
|
|
255
|
-
| `stopped` | the user stopped the run before it settled; the body says so, then the partial text
|
|
256
|
-
| `failed` | the run did not do the job, and the body says why: either the run ended on an error (such as an API error Pi's retries gave up on), and the body gives the error, then the partial text; or the session was stopped before it read the
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
or a prompt a command sent. Otherwise it is the highest `hops` plus one among
|
|
263
|
-
the envelopes that started the turn or steered into it, requests and messages
|
|
264
|
-
alike, and a reply a `message:inbound` listener took over. A reply shown
|
|
265
|
-
quietly starts no turn and does not count. The count starts afresh when the
|
|
266
|
-
run settles.
|
|
267
|
-
|
|
268
|
-
`session_mail_send` and `session_mail_ask` stamp the count on their mail, and
|
|
269
|
-
are refused before anything is written once the count has reached the limit (5
|
|
270
|
-
unless `session-mail.json` says otherwise; see
|
|
271
|
-
[Configuration](#configuration)). The refusal says that a person typing in
|
|
272
|
-
either session starts the count again. Automatic answers carry the count and
|
|
273
|
-
are never refused. A `message:send` is never refused at send time: its consumer
|
|
274
|
-
acts for the user. Emitted while the session is idle it carries 0; during a run
|
|
275
|
-
it carries that run's count, and the session that receives it counts it like
|
|
276
|
-
any other mail, so a chain that reaches the limit is refused there as a loop.
|
|
277
|
-
The limit is a loop guard, not a security boundary: a hand-written envelope
|
|
278
|
-
can claim any `hops`.
|
|
254
|
+
| `done` | the run settled; the body is its answer |
|
|
255
|
+
| `stopped` | the user stopped the run before it settled; the body says so, then the partial text |
|
|
256
|
+
| `failed` | the run did not do the job, and the body says why: either the run ended on an error (such as an API error Pi's retries gave up on), and the body gives the error, then the partial text; or the session was stopped before it read the ask, and nothing was done |
|
|
257
|
+
|
|
258
|
+
A reply to a request is different: its consumer sets the status and the body
|
|
259
|
+
through [`message:reply`](#messagereply), and `pi-session-mail` writes them as
|
|
260
|
+
given. It adds no wording and waits for nothing. What `done`, `stopped` and
|
|
261
|
+
`failed` mean for a request is for the consumer to define.
|
|
279
262
|
|
|
280
263
|
## Pruning
|
|
281
264
|
|
|
@@ -304,29 +287,30 @@ reports `Removed <N> mailbox folders of closed sessions`.
|
|
|
304
287
|
(`PI_CODING_AGENT_DIR`, or `~/.pi/agent`), so each agent directory sets its own:
|
|
305
288
|
|
|
306
289
|
```json
|
|
307
|
-
{ "
|
|
290
|
+
{ "pruneAfterDays": 30 }
|
|
308
291
|
```
|
|
309
292
|
|
|
310
293
|
| Key | Effect |
|
|
311
294
|
| --- | --- |
|
|
312
|
-
| `hopLimit` | A positive integer: how many hops a chain of sessions waking each other may reach before `session_mail_send` and `session_mail_ask` are refused. Default 5. |
|
|
313
295
|
| `pruneAfterDays` | A positive integer: how many days a closed session's mailbox folder is kept before it is [pruned](#pruning). Default 30. |
|
|
314
296
|
|
|
315
|
-
The file is read at
|
|
297
|
+
The file is read at session start. A missing file, or one
|
|
316
298
|
without a key, means that key's default. An unreadable or invalid file, or an
|
|
317
|
-
invalid value, also means the default (
|
|
318
|
-
session.
|
|
299
|
+
invalid value, also means the default (30 days), with one warning per
|
|
300
|
+
session. A key this version does not know, such as the former `hopLimit`, is ignored.
|
|
319
301
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
302
|
+
It reads two environment variables: the standard `XDG_STATE_HOME`, at every
|
|
303
|
+
call, to place the mail root (see [How mail works](#how-mail-works)), and
|
|
304
|
+
`PI_UNATTENDED`, which `session_mail_ask` checks to tell whether a person is
|
|
305
|
+
watching a TUI session (see [the tools](#running-sessions)). It sets no
|
|
306
|
+
environment variable of its own.
|
|
323
307
|
|
|
324
308
|
## Hooks
|
|
325
309
|
|
|
326
310
|
Other extensions integrate through the `pi.events` hooks below. They never
|
|
327
311
|
import this package or read its files.
|
|
328
312
|
|
|
329
|
-
`pi-session-mail` provides all
|
|
313
|
+
`pi-session-mail` provides all four hooks and consumes none. All four depend
|
|
330
314
|
on listeners doing all their work **synchronously**: the emitter reads the
|
|
331
315
|
results off the payload the moment `emit` returns, so a listener must finish
|
|
332
316
|
before its first `await`.
|
|
@@ -335,7 +319,9 @@ The `js` blocks below are the contract's worked examples, and
|
|
|
335
319
|
`test/hooks.test.ts` extracts and runs them verbatim, one per block, on a real
|
|
336
320
|
`createEventBus`. Their scope is the test harness's: `pi` is the extension
|
|
337
321
|
API, `peer` is another session's address, `bareBus` is an event bus with no
|
|
338
|
-
`message:send` provider,
|
|
322
|
+
`message:send` provider, `peerPi` is the extension API of the session at
|
|
323
|
+
`peer` (in the examples that need both ends), and `assert` is
|
|
324
|
+
`node:assert/strict`. Write them as
|
|
339
325
|
plain JavaScript so they stay runnable; the name after `js` is the test that
|
|
340
326
|
runs that block.
|
|
341
327
|
|
|
@@ -352,18 +338,13 @@ refuse rather than pretend the message was sent.
|
|
|
352
338
|
| --- | --- | --- |
|
|
353
339
|
| `to` | consumer | the recipient's address: a session id |
|
|
354
340
|
| `body` | consumer | the message text |
|
|
355
|
-
| `envelope` | provider | the written request: `id`, `from`, `to`, `kind` (`"request"`), `
|
|
341
|
+
| `envelope` | provider | the written request: `id`, `from`, `to`, `kind` (`"request"`), `in_reply_to`, `status`, `ts`, `body` |
|
|
356
342
|
| `error` | provider | why nothing was written: no active session, or an invalid `to` or `body` |
|
|
357
343
|
|
|
358
|
-
While a run is active, between `agent_start` and `agent_settled`, the request
|
|
359
|
-
carries that run's hop count (see [Hop limit](#hop-limit)), so two models that
|
|
360
|
-
delegate to each other through an extension cannot escape the limit. While the
|
|
361
|
-
session is idle it carries 0, as the example below does. The provider never
|
|
362
|
-
refuses a send for its hop count; the receiving session refuses past the limit
|
|
363
|
-
as a loop.
|
|
364
|
-
|
|
365
344
|
The request's `id` is what a reply names in its `in_reply_to`, so a consumer
|
|
366
|
-
that wants its answers back should keep it.
|
|
345
|
+
that wants its answers back should keep it. `pi-session-mail` never answers a
|
|
346
|
+
request by itself: the extension that consumes it in the recipient session
|
|
347
|
+
sends any reply, through [`message:reply`](#messagereply).
|
|
367
348
|
|
|
368
349
|
```js message:send
|
|
369
350
|
// Send a request from this session and keep the id a reply will name.
|
|
@@ -374,7 +355,7 @@ assert.equal(payload.error, undefined);
|
|
|
374
355
|
assert.ok(payload.envelope, "a provider sets `envelope` before `emit` returns");
|
|
375
356
|
assert.equal(typeof payload.envelope.id, "string");
|
|
376
357
|
assert.equal(payload.envelope.kind, "request");
|
|
377
|
-
assert.equal(payload.envelope
|
|
358
|
+
assert.equal("hops" in payload.envelope, false);
|
|
378
359
|
```
|
|
379
360
|
|
|
380
361
|
```js no-provider
|
|
@@ -385,10 +366,92 @@ assert.equal(probe.envelope, undefined);
|
|
|
385
366
|
assert.equal(probe.error, undefined);
|
|
386
367
|
```
|
|
387
368
|
|
|
369
|
+
### `message:reply`
|
|
370
|
+
|
|
371
|
+
A consumer that took over a request emits `{ to, inReplyTo, body, status }` to
|
|
372
|
+
answer it, whenever its work is done. A provider writes a reply (`kind:
|
|
373
|
+
"reply"`) from its own session's address and sets `envelope` on the same
|
|
374
|
+
object before `emit` returns, or sets `error` instead and writes nothing.
|
|
375
|
+
**If neither is set, no provider is installed**, as with `message:send`.
|
|
376
|
+
|
|
377
|
+
| Field | Set by | Meaning |
|
|
378
|
+
| --- | --- | --- |
|
|
379
|
+
| `to` | consumer | the request's sender: a session id, checked as `message:send` checks it |
|
|
380
|
+
| `inReplyTo` | consumer | the ids of the requests this answers: a non-empty list of non-empty strings, at most 50 |
|
|
381
|
+
| `body` | consumer | the reply text: any string, empty included |
|
|
382
|
+
| `status` | consumer | required: `"done"`, `"stopped"` or `"failed"`; the consumer defines what each means for its request (see [Envelopes](#envelopes)) |
|
|
383
|
+
| `envelope` | provider | the written reply: `id`, `from`, `to`, `kind` (`"reply"`), `in_reply_to`, `status`, `ts`, `body` |
|
|
384
|
+
| `error` | provider | why nothing was written: no active session, an invalid field, or a failed write |
|
|
385
|
+
|
|
386
|
+
The hook is for answering requests. The reply's `from` is always this
|
|
387
|
+
session; a `from` on the payload is ignored. `body` and `status` are written
|
|
388
|
+
exactly as given: the reply does not wait for the run to settle, does not use
|
|
389
|
+
the model's answer and adds no wording. `inReplyTo` is checked for shape only:
|
|
390
|
+
its ids are not looked up in this session's mailbox, so the hook does not
|
|
391
|
+
check that they name requests this session received. Nothing stops a second
|
|
392
|
+
reply to the same request either: every valid call writes one, and the
|
|
393
|
+
consumer makes sure it answers once. An ask is answered with
|
|
394
|
+
`session_mail_reply` or at settle, never through this hook, which leaves an
|
|
395
|
+
ask's bookkeeping alone.
|
|
396
|
+
|
|
397
|
+
The reply goes through the mailbox like any other: one the sender is not
|
|
398
|
+
running to receive waits in its inbox and is claimed when it starts again.
|
|
399
|
+
The sender then gets it through `message:inbound` with its `requests` copies,
|
|
400
|
+
and a listener there can take it over.
|
|
401
|
+
|
|
402
|
+
```js message:reply
|
|
403
|
+
// The consumer in the peer session takes over the requests it receives.
|
|
404
|
+
const taken = [];
|
|
405
|
+
peerPi.events.on("message:inbound", (inbound) => {
|
|
406
|
+
if (inbound.handled || inbound.envelope.kind !== "request") return;
|
|
407
|
+
inbound.handled = true;
|
|
408
|
+
taken.push(inbound.envelope);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// This session sends a request, and the peer claims it now.
|
|
412
|
+
const request = { to: peer, body: "Please run the test suite." };
|
|
413
|
+
pi.events.emit("message:send", request);
|
|
414
|
+
peerPi.events.emit("message:scan", {});
|
|
415
|
+
assert.equal(taken.length, 1);
|
|
416
|
+
|
|
417
|
+
// When its work is done, the consumer answers from the peer session.
|
|
418
|
+
const reply = { to: taken[0].from, inReplyTo: [taken[0].id], body: "All 42 tests pass.", status: "done" };
|
|
419
|
+
peerPi.events.emit("message:reply", reply);
|
|
420
|
+
assert.equal(reply.error, undefined);
|
|
421
|
+
assert.ok(reply.envelope, "a provider sets `envelope` before `emit` returns");
|
|
422
|
+
assert.equal(reply.envelope.kind, "reply");
|
|
423
|
+
assert.equal(reply.envelope.from, peer);
|
|
424
|
+
|
|
425
|
+
// The reply reaches this session through `message:inbound`, unchanged.
|
|
426
|
+
const replies = [];
|
|
427
|
+
pi.events.on("message:inbound", (inbound) => replies.push(inbound));
|
|
428
|
+
pi.events.emit("message:scan", {});
|
|
429
|
+
assert.equal(replies.length, 1);
|
|
430
|
+
const [{ envelope, requests }] = replies;
|
|
431
|
+
assert.deepEqual(envelope.in_reply_to, [request.envelope.id]);
|
|
432
|
+
assert.equal(envelope.body, "All 42 tests pass.");
|
|
433
|
+
assert.equal(envelope.status, "done");
|
|
434
|
+
assert.equal(requests[0].envelope.id, request.envelope.id);
|
|
435
|
+
|
|
436
|
+
// Invalid input sets `error` and writes nothing.
|
|
437
|
+
const unknown = { to: peer, inReplyTo: [request.envelope.id], body: "?", status: "maybe" };
|
|
438
|
+
pi.events.emit("message:reply", unknown);
|
|
439
|
+
assert.equal(unknown.envelope, undefined);
|
|
440
|
+
assert.match(unknown.error, /status/);
|
|
441
|
+
|
|
442
|
+
// With no provider installed, neither field is set.
|
|
443
|
+
const probe = { to: peer, inReplyTo: [request.envelope.id], body: "hello", status: "done" };
|
|
444
|
+
bareBus.emit("message:reply", probe);
|
|
445
|
+
assert.equal(probe.envelope, undefined);
|
|
446
|
+
assert.equal(probe.error, undefined);
|
|
447
|
+
```
|
|
448
|
+
|
|
388
449
|
### `message:inbound`
|
|
389
450
|
|
|
390
451
|
`pi-session-mail` emits this for every claimed envelope before injecting it,
|
|
391
|
-
requests, messages and replies alike
|
|
452
|
+
requests, asks, messages and replies alike, except the reply to an ask this
|
|
453
|
+
session is waiting on: that goes to the waiting `session_mail_ask` call as its
|
|
454
|
+
result and is neither emitted nor injected.
|
|
392
455
|
|
|
393
456
|
`pi-session-mail` never emits it from inside a `session_start` handler. Mail
|
|
394
457
|
waiting when a session starts is claimed on a later event-loop turn
|
|
@@ -400,15 +463,16 @@ listener's handler. The watcher and the poll timer deliver later mail as usual.
|
|
|
400
463
|
|
|
401
464
|
| Field | Meaning |
|
|
402
465
|
| --- | --- |
|
|
403
|
-
| `envelope` | the claimed envelope, with `from`, `kind`, `
|
|
466
|
+
| `envelope` | the claimed envelope, with `from`, `kind`, `in_reply_to`, `status`, `body` and the rest; an old envelope without `kind` gets it filled in as described above |
|
|
404
467
|
| `path` | the envelope's path in this session's `cur/` |
|
|
405
468
|
| `requests` | a reply's `in_reply_to` requests found in this session's `sent/`, each as `{ envelope, path }`; empty for a request, and shorter than `in_reply_to` when a copy is missing |
|
|
406
469
|
| `handled` | set to `true` by a listener that shows the message itself; `pi-session-mail` then injects nothing |
|
|
407
470
|
|
|
408
471
|
A listener that sets `handled` owns the display, and chooses whether its own
|
|
409
|
-
message starts a turn (`triggerTurn`).
|
|
410
|
-
|
|
411
|
-
|
|
472
|
+
message starts a turn (`triggerTurn`). Handled mail counts as read, and an
|
|
473
|
+
ask a listener handled is still answered when this session settles. A request
|
|
474
|
+
arms no answer, handled or not: the listener that consumes it owns its reply. A
|
|
475
|
+
reply `pi-session-mail` injects itself quotes each
|
|
412
476
|
request the same way, capped at 2 KiB with the copy's path.
|
|
413
477
|
|
|
414
478
|
```js message:inbound
|
|
@@ -436,7 +500,9 @@ pi.events.on("message:inbound", (payload) => {
|
|
|
436
500
|
A consumer emits `{}` to have `pi-session-mail` claim the mail waiting in its
|
|
437
501
|
inbox now, instead of at the next watcher event or poll. The listener runs the
|
|
438
502
|
inbox scan synchronously, so every waiting envelope has been emitted as
|
|
439
|
-
`message:inbound` (and delivered) by the time `emit` returns,
|
|
503
|
+
`message:inbound` (and delivered) by the time `emit` returns, except the
|
|
504
|
+
reply to an ask this session is waiting on, which goes to the waiting
|
|
505
|
+
`session_mail_ask` call instead. The listener then sets
|
|
440
506
|
`scanned` to `true`. **If `scanned` is not set, no provider is installed**,
|
|
441
507
|
and the consumer must not conclude that no reply is waiting.
|
|
442
508
|
|
|
@@ -469,21 +535,21 @@ assert.equal(probe.scanned, undefined);
|
|
|
469
535
|
Sessions running older and newer copies of `pi-session-mail` share one mail
|
|
470
536
|
root, and other extensions depend on its hooks, so these are stable:
|
|
471
537
|
|
|
472
|
-
- The
|
|
473
|
-
their payloads as [Hooks](#hooks) describes them.
|
|
538
|
+
- The four hooks, `message:send`, `message:reply`, `message:inbound` and
|
|
539
|
+
`message:scan`, and their payloads as [Hooks](#hooks) describes them.
|
|
474
540
|
- The `[mailbox] From …` label and the `[mailbox] End of the mail …` line that
|
|
475
541
|
frame injected mail.
|
|
476
542
|
- The mail root path, `$XDG_STATE_HOME/pi-session-mail/`, or
|
|
477
543
|
`~/.local/state/pi-session-mail/` when `XDG_STATE_HOME` is unset or not
|
|
478
544
|
absolute.
|
|
479
|
-
- The envelope fields (`id`, `from`, `to`, `kind`, `
|
|
545
|
+
- The envelope fields (`id`, `from`, `to`, `kind`, `in_reply_to`,
|
|
480
546
|
`status`, `ts`, `body`), the kinds (`request`, `reply`, `message`, `ask`)
|
|
481
547
|
and a reply's statuses (`done`, `stopped`, `failed`); see
|
|
482
548
|
[Envelopes](#envelopes).
|
|
483
549
|
- The names of the tools (`session_mail_list`, `session_mail_send`,
|
|
484
550
|
`session_mail_ask`, `session_mail_reply`) and of the `/mailbox`,
|
|
485
551
|
`/mailbox:send` and `/mailbox:prune` commands.
|
|
486
|
-
- The keys of `session-mail.json`, today `
|
|
552
|
+
- The keys of `session-mail.json`, today `pruneAfterDays`.
|
|
487
553
|
|
|
488
554
|
Changing any of them needs a major version from 1.0 on, and a minor version
|
|
489
555
|
while the package is at 0.x.
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// The settings in `<agent dir>/session-mail.json`, parsed from its text.
|
|
2
2
|
// Pure: reading the file is the caller's job, so this module imports nothing.
|
|
3
3
|
|
|
4
|
-
/** The hop limit when `session-mail.json` is missing or broken. */
|
|
5
|
-
export const DEFAULT_HOP_LIMIT = 5;
|
|
6
|
-
|
|
7
4
|
/** Days a closed session's mailbox folder is kept before it is pruned. */
|
|
8
5
|
export const DEFAULT_PRUNE_AFTER_DAYS = 30;
|
|
9
6
|
|
|
10
7
|
/** The settings, each valid or its default, and what was wrong with the file, if anything. */
|
|
11
|
-
export type Config = {
|
|
8
|
+
export type Config = { pruneAfterDays: number; problems: string[] };
|
|
12
9
|
|
|
13
10
|
const isPositiveInteger = (value: unknown): value is number =>
|
|
14
11
|
typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
@@ -20,9 +17,9 @@ const isPositiveInteger = (value: unknown): value is number =>
|
|
|
20
17
|
* every default and one problem.
|
|
21
18
|
*/
|
|
22
19
|
export function parseConfig(text: string | undefined, path: string): Config {
|
|
23
|
-
const defaults = {
|
|
20
|
+
const defaults = { pruneAfterDays: DEFAULT_PRUNE_AFTER_DAYS };
|
|
24
21
|
if (text === undefined) return { ...defaults, problems: [] };
|
|
25
|
-
const using = `using the
|
|
22
|
+
const using = `using the default, pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`;
|
|
26
23
|
let config: unknown;
|
|
27
24
|
try {
|
|
28
25
|
config = JSON.parse(text);
|
|
@@ -31,16 +28,16 @@ export function parseConfig(text: string | undefined, path: string): Config {
|
|
|
31
28
|
}
|
|
32
29
|
if (typeof config !== "object" || config === null || Array.isArray(config))
|
|
33
30
|
return { ...defaults, problems: [`${path} is not a JSON object; ${using}`] };
|
|
34
|
-
const values = config as {
|
|
31
|
+
const values = config as { pruneAfterDays?: unknown };
|
|
35
32
|
const problems: string[] = [];
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (isPositiveInteger(value))
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return {
|
|
33
|
+
const value = values.pruneAfterDays;
|
|
34
|
+
let pruneAfterDays = defaults.pruneAfterDays;
|
|
35
|
+
if (value !== undefined) {
|
|
36
|
+
if (isPositiveInteger(value)) pruneAfterDays = value;
|
|
37
|
+
else
|
|
38
|
+
problems.push(
|
|
39
|
+
`pruneAfterDays in ${path} must be a positive integer, not ${JSON.stringify(value)}; using ${defaults.pruneAfterDays}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { pruneAfterDays, problems };
|
|
46
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
// expects no answer. Mail in the inbox is claimed into `cur/` and injected with
|
|
14
14
|
// a label naming the sending session. Requests and messages
|
|
15
15
|
// wake an idle session and steer into a busy one; a reply quotes each request
|
|
16
|
-
// it answers from this session's `sent/` copy and starts no turn.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// `
|
|
16
|
+
// it answers from this session's `sent/` copy and starts no turn. A request
|
|
17
|
+
// gets no automatic answer: the extension that consumes it owns its reply and
|
|
18
|
+
// sends it through `message:reply`. An
|
|
19
|
+
// ask not answered with `session_mail_reply` is answered when the recipient
|
|
20
|
+
// settles, its last answer going back to each sender as one reply — `done`
|
|
21
|
+
// normally, `stopped` when the user stopped the run, with any partial text, or
|
|
22
|
+
// `failed` when the run ended on an error, with the error and any partial
|
|
23
|
+
// text. Asks that never entered the conversation (an abort drops queued
|
|
24
|
+
// follow-ups) get a `failed` reply instead. Replies and messages are never
|
|
25
|
+
// answered. Other extensions use `pi.events` (below).
|
|
25
26
|
//
|
|
26
27
|
// The hook contracts live in this package's README.
|
|
27
28
|
import { type FSWatcher, statSync, watch } from "node:fs";
|
|
@@ -31,7 +32,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
31
32
|
import { mailStatus } from "./mail-status.ts";
|
|
32
33
|
import { type Peer, type PickerFilter, type PickerItem, type PickResult, picker } from "./picker.ts";
|
|
33
34
|
import { isRunning, label, listRunning, type RunningRecord, removeRecord, type State, writeRecord } from "./running.ts";
|
|
34
|
-
import { type
|
|
35
|
+
import { type SettleAsksInput, settleAsks } from "./settle.ts";
|
|
35
36
|
import {
|
|
36
37
|
boxPath,
|
|
37
38
|
claim,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
findSent,
|
|
42
43
|
isAddress,
|
|
43
44
|
listNew,
|
|
45
|
+
MAX_REPLY_IDS,
|
|
44
46
|
pruneClosed,
|
|
45
47
|
readEnvelope,
|
|
46
48
|
readMailbox,
|
|
@@ -142,7 +144,6 @@ function inboundText(
|
|
|
142
144
|
const id = plain(envelope.id);
|
|
143
145
|
const replyIds = envelope.in_reply_to.map(plain);
|
|
144
146
|
const header = [sender.line];
|
|
145
|
-
if (envelope.kind === "request") header.push("Your final answer this turn goes back to it automatically.");
|
|
146
147
|
if (envelope.kind === "ask")
|
|
147
148
|
header.push(
|
|
148
149
|
`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.`,
|
|
@@ -167,20 +168,35 @@ function inboundText(
|
|
|
167
168
|
* synchronous code before `emit` returns; do all work before any `await`.
|
|
168
169
|
*
|
|
169
170
|
* - `message:send`: the caller emits `{ to, body }`; `pi-session-mail` writes a
|
|
170
|
-
* request
|
|
171
|
-
* and sets `envelope` (or `error`) on the same object. Neither set means no
|
|
171
|
+
* request and sets `envelope` (or `error`) on the same object. Neither set means no
|
|
172
172
|
* provider is installed.
|
|
173
173
|
* - `message:inbound`: emitted for every claimed envelope, requests and
|
|
174
|
-
* replies alike, before injection
|
|
175
|
-
*
|
|
176
|
-
*
|
|
174
|
+
* replies alike, before injection, except the reply to an ask this session
|
|
175
|
+
* is waiting on, which is the waiting tool call's result; `requests` holds
|
|
176
|
+
* a reply's request copies from this session's `sent/`, and a listener sets
|
|
177
|
+
* `handled` to show the message itself.
|
|
178
|
+
* - `message:reply`: the caller emits `{ to, inReplyTo, body, status }`;
|
|
179
|
+
* `pi-session-mail` writes a reply from this session and sets `envelope`
|
|
180
|
+
* (or `error`), as for `message:send`. It answers requests a consumer took
|
|
181
|
+
* over; the ids are checked for shape only, never looked up.
|
|
177
182
|
*/
|
|
178
183
|
const SEND = "message:send";
|
|
184
|
+
const REPLY = "message:reply";
|
|
185
|
+
/** The statuses a `message:reply` caller may give, the same as a settled ask's. */
|
|
186
|
+
const REPLY_STATUSES: readonly string[] = ["done", "stopped", "failed"];
|
|
179
187
|
const INBOUND = "message:inbound";
|
|
180
188
|
/** A consumer's request to claim waiting mail now; see the README's `message:scan`. */
|
|
181
189
|
const SCAN = "message:scan";
|
|
182
190
|
|
|
183
191
|
type SendPayload = { to: unknown; body: unknown; envelope?: Envelope; error?: string };
|
|
192
|
+
type ReplyPayload = {
|
|
193
|
+
to: unknown;
|
|
194
|
+
inReplyTo: unknown;
|
|
195
|
+
body: unknown;
|
|
196
|
+
status: unknown;
|
|
197
|
+
envelope?: Envelope;
|
|
198
|
+
error?: string;
|
|
199
|
+
};
|
|
184
200
|
type InboundPayload = { envelope: Envelope; path: string; requests: SentCopy[]; handled: boolean };
|
|
185
201
|
|
|
186
202
|
type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown };
|
|
@@ -222,15 +238,8 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
222
238
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
223
239
|
/** Envelope ids already handed to Pi in this process. */
|
|
224
240
|
const delivered = new Set<string>();
|
|
225
|
-
/**
|
|
226
|
-
let owed = new Map<string, string[]>();
|
|
227
|
-
/** Owed request ids whose message entered the conversation (or a listener took over). */
|
|
241
|
+
/** Ids of mail whose message entered the conversation (or a listener took over); settle reads it for asks. */
|
|
228
242
|
let seen = new Set<string>();
|
|
229
|
-
/**
|
|
230
|
-
* Request ids held across a stop: the user stopped a run while they were
|
|
231
|
-
* owed, so they wait, still owed, for the next run that completes.
|
|
232
|
-
*/
|
|
233
|
-
let held = new Set<string>();
|
|
234
243
|
/** Text of the last assistant message, remembered at `agent_end`. */
|
|
235
244
|
let lastAnswer: string | undefined;
|
|
236
245
|
/** Whether the user stopped that run, whatever its last message's `stopReason`. */
|
|
@@ -239,12 +248,6 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
239
248
|
let lastError: string | undefined;
|
|
240
249
|
/** Ids of injected mail that has not entered the conversation yet, for the status's unread count. */
|
|
241
250
|
let unread = new Set<string>();
|
|
242
|
-
/**
|
|
243
|
-
* The hop count of the current turn: 0 once the user starts or steers it,
|
|
244
|
-
* otherwise the highest `hops` + 1 among the envelopes that started or
|
|
245
|
-
* steered into it. Mail sent from the turn carries it.
|
|
246
|
-
*/
|
|
247
|
-
let hops = 0;
|
|
248
251
|
/** The problems with `session-mail.json` this session has already warned about. */
|
|
249
252
|
let configWarned = new Set<string>();
|
|
250
253
|
/** What this session's running-session record says. */
|
|
@@ -395,13 +398,9 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
395
398
|
// Listeners run synchronously inside emit, so `handled` is final when it returns.
|
|
396
399
|
const inbound: InboundPayload = { envelope, path, requests, handled: false };
|
|
397
400
|
pi.events.emit(INBOUND, inbound);
|
|
398
|
-
// Only
|
|
399
|
-
//
|
|
400
|
-
if (envelope.kind === "request") owed.set(envelope.from, [...(owed.get(envelope.from) ?? []), envelope.id]);
|
|
401
|
+
// Only asks arm an answer: the extension that consumes a request owns its
|
|
402
|
+
// reply, replies are never answered, and messages expect none.
|
|
401
403
|
tools?.armAsk(envelope);
|
|
402
|
-
// Mail that wakes or steers the session raises its count; a reply shown
|
|
403
|
-
// quietly starts no turn, but one a listener took over may.
|
|
404
|
-
if (wakes || inbound.handled) hops = Math.max(hops, envelope.hops + 1);
|
|
405
404
|
// `pi-session-mail` cannot observe a listener's own message, so a takeover counts as seen.
|
|
406
405
|
if (inbound.handled) {
|
|
407
406
|
seen.add(envelope.id);
|
|
@@ -458,11 +457,32 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
458
457
|
try {
|
|
459
458
|
if (address === undefined) throw new Error("no active session has a mailbox address");
|
|
460
459
|
if (typeof payload.body !== "string") throw new Error("body must be a string");
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
460
|
+
payload.envelope = send(address, payload.to as string, payload.body, { kind: "request" });
|
|
461
|
+
} catch (err) {
|
|
462
|
+
payload.error = (err as Error).message;
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Synchronous on purpose, like `message:send`. The reply is written as the
|
|
467
|
+
// caller gives it, now: no settle, no assistant text, no added wording, and
|
|
468
|
+
// no lookup or duplicate guard on `inReplyTo`; the consumer owns all that.
|
|
469
|
+
pi.events.on(REPLY, (data) => {
|
|
470
|
+
const payload = data as ReplyPayload;
|
|
471
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
472
|
+
try {
|
|
473
|
+
if (address === undefined) throw new Error("no active session has a mailbox address");
|
|
474
|
+
const ids = payload.inReplyTo;
|
|
475
|
+
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === "string" && id !== ""))
|
|
476
|
+
throw new Error("inReplyTo must be a non-empty list of non-empty request ids");
|
|
477
|
+
if (ids.length > MAX_REPLY_IDS) throw new Error(`inReplyTo names more than ${MAX_REPLY_IDS} requests`);
|
|
478
|
+
if (typeof payload.body !== "string") throw new Error("body must be a string");
|
|
479
|
+
if (typeof payload.status !== "string" || !REPLY_STATUSES.includes(payload.status))
|
|
480
|
+
throw new Error(`status must be one of ${REPLY_STATUSES.map((s) => JSON.stringify(s)).join(", ")}`);
|
|
481
|
+
payload.envelope = send(address, payload.to as string, payload.body, {
|
|
482
|
+
kind: "reply",
|
|
483
|
+
inReplyTo: [...ids],
|
|
484
|
+
status: payload.status,
|
|
485
|
+
});
|
|
466
486
|
} catch (err) {
|
|
467
487
|
payload.error = (err as Error).message;
|
|
468
488
|
}
|
|
@@ -497,14 +517,11 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
497
517
|
pi.on("session_start", async (_event, context) => {
|
|
498
518
|
stop();
|
|
499
519
|
ctx = context;
|
|
500
|
-
owed = new Map();
|
|
501
520
|
seen = new Set();
|
|
502
|
-
held = new Set();
|
|
503
521
|
lastAnswer = undefined;
|
|
504
522
|
lastAborted = false;
|
|
505
523
|
lastError = undefined;
|
|
506
524
|
unread = new Set();
|
|
507
|
-
hops = 0;
|
|
508
525
|
tools?.abandonAsk();
|
|
509
526
|
waitingOn = "";
|
|
510
527
|
configWarned = new Set();
|
|
@@ -563,12 +580,6 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
563
580
|
if (unread.delete(message.details.id)) updateStatus();
|
|
564
581
|
});
|
|
565
582
|
|
|
566
|
-
// Typed text, RPC input and a prompt a command sends all come from the
|
|
567
|
-
// user, so each starts the chain again.
|
|
568
|
-
pi.on("input", async () => {
|
|
569
|
-
hops = 0;
|
|
570
|
-
});
|
|
571
|
-
|
|
572
583
|
pi.on("session_info_changed", async (event) => {
|
|
573
584
|
name = event.name || undefined;
|
|
574
585
|
announce();
|
|
@@ -606,24 +617,12 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
606
617
|
// queued follow-up after `agent_end` would otherwise get a premature answer.
|
|
607
618
|
pi.on("agent_settled", async () => {
|
|
608
619
|
const me = address;
|
|
609
|
-
|
|
610
|
-
const answerHops = hops;
|
|
611
|
-
hops = 0;
|
|
612
|
-
const end: SettleInput["end"] = lastAborted
|
|
620
|
+
const end: SettleAsksInput["end"] = lastAborted
|
|
613
621
|
? { kind: "stopped" }
|
|
614
622
|
: lastError !== undefined
|
|
615
623
|
? { kind: "failed", error: lastError }
|
|
616
624
|
: { kind: "done" };
|
|
617
|
-
const
|
|
618
|
-
requests: owed,
|
|
619
|
-
asks: tools?.takeOwedAsks() ?? new Map(),
|
|
620
|
-
seen,
|
|
621
|
-
held,
|
|
622
|
-
end,
|
|
623
|
-
answer: lastAnswer,
|
|
624
|
-
});
|
|
625
|
-
owed = decision.owed;
|
|
626
|
-
held = decision.held;
|
|
625
|
+
const replies = settleAsks({ asks: tools?.takeOwedAsks() ?? new Map(), seen, end, answer: lastAnswer });
|
|
627
626
|
seen = new Set();
|
|
628
627
|
lastAnswer = undefined;
|
|
629
628
|
lastAborted = false;
|
|
@@ -632,11 +631,10 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
632
631
|
state = "idle";
|
|
633
632
|
announce();
|
|
634
633
|
if (me === undefined) return;
|
|
635
|
-
for (const reply of
|
|
634
|
+
for (const reply of replies) {
|
|
636
635
|
const { to } = reply;
|
|
637
636
|
try {
|
|
638
|
-
|
|
639
|
-
send(me, to, reply.body, { kind: "reply", hops: answerHops, inReplyTo: reply.ids, status: reply.status });
|
|
637
|
+
send(me, to, reply.body, { kind: "reply", inReplyTo: reply.ids, status: reply.status });
|
|
640
638
|
} catch (err) {
|
|
641
639
|
warn(`could not reply to ${to}: ${(err as Error).message}`);
|
|
642
640
|
}
|
|
@@ -663,8 +661,6 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
663
661
|
waitingOn = to;
|
|
664
662
|
announce();
|
|
665
663
|
},
|
|
666
|
-
hops: () => hops,
|
|
667
|
-
configProblem,
|
|
668
664
|
});
|
|
669
665
|
|
|
670
666
|
pi.registerCommand("mailbox", {
|
|
@@ -721,7 +717,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
721
717
|
const text = (await context.ui.input("Message", "type a message"))?.trim();
|
|
722
718
|
if (text === undefined || text === "") return;
|
|
723
719
|
try {
|
|
724
|
-
const envelope = send(me, target.address, text, { kind: "message"
|
|
720
|
+
const envelope = send(me, target.address, text, { kind: "message" });
|
|
725
721
|
context.ui.notify(`Sent ${envelope.id} to ${envelope.to}`, "info");
|
|
726
722
|
} catch (err) {
|
|
727
723
|
context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
|
package/src/settle.ts
CHANGED
|
@@ -1,30 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** What a settle decides on: the open asks, the ids seen, how the run ended and the answer text it left. */
|
|
2
|
+
export type SettleAsksInput = {
|
|
3
3
|
asks: ReadonlyMap<string, string[]>;
|
|
4
4
|
seen: ReadonlySet<string>;
|
|
5
|
-
held: ReadonlySet<string>;
|
|
6
5
|
end: { kind: "done" } | { kind: "stopped" } | { kind: "failed"; error: string };
|
|
7
6
|
answer: string | undefined;
|
|
8
7
|
};
|
|
9
8
|
export type Reply = { to: string; ids: string[]; body: string; status: "done" | "stopped" | "failed" };
|
|
10
|
-
export type SettleOutput = { replies: Reply[]; owed: Map<string, string[]>; held: Set<string> };
|
|
11
9
|
|
|
12
10
|
/** Reply body when the run ended with no assistant text. */
|
|
13
11
|
export const NO_ANSWER = "(The session settled with no answer text.)";
|
|
14
12
|
/** Reply body of a run the user stopped, with any partial answer text after it. */
|
|
15
13
|
export const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
|
|
16
|
-
/** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
|
|
17
|
-
export const TOOK_OVER =
|
|
18
|
-
"(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
|
|
19
14
|
/** Opening line of the reply body of a run that ended on an error. */
|
|
20
15
|
export const ERRORED = (error: string): string =>
|
|
21
16
|
`(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
|
|
22
|
-
/** Body of a `failed` reply to
|
|
17
|
+
/** Body of a `failed` reply to asks that never entered the conversation. */
|
|
23
18
|
export const UNSEEN =
|
|
24
19
|
"(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
|
|
25
20
|
|
|
26
21
|
/** The body and status of the reply to a run that ended this way: how it ended, then what it had. */
|
|
27
|
-
function describeEnd(
|
|
22
|
+
function describeEnd(
|
|
23
|
+
end: SettleAsksInput["end"],
|
|
24
|
+
answer: string | undefined,
|
|
25
|
+
): { body: string; status: Reply["status"] } {
|
|
28
26
|
const text = answer ?? NO_ANSWER;
|
|
29
27
|
const withOpening = (opening: string): string => (answer === undefined ? opening : `${opening}\n\n${answer}`);
|
|
30
28
|
if (end.kind === "stopped") return { body: withOpening(STOPPED), status: "stopped" };
|
|
@@ -32,12 +30,14 @@ function describeEnd(end: SettleInput["end"], answer: string | undefined): { bod
|
|
|
32
30
|
return { body: text, status: "done" };
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
): Reply[] {
|
|
33
|
+
/**
|
|
34
|
+
* The replies to the open asks when a run settles: read ones get the run's
|
|
35
|
+
* answer, the rest fail as unseen. Asks are never held across a stop, and
|
|
36
|
+
* requests are never answered here; the extension that consumes one owns its reply.
|
|
37
|
+
*/
|
|
38
|
+
export function settleAsks(input: SettleAsksInput): Reply[] {
|
|
39
|
+
const { asks, seen, end, answer } = input;
|
|
40
|
+
const run = describeEnd(end, answer);
|
|
41
41
|
const replies: Reply[] = [];
|
|
42
42
|
for (const [to, ids] of asks) {
|
|
43
43
|
const read = ids.filter((id) => seen.has(id));
|
|
@@ -47,36 +47,3 @@ function askReplies(
|
|
|
47
47
|
}
|
|
48
48
|
return replies;
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Decide the replies when a run settles, and which requests stay owed.
|
|
53
|
-
*
|
|
54
|
-
* A request the user stopped a run on is held: it stays owed, through stops
|
|
55
|
-
* and failed runs alike, until a run completes, and is then answered "took
|
|
56
|
-
* over". A request or ask never read is failed as unseen. Asks are never held;
|
|
57
|
-
* their asker is blocked waiting.
|
|
58
|
-
*/
|
|
59
|
-
export function settle(input: SettleInput): SettleOutput {
|
|
60
|
-
const { end, answer, seen, held: wasHeld } = input;
|
|
61
|
-
const stopped = end.kind === "stopped";
|
|
62
|
-
const failed = end.kind === "failed";
|
|
63
|
-
const { body, status } = describeEnd(end, answer);
|
|
64
|
-
const tookOverBody = `${TOOK_OVER}\n\n${answer ?? NO_ANSWER}`;
|
|
65
|
-
const replies: Reply[] = [];
|
|
66
|
-
const owed = new Map<string, string[]>();
|
|
67
|
-
const held = new Set<string>();
|
|
68
|
-
for (const [to, ids] of input.requests) {
|
|
69
|
-
const unseen = ids.filter((id) => !seen.has(id) && !wasHeld.has(id));
|
|
70
|
-
const keep = ids.filter((id) => !unseen.includes(id) && (stopped || (failed && wasHeld.has(id))));
|
|
71
|
-
const answered = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
|
|
72
|
-
if (keep.length > 0) owed.set(to, keep);
|
|
73
|
-
for (const id of keep) held.add(id);
|
|
74
|
-
const plain = answered.filter((id) => !wasHeld.has(id));
|
|
75
|
-
const tookOver = answered.filter((id) => wasHeld.has(id));
|
|
76
|
-
if (plain.length > 0) replies.push({ to, ids: plain, body, status });
|
|
77
|
-
if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
|
|
78
|
-
if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
|
|
79
|
-
}
|
|
80
|
-
replies.push(...askReplies(input.asks, seen, { body, status }));
|
|
81
|
-
return { replies, owed, held };
|
|
82
|
-
}
|
package/src/store.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { dirname, isAbsolute, join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { type MailFile, mailStatus } from "./mail-status.ts";
|
|
14
14
|
|
|
15
|
-
/** What an envelope is: answered at settle,
|
|
15
|
+
/** What an envelope is: a request its consuming extension answers, an ask answered at settle, a plain message, or an answer. */
|
|
16
16
|
export type Kind = "request" | "ask" | "message" | "reply";
|
|
17
17
|
const KINDS: readonly Kind[] = ["request", "ask", "message", "reply"];
|
|
18
18
|
|
|
@@ -21,8 +21,6 @@ export type Envelope = {
|
|
|
21
21
|
from: string;
|
|
22
22
|
to: string;
|
|
23
23
|
kind: Kind;
|
|
24
|
-
/** How many times this chain of mail has woken or steered a session with no person typing. */
|
|
25
|
-
hops: number;
|
|
26
24
|
/** Request ids this envelope answers; empty for a request. */
|
|
27
25
|
in_reply_to: string[];
|
|
28
26
|
status: string;
|
|
@@ -36,7 +34,7 @@ const BOXES: Box[] = ["tmp", "new", "cur", "sent"];
|
|
|
36
34
|
const ADDRESS = /^[A-Za-z0-9_-]+$/;
|
|
37
35
|
|
|
38
36
|
/** The most requests one reply may name; an envelope over this is not read. */
|
|
39
|
-
const MAX_REPLY_IDS = 50;
|
|
37
|
+
export const MAX_REPLY_IDS = 50;
|
|
40
38
|
|
|
41
39
|
/** An address is a single safe path segment: letters, digits, `-` and `_`. */
|
|
42
40
|
export const isAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value);
|
|
@@ -80,7 +78,7 @@ function stamp(): number {
|
|
|
80
78
|
const fileName = (ms: number, id: string) => `${String(ms).padStart(15, "0")}-${id}.json`;
|
|
81
79
|
|
|
82
80
|
/** How `send` writes an envelope. A reply names what it answers in `inReplyTo`. */
|
|
83
|
-
export type SendOptions = { kind: Kind;
|
|
81
|
+
export type SendOptions = { kind: Kind; inReplyTo?: string[]; status?: string };
|
|
84
82
|
|
|
85
83
|
/**
|
|
86
84
|
* Write an envelope to `to`'s inbox: into its `tmp/`, then renamed into its
|
|
@@ -94,7 +92,6 @@ export function send(from: string, to: string, body: string, options: SendOption
|
|
|
94
92
|
if (!isAddress(from)) throw new Error(`invalid sender address: ${JSON.stringify(from)}`);
|
|
95
93
|
if (!isAddress(to)) throw new Error(`invalid address: ${JSON.stringify(to)} (expected a session id)`);
|
|
96
94
|
if (!KINDS.includes(options.kind)) throw new Error(`invalid kind: ${JSON.stringify(options.kind)}`);
|
|
97
|
-
if (!isHops(options.hops)) throw new Error(`invalid hops: ${JSON.stringify(options.hops)}`);
|
|
98
95
|
const inReplyTo = options.inReplyTo ?? [];
|
|
99
96
|
const ms = stamp();
|
|
100
97
|
const envelope: Envelope = {
|
|
@@ -102,7 +99,6 @@ export function send(from: string, to: string, body: string, options: SendOption
|
|
|
102
99
|
from,
|
|
103
100
|
to,
|
|
104
101
|
kind: options.kind,
|
|
105
|
-
hops: options.hops,
|
|
106
102
|
in_reply_to: inReplyTo,
|
|
107
103
|
status: options.kind === "reply" ? (options.status ?? "done") : "",
|
|
108
104
|
ts: new Date(ms).toISOString(),
|
|
@@ -179,8 +175,6 @@ export function claim(address: string, name: string): string | null {
|
|
|
179
175
|
/** A request's copy in its sender's `sent/`, with its path. */
|
|
180
176
|
export type SentCopy = { envelope: Envelope; path: string };
|
|
181
177
|
|
|
182
|
-
const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
183
|
-
|
|
184
178
|
/** The request ids an envelope answers; throws when the list is not a short list of strings. */
|
|
185
179
|
function replyIds(value: unknown): string[] {
|
|
186
180
|
if (!Array.isArray(value) || !value.every((id) => typeof id === "string"))
|
|
@@ -191,8 +185,8 @@ function replyIds(value: unknown): string[] {
|
|
|
191
185
|
|
|
192
186
|
/**
|
|
193
187
|
* Parse an envelope value; throws when it is not a well-formed envelope from a
|
|
194
|
-
* valid address. An envelope written before `kind`
|
|
195
|
-
*
|
|
188
|
+
* valid address. An envelope written before `kind` existed is read as a reply
|
|
189
|
+
* when it answers something and as a request otherwise.
|
|
196
190
|
* A `kind` this version does not know is read as `message`, so mail from a
|
|
197
191
|
* newer copy is delivered rather than stranded; the original value is dropped.
|
|
198
192
|
*/
|
|
@@ -206,8 +200,6 @@ function parseEnvelope(value: unknown): Envelope {
|
|
|
206
200
|
if (typeof envelope.body !== "string") throw new Error("missing body");
|
|
207
201
|
if (envelope.kind === undefined) envelope.kind = envelope.in_reply_to.length > 0 ? "reply" : "request";
|
|
208
202
|
else if (!KINDS.includes(envelope.kind)) envelope.kind = "message";
|
|
209
|
-
if (envelope.hops === undefined) envelope.hops = 0;
|
|
210
|
-
else if (!isHops(envelope.hops)) throw new Error(`invalid hops ${JSON.stringify(envelope.hops)}`);
|
|
211
203
|
return envelope as Envelope;
|
|
212
204
|
}
|
|
213
205
|
|
package/src/tools.ts
CHANGED
|
@@ -8,29 +8,23 @@
|
|
|
8
8
|
// `session_mail_ask` leaves an ask. In a session a person is watching it
|
|
9
9
|
// returns at once and the answer arrives as a message; elsewhere it waits, one
|
|
10
10
|
// at a time, for the answer, which comes back as its result. An unattended
|
|
11
|
-
// session must wait because ending its turn ends its work:
|
|
12
|
-
//
|
|
11
|
+
// session must wait because ending its turn ends its work: print mode exits,
|
|
12
|
+
// and an unattended TUI has no person to read a late answer.
|
|
13
13
|
// `session_mail_reply` answers an ask this session received, mid-run; the asks
|
|
14
14
|
// this session owes, and the kind of every envelope it has received, are kept
|
|
15
|
-
// here for it.
|
|
16
|
-
// tool sends carries the turn's hop count and is refused once the count
|
|
17
|
-
// reaches `hopLimit` from `<agent dir>/session-mail.json`.
|
|
15
|
+
// here for it.
|
|
18
16
|
import { readFileSync } from "node:fs";
|
|
19
17
|
import { join } from "node:path";
|
|
20
18
|
|
|
21
19
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
22
20
|
import { Type } from "typebox";
|
|
23
21
|
|
|
24
|
-
import {
|
|
22
|
+
import { DEFAULT_PRUNE_AFTER_DAYS, parseConfig } from "./config.ts";
|
|
25
23
|
import { label, listRunning, type RunningRecord, resolveTo } from "./running.ts";
|
|
26
24
|
import { type Envelope, send } from "./store.ts";
|
|
27
25
|
|
|
28
26
|
/** What the tools need from the extension around them. */
|
|
29
27
|
export type ToolHooks = {
|
|
30
|
-
/** The current turn's hop count, which mail sent now carries. */
|
|
31
|
-
hops: () => number;
|
|
32
|
-
/** Report a broken `session-mail.json`; the extension shows one warning per session. */
|
|
33
|
-
configProblem: (message: string) => void;
|
|
34
28
|
/** The address this session's ask now waits on, or "" when it waits on none; written to its running record. */
|
|
35
29
|
waitingOn: (address: string) => void;
|
|
36
30
|
};
|
|
@@ -92,7 +86,7 @@ function askText(ask: Envelope, target: string, outcome: AskOutcome): string {
|
|
|
92
86
|
|
|
93
87
|
/** Why `session_mail_reply` refuses mail that is not an ask, by the kind of that mail. */
|
|
94
88
|
const NOT_AN_ASK: Record<Exclude<Envelope["kind"], "ask">, string> = {
|
|
95
|
-
request: "A request is answered
|
|
89
|
+
request: "A request is answered by the extension that consumes it, not by this tool.",
|
|
96
90
|
message: "A message expects no answer; session_mail_send sends one if it is wanted.",
|
|
97
91
|
reply: "A reply is never answered.",
|
|
98
92
|
};
|
|
@@ -101,7 +95,7 @@ const NOT_AN_ASK: Record<Exclude<Envelope["kind"], "ask">, string> = {
|
|
|
101
95
|
* The settings from `<agent dir>/session-mail.json`, read at each call; each
|
|
102
96
|
* problem goes to `onProblem`, which the extension shows once per session.
|
|
103
97
|
*/
|
|
104
|
-
export function readSettings(onProblem: (message: string) => void): {
|
|
98
|
+
export function readSettings(onProblem: (message: string) => void): { pruneAfterDays: number } {
|
|
105
99
|
const path = join(getAgentDir(), "session-mail.json");
|
|
106
100
|
let text: string | undefined;
|
|
107
101
|
try {
|
|
@@ -109,7 +103,7 @@ export function readSettings(onProblem: (message: string) => void): { hopLimit:
|
|
|
109
103
|
} catch (err) {
|
|
110
104
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT")
|
|
111
105
|
onProblem(
|
|
112
|
-
`could not read ${path} (${(err as Error).message}); using the
|
|
106
|
+
`could not read ${path} (${(err as Error).message}); using the default, pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`,
|
|
113
107
|
);
|
|
114
108
|
text = undefined;
|
|
115
109
|
}
|
|
@@ -118,15 +112,6 @@ export function readSettings(onProblem: (message: string) => void): { hopLimit:
|
|
|
118
112
|
return settings;
|
|
119
113
|
}
|
|
120
114
|
|
|
121
|
-
/** Refuse, before anything is written, once the turn's hop count has reached the limit. */
|
|
122
|
-
function checkHops(hops: number, hooks: ToolHooks): void {
|
|
123
|
-
const limit = readSettings(hooks.configProblem).hopLimit;
|
|
124
|
-
if (hops < limit) return;
|
|
125
|
-
throw new Error(
|
|
126
|
-
`nothing was sent: this turn is ${hops} hops into a chain of sessions waking each other with nobody typing, and the hop limit of ${limit} is reached. A person typing in either session starts the count again.`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
115
|
/**
|
|
131
116
|
* The one result every tool returns. `structuredContent` is what codemode scripts
|
|
132
117
|
* receive; it must match the tool's `outputSchema`, which Pi does not check.
|
|
@@ -203,7 +188,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
203
188
|
let waiting: WaitingAsk | undefined;
|
|
204
189
|
/** The kind of each envelope this session has claimed, by id, so a reply can say why it is refused. */
|
|
205
190
|
const receivedKinds = new Map<string, Envelope["kind"]>();
|
|
206
|
-
/** Ask ids awaiting this session's answer, keyed by sender
|
|
191
|
+
/** Ask ids awaiting this session's answer, keyed by sender. */
|
|
207
192
|
let owedAsks = new Map<string, string[]>();
|
|
208
193
|
/** This session's address as the extension last set it; undefined when it has none. */
|
|
209
194
|
let address: string | undefined;
|
|
@@ -219,8 +204,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
219
204
|
const from = [...owedAsks].find(([, ids]) => ids.includes(id))?.[0];
|
|
220
205
|
// An answered ask, by this tool or at settle, is no longer owed.
|
|
221
206
|
if (from === undefined) throw new Error(`ask ${id} is already answered; nothing was sent`);
|
|
222
|
-
|
|
223
|
-
const answer = send(me, from, body, { kind: "reply", hops: hooks.hops(), inReplyTo: [id], status: "done" });
|
|
207
|
+
const answer = send(me, from, body, { kind: "reply", inReplyTo: [id], status: "done" });
|
|
224
208
|
const rest = (owedAsks.get(from) ?? []).filter((owedId) => owedId !== id);
|
|
225
209
|
if (rest.length > 0) owedAsks.set(from, rest);
|
|
226
210
|
else owedAsks.delete(from);
|
|
@@ -287,9 +271,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
287
271
|
const body = typeof params.message === "string" ? params.message.trim() : "";
|
|
288
272
|
if (body === "") throw new Error("the message is empty; nothing was sent");
|
|
289
273
|
const to = resolveTo(typeof params.to === "string" ? params.to : "", me);
|
|
290
|
-
const
|
|
291
|
-
checkHops(hops, hooks);
|
|
292
|
-
const envelope = send(me, to, body, { kind: "message", hops });
|
|
274
|
+
const envelope = send(me, to, body, { kind: "message" });
|
|
293
275
|
const target = listRunning().find((record) => record.address === to);
|
|
294
276
|
const text =
|
|
295
277
|
target === undefined
|
|
@@ -340,9 +322,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
340
322
|
throw new Error(
|
|
341
323
|
`nothing was sent: ${target} is waiting on an answer from this session, so asking it back would leave both waiting; answer it first`,
|
|
342
324
|
);
|
|
343
|
-
const
|
|
344
|
-
checkHops(hops, hooks);
|
|
345
|
-
const ask = send(me, to, body, { kind: "ask", hops });
|
|
325
|
+
const ask = send(me, to, body, { kind: "ask" });
|
|
346
326
|
const outcome: AskOutcome = attended(ctx)
|
|
347
327
|
? { outcome: "background" }
|
|
348
328
|
: await new Promise<AskOutcome>((resolve) => {
|
|
@@ -374,7 +354,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
374
354
|
name: "session_mail_reply",
|
|
375
355
|
label: "Session mail: reply",
|
|
376
356
|
description:
|
|
377
|
-
"Answer an ask another Pi session sent this session, at once, without ending this run. The asker is waiting, and gets the message as the answer to its ask; the answer this run's last message would otherwise send is then skipped for that ask. `ask` is the ask id its label gives. Each ask is answered once; requests are answered
|
|
357
|
+
"Answer an ask another Pi session sent this session, at once, without ending this run. The asker is waiting, and gets the message as the answer to its ask; the answer this run's last message would otherwise send is then skipped for that ask. `ask` is the ask id its label gives. Each ask is answered once; requests are answered by the extension that consumes them, and messages expect no answer.",
|
|
378
358
|
parameters: Type.Object({
|
|
379
359
|
ask: Type.String({ description: "The ask id, as given in the ask's label." }),
|
|
380
360
|
message: Type.String({ description: "The answer." }),
|