@hachej/boring-agent 0.4.18 → 0.4.20

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 CHANGED
@@ -205,6 +205,18 @@ Failure is fail-open and Pi's: a throwing `context` is reported through `Harness
205
205
 
206
206
  Screen context: a browser can send what the person is looking at with each submit. `createRemoteChat({ requestContext })` adds the returned plain object to the submit body as `context`. In `ChatTransportAccess`, `requestContext(value)` receives it after validation and before `conversation.submit`; a value that is not a plain JSON object of at most 4 KB is `400 invalid-request`, and a host without `requestContext` ignores the field. The host stores `context` by conversation id in `requestContext` and renders it in `createPromptContext({ context })`; the transport keeps nothing. With `useRemoteChat` the context is the `requestContext` value as it is (`name`, `params`: a host written before placement keeps working) plus `placement` (`docked` or `floating`), reported by `PiChat` / `AmbientChat`; `PiChat` takes a `placement` prop for a host that floats it, as the `AgentLayer` does. `renderRequestContext(value)` (from `@hachej/boring-agent/prompt-context`) turns it into the agent's role and the screen for `createPromptContext`; placement changes behaviour, never authority (see [PAGE-TOOLS.md](../../docs/architecture/PAGE-TOOLS.md)). Tests: `test/packages/agent-chat-transport-context.test.mjs`, `test/packages/ui-remote-chat-context.test.mjs`, `test/packages/agent-prompt-context.test.mjs`, `test/contracts/chat-placement.test.mjs`, `test/contracts/chat-placement-registry.test.mjs`.
207
207
 
208
+ ## Room
209
+
210
+ `@hachej/boring-agent/room` is a shared room of people and agents on one pi-durable session that never runs a model (the Studio channel plan, boring-factory#566). Conversations are named (`main`, `admin`, `agents:<a>:<b>`); `createRoom({ harness, context })` maps each name to a native conversation on first use.
211
+
212
+ - **Entries.** Seven kinds (`room.message`, `room.removed`, `room.card`, `room.pin`, `room.question`, `room.action`, `room.system`); every `data` names its `author`. `write(name, { requestId, kind, data }, { people, agents })` appends the entry, its native passive-write record (`type: 'write'`, `status: 'done'`, keyed by the writer-namespaced `requestId`) and the wakes it causes in one `harness.commit`. The same `requestId` again returns the first entry and wakes nobody; `Conversation.submit` with that id sees the same record.
213
+ - **The view.** `roomView(entries, cards, viewer)` is a pure fold: a removed message reads « message supprimé » without attachments, a question is `answered` once a message replies to it, a card shows the state the host passes in. The cursor is the last entry id; `entries(name, after)` resumes after it.
214
+ - **Who answers.** `wakeFor(entry, room)` is pure: mentions wake at most two agents, a reply wakes the agent replied to, a reply to a question answers it (`{ kind: 'answer' }`, not a new turn), and an unaddressed message wakes `agent:pm` only when its sender is the only person in the room. Agents never wake each other in `main`; in `agents:<a>:<b>` (`agentsConversation(a, b)`) each message wakes the other agent, and the sixth turn returns `{ kind: 'capped' }`. A person's card action wakes the card's agent; platform changes wake nobody.
215
+ - **Wakes.** An open wake (`boring.room.wakes`) is closed by the agent's `settle(agent, wakeId, reply)`, a `room.message` keyed `wake:<wakeId>`; without an open wake for that agent nothing is written. `resend()` is the alarm's step: a wake open for two minutes is resent with doubling backoff, at most five times. After that the room closes it itself as `platform:room` with one « Je n'ai pas pu répondre. Réessayez. » message under the same key, so a late reply adds nothing.
216
+ - **The agent's side.** `createRoomTurn({ conversation, context, port })` runs in the agent's own home, lifted from the WhatsApp gateway: `wake({ wakeId, input })` records the owed settle, submits `room:<wakeId>` with `whenBusy: 'followUp'`, relays frames (`port.stream`) and new `ask_user` questions (`port.question`) from `conversation.watch`, and calls `port.settle` with the answer text. A resent wake finds its submission by `requestId` and settles again without a new run. `start()` after `harness.resume()` settles what a crash left owed; `answer(callId, text)` feeds a room reply into the pending `ask_user`.
217
+
218
+ Membership, roles, rates, spend and the doors stay with the host, which checks them before calling. Tests: `test/packages/agent-room.test.mjs`, and `test/contracts/room-crash.test.mjs` (SIGKILL mid-run, before and after the settle).
219
+
208
220
  ## Chat over a hibernatable WebSocket
209
221
 
210
222
  `createConversationSocketHandler` (from `@hachej/boring-agent/chat-transport`) serves the chat transport over WebSockets accepted with the Durable Object Hibernation API, so an idle open page does not keep the object in memory (or billed). One socket carries the watch (the same `view` then `ops` frames as the NDJSON stream) and every call: `{"kind":"call","id","op","params","body"}` is answered by `{"kind":"reply","id","status","body"}` and runs through `createChatTransportHandler` unchanged (validation, `allow`, `project`, `submission-refused`, request-id deduplication). A submit without `whenBusy` is a follow-up when the conversation is busy. `ping` is answered `pong` (on Cloudflare by `setWebSocketAutoResponse`, without waking the object).
package/dist/room.d.ts ADDED
@@ -0,0 +1,306 @@
1
+ import type { Conversation, ConversationId, EntryRecord, Harness } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ import type { AskUserAnswer } from './ask-user.js';
4
+ /**
5
+ * A shared room on pi-durable: people and agents in one session that never runs a model. Every entry is a passive write
6
+ * keyed by a namespaced `requestId` (CH-20); `roomView` is the only reader (CH-24); `wakeFor` alone decides which agent
7
+ * runs (CH-22); an agent posts only through an open wake (CH-23). The agent side, `createRoomTurn`, runs the woken agent
8
+ * in its own home and settles the wake, lifted from the WhatsApp channel gateway (`examples/whatsapp/channels.ts`).
9
+ * Membership, roles and the doors stay with the host.
10
+ */
11
+ export declare const ROOM: Readonly<{
12
+ schema: "boring.room";
13
+ version: 1;
14
+ /** Agents one person's message may wake by mention. */
15
+ maxMentions: 2;
16
+ /** Agent turns in one `agents:<a>:<b>` conversation per cause. */
17
+ maxAgentTurns: 6;
18
+ /** A wake still open this long is resent, doubling each time, at most `maxResends` times. */
19
+ resendAfterMs: 120000;
20
+ maxResends: 5;
21
+ /** The room's own actor: it closes a wake its agent never settled. */
22
+ platform: "platform:room";
23
+ unanswered: "Je n'ai pas pu répondre. Réessayez.";
24
+ removed: "message supprimé";
25
+ }>;
26
+ /** `agent:<name>` ids are agents; `platform:<name>` ids are the platform; anything else is a person. */
27
+ export declare const isAgent: (id: string) => boolean;
28
+ export type RoomStep = {
29
+ label: string;
30
+ status: string;
31
+ };
32
+ /** The `data` of each room entry kind. Every entry names its `author`. */
33
+ export type RoomEntryData = {
34
+ 'room.message': {
35
+ author: string;
36
+ via?: string;
37
+ for?: string;
38
+ text: string;
39
+ mentions: string[];
40
+ replyTo?: number;
41
+ attachments: {
42
+ path: string;
43
+ }[];
44
+ steps: RoomStep[];
45
+ };
46
+ /** The target stays stored and reads as « message supprimé » (CH-6). */
47
+ 'room.removed': {
48
+ author: string;
49
+ target: number;
50
+ };
51
+ /** A request first shown here; its state lives in the cards the host passes to `roomView`. */
52
+ 'room.card': {
53
+ author: string;
54
+ requestId: string;
55
+ };
56
+ 'room.pin': {
57
+ author: string;
58
+ title: string;
59
+ line: string;
60
+ anchor: string;
61
+ };
62
+ /** An agent's `ask_user`, as the room shows it. */
63
+ 'room.question': {
64
+ author: string;
65
+ callId: string;
66
+ prompt: string;
67
+ options: string[];
68
+ allowFreeText: boolean;
69
+ };
70
+ 'room.action': {
71
+ author: string;
72
+ requestId: string;
73
+ actionId: string;
74
+ };
75
+ /** « X joined » and « X left » only; never written by an agent. */
76
+ 'room.system': {
77
+ author: string;
78
+ line: string;
79
+ };
80
+ };
81
+ export type RoomKind = keyof RoomEntryData;
82
+ export declare const ROOM_KINDS: readonly ["room.message", "room.removed", "room.card", "room.pin", "room.question", "room.action", "room.system"];
83
+ export type RoomEntry = {
84
+ [K in RoomKind]: {
85
+ readonly id: number;
86
+ readonly kind: K;
87
+ readonly data: RoomEntryData[K];
88
+ };
89
+ }[RoomKind];
90
+ export type RoomCard = {
91
+ version: number;
92
+ title: string;
93
+ state: string;
94
+ line: string;
95
+ since: number;
96
+ actions: string[];
97
+ };
98
+ /** Room entries of a native transcript, in entry order; other entries are skipped. */
99
+ export declare function roomEntries(records: readonly EntryRecord[]): RoomEntry[];
100
+ export type RoomItem = {
101
+ kind: 'message';
102
+ id: number;
103
+ author: string;
104
+ via?: string;
105
+ for?: string;
106
+ text: string;
107
+ mentions: string[];
108
+ replyTo?: number;
109
+ attachments: {
110
+ path: string;
111
+ }[];
112
+ steps: RoomStep[];
113
+ mine: boolean;
114
+ removed: boolean;
115
+ } | {
116
+ kind: 'card';
117
+ id: number;
118
+ author: string;
119
+ requestId: string;
120
+ card: RoomCard | null;
121
+ } | {
122
+ kind: 'pin';
123
+ id: number;
124
+ author: string;
125
+ title: string;
126
+ line: string;
127
+ anchor: string;
128
+ mine: boolean;
129
+ } | {
130
+ kind: 'question';
131
+ id: number;
132
+ author: string;
133
+ callId: string;
134
+ prompt: string;
135
+ options: string[];
136
+ allowFreeText: boolean;
137
+ answered: boolean;
138
+ } | {
139
+ kind: 'system';
140
+ id: number;
141
+ line: string;
142
+ };
143
+ export type RoomView = {
144
+ items: RoomItem[];
145
+ cursor: number | null;
146
+ };
147
+ /**
148
+ * The one pure fold from entries to what `viewer` sees. History, `channel_read`, the socket's resume and the tests all
149
+ * use it, so replaying the same entries gives the same view, byte for byte. Keys are written in a fixed order and absent
150
+ * optional fields are left out, so `JSON.stringify` of the view is stable.
151
+ */
152
+ export declare function roomView(entries: readonly RoomEntry[], cards: Readonly<Record<string, RoomCard>>, viewer: {
153
+ readonly id: string;
154
+ }): RoomView;
155
+ /** The 1:1 conversation of two agents; the same name whichever opens it. */
156
+ export declare function agentsConversation(a: string, b: string): string;
157
+ export type RoomContext = {
158
+ /** `main`, `admin` or `agents:<a>:<b>`. */
159
+ readonly conversation: string;
160
+ /** People in the room (the directory's projection) and the agents provisioned in it. */
161
+ readonly people: readonly string[];
162
+ readonly agents: readonly string[];
163
+ /** The entry this one points to: a message's `replyTo`, or an action's card. */
164
+ readonly target?: RoomEntry;
165
+ /** Agent turns this 1:1 already used for the current cause. */
166
+ readonly turns?: number;
167
+ };
168
+ export type RoomDecision = {
169
+ readonly kind: 'wake';
170
+ readonly agent: string;
171
+ readonly conversation: string;
172
+ }
173
+ /** Not a new turn: the reply answers the agent's pending `ask_user`. */
174
+ | {
175
+ readonly kind: 'answer';
176
+ readonly agent: string;
177
+ readonly callId: string;
178
+ }
179
+ /** The 1:1 hit its cap and closes; its opener tells the person in `main`. */
180
+ | {
181
+ readonly kind: 'capped';
182
+ readonly conversation: string;
183
+ };
184
+ /**
185
+ * Which agents a committed entry wakes (CH-22). A person's mention wakes at most two agents, a reply wakes the agent
186
+ * replied to, a reply to a question answers it, and an unaddressed message wakes the PM only when its sender is the
187
+ * only person in the room. An agent never wakes another agent in `main`; in `agents:<a>:<b>` each message wakes the
188
+ * other one, up to six turns. A person's card action wakes the card's agent; platform card changes wake nobody.
189
+ */
190
+ export declare function wakeFor(entry: RoomEntry, room: RoomContext): RoomDecision[];
191
+ /** An open wake: the WhatsApp outbox pattern. `cause` is the person's entry that started it; `id` starts with the id
192
+ * of the entry that woke the agent, so the wake's conversation is that entry's. */
193
+ export type RoomWake = {
194
+ id: string;
195
+ agent: string;
196
+ conversation: string;
197
+ cause: number;
198
+ turns: number;
199
+ attempts: number;
200
+ due: number;
201
+ };
202
+ export type RoomDraft = {
203
+ [K in RoomKind]: {
204
+ readonly requestId: string;
205
+ readonly kind: K;
206
+ readonly data: RoomEntryData[K];
207
+ };
208
+ }[RoomKind];
209
+ export type RoomWrite = {
210
+ readonly entry: RoomEntry;
211
+ readonly duplicate: boolean;
212
+ readonly decisions: readonly RoomDecision[];
213
+ readonly wakes: readonly RoomWake[];
214
+ };
215
+ export type RoomRefusal = {
216
+ readonly refused: 'no-open-wake';
217
+ };
218
+ export type RoomReply = {
219
+ readonly text: string;
220
+ readonly steps?: readonly RoomStep[];
221
+ };
222
+ export type RoomQuestionDraft = {
223
+ readonly callId: string;
224
+ readonly prompt: string;
225
+ readonly options: readonly string[];
226
+ readonly allowFreeText: boolean;
227
+ };
228
+ export interface Room {
229
+ /** The native conversation of a room conversation name, created on first use. */
230
+ readonly conversation: (name: string) => Promise<ConversationId>;
231
+ /**
232
+ * Write one entry: the entry, its native passive-write record (the `requestId` dedupe record) and the wakes it causes
233
+ * land in one commit or not at all (B2). The same `requestId` again returns the first entry and wakes nobody.
234
+ */
235
+ readonly write: (name: string, draft: RoomDraft, members: Pick<RoomContext, 'people' | 'agents'>) => Promise<RoomWrite>;
236
+ /** The agent's reply on its open wake, keyed `wake:<id>`, closing it. A repeat returns the first message; without a wake, nothing is written (CH-23). */
237
+ readonly settle: (agent: string, wakeId: string, reply: RoomReply) => Promise<RoomWrite | RoomRefusal>;
238
+ /** The agent's `ask_user` on its open wake, keyed `q:<callId>`. */
239
+ readonly question: (agent: string, wakeId: string, question: RoomQuestionDraft) => Promise<RoomWrite | RoomRefusal>;
240
+ /**
241
+ * The alarm's step. Due wakes are returned to resend, with backoff. A wake past its last resend is closed by the room
242
+ * itself, as `platform:room`, with one message keyed `wake:<id>`, so a late reply can never add a second one.
243
+ */
244
+ readonly resend: () => Promise<{
245
+ readonly resend: readonly RoomWake[];
246
+ readonly closed: readonly RoomWake[];
247
+ readonly next: number | null;
248
+ }>;
249
+ readonly wakes: () => Promise<RoomWake[]>;
250
+ /** The room entries of a conversation after the `after` cursor, oldest first. */
251
+ readonly entries: (name: string, after?: number) => Promise<RoomEntry[]>;
252
+ }
253
+ /** The room over one Harness that never runs a model. Membership, roles and rates are the host's, before it calls. */
254
+ export declare function createRoom(options: {
255
+ readonly harness: Harness;
256
+ readonly context: Context;
257
+ readonly now?: () => number;
258
+ }): Room;
259
+ export interface PendingQuestion {
260
+ readonly callId: string;
261
+ readonly prompt: string;
262
+ readonly options: readonly string[];
263
+ readonly allowFreeText: boolean;
264
+ }
265
+ /** `ask_user` calls of the active transcript that have no tool result yet, oldest first. */
266
+ export declare function pendingQuestions(entries: readonly EntryRecord[]): PendingQuestion[];
267
+ /** Live frames of a wake's run. The room relays them and never stores them: the settled message is the truth (CH-24). */
268
+ export type RoomFrame = {
269
+ readonly type: 'text';
270
+ readonly text: string;
271
+ } | {
272
+ readonly type: 'step';
273
+ readonly label: string;
274
+ readonly status: 'running' | 'done';
275
+ };
276
+ /** The agent's door into the room, bound to the agent by its host: the agent id is never an argument. */
277
+ export interface RoomPort {
278
+ readonly settle: (wakeId: string, reply: RoomReply) => Promise<unknown>;
279
+ readonly stream?: (wakeId: string, frames: readonly RoomFrame[]) => Promise<unknown>;
280
+ readonly question?: (wakeId: string, question: PendingQuestion) => Promise<unknown>;
281
+ }
282
+ export interface RoomTurn {
283
+ /**
284
+ * `AgentPort.wake`: submit the wake's input as `room:<wakeId>` and settle it through the port in the background.
285
+ * Resolves once the input is durably admitted. Pi deduplicates the `requestId`, so a resent wake starts nothing: once
286
+ * its run is done it settles again with the same answer, and the room's `wake:<id>` record absorbs the repeat.
287
+ */
288
+ readonly wake: (wake: {
289
+ readonly wakeId: string;
290
+ readonly input: string;
291
+ }) => Promise<void>;
292
+ /** The room's answer to a `room.question`, into this conversation's pending `ask_user`. */
293
+ readonly answer: (callId: string, text: string) => Promise<AskUserAnswer>;
294
+ /** Settle every wake admitted before a restart. Call once after `harness.resume()`. */
295
+ readonly start: () => Promise<void>;
296
+ /** Stop waiting. Owed settles stay durable for the next `start`. */
297
+ readonly close: () => Promise<void>;
298
+ }
299
+ /** The agent's side of a room, over its own conversation in its own home. Delivery to the port is at least once. */
300
+ export declare function createRoomTurn(options: {
301
+ readonly conversation: Conversation;
302
+ readonly context: Context;
303
+ readonly port: RoomPort;
304
+ readonly onError?: (error: unknown) => void;
305
+ }): RoomTurn;
306
+ //# sourceMappingURL=room.d.ts.map
package/dist/room.js ADDED
@@ -0,0 +1,371 @@
1
+ import { defineDoc } from '@earendil-works/pi-durable';
2
+ import { withAbortSignal } from '@earendil-works/chord/context';
3
+ import { ASK_USER_TOOL, answerUserQuestion, askUserQuestionId } from './ask-user.js';
4
+ /**
5
+ * A shared room on pi-durable: people and agents in one session that never runs a model. Every entry is a passive write
6
+ * keyed by a namespaced `requestId` (CH-20); `roomView` is the only reader (CH-24); `wakeFor` alone decides which agent
7
+ * runs (CH-22); an agent posts only through an open wake (CH-23). The agent side, `createRoomTurn`, runs the woken agent
8
+ * in its own home and settles the wake, lifted from the WhatsApp channel gateway (`examples/whatsapp/channels.ts`).
9
+ * Membership, roles and the doors stay with the host.
10
+ */
11
+ export const ROOM = Object.freeze({
12
+ schema: 'boring.room', version: 1,
13
+ /** Agents one person's message may wake by mention. */
14
+ maxMentions: 2,
15
+ /** Agent turns in one `agents:<a>:<b>` conversation per cause. */
16
+ maxAgentTurns: 6,
17
+ /** A wake still open this long is resent, doubling each time, at most `maxResends` times. */
18
+ resendAfterMs: 120_000, maxResends: 5,
19
+ /** The room's own actor: it closes a wake its agent never settled. */
20
+ platform: 'platform:room',
21
+ unanswered: 'Je n\'ai pas pu répondre. Réessayez.',
22
+ removed: 'message supprimé',
23
+ });
24
+ /** `agent:<name>` ids are agents; `platform:<name>` ids are the platform; anything else is a person. */
25
+ export const isAgent = (id) => id.startsWith('agent:');
26
+ const isPerson = (id) => !isAgent(id) && !id.startsWith('platform:');
27
+ export const ROOM_KINDS = Object.freeze(['room.message', 'room.removed', 'room.card', 'room.pin', 'room.question', 'room.action', 'room.system']);
28
+ /** Room entries of a native transcript, in entry order; other entries are skipped. */
29
+ export function roomEntries(records) {
30
+ return records.flatMap(record => ROOM_KINDS.includes(record.kind) && record.data && typeof record.data === 'object'
31
+ ? [{ id: Number(record.id), kind: record.kind, data: record.data }] : []);
32
+ }
33
+ /**
34
+ * The one pure fold from entries to what `viewer` sees. History, `channel_read`, the socket's resume and the tests all
35
+ * use it, so replaying the same entries gives the same view, byte for byte. Keys are written in a fixed order and absent
36
+ * optional fields are left out, so `JSON.stringify` of the view is stable.
37
+ */
38
+ export function roomView(entries, cards, viewer) {
39
+ const removed = new Set();
40
+ const answered = new Set();
41
+ for (const entry of entries) {
42
+ if (entry.kind === 'room.removed')
43
+ removed.add(entry.data.target);
44
+ if (entry.kind === 'room.message' && entry.data.replyTo !== undefined)
45
+ answered.add(entry.data.replyTo);
46
+ }
47
+ const items = [];
48
+ for (const { id, kind, data } of entries) {
49
+ if (kind === 'room.message') {
50
+ const gone = removed.has(id);
51
+ items.push({
52
+ kind: 'message', id, author: data.author, ...(data.via ? { via: data.via } : {}), ...(data.for ? { for: data.for } : {}),
53
+ text: gone ? ROOM.removed : data.text, mentions: [...data.mentions], ...(data.replyTo !== undefined ? { replyTo: data.replyTo } : {}),
54
+ attachments: gone ? [] : data.attachments.map(({ path }) => ({ path })), steps: gone ? [] : data.steps.map(({ label, status }) => ({ label, status })),
55
+ mine: data.author === viewer.id || data.via === viewer.id, removed: gone,
56
+ });
57
+ }
58
+ else if (kind === 'room.card') {
59
+ const card = cards[data.requestId];
60
+ items.push({ kind: 'card', id, author: data.author, requestId: data.requestId,
61
+ card: card ? { version: card.version, title: card.title, state: card.state, line: card.line, since: card.since, actions: [...card.actions] } : null });
62
+ }
63
+ else if (kind === 'room.pin') {
64
+ items.push({ kind: 'pin', id, author: data.author, title: data.title, line: data.line, anchor: data.anchor, mine: data.author === viewer.id });
65
+ }
66
+ else if (kind === 'room.question') {
67
+ items.push({ kind: 'question', id, author: data.author, callId: data.callId, prompt: data.prompt, options: [...data.options], allowFreeText: data.allowFreeText, answered: answered.has(id) });
68
+ }
69
+ else if (kind === 'room.system') {
70
+ items.push({ kind: 'system', id, line: data.line });
71
+ }
72
+ }
73
+ return { items, cursor: entries.at(-1)?.id ?? null };
74
+ }
75
+ // ─── Who answers ───────────────────────────────────────────────────────────────────────────────────────────────────
76
+ /** The 1:1 conversation of two agents; the same name whichever opens it. */
77
+ export function agentsConversation(a, b) {
78
+ const names = [a, b].map(id => { if (!isAgent(id) || id.slice(6).includes(':') || id.length === 6)
79
+ throw new Error(`Not an agent id: ${id}`); return id.slice(6); }).sort();
80
+ return `agents:${names[0]}:${names[1]}`;
81
+ }
82
+ const agentsOf = (conversation) => {
83
+ const parts = conversation.split(':');
84
+ return parts.length === 3 && parts[0] === 'agents' ? [`agent:${parts[1]}`, `agent:${parts[2]}`] : undefined;
85
+ };
86
+ /**
87
+ * Which agents a committed entry wakes (CH-22). A person's mention wakes at most two agents, a reply wakes the agent
88
+ * replied to, a reply to a question answers it, and an unaddressed message wakes the PM only when its sender is the
89
+ * only person in the room. An agent never wakes another agent in `main`; in `agents:<a>:<b>` each message wakes the
90
+ * other one, up to six turns. A person's card action wakes the card's agent; platform card changes wake nobody.
91
+ */
92
+ export function wakeFor(entry, room) {
93
+ const { author } = entry.data;
94
+ const wake = (agent) => ({ kind: 'wake', agent, conversation: room.conversation });
95
+ if (isAgent(author)) {
96
+ const pair = entry.kind === 'room.message' ? agentsOf(room.conversation) : undefined;
97
+ if (!pair?.includes(author))
98
+ return [];
99
+ if ((room.turns ?? 0) + 1 >= ROOM.maxAgentTurns)
100
+ return [{ kind: 'capped', conversation: room.conversation }];
101
+ return [wake(pair[0] === author ? pair[1] : pair[0])];
102
+ }
103
+ if (!isPerson(author))
104
+ return [];
105
+ const target = room.target;
106
+ if (entry.kind === 'room.action')
107
+ return target?.kind === 'room.card' && isAgent(target.data.author) && room.agents.includes(target.data.author) ? [wake(target.data.author)] : [];
108
+ if (entry.kind !== 'room.message')
109
+ return [];
110
+ if (target?.kind === 'room.question')
111
+ return [{ kind: 'answer', agent: target.data.author, callId: target.data.callId }];
112
+ const agents = entry.data.mentions.filter(id => room.agents.includes(id)).slice(0, ROOM.maxMentions);
113
+ if (target && (target.kind === 'room.message' || target.kind === 'room.card') && room.agents.includes(target.data.author))
114
+ agents.push(target.data.author);
115
+ const woken = [...new Set(agents)];
116
+ if (woken.length)
117
+ return woken.map(wake);
118
+ return room.people.length === 1 && room.people[0] === author && room.agents.includes('agent:pm') ? [wake('agent:pm')] : [];
119
+ }
120
+ /** Conversation names to native ids, and each card's first entry (an action's target). */
121
+ const RoomIndex = defineDoc({ kind: 'boring.room.index', version: 1, scope: 'session', initial: () => ({ conversations: {}, cards: {} }) });
122
+ const RoomWakes = defineDoc({ kind: 'boring.room.wakes', version: 1, scope: 'session', initial: () => ({ open: [] }) });
123
+ const REFUSED = Object.freeze({ refused: 'no-open-wake' });
124
+ const copyWake = (wake) => ({ ...wake });
125
+ /** The room over one Harness that never runs a model. Membership, roles and rates are the host's, before it calls. */
126
+ export function createRoom(options) {
127
+ const { harness, context } = options;
128
+ const now = options.now ?? Date.now;
129
+ const conversation = (name) => harness.commit(async (tx) => {
130
+ const index = await tx.doc(RoomIndex);
131
+ const known = index.conversations[name];
132
+ if (known !== undefined)
133
+ return known;
134
+ const created = await tx.createConversation({ ownership: { kind: 'ownerless' } });
135
+ index.conversations[name] = created.id;
136
+ return created.id;
137
+ }, context);
138
+ /** One commit: table reads first, then the entry and its native write record, then the documents. */
139
+ const commitEntry = (conversationId, draft, decide, admit) => harness.commit(async (tx) => {
140
+ const known = await tx.submissionByRequest(conversationId, draft.requestId);
141
+ if (known?.status === 'done')
142
+ return { entry: roomEntries([(await tx.entry(known.entry))])[0], duplicate: true, decisions: [], wakes: [] };
143
+ const targetId = draft.kind === 'room.message' ? draft.data.replyTo : draft.kind === 'room.action' ? (await tx.doc(RoomIndex)).cards[draft.data.requestId] : undefined;
144
+ const targetRecord = targetId === undefined ? undefined : await tx.entry(targetId);
145
+ const target = targetRecord?.conversationId === conversationId ? roomEntries([targetRecord])[0] : undefined;
146
+ if (admit && !admit((await tx.doc(RoomWakes)).open))
147
+ return REFUSED;
148
+ const record = await tx.appendEntry(conversationId, { kind: draft.kind, data: draft.data });
149
+ // A display-only write is `done` at once, as Pi's own admission would place it; `Conversation.submit` sees this record.
150
+ await tx.createSubmission({ type: 'write', conversationId, requestId: draft.requestId, status: 'done', entry: record.id });
151
+ const entry = roomEntries([record])[0];
152
+ if (draft.kind === 'room.card')
153
+ (await tx.doc(RoomIndex)).cards[draft.data.requestId] ??= entry.id;
154
+ const { decisions, wakes } = await decide(tx, entry, target);
155
+ if (wakes.length)
156
+ (await tx.doc(RoomWakes)).open.push(...wakes);
157
+ return { entry, duplicate: false, decisions, wakes: wakes.map(copyWake) };
158
+ }, context);
159
+ const wakesOf = (entry, name, decisions, turns, cause) => decisions.flatMap(decision => decision.kind !== 'wake' ? []
160
+ : [{ id: `${entry.id}:${decision.agent}`, agent: decision.agent, conversation: name, cause, turns: name.startsWith('agents:') ? turns + 1 : 0, attempts: 0, due: now() + ROOM.resendAfterMs }]);
161
+ /** Write on the open wake of `agent`. `close` ends it; a reply in a 1:1 carries the wake's cause and turns forward. */
162
+ async function onWake(agent, wakeId, draft, close) {
163
+ const cause = /^(\d+):agent:[^:]+$/.exec(wakeId)?.[1];
164
+ const conversationId = cause === undefined ? undefined : await harness.commit(async (tx) => (await tx.entry(Number(cause)))?.conversationId, context);
165
+ if (conversationId === undefined)
166
+ return REFUSED;
167
+ const mine = (wake) => wake.id === wakeId && wake.agent === agent;
168
+ // Without an open wake only a repeat of an earlier write succeeds; `admit` refuses a new entry.
169
+ return commitEntry(conversationId, draft, async (tx, entry) => {
170
+ const wakes = await tx.doc(RoomWakes);
171
+ const current = wakes.open.find(mine);
172
+ if (!close || !current)
173
+ return { decisions: [], wakes: [] };
174
+ wakes.open.splice(wakes.open.indexOf(current), 1);
175
+ const decisions = wakeFor(entry, { conversation: current.conversation, people: [], agents: [], turns: current.turns });
176
+ return { decisions, wakes: wakesOf(entry, current.conversation, decisions, current.turns, current.cause) };
177
+ }, open => open.some(mine));
178
+ }
179
+ const openWakes = async () => ((await harness.snapshot(RoomWakes, context))?.open ?? []).map(copyWake);
180
+ return {
181
+ conversation,
182
+ write: async (name, draft, members) => commitEntry(await conversation(name), draft, async (_tx, entry, target) => {
183
+ const decisions = wakeFor(entry, { conversation: name, people: members.people, agents: members.agents, ...(target ? { target } : {}) });
184
+ return { decisions, wakes: wakesOf(entry, name, decisions, 0, entry.id) };
185
+ }),
186
+ settle: (agent, wakeId, reply) => onWake(agent, wakeId, {
187
+ requestId: `wake:${wakeId}`, kind: 'room.message',
188
+ data: { author: agent, text: reply.text, mentions: [], attachments: [], steps: (reply.steps ?? []).map(({ label, status }) => ({ label, status })) },
189
+ }, true),
190
+ question: (agent, wakeId, question) => onWake(agent, wakeId, {
191
+ requestId: `q:${question.callId}`, kind: 'room.question',
192
+ data: { author: agent, callId: question.callId, prompt: question.prompt, options: [...question.options], allowFreeText: question.allowFreeText },
193
+ }, false),
194
+ resend: async () => {
195
+ const at = now();
196
+ const { resend, closed } = await harness.commit(async (tx) => {
197
+ const wakes = await tx.doc(RoomWakes);
198
+ const resend = [];
199
+ const closed = [];
200
+ for (const wake of wakes.open.filter(wake => wake.due <= at)) {
201
+ if (wake.attempts >= ROOM.maxResends) {
202
+ closed.push(copyWake(wake));
203
+ continue;
204
+ }
205
+ wake.attempts += 1;
206
+ wake.due = at + ROOM.resendAfterMs * 2 ** wake.attempts;
207
+ resend.push(copyWake(wake));
208
+ }
209
+ return { resend, closed };
210
+ }, context);
211
+ // The room's own line, keyed like the agent's reply: whichever lands first is the only message.
212
+ for (const wake of closed)
213
+ await commitEntry(await conversation(wake.conversation), {
214
+ requestId: `wake:${wake.id}`, kind: 'room.message',
215
+ data: { author: ROOM.platform, for: wake.agent, text: ROOM.unanswered, mentions: [], attachments: [], steps: [] },
216
+ }, async (tx) => { const wakes = await tx.doc(RoomWakes); wakes.open = wakes.open.filter(item => item.id !== wake.id); return { decisions: [], wakes: [] }; });
217
+ const open = await openWakes();
218
+ return { resend, closed, next: open.length ? Math.min(...open.map(wake => wake.due)) : null };
219
+ },
220
+ wakes: openWakes,
221
+ entries: async (name, after) => {
222
+ const handle = await harness.conversation(await conversation(name), context);
223
+ const records = [];
224
+ let cursor;
225
+ do {
226
+ const page = await handle.entries({ order: 'ascending', ...(after === undefined ? {} : { minEntryId: (after + 1) }) }, 200, cursor, context);
227
+ records.push(...page.items);
228
+ cursor = page.next;
229
+ } while (cursor !== undefined);
230
+ return roomEntries(records);
231
+ },
232
+ };
233
+ }
234
+ /** `ask_user` calls of the active transcript that have no tool result yet, oldest first. */
235
+ export function pendingQuestions(entries) {
236
+ const pending = new Map();
237
+ for (const entry of entries)
238
+ for (const message of entry.model ?? []) {
239
+ if (message.role === 'toolResult')
240
+ pending.delete(message.toolCallId);
241
+ if (message.role !== 'assistant')
242
+ continue;
243
+ for (const part of message.content) {
244
+ if (part.type !== 'toolCall' || part.name !== ASK_USER_TOOL)
245
+ continue;
246
+ const args = part.arguments;
247
+ if (typeof args.question !== 'string')
248
+ continue;
249
+ const options = Array.isArray(args.options) ? args.options.filter((option) => typeof option === 'string') : [];
250
+ pending.set(part.id, { callId: askUserQuestionId(entry.id, part.id), prompt: args.question, options, allowFreeText: args.allowFreeText !== false || options.length === 0 });
251
+ }
252
+ }
253
+ return [...pending.values()];
254
+ }
255
+ /** The visible text of the assistant entry that answered a submission. */
256
+ function answerText(entry) {
257
+ return (entry?.model ?? []).flatMap(message => message.role === 'assistant' ? message.content : [])
258
+ .flatMap(part => part.type === 'text' ? [part.text] : []).join('\n\n').trim();
259
+ }
260
+ const framesOf = (entries) => entries.flatMap(entry => (entry.model ?? []).flatMap((message) => {
261
+ if (message.role === 'toolResult')
262
+ return [{ type: 'step', label: message.toolName, status: 'done' }];
263
+ if (message.role !== 'assistant')
264
+ return [];
265
+ return message.content.flatMap((part) => part.type === 'text' && part.text ? [{ type: 'text', text: part.text }] : part.type === 'toolCall' ? [{ type: 'step', label: part.name, status: 'running' }] : []);
266
+ }));
267
+ const Turns = defineDoc({ kind: 'boring.room.turns', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ owed: [] }) });
268
+ /** The agent's side of a room, over its own conversation in its own home. Delivery to the port is at least once. */
269
+ export function createRoomTurn(options) {
270
+ const { conversation, port } = options;
271
+ const closing = new AbortController();
272
+ const context = withAbortSignal(closing.signal, options.context);
273
+ const report = (error) => { if (!closing.signal.aborted)
274
+ try {
275
+ options.onError?.(error);
276
+ }
277
+ catch { /* observers never break delivery */ } };
278
+ const delivering = new Map();
279
+ const requestId = (wakeId) => `room:${wakeId}`;
280
+ const owed = (wakeId) => conversation.commit(async (tx) => {
281
+ const item = (await tx.doc(Turns, conversation.id)).owed.find(item => item.wakeId === wakeId);
282
+ return item && { ...item, asked: [...item.asked] };
283
+ }, context);
284
+ const update = (wakeId, change) => conversation.commit(async (tx) => {
285
+ const items = (await tx.doc(Turns, conversation.id)).owed;
286
+ const index = items.findIndex(item => item.wakeId === wakeId);
287
+ if (index >= 0)
288
+ change(items, index);
289
+ }, context);
290
+ /** Relay the run's frames and each new `ask_user` while the wake is open. */
291
+ async function follow(wakeId, from) {
292
+ const watch = await conversation.watch(context);
293
+ const seen = new Set();
294
+ let queue = Promise.resolve();
295
+ // Port calls are chained off the watch so a slow room never holds native frames.
296
+ const relay = async (view) => {
297
+ const fresh = view.entries.filter(entry => entry.id > from && !seen.has(entry.id));
298
+ for (const entry of fresh)
299
+ seen.add(entry.id);
300
+ const frames = framesOf(fresh);
301
+ const questions = pendingQuestions(view.entries);
302
+ queue = queue.then(async () => {
303
+ if (frames.length && port.stream)
304
+ await port.stream(wakeId, frames);
305
+ if (!port.question)
306
+ return;
307
+ const asked = (await owed(wakeId))?.asked ?? [];
308
+ for (const question of questions.filter(question => !asked.includes(question.callId))) {
309
+ await port.question(wakeId, question);
310
+ // Asking cannot share the native commit: a crash before this acknowledgement repeats it, and `q:<callId>` absorbs that.
311
+ await update(wakeId, (items, index) => { if (!items[index].asked.includes(question.callId))
312
+ items[index].asked.push(question.callId); });
313
+ }
314
+ }).catch(report);
315
+ };
316
+ await relay(watch.value);
317
+ watch.start(relay);
318
+ return async () => { await watch.stop(); await queue; };
319
+ }
320
+ function deliver(wakeId) {
321
+ const existing = delivering.get(wakeId);
322
+ if (existing)
323
+ return existing;
324
+ const run = (async () => {
325
+ const item = await owed(wakeId);
326
+ if (!item)
327
+ return;
328
+ // The same `requestId` returns the first submission: a resend or a restart never starts a second run.
329
+ const submission = await conversation.submit({ type: 'input', requestId: requestId(wakeId), content: item.input, whenBusy: 'followUp' }, context);
330
+ const placed = await conversation.commit(tx => tx.submissionByRequest(conversation.id, requestId(wakeId)), context);
331
+ const stop = placed?.entry === undefined || placed.status === 'done' || placed.status === 'unanswered' ? async () => { } : await follow(wakeId, placed.entry);
332
+ let record;
333
+ try {
334
+ record = await submission.wait(context);
335
+ }
336
+ finally {
337
+ await stop();
338
+ }
339
+ // An unanswered run leaves the wake open: the room's resend cap closes it with its own line.
340
+ if (record.type === 'input' && record.status === 'done') {
341
+ const entry = (await conversation.entries({ minEntryId: record.answer, maxEntryId: record.answer }, 1, undefined, context)).items[0];
342
+ await port.settle(wakeId, { text: answerText(entry) });
343
+ }
344
+ await update(wakeId, (items, index) => { items.splice(index, 1); });
345
+ })().catch(report).finally(() => delivering.delete(wakeId));
346
+ delivering.set(wakeId, run);
347
+ return run;
348
+ }
349
+ return {
350
+ wake: async ({ wakeId, input }) => {
351
+ await conversation.commit(async (tx) => {
352
+ const items = (await tx.doc(Turns, conversation.id)).owed;
353
+ if (!items.some(item => item.wakeId === wakeId))
354
+ items.push({ wakeId, input, asked: [] });
355
+ }, context);
356
+ // Admitted before resolving, so the room may count the wake delivered.
357
+ await conversation.submit({ type: 'input', requestId: requestId(wakeId), content: input, whenBusy: 'followUp' }, context);
358
+ void deliver(wakeId);
359
+ },
360
+ answer: (callId, text) => answerUserQuestion(conversation, callId, text, context),
361
+ start: async () => {
362
+ const items = await conversation.commit(async (tx) => (await tx.doc(Turns, conversation.id)).owed.map(item => item.wakeId), context);
363
+ for (const wakeId of items)
364
+ void deliver(wakeId);
365
+ },
366
+ close: async () => {
367
+ closing.abort();
368
+ await Promise.allSettled([...delivering.values()]);
369
+ },
370
+ };
371
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -144,6 +144,10 @@
144
144
  "./prompt-context": {
145
145
  "types": "./dist/prompt-context.d.ts",
146
146
  "import": "./dist/prompt-context.js"
147
+ },
148
+ "./room": {
149
+ "types": "./dist/room.d.ts",
150
+ "import": "./dist/room.js"
147
151
  }
148
152
  },
149
153
  "dependencies": {
@@ -156,8 +160,8 @@
156
160
  "@earendil-works/pi-durable": "~1.2.0",
157
161
  "@earendil-works/pi-ai": "~1.2.0",
158
162
  "@earendil-works/chord": "~1.2.0",
159
- "@hachej/boring-files": "^0.4.18",
160
- "@hachej/boring-ui-kit": "^0.4.18",
163
+ "@hachej/boring-files": "^0.4.20",
164
+ "@hachej/boring-ui-kit": "^0.4.20",
161
165
  "xlsx": "^0.20.3"
162
166
  },
163
167
  "peerDependenciesMeta": {