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.
@@ -1,12 +1,15 @@
1
1
  // The model-facing tools of `pi-session-mail`. Internal to the package: tests reach
2
- // them only through the extension's registration function.
2
+ // them only through the extension's registration function (`settle.ts` is the
3
+ // one module tested directly).
3
4
  //
4
5
  // `session_mail_list` reports every running Pi session on this machine, in
5
6
  // every agent directory, and marks the calling one. `session_mail_send` leaves a plain
6
7
  // message, which wakes or steers its recipient and expects no answer.
7
8
  // `session_mail_ask` leaves an ask and waits, one at a time, for the answer,
8
9
  // which comes back as its result rather than as a message.
9
- // `session_mail_reply` answers an ask this session received, mid-run. Mail a
10
+ // `session_mail_reply` answers an ask this session received, mid-run; the asks
11
+ // this session owes, and the kind of every envelope it has received, are kept
12
+ // here for it. Mail a
10
13
  // tool sends carries the turn's hop count and is refused once the count
11
14
  // reaches `hopLimit` from `<agent dir>/session-mail.json`.
12
15
  import { readFileSync } from "node:fs";
@@ -15,6 +18,7 @@ import { join } from "node:path";
15
18
  import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
16
19
  import { Type } from "typebox";
17
20
 
21
+ import { DEFAULT_HOP_LIMIT, DEFAULT_PRUNE_AFTER_DAYS, parseConfig } from "./config.ts";
18
22
  import { label, listRunning, type RunningRecord, resolveTo } from "./running.ts";
19
23
  import { type Envelope, send } from "./store.ts";
20
24
 
@@ -28,12 +32,18 @@ export type ToolHooks = {
28
32
  configProblem: (message: string) => void;
29
33
  /** The address this session's ask now waits on, or "" when it waits on none; written to its running record. */
30
34
  waitingOn: (address: string) => void;
31
- /** Answer the open ask `id` this session received with `body` at once; throws, sending nothing, when it cannot. */
32
- reply: (id: string, body: string) => Envelope;
33
35
  };
34
36
 
35
- /** What the extension needs from the tools: the ask that is waiting. */
37
+ /** What the extension needs from the tools: the ask that is waiting, and the asks owed. */
36
38
  export type Tools = {
39
+ /** Remember the kind of a claimed envelope, so a refused reply can say why; never forgotten. */
40
+ recordKind: (envelope: Envelope) => void;
41
+ /** Arm an answer for an ask: its sender is owed one. Any other kind is ignored. */
42
+ armAsk: (envelope: Envelope) => void;
43
+ /** The session's mailbox address, or undefined when it has none (before a valid start, after shutdown); owed asks are dropped. */
44
+ setAddress: (address: string | undefined) => void;
45
+ /** The ask ids still owed an answer, by sender; they are no longer owed afterwards. */
46
+ takeOwedAsks: () => Map<string, string[]>;
37
47
  /** Hand a reply to the waiting ask it answers; false when it answers none, so it is delivered as usual. */
38
48
  takeAnswer: (envelope: Envelope) => boolean;
39
49
  /** Stop waiting, as when the session shuts down. */
@@ -71,53 +81,37 @@ function askText(ask: Envelope, target: string, outcome: AskOutcome): string {
71
81
  }
72
82
  }
73
83
 
74
- /** The hop limit when `session-mail.json` is missing or broken (spec Q14). */
75
- const DEFAULT_HOP_LIMIT = 5;
84
+ /** Why `session_mail_reply` refuses mail that is not an ask, by the kind of that mail. */
85
+ const NOT_AN_ASK: Record<Exclude<Envelope["kind"], "ask">, string> = {
86
+ request: "A request is answered automatically when this run settles.",
87
+ message: "A message expects no answer; session_mail_send sends one if it is wanted.",
88
+ reply: "A reply is never answered.",
89
+ };
76
90
 
77
91
  /**
78
- * `hopLimit` from `<agent dir>/session-mail.json`, read at each send. A
79
- * missing file, or one without `hopLimit`, means the default; an unreadable
80
- * or invalid one means the default and a problem report.
92
+ * The settings from `<agent dir>/session-mail.json`, read at each call; each
93
+ * problem goes to `onProblem`, which the extension shows once per session.
81
94
  */
82
- function hopLimit(hooks: ToolHooks): number {
95
+ export function readSettings(onProblem: (message: string) => void): { hopLimit: number; pruneAfterDays: number } {
83
96
  const path = join(getAgentDir(), "session-mail.json");
84
- let text: string;
97
+ let text: string | undefined;
85
98
  try {
86
99
  text = readFileSync(path, "utf8");
87
100
  } catch (err) {
88
101
  if ((err as NodeJS.ErrnoException).code !== "ENOENT")
89
- hooks.configProblem(
90
- `could not read ${path} (${(err as Error).message}); using the hop limit ${DEFAULT_HOP_LIMIT}`,
102
+ onProblem(
103
+ `could not read ${path} (${(err as Error).message}); using the hop limit ${DEFAULT_HOP_LIMIT} and pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`,
91
104
  );
92
- return DEFAULT_HOP_LIMIT;
93
- }
94
- let config: unknown;
95
- try {
96
- config = JSON.parse(text);
97
- } catch (err) {
98
- hooks.configProblem(
99
- `${path} is not valid JSON (${(err as Error).message}); using the hop limit ${DEFAULT_HOP_LIMIT}`,
100
- );
101
- return DEFAULT_HOP_LIMIT;
102
- }
103
- if (typeof config !== "object" || config === null || Array.isArray(config)) {
104
- hooks.configProblem(`${path} is not a JSON object; using the hop limit ${DEFAULT_HOP_LIMIT}`);
105
- return DEFAULT_HOP_LIMIT;
105
+ text = undefined;
106
106
  }
107
- const limit = (config as { hopLimit?: unknown }).hopLimit;
108
- if (limit === undefined) return DEFAULT_HOP_LIMIT;
109
- if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) {
110
- hooks.configProblem(
111
- `hopLimit in ${path} must be a positive integer, not ${JSON.stringify(limit)}; using ${DEFAULT_HOP_LIMIT}`,
112
- );
113
- return DEFAULT_HOP_LIMIT;
114
- }
115
- return limit;
107
+ const { problems, ...settings } = parseConfig(text, path);
108
+ for (const problem of problems) onProblem(problem);
109
+ return settings;
116
110
  }
117
111
 
118
112
  /** Refuse, before anything is written, once the turn's hop count has reached the limit. */
119
113
  function checkHops(hops: number, hooks: ToolHooks): void {
120
- const limit = hopLimit(hooks);
114
+ const limit = readSettings(hooks.configProblem).hopLimit;
121
115
  if (hops < limit) return;
122
116
  throw new Error(
123
117
  `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.`,
@@ -144,6 +138,31 @@ function waitLabel(address: string, byAddress: ReadonlyMap<string, RunningRecord
144
138
  export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
145
139
  /** The one ask of this session that is waiting for its answer (spec Q20). */
146
140
  let waiting: WaitingAsk | undefined;
141
+ /** The kind of each envelope this session has claimed, by id, so a reply can say why it is refused. */
142
+ const receivedKinds = new Map<string, Envelope["kind"]>();
143
+ /** Ask ids awaiting this session's answer, keyed by sender; answered apart from requests. */
144
+ let owedAsks = new Map<string, string[]>();
145
+ /** This session's address as the extension last set it; undefined when it has none. */
146
+ let address: string | undefined;
147
+
148
+ /** Answer the open ask `id` with `body` at once; throws, sending nothing, when it cannot. */
149
+ function reply(id: string, body: string): Envelope {
150
+ const me = address;
151
+ if (me === undefined) throw new Error("this session has no mailbox address; nothing was sent");
152
+ const kind = receivedKinds.get(id);
153
+ if (kind === undefined)
154
+ throw new Error(`no mail with id ${JSON.stringify(id)} reached this session; nothing was sent`);
155
+ if (kind !== "ask") throw new Error(`${id} is a ${kind}, not an ask; nothing was sent. ${NOT_AN_ASK[kind]}`);
156
+ const from = [...owedAsks].find(([, ids]) => ids.includes(id))?.[0];
157
+ // An answered ask, by this tool or at settle, is no longer owed.
158
+ if (from === undefined) throw new Error(`ask ${id} is already answered; nothing was sent`);
159
+ // Answers carry the turn's count and are never refused by the hop limit.
160
+ const answer = send(me, from, body, { kind: "reply", hops: hooks.hops(), inReplyTo: [id], status: "done" });
161
+ const rest = (owedAsks.get(from) ?? []).filter((owedId) => owedId !== id);
162
+ if (rest.length > 0) owedAsks.set(from, rest);
163
+ else owedAsks.delete(from);
164
+ return answer;
165
+ }
147
166
 
148
167
  pi.registerTool({
149
168
  name: "session_mail_list",
@@ -277,11 +296,17 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
277
296
  ask: Type.String({ description: "The ask id, as given in the ask's label." }),
278
297
  message: Type.String({ description: "The answer." }),
279
298
  }),
280
- async execute(_toolCallId: string, params: { ask: string; message: string }) {
299
+ async execute(
300
+ _toolCallId: string,
301
+ params: { ask: string; message: string },
302
+ _signal: unknown,
303
+ _onUpdate: unknown,
304
+ _ctx: ExtensionContext,
305
+ ) {
281
306
  const id = typeof params.ask === "string" ? params.ask.trim() : "";
282
307
  const body = typeof params.message === "string" ? params.message.trim() : "";
283
308
  if (body === "") throw new Error("the answer is empty; nothing was sent");
284
- const answer = hooks.reply(id, body);
309
+ const answer = reply(id, body);
285
310
  hooks.sent();
286
311
  const sender = listRunning().find((record) => record.address === answer.to);
287
312
  const who = sender === undefined ? answer.to : `${label(sender)} (${answer.to})`;
@@ -294,6 +319,21 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
294
319
  });
295
320
 
296
321
  return {
322
+ recordKind(envelope) {
323
+ receivedKinds.set(envelope.id, envelope.kind);
324
+ },
325
+ armAsk(envelope) {
326
+ if (envelope.kind === "ask") owedAsks.set(envelope.from, [...(owedAsks.get(envelope.from) ?? []), envelope.id]);
327
+ },
328
+ setAddress(next) {
329
+ address = next;
330
+ owedAsks = new Map();
331
+ },
332
+ takeOwedAsks() {
333
+ const taken = owedAsks;
334
+ owedAsks = new Map();
335
+ return taken;
336
+ },
297
337
  takeAnswer(envelope) {
298
338
  if (waiting === undefined || envelope.kind !== "reply" || !envelope.in_reply_to.includes(waiting.id))
299
339
  return false;
package/store.ts DELETED
@@ -1,222 +0,0 @@
1
- // The mailbox's directories and envelopes on disk. Internal to `pi-session-mail`:
2
- // tests reach it only through the extension's registration function.
3
- //
4
- // Layout: <root>/<address>/{tmp,new,cur,sent}/<ms>-<id>.json, where the root
5
- // is `$XDG_STATE_HOME/pi-session-mail/` (or `~/.local/state/pi-session-mail/`)
6
- // and is shared by every Pi agent directory on this machine.
7
- import { randomUUID } from "node:crypto";
8
- import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
- import { homedir } from "node:os";
10
- import { isAbsolute, join } from "node:path";
11
-
12
- /** What an envelope is: answered at settle, waited on, plain, or an answer. */
13
- export type Kind = "request" | "ask" | "message" | "reply";
14
- const KINDS: readonly Kind[] = ["request", "ask", "message", "reply"];
15
-
16
- export type Envelope = {
17
- id: string;
18
- from: string;
19
- to: string;
20
- kind: Kind;
21
- /** How many times this chain of mail has woken or steered a session with no person typing. */
22
- hops: number;
23
- /** Request ids this envelope answers; empty for a request. */
24
- in_reply_to: string[];
25
- status: string;
26
- ts: string;
27
- body: string;
28
- };
29
-
30
- type Box = "tmp" | "new" | "cur" | "sent";
31
- const BOXES: Box[] = ["tmp", "new", "cur", "sent"];
32
-
33
- const ADDRESS = /^[A-Za-z0-9_-]+$/;
34
-
35
- /** An address is a single safe path segment: letters, digits, `-` and `_`. */
36
- export const isAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value);
37
-
38
- /**
39
- * The mail root, read at call time so a changed `XDG_STATE_HOME` is honoured.
40
- * A relative `XDG_STATE_HOME` is ignored, as the XDG spec requires.
41
- */
42
- export function mailRoot(): string {
43
- const state = process.env.XDG_STATE_HOME;
44
- const base = state !== undefined && isAbsolute(state) ? state : join(homedir(), ".local", "state");
45
- return join(base, "pi-session-mail");
46
- }
47
-
48
- export function boxPath(address: string, box: Box): string {
49
- if (!isAddress(address)) throw new Error(`invalid mailbox address: ${JSON.stringify(address)}`);
50
- return join(mailRoot(), address, box);
51
- }
52
-
53
- /** Create the root and `address`'s boxes; every directory made here is owner-only. */
54
- export function ensureBoxes(address: string): void {
55
- mkdirSync(mailRoot(), { recursive: true, mode: 0o700 });
56
- for (const box of BOXES) mkdirSync(boxPath(address, box), { recursive: true, mode: 0o700 });
57
- }
58
-
59
- // Names sort in send order: a strictly increasing millisecond stamp in this
60
- // process, zero-padded, then the id.
61
- let lastMs = 0;
62
- function stamp(): number {
63
- lastMs = Math.max(Date.now(), lastMs + 1);
64
- return lastMs;
65
- }
66
-
67
- const fileName = (ms: number, id: string) => `${String(ms).padStart(15, "0")}-${id}.json`;
68
-
69
- /** How `send` writes an envelope. A reply names what it answers in `inReplyTo`. */
70
- export type SendOptions = { kind: Kind; hops: number; inReplyTo?: string[]; status?: string };
71
-
72
- /**
73
- * Write an envelope to `to`'s inbox: into its `tmp/`, then renamed into its
74
- * `new/`. A request is also copied into the sender's `sent/`. Synchronous,
75
- * so a `pi.events` caller sees the result as soon as `emit` returns.
76
- * A reply's `status` is `done` unless given; any other kind's is empty.
77
- */
78
- export function send(from: string, to: string, body: string, options: SendOptions): Envelope {
79
- if (!isAddress(from)) throw new Error(`invalid sender address: ${JSON.stringify(from)}`);
80
- if (!isAddress(to)) throw new Error(`invalid address: ${JSON.stringify(to)} (expected a session id)`);
81
- if (!KINDS.includes(options.kind)) throw new Error(`invalid kind: ${JSON.stringify(options.kind)}`);
82
- if (!isHops(options.hops)) throw new Error(`invalid hops: ${JSON.stringify(options.hops)}`);
83
- const inReplyTo = options.inReplyTo ?? [];
84
- const ms = stamp();
85
- const envelope: Envelope = {
86
- id: randomUUID(),
87
- from,
88
- to,
89
- kind: options.kind,
90
- hops: options.hops,
91
- in_reply_to: inReplyTo,
92
- status: options.kind === "reply" ? (options.status ?? "done") : "",
93
- ts: new Date(ms).toISOString(),
94
- body,
95
- };
96
- const name = fileName(ms, envelope.id);
97
- const text = `${JSON.stringify(envelope, null, 2)}\n`;
98
- ensureBoxes(to);
99
- const tmp = join(boxPath(to, "tmp"), name);
100
- writeFileSync(tmp, text);
101
- renameSync(tmp, join(boxPath(to, "new"), name));
102
- if (options.kind !== "reply") {
103
- ensureBoxes(from);
104
- writeFileSync(join(boxPath(from, "sent"), name), text);
105
- }
106
- return envelope;
107
- }
108
-
109
- /** Regular `.json` files waiting in `new/`, in send order. */
110
- export function listNew(address: string): string[] {
111
- const dir = boxPath(address, "new");
112
- let names: string[];
113
- try {
114
- names = readdirSync(dir);
115
- } catch {
116
- return [];
117
- }
118
- return names
119
- .filter((name) => name.endsWith(".json"))
120
- .filter((name) => {
121
- try {
122
- return lstatSync(join(dir, name)).isFile();
123
- } catch {
124
- return false;
125
- }
126
- })
127
- .sort();
128
- }
129
-
130
- /** Claim a file by renaming it from `new/` into `cur/`; null when another scanner got it first. */
131
- export function claim(address: string, name: string): string | null {
132
- const target = join(boxPath(address, "cur"), name);
133
- try {
134
- renameSync(join(boxPath(address, "new"), name), target);
135
- return target;
136
- } catch {
137
- return null;
138
- }
139
- }
140
-
141
- /** A request's copy in its sender's `sent/`, with its path. */
142
- export type SentCopy = { envelope: Envelope; path: string };
143
-
144
- const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
145
-
146
- /**
147
- * Parse an envelope file; throws when it is not a well-formed envelope from a
148
- * valid address. An envelope written before `kind` and `hops` existed is read
149
- * as a reply when it answers something and as a request otherwise, with hops 0.
150
- */
151
- export function readEnvelope(path: string): Envelope {
152
- const value = JSON.parse(readFileSync(path, "utf8")) as Partial<Envelope>;
153
- if (typeof value !== "object" || value === null) throw new Error("not an object");
154
- if (typeof value.id !== "string" || value.id === "") throw new Error("missing id");
155
- if (!isAddress(value.from)) throw new Error(`invalid from address ${JSON.stringify(value.from)}`);
156
- if (!Array.isArray(value.in_reply_to) || !value.in_reply_to.every((id) => typeof id === "string"))
157
- throw new Error("in_reply_to is not a list of ids");
158
- if (typeof value.body !== "string") throw new Error("missing body");
159
- if (value.kind === undefined) value.kind = value.in_reply_to.length > 0 ? "reply" : "request";
160
- else if (!KINDS.includes(value.kind)) throw new Error(`invalid kind ${JSON.stringify(value.kind)}`);
161
- if (value.hops === undefined) value.hops = 0;
162
- else if (!isHops(value.hops)) throw new Error(`invalid hops ${JSON.stringify(value.hops)}`);
163
- return value as Envelope;
164
- }
165
-
166
- /**
167
- * Find this session's `sent/` copy of the request with envelope id `id`;
168
- * undefined when no readable copy exists. Only names matching the id are read,
169
- * because `sent/` is never pruned and grows with the address's age.
170
- */
171
- export function findSent(address: string, id: string): SentCopy | undefined {
172
- const dir = boxPath(address, "sent");
173
- let names: string[];
174
- try {
175
- names = readdirSync(dir).filter((name) => name.endsWith(`-${id}.json`));
176
- } catch {
177
- return undefined;
178
- }
179
- for (const name of names) {
180
- const path = join(dir, name);
181
- try {
182
- const envelope = readEnvelope(path);
183
- if (envelope.id === id) return { envelope, path };
184
- } catch {
185
- // an unreadable copy is no copy
186
- }
187
- }
188
- return undefined;
189
- }
190
-
191
- function envelopesIn(address: string, box: Box): Partial<Envelope>[] {
192
- const dir = boxPath(address, box);
193
- let names: string[];
194
- try {
195
- names = readdirSync(dir).filter((name) => name.endsWith(".json"));
196
- } catch {
197
- return [];
198
- }
199
- const out: Partial<Envelope>[] = [];
200
- for (const name of names) {
201
- try {
202
- out.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
203
- } catch {
204
- out.push({});
205
- }
206
- }
207
- return out;
208
- }
209
-
210
- /**
211
- * Counts derived from disk: files still in `new/`, envelopes in `cur/`, and
212
- * requests (not messages) in `sent/` that no envelope in `cur/` answers.
213
- */
214
- export function diskCounts(address: string): { unclaimed: number; read: number; awaiting: number } {
215
- const cur = envelopesIn(address, "cur");
216
- const answered = new Set(cur.flatMap((e) => (Array.isArray(e.in_reply_to) ? e.in_reply_to : [])));
217
- // A message expects no answer, so it never counts as awaiting one.
218
- const awaiting = envelopesIn(address, "sent").filter(
219
- (e) => typeof e.id === "string" && e.kind !== "message" && !answered.has(e.id),
220
- ).length;
221
- return { unclaimed: listNew(address).length, read: cur.length, awaiting };
222
- }