mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
227 lines
8.5 KiB
TypeScript
227 lines
8.5 KiB
TypeScript
// Chat mode (worker): the per-conversation switch and the agent's name (what people mention, `@pi` by default), kept
|
|
// in a Durable document; the router that turns messages into chat entries or mentions; and the view each browser
|
|
// holds.
|
|
|
|
import {
|
|
type ConversationId,
|
|
type Harness as DurableHarness,
|
|
defineDoc,
|
|
type EntryId,
|
|
type EntryRecord,
|
|
InboxDoc,
|
|
type InboxState,
|
|
} from "@earendil-works/pi-durable";
|
|
import { num, obj } from "@earendil-works/pi-schema";
|
|
import {
|
|
type Context,
|
|
type Ctx,
|
|
canView,
|
|
contribute,
|
|
defineFacet,
|
|
defineState,
|
|
deriveTyped,
|
|
forbidden,
|
|
invalid,
|
|
isOwner,
|
|
} from "@p2/app/v1";
|
|
import { agentConversation, harnessOf } from "@p2/app/v1/worker";
|
|
import { Conversation, SendRouters } from "../chat/contract.ts";
|
|
import { Attachments, MAX_ATTACHMENTS } from "../files/contract.ts";
|
|
import {
|
|
CHAT_ENTRY,
|
|
type ChatMessage,
|
|
ChatMode,
|
|
ChatModeSetting,
|
|
DEFAULT_AGENT_NAME,
|
|
mentionsAgent,
|
|
type ReplyTo,
|
|
} from "./contract.ts";
|
|
import { composeMention, digestFiles, quoteOf, readEnough, unmentioned } from "./digest.ts";
|
|
|
|
/**
|
|
* The switch and the agent's name, stored per agent (`agentConversation`: the main agent's on conversation 1, whichever
|
|
* `/tree` branch it works in; a new subagent starts with chat mode off and the default name).
|
|
*/
|
|
export const ChatModeDoc = defineDoc<{ enabled: boolean; name: string }>({
|
|
kind: "chat-mode.settings",
|
|
version: 2,
|
|
scope: "conversation",
|
|
history: "latest",
|
|
fork: "initial",
|
|
initial: () => ({ enabled: false, name: DEFAULT_AGENT_NAME }),
|
|
checkpointWhen: () => true,
|
|
// Version 1 had the switch only.
|
|
migrate: (value) => ({ enabled: value.enabled === true, name: DEFAULT_AGENT_NAME }),
|
|
});
|
|
|
|
/** A conversation's newest entries, newest first, until `unmentioned` has what it needs (at most `limit`). */
|
|
async function recentEntries(durable: DurableHarness, id: ConversationId, ctx: Context, limit = 2000) {
|
|
const conversation = await durable.conversation(id, ctx);
|
|
const entries: EntryRecord[] = [];
|
|
let before = Number.MAX_SAFE_INTEGER;
|
|
while (conversation && entries.length < limit && !readEnough(entries)) {
|
|
const page = await conversation.entries({ maxEntryId: (before - 1) as EntryId }, 256, undefined, ctx);
|
|
for (const entry of page.items) {
|
|
entries.push(entry);
|
|
before = entry.id;
|
|
}
|
|
if (page.next === undefined) break;
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* What a reply quotes, read from the entry itself. Only an entry of this conversation qualifies: Durable lists a
|
|
* conversation's own entries, so an ID from another conversation is not found.
|
|
*/
|
|
export async function replyTarget(durable: DurableHarness, id: ConversationId, entryId: number, ctx: Context) {
|
|
const conversation = await durable.conversation(id, ctx);
|
|
const only = { minEntryId: entryId as EntryId, maxEntryId: entryId as EntryId };
|
|
const page = await conversation?.entries(only, 1, undefined, ctx);
|
|
const entry = page?.items[0];
|
|
const quote: ReplyTo | undefined = entry?.id === entryId ? quoteOf(entry) : undefined;
|
|
if (!quote) throw invalid("args", "That message cannot be replied to");
|
|
return quote;
|
|
}
|
|
|
|
/** How many chat messages a mention would pass on now: computed from the transcript, which the view only windows. */
|
|
const Waiting = defineState("p2.chat-mode-setting.waiting", {
|
|
schema: obj({ count: num() }),
|
|
version: 1,
|
|
initial: () => ({ count: 0 }),
|
|
});
|
|
|
|
export default defineFacet((host) => {
|
|
host.serve(
|
|
ChatModeSetting,
|
|
({ conversation }, self) => {
|
|
const id = conversation as ConversationId;
|
|
const harness = harnessOf(self);
|
|
// The conversation's view (shared with its viewers): its entries and queue change what waits for a mention.
|
|
const chat = self.get(Conversation, { conversation });
|
|
/** Where the agent's chat mode is (the main agent's on conversation 1); found when the document is followed. */
|
|
let holder = id;
|
|
const settings = self.follow(async (observer, ctx) => {
|
|
const durable = harness().durable;
|
|
holder = await agentConversation(durable, id, ctx);
|
|
const observed = await durable.observeDoc(ChatModeDoc, holder, observer, ctx);
|
|
if (observed !== undefined) return observed;
|
|
// Nobody switched it yet: the document is absent, and an absent address cannot be followed. Create it as it
|
|
// reads (off, the default name).
|
|
await durable.commit(async (tx) => {
|
|
await tx.doc(ChatModeDoc, holder);
|
|
}, ctx);
|
|
return durable.observeDoc(ChatModeDoc, holder, observer, ctx);
|
|
});
|
|
const waiting = self.state(Waiting);
|
|
const counted = deriveTyped(self, [settings, chat.view], ([doc, view]) => ({
|
|
enabled: doc.enabled,
|
|
newest: view?.entries.at(-1)?.id ?? 0,
|
|
inbox: (view?.docs[InboxDoc.definition.kind] ?? null) as InboxState | null,
|
|
}));
|
|
// The view holds only a window of the entries: the router's backwards scan counts what waits.
|
|
self.on(counted, async ({ value }, ctx) => {
|
|
const count = value.enabled
|
|
? unmentioned(await recentEntries(harness().durable, id, ctx), value.inbox ?? undefined).length
|
|
: 0;
|
|
if (waiting.value.count !== count)
|
|
await waiting.change((draft) => {
|
|
draft.count = count;
|
|
}, ctx);
|
|
});
|
|
const commit = async (ctx: Ctx, change: (doc: { enabled: boolean; name: string }) => void) => {
|
|
const durable = await harness().durable.conversation(id, ctx);
|
|
if (!durable) throw invalid("args", `Unknown conversation ${conversation}`);
|
|
await durable.commit(async (tx) => change(await tx.doc(ChatModeDoc, holder)), ctx);
|
|
};
|
|
return {
|
|
enabled: self.derive([settings], ([doc]) => doc.enabled),
|
|
name: self.derive([settings], ([doc]) => doc.name),
|
|
waiting: self.derive([waiting], ([value]) => value.count),
|
|
set: async ({ enabled: on }, ctx) => {
|
|
await commit(ctx, (doc) => {
|
|
doc.enabled = on;
|
|
});
|
|
return null;
|
|
},
|
|
rename: async ({ name: next }, ctx) => {
|
|
await commit(ctx, (doc) => {
|
|
doc.name = next;
|
|
});
|
|
return null;
|
|
},
|
|
};
|
|
},
|
|
{ linger: 30_000 },
|
|
);
|
|
|
|
contribute(host, "p2.chat-mode-router", (self) => {
|
|
const harness = harnessOf(self);
|
|
/** Validates upload IDs of this session (copying them to a remote environment, like any input's). */
|
|
const resolve = async (ids: readonly string[], conversation: number, ctx: Ctx) => {
|
|
const attachments = await ctx.get(Attachments, {});
|
|
const files = [];
|
|
for (let start = 0; start < ids.length; start += MAX_ATTACHMENTS)
|
|
files.push(
|
|
...(await attachments.resolve({ ids: ids.slice(start, start + MAX_ATTACHMENTS), conversation }, ctx)),
|
|
);
|
|
return files;
|
|
};
|
|
self.contribute(SendRouters, {
|
|
"chat-mode": async ({ conversation, author, text, requestId, whenBusy, attachments, replyTo }, ctx) => {
|
|
const durable = harness().durable;
|
|
const id = conversation as ConversationId;
|
|
const settings = await durable.snapshot(ChatModeDoc, await agentConversation(durable, id, ctx), ctx);
|
|
if (!settings?.enabled) return undefined;
|
|
const chat = await ctx.get(Conversation, { conversation });
|
|
// The client names the entry; whom it quotes and what comes from the entry.
|
|
const answering = replyTo === undefined ? undefined : await replyTarget(durable, id, replyTo, ctx);
|
|
if (!mentionsAgent(text, settings.name ?? DEFAULT_AGENT_NAME)) {
|
|
const files = await resolve(attachments, conversation, ctx);
|
|
const data: ChatMessage = {
|
|
author,
|
|
text,
|
|
attachments: files.map(({ id, name }) => ({ id, name })),
|
|
...(answering ? { replyTo: answering } : {}),
|
|
};
|
|
return chat.write({ kind: CHAT_ENTRY, data, requestId, author }, ctx);
|
|
}
|
|
const inbox = await durable.snapshot(InboxDoc, id, ctx);
|
|
const earlier = unmentioned(await recentEntries(durable, id, ctx), inbox);
|
|
const files = await resolve(digestFiles(earlier, attachments), conversation, ctx);
|
|
const own = files.filter((file) => attachments.includes(file.id));
|
|
const mention = {
|
|
author,
|
|
text,
|
|
chat: earlier,
|
|
replyTo: answering,
|
|
attachments: own,
|
|
files,
|
|
requestId,
|
|
whenBusy,
|
|
};
|
|
return chat.submit(composeMention(mention), ctx);
|
|
},
|
|
});
|
|
});
|
|
|
|
host.serve(ChatMode, ({ session, conversation }, principal, self) => {
|
|
if (!canView(principal, session)) throw forbidden("viewers only");
|
|
const setting = self.get(ChatModeSetting, { conversation });
|
|
if (!isOwner(principal))
|
|
return {
|
|
variant: "reader",
|
|
members: { enabled: setting.enabled, waiting: setting.waiting, name: setting.name },
|
|
};
|
|
return {
|
|
variant: "owner",
|
|
members: {
|
|
enabled: setting.enabled,
|
|
waiting: setting.waiting,
|
|
name: setting.name,
|
|
set: (change, ctx) => setting.set(change, ctx),
|
|
rename: (change, ctx) => setting.rename(change, ctx),
|
|
},
|
|
};
|
|
});
|
|
});
|