Files
sausage/extensions/todos/server.ts
T

177 lines
6.5 KiB
TypeScript

/** The world's root to-do list, its todo tool, and changed-list notes for the standing @ultimate 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<Record<string, ConversationId>>({
kind: 'ultimate.standing-agents', checkpointWhen: () => false, version: 1,
scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}),
})
export type Todo = { id: number; text: string; owner: 'user' | 'ultimate'; 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 @ultimate 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', 'ultimate'] 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 'ultimate'")
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 === 'ultimate')
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')
}
/** @ultimate'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.ultimate === 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 ('ultimate') or the user ('user'): add {text, owner}, done {id}, list. Keep open loops here. Only the standing @ultimate conversation maintains this list.",
parameters: obj({
action: oneOf(['add', 'done', 'list']),
text: opt(str()),
owner: opt(oneOf(['user', 'ultimate'])),
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 @ultimate keeps the to-dos; ask @ultimate 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 }] }
},
}),
],
})
}