/** The world's root to-do list, its todo tool, and changed-list notes for the standing conversation. */ import { int, obj, oneOf, opt, str } from '@earendil-works/pi-schema' import { defineDoc, defineExtension, defineTool, GenerationTask, hook, ROOT_CONVERSATION_ID, } from '@earendil-works/pi-durable' import type { ConversationId, Tx } from '@earendil-works/pi-durable' // Read subagents' standing-agent contract without requiring its extension yet. export const Standing = defineDoc>({ kind: 'standing.standing-agents', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}), }) export type Todo = { id: number; text: string; owner: 'user' | 'standing'; at: number; done?: number } /** Done to-dos kept; older ones are dropped when another is ticked. */ export const DONE_LIMIT = 50 const TEXT_CHARS = 300 export const Todos = defineDoc<{ next: number; items: Todo[] }>({ kind: 'todos', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ next: 1, items: [] }), }) /** The open list the standing agent last heard, so it hears again only after a change. */ const Seen = defineDoc<{ key?: string }>({ kind: 'todos.seen', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}), }) export const docs = [Todos] const OWNERS = ['user', 'standing'] as const export async function add(tx: Tx, text: unknown, owner: unknown) { if (typeof text !== 'string' || !text.trim()) throw new Error('text must be nonempty') if (text.trim().length > TEXT_CHARS) throw new Error(`text must be at most ${TEXT_CHARS} characters`) if (!OWNERS.includes(owner as Todo['owner'])) throw new Error("owner must be 'user' or 'standing'") const todos = await tx.doc(Todos, ROOT_CONVERSATION_ID) const todo: Todo = { id: todos.next++, text: text.trim(), owner: owner as Todo['owner'], at: Date.now(), } todos.items.push(todo) return todo } /** Tick to-do `id` (or open it again), keeping only the newest `DONE_LIMIT` done ones. */ export async function mark(tx: Tx, id: unknown, done: boolean) { const todos = await tx.doc(Todos, ROOT_CONVERSATION_ID) const todo = todos.items.find((item) => item.id === id) if (!todo) throw new Error(`No to-do ${String(id)}`) if (done) { todo.done ??= Date.now() const ticked = todos.items.filter((item) => item.done !== undefined) const drop = new Set( ticked .toSorted((a, b) => b.done! - a.done!) .slice(DONE_LIMIT) .map((item) => item.id), ) todos.items = todos.items.filter((item) => !drop.has(item.id)) } else delete todo.done // A copy: the document's overlay is unusable once the commit settles, and callers reply with this. return { ...todo } } const line = (todo: Todo) => `- ${todo.id}: ${todo.text}` /** The open to-dos as model text, by owner; undefined when none is open. */ export function listText(items: readonly Todo[]) { const open = items.filter((item) => item.done === undefined) if (!open.length) return undefined const mine = open.filter((item) => item.owner === 'standing') const theirs = open.filter((item) => item.owner === 'user') return [ ...(mine.length ? ['Yours:', ...mine.map(line)] : []), ...(theirs.length ? ["The user's:", ...theirs.map(line)] : []), ].join('\n') } /** the standing agent's note when the open list changed since it last heard it; undefined otherwise. */ export async function noteFor(tx: Tx, conversationId: ConversationId, now: number) { const items = (await tx.doc(Todos, ROOT_CONVERSATION_ID)).items const text = listText(items) const key = text ?? '' const seen = await tx.doc(Seen, conversationId) if ((seen.key ?? '') === key) return undefined seen.key = key return { entries: [ { kind: 'todos.note', data: { open: items.filter((item) => item.done === undefined).length }, model: [ { role: 'user' as const, content: text ? `Open to-dos (todo tool: done when finished):\n${text}` : 'No to-dos are open.', timestamp: now, }, ], }, ], } } const standing = (tx: Tx, id: ConversationId) => tx.doc(Standing, ROOT_CONVERSATION_ID).then((doc) => doc['$standing'] === id) export default function todosExtension() { return defineExtension({ name: 'todos', hooks: [ hook(GenerationTask, { // Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook. // A per-input note when the list changed, never a system-prompt section, keeps the prompt cached. beforeInput: async (_input, { tx, conversationId }) => (await standing(tx, conversationId)) ? noteFor(tx, conversationId, Date.now()) : undefined, }), ], tools: [ defineTool({ name: 'todo', deferred: true, description: "The world's to-dos, owned by you ('standing') or the user ('user'): add {text, owner}, done {id}, list. Keep open loops here. Only the standing conversation maintains this list.", parameters: obj({ action: oneOf(['add', 'done', 'list']), text: opt(str()), owner: opt(oneOf(['user', 'standing'])), id: opt(int({ minimum: 1 })), }), // Not replay-safe (the default): an interrupted add is never rerun, so it commits at most once. async execute(args, api, ctx) { // Side chats are not part of this port. Each caller acts as itself. const caller = api.conversationId const text = await api.commit(async (tx) => { // Deferred only hides it: a branch can still find it with tool_search, so the caller is checked here. if (!(await standing(tx, caller))) throw new Error( 'Only the standing agent keeps the to-dos; ask the standing agent to add one.', ) if (args.action === 'add') { const todo = await add(tx, args.text, args.owner) return `Added ${todo.id} for ${todo.owner}: ${todo.text}` } if (args.action === 'done') { const todo = await mark(tx, args.id, true) return `Done ${todo.id}: ${todo.text}` } return listText((await tx.doc(Todos, ROOT_CONVERSATION_ID)).items) ?? 'No open to-dos.' }, ctx) return { output: [{ type: 'text', text }] } }, }), ], }) }