@trim21/personal-pi-extensions 0.0.192 → 0.0.194
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/README.md +64 -1
- package/package.json +4 -3
- package/src/opencode-edit-engine.ts +3 -2
- package/src/opencode-edit.ts +41 -3
- package/src/opencode-read.ts +91 -122
- package/src/{todowrite.ts → opencode-todo.ts} +6 -0
- package/src/opencode-write.ts +53 -3
- package/src/question.ts +5 -0
- package/src/talk/core.ts +630 -0
- package/src/talk/format.ts +54 -0
- package/src/talk/index.ts +351 -0
- package/src/talk/mailbox.ts +306 -0
- package/src/talk/policy.ts +84 -0
- package/src/talk/registry.ts +148 -0
- package/src/talk/storage.ts +142 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi adapter for the talk core: owns the pi API surface.
|
|
3
|
+
*
|
|
4
|
+
* The core layer (core.ts) is pi-free and drives all talk behavior through a
|
|
5
|
+
* TalkStorage backend; this file only:
|
|
6
|
+
* - wires pi lifecycle events to the core,
|
|
7
|
+
* - turns core deliveries/notifications into pi.sendMessage,
|
|
8
|
+
* - registers the talk tools, the /talk command, and the delivery renderer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
|
|
20
|
+
import { TalkCore } from "./core.js";
|
|
21
|
+
import { formatDelivery } from "./format.js";
|
|
22
|
+
import type { Letter } from "./mailbox.js";
|
|
23
|
+
import { deriveAddr, type SessionRecord } from "./registry.js";
|
|
24
|
+
import { SqliteTalkStorage } from "./storage.js";
|
|
25
|
+
|
|
26
|
+
const DELIVERY_TYPE = "talk:delivery";
|
|
27
|
+
const LIST_TYPE = "talk:list";
|
|
28
|
+
const NOTIFY_TYPE = "talk:notify";
|
|
29
|
+
|
|
30
|
+
const ASK_TIMEOUT_MS = 120_000;
|
|
31
|
+
|
|
32
|
+
function toolResult(text: string) {
|
|
33
|
+
return { content: [{ type: "text" as const, text }], details: {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── TUI presentation helpers ─────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** Strip peer-supplied ANSI escapes/control chars before they reach the terminal. */
|
|
39
|
+
function sanitizeTerminal(text: string): string {
|
|
40
|
+
return (
|
|
41
|
+
text
|
|
42
|
+
// eslint-disable-next-line no-control-regex -- intentionally strips CSI sequences
|
|
43
|
+
.replaceAll(/\x1B\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
44
|
+
// eslint-disable-next-line no-control-regex -- intentionally strips OSC sequences
|
|
45
|
+
.replaceAll(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, "")
|
|
46
|
+
// eslint-disable-next-line no-control-regex -- intentionally strips C0 controls (keeps \n \t)
|
|
47
|
+
.replaceAll(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One-line display name, length-capped. */
|
|
52
|
+
function displayName(name: string): string {
|
|
53
|
+
return sanitizeTerminal(name.replaceAll(/\s+/g, " ")).slice(0, 40);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Collapse $HOME to ~ for display. */
|
|
57
|
+
function shortCwd(cwd: string): string {
|
|
58
|
+
const home = os.homedir();
|
|
59
|
+
const display =
|
|
60
|
+
cwd === home ? "~" : cwd.startsWith(`${home}/`) ? `~/${cwd.slice(home.length + 1)}` : cwd;
|
|
61
|
+
return sanitizeTerminal(display);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function relativeTime(ts: number, now: number = Date.now()): string {
|
|
65
|
+
const s = Math.max(0, Math.round((now - ts) / 1000));
|
|
66
|
+
if (s < 60) return `${s}s ago`;
|
|
67
|
+
const m = Math.round(s / 60);
|
|
68
|
+
if (m < 60) return `${m}m ago`;
|
|
69
|
+
return `${Math.round(m / 60)}h ago`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Presentation metadata for a delivery. `details` is never sent to the LLM. */
|
|
73
|
+
interface DeliveryDetails {
|
|
74
|
+
id: string;
|
|
75
|
+
kind: Letter["kind"];
|
|
76
|
+
from: Letter["from"];
|
|
77
|
+
ts: number;
|
|
78
|
+
body: string;
|
|
79
|
+
replyTo?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Resumability (for sweep) ─────────────────────────────────────────────
|
|
83
|
+
// pi session files live at <agentDir>/sessions/<cwd-slug>/<ts>_<sessionId>.jsonl.
|
|
84
|
+
// Collected once per sweep; a session whose file exists can be resumed and
|
|
85
|
+
// must keep its mailbox address.
|
|
86
|
+
|
|
87
|
+
function collectResumableSessionIds(): Set<string> {
|
|
88
|
+
const ids = new Set<string>();
|
|
89
|
+
let dirs: string[];
|
|
90
|
+
try {
|
|
91
|
+
dirs = fs.readdirSync(path.join(getAgentDir(), "sessions"));
|
|
92
|
+
} catch {
|
|
93
|
+
return ids;
|
|
94
|
+
}
|
|
95
|
+
for (const dir of dirs) {
|
|
96
|
+
try {
|
|
97
|
+
for (const file of fs.readdirSync(path.join(getAgentDir(), "sessions", dir))) {
|
|
98
|
+
const match = /_([0-9a-f-]{36})\.jsonl$/.exec(file);
|
|
99
|
+
if (match) ids.add(match[1]);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// skip unreadable dir
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return ids;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read the default sqlite path from global settings.json: `{ "talk": { "db_path": "..." } }`. */
|
|
109
|
+
function readDbPathFromSettings(): string | undefined {
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
|
|
112
|
+
const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown } };
|
|
113
|
+
return typeof parsed.talk?.db_path === "string" ? parsed.talk.db_path : undefined;
|
|
114
|
+
} catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default function talk(pi: ExtensionAPI) {
|
|
120
|
+
const dbPath =
|
|
121
|
+
process.env.PI_TALK_DB ?? readDbPathFromSettings() ?? path.join(getAgentDir(), "talk.db");
|
|
122
|
+
const storage = new SqliteTalkStorage(dbPath);
|
|
123
|
+
|
|
124
|
+
let self: SessionRecord | undefined;
|
|
125
|
+
|
|
126
|
+
function deliverToSession(letter: Letter): boolean {
|
|
127
|
+
const details: DeliveryDetails = {
|
|
128
|
+
id: letter.id,
|
|
129
|
+
kind: letter.kind,
|
|
130
|
+
from: letter.from,
|
|
131
|
+
ts: letter.ts,
|
|
132
|
+
body: letter.body,
|
|
133
|
+
...(letter.replyTo !== undefined && { replyTo: letter.replyTo }),
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
// steer lands between tool calls mid-run; triggerTurn wakes an idle
|
|
137
|
+
// session. The core only removes the letter from the inbox after this
|
|
138
|
+
// returns true, so a failure keeps it queued for a later poll.
|
|
139
|
+
pi.sendMessage(
|
|
140
|
+
{ customType: DELIVERY_TYPE, content: formatDelivery(letter), display: true, details },
|
|
141
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
142
|
+
);
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const core = new TalkCore({
|
|
150
|
+
storage,
|
|
151
|
+
events: {
|
|
152
|
+
deliver: deliverToSession,
|
|
153
|
+
notify(content) {
|
|
154
|
+
// Presence transitions are informational — queue for the next turn
|
|
155
|
+
// rather than steering into a busy agent.
|
|
156
|
+
pi.sendMessage(
|
|
157
|
+
{ customType: NOTIFY_TYPE, content, display: true },
|
|
158
|
+
{ deliverAs: "nextTurn" },
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
collectResumableSessionIds,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function requireInit(): string | undefined {
|
|
166
|
+
if (!core.selfAddr) return "Talk is not initialized (no session_start yet).";
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
173
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
174
|
+
const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
self = {
|
|
177
|
+
addr: deriveAddr(cwd, sessionId),
|
|
178
|
+
sessionId,
|
|
179
|
+
name: pi.getSessionName() ?? "Unnamed session",
|
|
180
|
+
cwd,
|
|
181
|
+
pid: process.pid,
|
|
182
|
+
startedAt: now,
|
|
183
|
+
lastSeenAt: now,
|
|
184
|
+
status: "idle",
|
|
185
|
+
};
|
|
186
|
+
void core.start(self);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
pi.on("agent_start", () => core.setWorking());
|
|
190
|
+
pi.on("agent_end", () => core.setIdle());
|
|
191
|
+
pi.on("agent_settled", () => core.setIdle());
|
|
192
|
+
pi.on("session_info_changed", () => {
|
|
193
|
+
if (self) core.setSessionName(pi.getSessionName() ?? self.name);
|
|
194
|
+
});
|
|
195
|
+
pi.on("session_shutdown", () => {
|
|
196
|
+
void core.stop();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── Tools ──────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
pi.registerTool({
|
|
202
|
+
name: "talk-list-sessions",
|
|
203
|
+
label: "List Talk Sessions",
|
|
204
|
+
description:
|
|
205
|
+
"List other pi sessions on this machine with their presence (idle/working/not responding/offline), address, and working directory.",
|
|
206
|
+
promptSnippet: "List other pi sessions on this machine",
|
|
207
|
+
parameters: Type.Object({
|
|
208
|
+
cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
|
|
209
|
+
}),
|
|
210
|
+
async execute(_toolCallId, params) {
|
|
211
|
+
const initError = requireInit();
|
|
212
|
+
if (initError) return toolResult(initError);
|
|
213
|
+
return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.registerTool({
|
|
218
|
+
name: "talk-read-messages",
|
|
219
|
+
label: "Read Talk Messages",
|
|
220
|
+
description:
|
|
221
|
+
"Read and consume messages from your talk inbox. Use this to actively check for incoming messages instead of waiting for a steer notification.",
|
|
222
|
+
promptSnippet: "Read incoming talk messages",
|
|
223
|
+
parameters: Type.Object({
|
|
224
|
+
from: Type.Optional(
|
|
225
|
+
Type.String({ description: "Only read messages from this session (name/address/@alias)" }),
|
|
226
|
+
),
|
|
227
|
+
}),
|
|
228
|
+
async execute(_toolCallId, params) {
|
|
229
|
+
const initError = requireInit();
|
|
230
|
+
if (initError) return toolResult(initError);
|
|
231
|
+
return toolResult(await core.readMessages(params.from));
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.registerTool({
|
|
236
|
+
name: "talk-ask",
|
|
237
|
+
label: "Ask Talk",
|
|
238
|
+
description:
|
|
239
|
+
"Ask another pi session a question and block until it replies (or times out). Before asking, it checks whether that session already sent you something; if so, you are told to read and reply first instead of asking.",
|
|
240
|
+
promptSnippet: "Ask another pi session a question and wait for the reply",
|
|
241
|
+
parameters: Type.Object({
|
|
242
|
+
to: Type.String({ description: "Target session (name/address/@alias)" }),
|
|
243
|
+
message: Type.String({ description: "The question" }),
|
|
244
|
+
timeoutMs: Type.Optional(
|
|
245
|
+
Type.Number({ description: `Wait cap in ms; default ${ASK_TIMEOUT_MS}` }),
|
|
246
|
+
),
|
|
247
|
+
}),
|
|
248
|
+
async execute(_toolCallId, params, signal) {
|
|
249
|
+
const initError = requireInit();
|
|
250
|
+
if (initError) return toolResult(initError);
|
|
251
|
+
return toolResult(
|
|
252
|
+
await core.ask(
|
|
253
|
+
params.to,
|
|
254
|
+
params.message,
|
|
255
|
+
params.timeoutMs ?? ASK_TIMEOUT_MS,
|
|
256
|
+
signal ?? undefined,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
pi.registerTool({
|
|
263
|
+
name: "talk-wait",
|
|
264
|
+
label: "Wait for Talk Message",
|
|
265
|
+
description:
|
|
266
|
+
"Block until a new talk message arrives in your inbox (or until the timeout). Returns the message(s) that arrived.",
|
|
267
|
+
promptSnippet: "Wait for an incoming talk message",
|
|
268
|
+
parameters: Type.Object({
|
|
269
|
+
timeoutMs: Type.Optional(
|
|
270
|
+
Type.Number({ description: `How long to block in ms; default ${ASK_TIMEOUT_MS}` }),
|
|
271
|
+
),
|
|
272
|
+
}),
|
|
273
|
+
async execute(_toolCallId, params, signal) {
|
|
274
|
+
const initError = requireInit();
|
|
275
|
+
if (initError) return toolResult(initError);
|
|
276
|
+
return toolResult(await core.wait(params.timeoutMs ?? ASK_TIMEOUT_MS, signal ?? undefined));
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
pi.registerTool({
|
|
281
|
+
name: "talk-send",
|
|
282
|
+
label: "Send Talk Message",
|
|
283
|
+
description:
|
|
284
|
+
'Send a plain-text message to another pi session. Plain text only, ≤32KB — send a summary and a path, never file contents. `to: "*"` broadcasts to every session; `to: "cwd"` broadcasts to sessions in this cwd.',
|
|
285
|
+
promptSnippet: "Send a message to another pi session",
|
|
286
|
+
parameters: Type.Object({
|
|
287
|
+
to: Type.String({
|
|
288
|
+
description: 'Target session (name/address/@alias; "*" or "cwd" to broadcast)',
|
|
289
|
+
}),
|
|
290
|
+
message: Type.String({ description: "Message body" }),
|
|
291
|
+
}),
|
|
292
|
+
async execute(_toolCallId, params) {
|
|
293
|
+
const initError = requireInit();
|
|
294
|
+
if (initError) return toolResult(initError);
|
|
295
|
+
return toolResult(await core.send(params.to, params.message));
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
pi.registerTool({
|
|
300
|
+
name: "talk-reply",
|
|
301
|
+
label: "Reply Talk",
|
|
302
|
+
description:
|
|
303
|
+
"Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message, or from talk-read-messages).",
|
|
304
|
+
promptSnippet: "Reply to a talk ask",
|
|
305
|
+
parameters: Type.Object({
|
|
306
|
+
replyTo: Type.String({ description: "The ask/message id to reply to" }),
|
|
307
|
+
message: Type.String({ description: "The reply body" }),
|
|
308
|
+
}),
|
|
309
|
+
async execute(_toolCallId, params) {
|
|
310
|
+
const initError = requireInit();
|
|
311
|
+
if (initError) return toolResult(initError);
|
|
312
|
+
return toolResult(await core.reply(params.replyTo, params.message));
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── /talk command ─────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
pi.registerCommand("talk", {
|
|
319
|
+
description: "List registered pi sessions",
|
|
320
|
+
async handler() {
|
|
321
|
+
const text = requireInit() ?? (await core.list());
|
|
322
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ── Delivery card ──────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
|
|
329
|
+
const d = message.details;
|
|
330
|
+
if (
|
|
331
|
+
!d ||
|
|
332
|
+
typeof d.id !== "string" ||
|
|
333
|
+
typeof d.ts !== "number" ||
|
|
334
|
+
typeof d.body !== "string" ||
|
|
335
|
+
!d.from
|
|
336
|
+
) {
|
|
337
|
+
return; // pre-renderer entries: keep pi's default custom-message box
|
|
338
|
+
}
|
|
339
|
+
const id8 = d.id.slice(0, 8);
|
|
340
|
+
const chip = theme.inverse(` ${d.kind.toUpperCase()} `);
|
|
341
|
+
const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
|
|
342
|
+
const footer = theme.fg("dim", `id ${id8} · ${d.kind} · ${relativeTime(d.ts)}`);
|
|
343
|
+
const out = [header, sanitizeTerminal(d.body), "", footer];
|
|
344
|
+
if (d.kind === "ask") {
|
|
345
|
+
out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${id8}"`));
|
|
346
|
+
}
|
|
347
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
348
|
+
box.addChild(new Text(out.join("\n"), 0, 0));
|
|
349
|
+
return box;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Letter transport for the talk mailbox. Core layer — depends only on
|
|
3
|
+
* TalkStorage, never on pi.
|
|
4
|
+
*
|
|
5
|
+
* Guarantees:
|
|
6
|
+
* - A reader never sees half a letter: writes are atomic (single SQL upsert).
|
|
7
|
+
* - Consumption is decoupled from delivery: `listInbox` only reads; the
|
|
8
|
+
* caller removes a letter with `removeLetter` AFTER it has been handed to
|
|
9
|
+
* the session. A letter that could not be delivered stays in the inbox and
|
|
10
|
+
* is retried on the next poll.
|
|
11
|
+
* - Every value read from storage is validated with a TypeBox schema.
|
|
12
|
+
* - Every deposit and delivery appends one append-only audit line. The log
|
|
13
|
+
* never holds a full body, only a short preview.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
import { type Static, Type } from "typebox";
|
|
19
|
+
import { Value } from "typebox/value";
|
|
20
|
+
|
|
21
|
+
import { asksNs, assertAddress, inboxNs } from "./registry.js";
|
|
22
|
+
import type { TalkStorage } from "./storage.js";
|
|
23
|
+
|
|
24
|
+
export const LetterSchema = Type.Object({
|
|
25
|
+
id: Type.String(),
|
|
26
|
+
from: Type.Object({
|
|
27
|
+
addr: Type.String(),
|
|
28
|
+
name: Type.String(),
|
|
29
|
+
cwd: Type.String(),
|
|
30
|
+
sessionId: Type.String(),
|
|
31
|
+
}),
|
|
32
|
+
kind: Type.Union([
|
|
33
|
+
Type.Literal("message"),
|
|
34
|
+
Type.Literal("ask"),
|
|
35
|
+
Type.Literal("reply"),
|
|
36
|
+
Type.Literal("cancel"),
|
|
37
|
+
]),
|
|
38
|
+
body: Type.String(),
|
|
39
|
+
replyTo: Type.Optional(Type.String()),
|
|
40
|
+
ts: Type.Number(),
|
|
41
|
+
});
|
|
42
|
+
export type Letter = Static<typeof LetterSchema>;
|
|
43
|
+
export type LetterKind = Letter["kind"];
|
|
44
|
+
|
|
45
|
+
export const OutAskSchema = Type.Object({
|
|
46
|
+
askId: Type.String(),
|
|
47
|
+
toAddr: Type.String(),
|
|
48
|
+
body: Type.String(),
|
|
49
|
+
ts: Type.Number(),
|
|
50
|
+
});
|
|
51
|
+
export type OutAsk = Static<typeof OutAskSchema>;
|
|
52
|
+
|
|
53
|
+
export const AuditRecordSchema = Type.Object({
|
|
54
|
+
ts: Type.Number(),
|
|
55
|
+
event: Type.Union([
|
|
56
|
+
Type.Literal("deposit"),
|
|
57
|
+
Type.Literal("deliver"),
|
|
58
|
+
Type.Literal("deliver-failed"),
|
|
59
|
+
]),
|
|
60
|
+
kind: Type.Union([
|
|
61
|
+
Type.Literal("message"),
|
|
62
|
+
Type.Literal("ask"),
|
|
63
|
+
Type.Literal("reply"),
|
|
64
|
+
Type.Literal("cancel"),
|
|
65
|
+
]),
|
|
66
|
+
from: Type.String(),
|
|
67
|
+
to: Type.String(),
|
|
68
|
+
messageId: Type.String(),
|
|
69
|
+
preview: Type.String(),
|
|
70
|
+
});
|
|
71
|
+
export type AuditRecord = Static<typeof AuditRecordSchema>;
|
|
72
|
+
|
|
73
|
+
export const MAX_BODY_CHARS = 32 * 1024;
|
|
74
|
+
|
|
75
|
+
const MESSAGE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
76
|
+
|
|
77
|
+
export function newMessageId(): string {
|
|
78
|
+
return randomUUID();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertMessageId(id: string): void {
|
|
82
|
+
if (!MESSAGE_ID_PATTERN.test(id)) throw new TypeError(`Invalid talk message id: ${id}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The storage key for a letter: `<ts>-<id>.json`, sorted oldest-first. */
|
|
86
|
+
export function letterFileName(letter: Letter): string {
|
|
87
|
+
assertMessageId(letter.id);
|
|
88
|
+
if (!Number.isSafeInteger(letter.ts) || letter.ts < 0) {
|
|
89
|
+
throw new TypeError(`Invalid talk letter timestamp: ${letter.ts}`);
|
|
90
|
+
}
|
|
91
|
+
return `${letter.ts}-${letter.id}.json`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Structural validation plus filename safety (id/timestamp shape). */
|
|
95
|
+
export function isValidLetter(value: unknown): value is Letter {
|
|
96
|
+
if (!Value.Check(LetterSchema, value)) return false;
|
|
97
|
+
try {
|
|
98
|
+
letterFileName(value);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Inbox (delivery) ─────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Atomically deposit a letter into a peer's inbox, then append a deposit
|
|
109
|
+
* audit line.
|
|
110
|
+
*/
|
|
111
|
+
export async function deposit(storage: TalkStorage, toAddr: string, letter: Letter): Promise<void> {
|
|
112
|
+
assertAddress(toAddr);
|
|
113
|
+
await storage.writeJson(inboxNs(toAddr), letterFileName(letter), letter);
|
|
114
|
+
await appendAudit(storage, {
|
|
115
|
+
ts: Date.now(),
|
|
116
|
+
event: "deposit",
|
|
117
|
+
kind: letter.kind,
|
|
118
|
+
from: letter.from.addr,
|
|
119
|
+
to: toAddr,
|
|
120
|
+
messageId: letter.id,
|
|
121
|
+
preview: previewBody(letter.body),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface InboxItem {
|
|
126
|
+
fileName: string;
|
|
127
|
+
letter: Letter;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read every letter in the inbox, oldest first, WITHOUT removing anything. */
|
|
131
|
+
export async function listInbox(storage: TalkStorage, addr: string): Promise<InboxItem[]> {
|
|
132
|
+
assertAddress(addr);
|
|
133
|
+
const out: InboxItem[] = [];
|
|
134
|
+
for (const fileName of await storage.listKeys(inboxNs(addr))) {
|
|
135
|
+
const raw = await storage.readJson(inboxNs(addr), fileName);
|
|
136
|
+
if (isValidLetter(raw)) out.push({ fileName, letter: raw });
|
|
137
|
+
// corrupt letters are skipped; the caller may remove them separately
|
|
138
|
+
}
|
|
139
|
+
return out; // keys are already sorted by listKeys
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Remove a delivered letter from the inbox. Idempotent. */
|
|
143
|
+
export async function removeLetter(
|
|
144
|
+
storage: TalkStorage,
|
|
145
|
+
addr: string,
|
|
146
|
+
fileName: string,
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
assertAddress(addr);
|
|
149
|
+
return storage.removeKey(inboxNs(addr), fileName);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function unreadCount(storage: TalkStorage, addr: string): Promise<number> {
|
|
153
|
+
assertAddress(addr);
|
|
154
|
+
const keys = await storage.listKeys(inboxNs(addr));
|
|
155
|
+
return keys.length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Consumption receipt: after depositing to a LIVE target, wait briefly for
|
|
162
|
+
* the exact letter to vanish from the target's inbox. Under the
|
|
163
|
+
* deliver-then-remove semantics, disappearance means the receiver actually
|
|
164
|
+
* handed the letter to its session, not merely drained it.
|
|
165
|
+
*/
|
|
166
|
+
export async function awaitReceipt(
|
|
167
|
+
storage: TalkStorage,
|
|
168
|
+
toAddr: string,
|
|
169
|
+
letter: Letter,
|
|
170
|
+
timeoutMs = 1500,
|
|
171
|
+
): Promise<"delivered" | "queued"> {
|
|
172
|
+
assertAddress(toAddr);
|
|
173
|
+
const deadline = Date.now() + timeoutMs;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
const inbox = await listInbox(storage, toAddr);
|
|
176
|
+
const stillThere = inbox.some((i) => i.letter.id === letter.id);
|
|
177
|
+
if (!stillThere) return "delivered";
|
|
178
|
+
await sleep(100);
|
|
179
|
+
}
|
|
180
|
+
const inbox = await listInbox(storage, toAddr);
|
|
181
|
+
return inbox.some((i) => i.letter.id === letter.id) ? "queued" : "delivered";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Ask tracking ─────────────────────────────────────────────────────────
|
|
185
|
+
// Received asks live at asks/<addr>/<id>.json until we reply; our outgoing
|
|
186
|
+
// asks live at asks/<addr>/out-<id>.json until a reply/cancel arrives or we
|
|
187
|
+
// time out.
|
|
188
|
+
|
|
189
|
+
function askKey(askId: string): string {
|
|
190
|
+
assertMessageId(askId);
|
|
191
|
+
return `${askId}.json`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function outAskKey(askId: string): string {
|
|
195
|
+
assertMessageId(askId);
|
|
196
|
+
return `out-${askId}.json`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function trackIncomingAsk(
|
|
200
|
+
storage: TalkStorage,
|
|
201
|
+
addr: string,
|
|
202
|
+
letter: Letter,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await storage.writeJson(asksNs(addr), askKey(letter.id), letter);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function trackOutgoingAsk(
|
|
208
|
+
storage: TalkStorage,
|
|
209
|
+
addr: string,
|
|
210
|
+
out: OutAsk,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
await storage.writeJson(asksNs(addr), outAskKey(out.askId), out);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function readIncomingAsk(
|
|
216
|
+
storage: TalkStorage,
|
|
217
|
+
addr: string,
|
|
218
|
+
askId: string,
|
|
219
|
+
): Promise<Letter | null> {
|
|
220
|
+
const raw = await storage.readJson(asksNs(addr), askKey(askId));
|
|
221
|
+
return Value.Check(LetterSchema, raw) ? raw : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function readOutgoingAsk(
|
|
225
|
+
storage: TalkStorage,
|
|
226
|
+
addr: string,
|
|
227
|
+
askId: string,
|
|
228
|
+
): Promise<OutAsk | null> {
|
|
229
|
+
const raw = await storage.readJson(asksNs(addr), outAskKey(askId));
|
|
230
|
+
return Value.Check(OutAskSchema, raw) ? raw : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
|
|
234
|
+
export async function clearAsk(storage: TalkStorage, addr: string, askId: string): Promise<void> {
|
|
235
|
+
await storage.removeKey(asksNs(addr), askKey(askId));
|
|
236
|
+
await storage.removeKey(asksNs(addr), outAskKey(askId));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Asks we have received and not yet answered, oldest first. */
|
|
240
|
+
export async function pendingAsks(storage: TalkStorage, addr: string): Promise<Letter[]> {
|
|
241
|
+
const out: Letter[] = [];
|
|
242
|
+
for (const key of await storage.listKeys(asksNs(addr))) {
|
|
243
|
+
if (key.startsWith("out-")) continue;
|
|
244
|
+
const raw = await storage.readJson(asksNs(addr), key);
|
|
245
|
+
if (Value.Check(LetterSchema, raw)) out.push(raw);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Outgoing ask ids, used by the adapter for prefix resolution. */
|
|
251
|
+
export async function outgoingAskIds(storage: TalkStorage, addr: string): Promise<string[]> {
|
|
252
|
+
const out: string[] = [];
|
|
253
|
+
for (const key of await storage.listKeys(asksNs(addr))) {
|
|
254
|
+
if (!key.startsWith("out-") || !key.endsWith(".json")) continue;
|
|
255
|
+
out.push(key.slice("out-".length, -".json".length));
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Resolve a pending ask by explicit replyTo id or unique prefix. No inference. */
|
|
261
|
+
export async function resolveAskByRef(
|
|
262
|
+
storage: TalkStorage,
|
|
263
|
+
addr: string,
|
|
264
|
+
replyTo: string,
|
|
265
|
+
): Promise<Letter | null> {
|
|
266
|
+
if (!replyTo) return null; // empty prefix matches every id — an explicit ref is required
|
|
267
|
+
const asks = await pendingAsks(storage, addr);
|
|
268
|
+
return asks.find((a) => a.id === replyTo || a.id.startsWith(replyTo)) ?? null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Audit log ────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
const AUDIT_LOG = "audit";
|
|
274
|
+
const AUDIT_PREVIEW_CHARS = 80;
|
|
275
|
+
|
|
276
|
+
/** Whitespace-collapsed body preview — never the full payload. */
|
|
277
|
+
export function previewBody(body: string): string {
|
|
278
|
+
// Strip ANSI/CSI escapes — the preview is peer-controlled text that can
|
|
279
|
+
// reach a raw terminal via the non-UI audit print path.
|
|
280
|
+
// eslint-disable-next-line no-control-regex -- intentionally strips ANSI escapes
|
|
281
|
+
const stripped = body.replaceAll(/\x1B\[[0-9;?]*[a-zA-Z]|\x1B./g, "");
|
|
282
|
+
return stripped.replaceAll(/\s+/g, " ").trim().slice(0, AUDIT_PREVIEW_CHARS);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Append one audit record. Best-effort: never throws (audit must not break delivery). */
|
|
286
|
+
export async function appendAudit(storage: TalkStorage, record: AuditRecord): Promise<void> {
|
|
287
|
+
try {
|
|
288
|
+
await storage.appendLog(AUDIT_LOG, JSON.stringify(record));
|
|
289
|
+
} catch {
|
|
290
|
+
// audit failure never breaks the mail path
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Read the last `limit` audit entries (oldest-first within that tail). */
|
|
295
|
+
export async function readAudit(storage: TalkStorage, limit = 50): Promise<AuditRecord[]> {
|
|
296
|
+
const out: AuditRecord[] = [];
|
|
297
|
+
for (const line of await storage.readLog(AUDIT_LOG)) {
|
|
298
|
+
try {
|
|
299
|
+
const parsed: unknown = JSON.parse(line);
|
|
300
|
+
if (Value.Check(AuditRecordSchema, parsed)) out.push(parsed);
|
|
301
|
+
} catch {
|
|
302
|
+
// skip corrupt line — append-only, never fatal
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out.slice(-limit);
|
|
306
|
+
}
|