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
|
@@ -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
|
|
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
|
-
/**
|
|
75
|
-
const
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
-
}
|