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 Ctx,
canSend,
canView,
DEFAULT_OWNER_NAME,
defineFacet,
defineTypedState,
deriveTyped,
expectVariant,
type Factory,
forbidden,
INPUT_ENTRY,
isGuest,
isOwner,
type MessageData,
messageData,
SHELL_ENTRY,
SHELL_MAX_BYTES,
SHELL_MAX_LINES,
type ShellData,
senderText,
serveKeyed,
shellContextText,
shellTail,
signalOf,
} from "@p2/app/v1";
import { durableContext } from "@p2/app/v1/node";
import {
ContextTally,
ConversationConfigured,
conversationEnvironment,
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 {
Chat,
ChatCosts,
Conversation,
type EntriesPage,
type ModelMeta,
SendRouters,
SHELL_BUSY,
type ShellState,
ShellStateSchema,
StatsSchema,
} from "./contract.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;
/** How often the live output is published at most (the bash tool's sampling, 100 KiB/s). */
const SHELL_SAMPLE_MS = 200;
/** The conversation's live numbers and the running user shell command (in memory). */
const Live = defineTypedState("p2.conversation", {
schema: obj({ stats: StatsSchema, shell: ShellStateSchema }),
version: 1,
initial: () => ({ stats: null, shell: 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);
host.serve(
Conversation,
({ conversation }, self) => {
const id = conversation as ConversationId;
const harness = harnessOf(self);
const worker = workerOf(self);
const config = self.registry(HarnessConfig);
const gates = self.registry(InputGates);
const configured = self.registry(ConversationConfigured);
const models = () => config.value.get("default")?.models();
const live = self.state(Live);
const stats = {
get value() {
return live.value.stats;
},
};
/** The running command as last set: a change reaches the state asynchronously, the checks need it now. */
let shellNow: ShellState = null;
const shell = {
get value() {
return shellNow;
},
};
const setStats = (next: Extract, ctx: Ctx) =>
live.change((draft) => {
draft.stats = next;
}, ctx);
/** Awaited where `ctx` (a root's) seals right after: a change still pending then would be dropped. */
const setShell = async (next: ShellState, ctx: Ctx): Promise => {
shellNow = next;
await live
.change((draft) => {
draft.shell = next;
}, ctx)
.catch(() => {});
};
/** 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 are taken from
* the newest view after it.
*/
const tally = new ContextTally();
let reading = true;
let wake: (() => void) | undefined;
const publish = async (ctx: Ctx) => {
const next = tally.stats;
const current = stats.value;
if (
current?.contextTokens !== next.contextTokens ||
current.latestAssistantUsage !== next.latestAssistantUsage
)
await setStats(next, ctx);
};
self.on(view, async ({ value }, ctx) => {
if (!value || reading) return;
if (tally.advance(value)) await publish(ctx);
else {
reading = true;
wake?.();
}
});
self.onLive(async (ctx) => {
const signal = signalOf(ctx);
while (!signal.aborted) {
if (!reading)
await new Promise((resolve) => {
const abort = () => resolve();
wake = () => {
signal.removeEventListener("abort", abort);
resolve();
};
signal.addEventListener("abort", abort, { once: true });
});
wake = undefined;
if (signal.aborted) return;
tally.reset(await conversationResource.value.conversation.context(ctx));
const current = view.value;
reading = current !== null && !tally.advance(current);
await publish(ctx);
}
});
const target = (): { harness: DurableHarness; conversation: DurableConversation } =>
conversationResource.value;
/** The queued inputs (not writes) by submission order. */
const queued = async (context: ReturnType) => {
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);
};
/**
* The running user shell command (one per conversation) and the commands already admitted per author request,
* so a retried call runs once. Closing the object (no viewer for its linger time, a reload) cancels it.
*/
let running: { id: string; abort: AbortController } | undefined;
const admitted = new Map();
self.open(
async () => ({}),
() => running?.abort.abort(),
);
/** Runs `command` and places its entry; the live state follows the output (sampled). */
const runShell = async (
shellId: string,
input: { command: string; includeInContext: boolean; author: Author },
abort: AbortController,
ctx: Ctx,
): Promise => {
let live: Extract = {
id: shellId,
command: input.command,
author: input.author,
includeInContext: input.includeInContext,
output: "",
truncated: false,
running: true,
cancelled: false,
};
await setShell(live, ctx);
let output = "";
let truncated = false;
let timer: ReturnType | undefined;
const publish = () => {
timer = undefined;
if (shell.value?.id !== shellId) return;
live = { ...live, output, truncated };
void setShell(live, ctx);
};
let ended: Pick = {};
try {
// Where the conversation works: a subagent's own location may be on another machine than the session.
const { location, env } = await conversationEnvironment(harness().environments, id, durableContext(ctx));
const result = await env.exec(
input.command,
{
cwd: location.cwd,
inheritEnv: true,
spill: { afterBytes: SHELL_MAX_BYTES, afterLines: SHELL_MAX_LINES },
window: {
maxBytes: SHELL_MAX_BYTES,
maxLines: SHELL_MAX_LINES,
minIntervalMs: SHELL_SAMPLE_MS,
bytesPerSecond: 100 * 1024,
},
onOutput: (text, _context, info) => {
// The environment omitted output outside the kept tail: what follows replaces it.
if (info.skipped) {
output = "";
truncated = true;
}
const tail = shellTail(output + text);
output = tail.text;
truncated ||= tail.truncated;
timer ??= setTimeout(publish, SHELL_SAMPLE_MS);
},
},
durableContext(abort.signal),
);
if (result.ok)
ended = {
exitCode: result.value.exitCode,
...(result.value.spillPath ? { fullOutputPath: result.value.spillPath } : {}),
};
else if (result.error.code !== "aborted")
ended = {
error: result.error.code === "timeout" ? "Command timed out" : result.error.message,
...(result.error.spillPath ? { fullOutputPath: result.error.spillPath } : {}),
};
} catch (error) {
ended = { error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
const cancelled = abort.signal.aborted;
// A command's last newline ends its output; it is not an empty last line.
output = output.replace(/\r?\n$/, "");
const data: ShellData = {
author: input.author,
command: input.command,
output,
cancelled,
truncated,
includeInContext: input.includeInContext,
...ended,
};
live = {
...live,
output,
truncated,
running: false,
cancelled,
...(ended.exitCode === undefined ? {} : { exitCode: ended.exitCode }),
...(ended.error === undefined ? {} : { error: ended.error }),
};
await setShell(live, ctx);
try {
// A write never starts a run; while the agent works it is placed at the run's next boundary.
const submission = await target().conversation.submit(
{
type: "write",
entry: {
kind: SHELL_ENTRY,
data: data as unknown as JsonValue,
...(input.includeInContext
? {
model: [
{
role: "user" as const,
content: [
{ type: "text" as const, text: senderText(authorSender(input.author)) },
{ type: "text" as const, text: shellContextText(data) },
],
timestamp: Date.now(),
},
],
}
: {}),
},
requestId: `${input.author.id}:shell:${shellId}`,
},
durableContext(ctx),
);
await submission.wait(durableContext(ctx));
} finally {
// The entry shows the command from now on (or the write failed: nothing to keep showing).
if (shell.value?.id === shellId) await setShell(null, ctx);
}
};
return {
view,
stats: deriveTyped(self, [live], ([value]) => value.stats),
model: modelState,
shell: deriveTyped(self, [live], ([value]) => value.shell),
costs: async (ctx) =>
responseCosts((await target().conversation.context(durableContext(ctx))).entries).map(
({ cacheHit, ...cost }) => (cacheHit === undefined ? cost : { ...cost, cacheHit }),
),
runShell: async ({ command, includeInContext, requestId, author }, ctx) => {
const key = `${author.id}:${requestId}`;
const known = admitted.get(key);
if (known) return { shellId: known };
if (shell.value?.running) throw new Error(SHELL_BUSY);
for (const gate of gates.value.values()) await gate(ctx);
const shellId = crypto.randomUUID();
admitted.set(key, shellId);
if (admitted.size > 100) admitted.delete(admitted.keys().next().value!);
const abort = new AbortController();
running = { id: shellId, abort };
// An active call while it runs: the worker stays up, and a move or clone waits for it.
// The method returns now: the command runs under its own root context, not the call's.
void worker()
.liveness.call(() =>
worker().root((root) => runShell(shellId, { command, includeInContext, author }, abort, root)),
)
.catch((error: unknown) => console.error("shell:", error))
.finally(() => {
if (running?.id === shellId) running = undefined;
});
return { shellId };
},
abortShell: async ({ shellId }) => {
if (running?.id !== shellId || running.abort.signal.aborted) return { aborted: false };
running.abort.abort();
return { aborted: true };
},
compact: async ({ instructions }, ctx) => {
const taskId = await target().conversation.compact(
instructions?.trim() || undefined,
durableContext(ctx),
);
return { taskId };
},
reset: async ({ handoff }, ctx) => {
await target().conversation.reset(handoff?.trim() || undefined, durableContext(ctx));
return null;
},
abortCompaction: async (ctx) => {
const context = durableContext(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, context)) === "marked") aborted++;
return { aborted };
},
dequeueOwn: async ({ authorId }, ctx) => {
const context = durableContext(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(context)) {
const input = restoredInput(item);
if (input?.author.id !== authorId) continue;
if ((await durable.abortSubmission(item.id as SubmissionId, context, 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",
},
durableContext(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}` },
durableContext(ctx),
);
return { submissionId: submission.id };
},
stop: async (ctx) => {
const context = durableContext(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, context);
for (const item of inbox?.items ?? []) {
if (item.mode === "write") continue;
if ((await durable.abortSubmission(item.id as SubmissionId, context, current.id)) !== "aborted")
continue;
const input = restoredInput(item);
if (input) restored.push(input);
}
await current.abort(context, { background: false });
return { restored };
},
dequeue: async ({ submissionId, restore }, ctx) => {
const context = durableContext(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, context))?.items.find(
(entry) => entry.id === submissionId,
)
: undefined;
const result = await durable.abortSubmission(submissionId as SubmissionId, context, 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 context = durableContext(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,
context,
);
// 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 } : {}) },
durableContext(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 worker = workerOf(self);
const harness = harnessOf(self);
// Each live Chat object is a viewer: the worker stays up while someone looks.
self.open(
async () => worker().liveness.add("viewers", 1),
() => worker().liveness.add("viewers", -1),
);
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: chat.shell, entries },
};
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 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: chat.shell,
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: (input, ctx) =>
call(async () => {
// Anyone who may send may run commands, as tools run them for them (full guests included).
await writable(ctx);
if (!input.command.trim()) throw new Error("Invalid command");
return chat.runShell({ ...input, author: author() }, ctx);
}),
abortShell: ({ shellId }, ctx) =>
call(async () => {
// Its author or the owner; the command's author comes from the conversation's live state.
const live = chat.shell.value;
if (live?.id === shellId && principal.kind !== "owner" && live.author.id !== authorId)
throw forbidden("only its author or the owner cancels a command");
return chat.abortShell({ 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, durableContext(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);
});