pi-mono/apps/p2/plugins/chat/worker.ts
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

497 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `<attachments>\nThe user attached these files:\n${list}\n</attachments>`;
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<typeof Chat> = ({ 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 = <T>(fn: () => Promise<T>): Promise<T> => 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<void> => {
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<string[]> => {
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);
});