import { readFile } from "node:fs/promises";
import type { ImageContent } from "@earendil-works/pi-ai";
import type { Models } from "@earendil-works/pi-ai/models";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models";
import {
type AgentState,
type ConversationId,
type ConversationView,
type Conversation as DurableConversation,
type Harness as DurableHarness,
type EntryId,
type EntryRecord,
InboxDoc,
type InboxItem,
type SubmissionId,
type TaskId,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import type { JsonValue } from "@p2/app/v1";
import {
type Author,
authorIdOf,
authorOf,
authorSender,
type Context,
type Ctx,
canSend,
canView,
DEFAULT_OWNER_NAME,
defineFacet,
defineTypedState,
deriveTyped,
expectVariant,
type Factory,
forbidden,
INPUT_ENTRY,
isGuest,
isOwner,
type MessageData,
messageData,
senderText,
serveKeyed,
} from "@p2/app/v1";
import {
ContextTally,
ConversationConfigured,
HarnessConfig,
harnessOf,
InputGates,
mainConversation,
ReadOnlyConversations,
responseCosts,
workerOf,
} from "@p2/app/v1/worker";
import { DraftStore } from "../drafts/contract.ts";
import { type Attachment, Attachments } from "../files/contract.ts";
import { WorkerModels } from "../models/contract.ts";
import { Sessions } from "../sessions/contract.ts";
import { StatusReport } from "../status/contract.ts";
import {
Chat,
ChatCosts,
Conversation,
type EntriesPage,
type ModelMeta,
SendRouters,
StatsSchema,
} from "./contract.ts";
import { ShellCommands, serveShellCommands } from "./shell.ts";
/** Who wrote a queued input and what they typed, from its entry data; tool-originated input has none. */
function restoredInput(item: InboxItem): { author: Author; text: string } | undefined {
if (item.mode === "write") return undefined;
const message = messageData(item.entry?.data);
return message?.text.trim() ? { author: message.author, text: message.text } : undefined;
}
const MAX_INLINE_IMAGE = 5 * 1024 * 1024;
/** A `Chat.entries` page ends once its entries reach about this many bytes of JSON. */
const ENTRIES_PAGE_BYTES = 1_000_000;
/** The conversation's active context numbers (in memory), computed from its transcript. */
const Live = defineTypedState("p2.conversation.stats", {
schema: obj({ stats: StatsSchema }),
version: 1,
initial: () => ({ stats: null }),
});
/**
* The message text plus attachments: a path list the agent can read with its tools (uploads stay outside the
* project), and images inline when the conversation's model accepts them.
*/
async function content(
text: string,
files: readonly Attachment[],
conversation: DurableConversation,
models: Models | undefined,
ctx: Ctx,
maxImages = Number.POSITIVE_INFINITY,
): Promise<(ImageContent | { type: "text"; text: string })[]> {
if (!files.length) return [{ type: "text", text }];
const { model: ref } = await conversation.agent(ctx);
const model = ref ? models?.getModel(ref.provider, ref.modelId) : undefined;
const images: ImageContent[] = [];
const eligible = files.filter(
(file) => file.mimeType && model?.input.includes("image") && file.size <= MAX_INLINE_IMAGE,
);
// With a cap, the newest images (the last ones) are inlined; the rest are only listed.
for (const file of maxImages > 0 ? eligible.slice(-maxImages) : []) {
if (!file.mimeType) continue;
images.push({
type: "image",
mimeType: file.mimeType,
data: (await readFile(file.localPath ?? file.path)).toString("base64"),
});
}
const list = files.map((file) => `- ${file.path} (${file.name}, ${file.size} bytes)`).join("\n");
const note = `\nThe user attached these files:\n${list}\n`;
return [{ type: "text", text: text.trim() ? `${text}\n\n${note}` : note }, ...images];
}
export default defineFacet((host) => {
serveKeyed(host, SendRouters);
serveShellCommands(host);
host.serve(
Conversation,
({ conversation }, self) => {
const id = conversation as ConversationId;
const harness = harnessOf(self);
const config = self.registry(HarnessConfig);
const gates = self.registry(InputGates);
const configured = self.registry(ConversationConfigured);
// Methods that start or end work push the worker's status before they answer (the sidebar's working dot).
const status = self.get(StatusReport, {});
const models = () => config.value.get("default")?.models();
const live = self.state(Live);
/** The view's model from the catalog: name, window and levels, never provider configuration. */
const modelMeta = (view: ConversationView | null): ModelMeta | null => {
const ref = (view?.docs["pi.agent"] as AgentState | undefined)?.model;
const model = ref ? models()?.getModel(ref.provider, ref.modelId) : undefined;
return model
? {
provider: model.provider,
modelId: model.id,
name: model.name,
contextWindow: model.contextWindow,
reasoning: model.reasoning,
thinkingLevels: getSupportedThinkingLevels(model),
}
: null;
};
const conversationResource = self.open(
async (ctx) => {
const durable = harness().durable;
const found = await durable.conversation(id, ctx);
if (found === undefined) throw new Error(`Unknown conversation ${id}`);
return { harness: durable, conversation: found };
},
() => {},
);
// Durable's default window (the newest 50, appends, back to 50 past 100), shared by every viewer.
const view = deriveTyped(
self,
[self.follow((observer, ctx) => harness().durable.observeConversation(id, observer, ctx))],
([value]) => value as ConversationView | null,
);
const modelState = deriveTyped(self, [view, config], ([value]) => modelMeta(value));
/**
* The active context's numbers follow the entries the window appends (a scan of the new ones). A whole context
* is read only when the active range changes otherwise (`ContextTally.advance`): at the start, after a
* compaction, reset or edit, or when the window skipped entries. Views arriving while it is read come folded
* into the routine's next call.
*/
const tally = new ContextTally();
let counted = false;
self.on(view, async ({ value }, ctx) => {
if (!value) return;
if (!counted || !tally.advance(value)) {
tally.reset(await conversationResource.value.conversation.context(ctx));
counted = true;
const newest = view.value;
if (newest) tally.advance(newest);
}
const next = tally.stats;
const current = live.value.stats;
if (
current?.contextTokens !== next.contextTokens ||
current.latestAssistantUsage !== next.latestAssistantUsage
)
await live.change((draft) => {
draft.stats = next;
}, ctx);
});
const target = (): { harness: DurableHarness; conversation: DurableConversation } =>
conversationResource.value;
/** The queued inputs (not writes) by submission order. */
const queued = async (context: Context) => {
const { harness: durable, conversation: current } = target();
const inbox = await durable.snapshot(InboxDoc, current.id, context);
return (inbox?.items ?? []).filter((item) => item.mode !== "write").toSorted((a, b) => a.id - b.id);
};
return {
view,
stats: deriveTyped(self, [live], ([value]) => value.stats),
model: modelState,
costs: async (ctx) =>
responseCosts((await target().conversation.context(ctx)).entries).map(({ cacheHit, ...cost }) =>
cacheHit === undefined ? cost : { ...cost, cacheHit },
),
compact: async ({ instructions }, ctx) => {
const taskId = await target().conversation.compact(instructions?.trim() || undefined, ctx);
await status.push(ctx);
return { taskId };
},
reset: async ({ handoff }, ctx) => {
await target().conversation.reset(handoff?.trim() || undefined, ctx);
return null;
},
abortCompaction: async (ctx) => {
const { harness: durable } = target();
// The live compaction tasks of this conversation, as its view shows them.
const running = view.value?.docs["pi.live"] as { compactions?: { taskId: number }[] } | undefined;
let aborted = 0;
for (const { taskId } of running?.compactions ?? [])
if ((await durable.abortTask(taskId as TaskId, ctx)) === "marked") {
// The indicator goes with the task's terminal commit (its abort hook removes its status).
await durable.waitForTask(taskId as TaskId, ctx);
aborted++;
}
await status.push(ctx);
return { aborted };
},
dequeueOwn: async ({ authorId }, ctx) => {
const { harness: durable, conversation: current } = target();
// Selected by the author stamped into each input's entry, never by client-supplied IDs.
const restored: { author: Author; text: string }[] = [];
let skipped = 0;
for (const item of await queued(ctx)) {
const input = restoredInput(item);
if (input?.author.id !== authorId) continue;
if ((await durable.abortSubmission(item.id as SubmissionId, ctx, current.id)) === "aborted")
restored.push(input);
else skipped++;
}
return { restored, skipped };
},
submit: async ({ text, requestId, whenBusy, author, files, maxImages, entry }, ctx) => {
for (const gate of gates.value.values()) await gate(ctx);
const { conversation: current } = target();
const data: MessageData = {
author,
text,
attachments: (files ?? []).map(({ id, name }) => ({ id, name })),
};
const submission = await current.submit(
{
type: "input",
// The sender block is for the model (the system prompt attributes input by it); readers use the data.
content: [
{ type: "text", text: senderText(authorSender(author)) },
...(await content(text, files ?? [], current, models(), ctx, maxImages)),
],
entry: entry ? { kind: entry.kind, data: entry.data as JsonValue } : { kind: INPUT_ENTRY, data },
requestId: `${author.id}:${requestId}`,
whenBusy: whenBusy ?? "steer",
},
ctx,
);
await status.push(ctx);
return { submissionId: submission.id };
},
write: async ({ kind, data, requestId, author }, ctx) => {
for (const gate of gates.value.values()) await gate(ctx);
const submission = await target().conversation.submit(
{ type: "write", entry: { kind, data: data as JsonValue }, requestId: `${author.id}:${requestId}` },
ctx,
);
return { submissionId: submission.id };
},
stop: async (ctx) => {
const { harness: durable, conversation: current } = target();
// Conversation.abort() discards queued inputs. Withdraw them first, one by one, so each author gets
// their text back; already placed ones are skipped.
const restored: { author: Author; text: string }[] = [];
const inbox = await durable.snapshot(InboxDoc, current.id, ctx);
for (const item of inbox?.items ?? []) {
if (item.mode === "write") continue;
if ((await durable.abortSubmission(item.id as SubmissionId, ctx, current.id)) !== "aborted") continue;
const input = restoredInput(item);
if (input) restored.push(input);
}
await current.abort(ctx, { background: false });
await status.push(ctx);
return { restored };
},
dequeue: async ({ submissionId, restore }, ctx) => {
const { harness: durable, conversation: current } = target();
// Removing (no restore) discards the input: its text goes to nobody.
const item = restore
? (await durable.snapshot(InboxDoc, current.id, ctx))?.items.find(
(entry) => entry.id === submissionId,
)
: undefined;
const result = await durable.abortSubmission(submissionId as SubmissionId, ctx, current.id);
if (result === "not_found") throw new Error("Unknown queued message");
// If it raced into the transcript, it is not returned to the editor (no duplicate).
const input = result === "aborted" && item ? restoredInput(item) : undefined;
return { restored: input ? [input] : [] };
},
entries: async ({ limit, ...query }, ctx) => {
const { conversation: current } = target();
const page = await current.entries(
{
...(query.minEntryId === undefined ? {} : { minEntryId: query.minEntryId as EntryId }),
...(query.maxEntryId === undefined ? {} : { maxEntryId: query.maxEntryId as EntryId }),
...(query.order === undefined ? {} : { order: query.order }),
},
limit,
undefined,
ctx,
);
// Cut at about a megabyte (images make entries large); the loader asks again from where it ends.
const items: EntryRecord[] = [];
let bytes = 0;
for (const entry of page.items) {
if (items.length > 0 && bytes >= ENTRIES_PAGE_BYTES) break;
items.push(entry);
bytes += JSON.stringify(entry).length;
}
return {
items,
more: items.length < page.items.length || page.next !== undefined,
} satisfies EntriesPage;
},
configure: async ({ model, thinkingLevel }, ctx) => {
if (model && !models()?.getModel(model.provider, model.modelId)) throw new Error("Unknown model");
const current = target().conversation;
await current.configure(
{ ...(model ? { model } : {}), ...(thinkingLevel ? { thinkingLevel: thinkingLevel as never } : {}) },
ctx,
);
for (const after of configured.value.values()) await after(current, ctx);
return null;
},
};
},
// Switching back to a conversation quickly does not reopen its watch.
{ linger: 30_000 },
);
/** A conversation as a principal sees it (a declared factory, so the call below can take options). */
const chatFactory: Factory = ({ session, conversation }, principal, self) => {
if (!canView(principal, session)) throw forbidden("viewers only");
if (!isOwner(principal) && !isGuest(principal)) throw forbidden("people only");
const authorId = authorIdOf(principal);
const chat = self.get(Conversation, { conversation });
const settings = self.get(WorkerModels, {});
/** The author an entry records, with the owner's name as settings.json says now. */
const author = (): Author => authorOf(principal, settings.settings.value.ownerName ?? DEFAULT_OWNER_NAME);
const shells = self.get(ShellCommands, {});
const liveShell = deriveTyped(self, [shells.shells], ([value]) => value[conversation] ?? null);
const entries: typeof chat.entries = (query, ctx) => chat.entries(query, ctx);
if (!canSend(principal, session))
return {
variant: "viewer",
members: { view: chat.view, stats: chat.stats, model: chat.model, liveShell, entries },
};
const worker = workerOf(self);
const harness = harnessOf(self);
/** A participant's call: refused while the session moves, which waits for running calls (`Liveness.call`). */
const call = (fn: () => Promise): Promise => worker().liveness.call(fn);
/** Refuses writes a plugin does not allow here (a one-shot agent's conversation); Stop is not one of them. */
const readOnlyConversations = self.registry(ReadOnlyConversations);
const routers = self.registry(SendRouters);
const gates = self.registry(InputGates);
const writable = async (ctx: Ctx): Promise => {
for (const readOnly of readOnlyConversations.value.values()) {
const reason = await readOnly(conversation, ctx);
if (reason) throw forbidden(reason);
}
};
/** The caller's own texts; other authors' go to their drafts (restored to their composers). */
const own = async (restored: readonly { author: Author; text: string }[], ctx: Ctx): Promise => {
const others = restored.filter((item) => item.author.id !== authorId);
if (others.length) await (await ctx.get(DraftStore, {})).add({ conversation, items: others }, ctx);
return restored.filter((item) => item.author.id === authorId).map((item) => item.text);
};
return {
variant: "participant",
members: {
view: chat.view,
stats: chat.stats,
model: chat.model,
liveShell,
entries,
compact: (args, ctx) =>
call(async () => {
await writable(ctx);
return chat.compact(args, ctx);
}),
abortCompaction: (ctx) => call(() => chat.abortCompaction(ctx)),
reset: (args, ctx) =>
call(async () => {
// A new context changes what the agent knows for everyone in the session: the owner's choice.
if (principal.kind !== "owner") throw forbidden("only the owner starts a new context");
await writable(ctx);
return chat.reset(args, ctx);
}),
shell: ({ command, includeInContext, requestId }, ctx) =>
call(async () => {
// Anyone who may send may run commands, as tools run them for them (full guests included).
await writable(ctx);
if (!command.trim()) throw new Error("Invalid command");
for (const gate of gates.value.values()) await gate(ctx);
return shells.start({ conversation, command, includeInContext, requestId, author: author() }, ctx);
}),
abortShell: ({ shellId }, ctx) =>
call(async () => {
// Its author or the owner; the command's author comes from its running state.
const live = liveShell.value;
// Only this conversation's command: `shellId` is its caller's request ID, not secret.
if (live?.id !== shellId) return { aborted: false };
if (principal.kind !== "owner" && live.author.id !== authorId)
throw forbidden("only its author or the owner cancels a command");
return shells.abort({ shellId }, ctx);
}),
dequeueOwn: (ctx) =>
call(async () => {
await writable(ctx);
const { restored, skipped } = await chat.dequeueOwn({ authorId }, ctx);
return { restored: restored.map((item) => item.text), skipped };
}),
send: ({ attachments, ...input }, ctx) =>
call(async () => {
// As Pi Mobile: a message has text or attachments.
if (!input.text.trim() && !attachments?.length) throw new Error("Invalid content");
await writable(ctx);
for (const route of routers.value.values()) {
const routed = await route(
{ ...input, conversation, author: author(), attachments: attachments ?? [] },
ctx,
);
if (routed) return routed;
}
const files = attachments?.length
? await (await ctx.get(Attachments, {})).resolve({ ids: attachments, conversation }, ctx)
: undefined;
return chat.submit({ ...input, author: author(), ...(files ? { files } : {}) }, ctx);
}),
stop: (ctx) =>
call(async () => {
const { restored } = await chat.stop(ctx);
return { restored: await own(restored, ctx) };
}),
dequeue: ({ submissionId, restore }, ctx) =>
call(async () => {
await writable(ctx);
// Moving back to the editor returns the caller's own text and files other authors' as their drafts;
// removing (×) discards it, whoever wrote it.
const { restored } = await chat.dequeue({ submissionId, restore: restore ?? false }, ctx);
const [text] = await own(restored, ctx);
return { text: text ?? null };
}),
configure: (change, ctx) =>
call(async () => {
// The model and thinking level are the owner's choice (and cost).
if (principal.kind !== "owner") throw forbidden("only the owner configures the model");
await writable(ctx);
await chat.configure(change, ctx);
// The main conversation's model is the session's: the sidebar and the next worker start use it. Either
// field alone updates it (Pi Mobile's configure).
if (
(change.model || change.thinkingLevel) &&
conversation === (await mainConversation(harness().durable, ctx))
) {
await expectVariant(await ctx.get(Sessions, {}), "worker").configureSelf(
{
...(change.model ? { model: change.model } : {}),
...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}),
},
ctx,
);
}
return null;
}),
},
};
};
host.serve(ChatCosts, ({ session, conversation }, principal, self) => {
if (!canView(principal, session)) throw forbidden("viewers only");
const chat = self.get(Conversation, { conversation });
return { variant: "viewer", members: { costs: (ctx) => chat.costs(ctx) } };
});
host.serve(Chat, chatFactory);
});