Port plan and todos as world extensions
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Plan
|
||||
|
||||
The `plan` tool maintains a durable epic/task ledger in `ultimate.plan`.
|
||||
Task-owned descendants read their top owner's ledger. Only that owner edits it.
|
||||
Ownerless conversations maintain separate ledgers.
|
||||
|
||||
Edits and receipts commit together. Memo namespaces distinguish nested calls that share a task.
|
||||
The tool returns both JSON text and structured content.
|
||||
The compact prompt section excludes completed epics.
|
||||
|
||||
The root document `ultimate.plan.tasks` indexes linked child conversations.
|
||||
A new link or task title change updates the child's metadata title.
|
||||
Other edits preserve a title that the user supplied.
|
||||
|
||||
The first input of a new lineage branch clears its inherited plan atomically.
|
||||
The hook preserves a branch's plan if the branch already answered.
|
||||
The reader uses the existing `lineage.branch` document contract without requiring the lineage extension.
|
||||
|
||||
`host/tests/plan_todos.rs` verifies the port through the world extension loader.
|
||||
@@ -0,0 +1,315 @@
|
||||
/** The durable epic/task ledger, linked-child index, and first-input branch reset. */
|
||||
import { arr, int, obj, oneOf, opt, str } from '@earendil-works/pi-schema'
|
||||
import {
|
||||
defineDoc,
|
||||
defineExtension,
|
||||
defineTool,
|
||||
GenerationTask,
|
||||
hook,
|
||||
ROOT_CONVERSATION_ID,
|
||||
section,
|
||||
} from '@earendil-works/pi-durable'
|
||||
import type { ConversationDocToken, ConversationId, Cursor, EntryId, JsonObject, Tx } from '@earendil-works/pi-durable'
|
||||
// Read lineage's document contract without requiring its extension to exist yet.
|
||||
export const Branch = defineDoc<{ branch?: { from: number; at: number; intent: string; brief?: true } }>({
|
||||
kind: 'lineage.branch', checkpointWhen: () => false, version: 1,
|
||||
scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}),
|
||||
})
|
||||
type ExtensionHost = { metadata: ConversationDocToken<{ title: string; label: string }> }
|
||||
|
||||
export type Task = {
|
||||
key: string
|
||||
title: string
|
||||
role: 'worker' | 'reviewer'
|
||||
description: string
|
||||
deps: string[]
|
||||
status: 'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done' | 'blocked' | 'canceled'
|
||||
child?: number
|
||||
verdict?: string
|
||||
notes: { at: string; text: string }[]
|
||||
}
|
||||
export type Epic = {
|
||||
key: string
|
||||
title: string
|
||||
body: string
|
||||
status: 'planning' | 'in_progress' | 'done' | 'blocked'
|
||||
verdict?: string
|
||||
tasks: Record<string, Task>
|
||||
}
|
||||
export const Plan = defineDoc<{ next: number; epics: Record<string, Epic> }>({
|
||||
kind: 'ultimate.plan',
|
||||
checkpointWhen: () => false,
|
||||
version: 1,
|
||||
scope: 'conversation',
|
||||
history: 'rewindable',
|
||||
fork: 'asOf',
|
||||
initial: () => ({ next: 1, epics: {} }),
|
||||
})
|
||||
/** The task each linked child conversation works on, across every coordinator's plan; kept with the root. */
|
||||
export type TaskLink = {
|
||||
coordinator: number
|
||||
epic: string
|
||||
key: string
|
||||
title: string
|
||||
status: Task['status']
|
||||
}
|
||||
export const TaskIndex = defineDoc<{ children: Record<string, TaskLink> }>({
|
||||
kind: 'ultimate.plan.tasks',
|
||||
checkpointWhen: () => false,
|
||||
version: 1,
|
||||
scope: 'conversation',
|
||||
history: 'latest',
|
||||
fork: 'initial',
|
||||
initial: () => ({ children: {} }),
|
||||
})
|
||||
export const docs = [Plan, TaskIndex]
|
||||
// Commit the receipt with the edit, closing the crash window before api.memo stores the result.
|
||||
const Receipt = defineDoc<{ results: Record<string, string> }>({
|
||||
kind: 'ultimate.plan.receipt',
|
||||
checkpointWhen: () => false,
|
||||
version: 1,
|
||||
scope: 'task',
|
||||
initial: () => ({ results: {} }),
|
||||
})
|
||||
// structuredContent hands codemode scripts the object itself. As text, `shown.description` was undefined, and
|
||||
// `update {description: shown.description + more}` wrote "undefined" over the description (#159728).
|
||||
const reply = (text: string) => ({
|
||||
output: [{ type: 'text' as const, text }],
|
||||
structuredContent: JSON.parse(text) as JsonObject,
|
||||
})
|
||||
const required = (value: string | undefined, field: string) => {
|
||||
if (value === undefined) throw new Error(`${field} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the index entries of `coordinator` from its plan. A link that is new or whose task title changed renames
|
||||
* the child after its task.
|
||||
*/
|
||||
async function reindex(
|
||||
tx: Tx,
|
||||
host: Pick<ExtensionHost, 'metadata'>,
|
||||
coordinator: ConversationId,
|
||||
plan: { epics: Record<string, Epic> },
|
||||
) {
|
||||
const index = await tx.doc(TaskIndex, ROOT_CONVERSATION_ID)
|
||||
const previous = { ...index.children }
|
||||
for (const [child, link] of Object.entries(previous))
|
||||
if (link.coordinator === coordinator) delete index.children[child]
|
||||
for (const epic of Object.values(plan.epics)) {
|
||||
for (const task of Object.values(epic.tasks)) {
|
||||
if (task.child === undefined) continue
|
||||
const child = String(task.child)
|
||||
const before = previous[child]
|
||||
index.children[child] = {
|
||||
coordinator,
|
||||
epic: epic.key,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
}
|
||||
const renamed =
|
||||
before?.coordinator !== coordinator ||
|
||||
before.key !== task.key ||
|
||||
before.title !== task.title
|
||||
if (renamed && (await tx.conversation(task.child as ConversationId)))
|
||||
(await tx.doc(host.metadata, task.child as ConversationId)).title = task.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Set by a conversation's first run; a fork starts without it. */
|
||||
export const Started = defineDoc<{ started?: true }>({
|
||||
kind: 'plan.started',
|
||||
checkpointWhen: () => false,
|
||||
version: 1,
|
||||
scope: 'conversation',
|
||||
history: 'latest',
|
||||
fork: 'initial',
|
||||
initial: () => ({}),
|
||||
})
|
||||
|
||||
/** Whether branch `id` has answered since it forked at entry `at`. */
|
||||
async function answered(tx: Tx, id: ConversationId, at: number) {
|
||||
let cursor: Cursor | undefined
|
||||
do {
|
||||
const page = await tx.scanEntries(
|
||||
{ conversationId: id, minEntryId: (at + 1) as EntryId, order: 'ascending' },
|
||||
64,
|
||||
cursor,
|
||||
)
|
||||
if (page.items.some((entry) => entry.kind === 'pi.assistant')) return true
|
||||
cursor = page.next
|
||||
} while (cursor !== undefined)
|
||||
return false
|
||||
}
|
||||
|
||||
export default (host: ExtensionHost) => {
|
||||
return defineExtension({
|
||||
name: 'plan',
|
||||
hooks: [
|
||||
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
|
||||
// A lineage branch starts with an empty plan, not the one it forked with: emptied in the commit that places its
|
||||
// first input, so its first request never sees the inherited plan. A branch that answered before it was marked
|
||||
// started (one made before this hook) keeps its own.
|
||||
hook(GenerationTask, {
|
||||
beforeInput: async (_input, { tx, conversationId }) => {
|
||||
if ((await tx.doc(Started, conversationId)).started) return undefined
|
||||
const branch = (await tx.doc(Branch, conversationId)).branch
|
||||
const reset = !!branch && !(await answered(tx, conversationId, branch.at))
|
||||
;(await tx.doc(Started, conversationId)).started = true
|
||||
if (reset) Object.assign(await tx.doc(Plan, conversationId), Plan.definition.initial())
|
||||
return undefined
|
||||
},
|
||||
}),
|
||||
],
|
||||
sections: [
|
||||
section('plan', async ({ read, conversationId }, context) => {
|
||||
const plan = await read.snapshot(Plan, conversationId, context)
|
||||
const lines = Object.values(plan?.epics ?? {})
|
||||
.filter((epic) => epic.status !== 'done')
|
||||
.flatMap((epic) => [
|
||||
`${epic.key} ${epic.status} ${epic.title}`,
|
||||
...Object.values(epic.tasks).map(
|
||||
(task) =>
|
||||
`${task.key} ${task.status} ${task.title}` +
|
||||
(task.deps.length ? ` deps:${task.deps.join(',')}` : '') +
|
||||
(task.child !== undefined ? ` child:${task.child}` : ''),
|
||||
),
|
||||
])
|
||||
return lines.length ? lines.join('\n') : undefined
|
||||
}),
|
||||
],
|
||||
tools: [
|
||||
defineTool({
|
||||
name: 'plan',
|
||||
description:
|
||||
'Maintain the durable epic/task ledger. The tool assigns keys: epics E1, E2; tasks E1.1, E1.2. Use only keys a call has returned: wait for create/add results before calls that reference them (epic, deps, key), never in the same parallel batch. create {title,body}; add {epic,title,role,description,deps?}; update {key,status?,title?,description?,body?,child?,verdict?} replaces only the fields given (description and body whole: show first to extend them); in codemode every action returns the object; note {key,text} notes a task only (E1.1, not E1; record on an epic with update verdict or body); show {key?} reads an epic, task, or the whole ledger. Epic bodies use Request, Selected approach, Intent, Scope (in/out), binary Acceptance criteria, Implementation, Verification, Risks. Task descriptions use Outcome, Scope, Non-goals, Files, Acceptance criteria with a verification command, Constraints. Epic statuses: planning, in_progress, done, blocked. Task statuses: backlog, todo, in_progress, in_review, done, blocked, canceled. child links a conversation number; deps are task keys. body is epic-only; verdict records evidence on either an epic or a task. Children read the root coordinator’s ledger with show; only that coordinator can edit it. Read full plans with show; the prompt contains only a compact ledger.',
|
||||
// A memoized receipt id records the result in the same transaction as the ledger change.
|
||||
replay: 'safe',
|
||||
parameters: obj({
|
||||
action: oneOf(['create', 'add', 'update', 'note', 'show'] as const),
|
||||
title: opt(str()),
|
||||
body: opt(str()),
|
||||
epic: opt(str({ description: 'Epic key returned by create, e.g. E1.' })),
|
||||
role: opt(oneOf(['worker', 'reviewer'] as const)),
|
||||
description: opt(str()),
|
||||
deps: opt(
|
||||
arr(str(), {
|
||||
description: 'Keys of tasks already added, e.g. ["E1.1"].',
|
||||
}),
|
||||
),
|
||||
key: opt(
|
||||
str({
|
||||
description: 'A returned key: epic E1 or task E1.1; note takes a task key.',
|
||||
}),
|
||||
),
|
||||
status: opt(str()),
|
||||
child: opt(int({ minimum: 1 })),
|
||||
verdict: opt(str()),
|
||||
text: opt(str()),
|
||||
}),
|
||||
execute: async (args, api, context) => {
|
||||
const cached = await api.memo<ReturnType<typeof reply>>('result', context)
|
||||
if (cached) return cached
|
||||
const receiptId = await api.memo('receipt-id', crypto.randomUUID(), context)
|
||||
const text = await api.commit(async (tx) => {
|
||||
let coordinator = api.conversationId
|
||||
let owner = (await tx.conversation(coordinator))?.owner
|
||||
while (owner) {
|
||||
coordinator = owner.conversationId
|
||||
owner = (await tx.conversation(coordinator))?.owner
|
||||
}
|
||||
if (args.action !== 'show' && coordinator !== api.conversationId)
|
||||
throw new Error(
|
||||
`Only conversation ${coordinator} edits this plan; report to it instead`,
|
||||
)
|
||||
const receipt = await tx.doc(Receipt, api.taskId)
|
||||
if (receipt.results[receiptId] !== undefined) return receipt.results[receiptId]
|
||||
const plan = await tx.doc(Plan, coordinator)
|
||||
const lookup = (key: string) => {
|
||||
const epicKey = key.split('.')[0]
|
||||
const epic = Object.hasOwn(plan.epics, epicKey) ? plan.epics[epicKey] : undefined
|
||||
const item = key.includes('.')
|
||||
? epic && Object.hasOwn(epic.tasks, key)
|
||||
? epic.tasks[key]
|
||||
: undefined
|
||||
: epic
|
||||
if (!item) throw new Error(`Unknown key: ${key}`)
|
||||
return item
|
||||
}
|
||||
let result: unknown
|
||||
if (args.action === 'create') {
|
||||
const title = required(args.title, 'title')
|
||||
const body = required(args.body, 'body')
|
||||
const key = `E${plan.next++}`
|
||||
plan.epics[key] = {
|
||||
key,
|
||||
title,
|
||||
body,
|
||||
status: 'planning',
|
||||
tasks: {},
|
||||
}
|
||||
result = plan.epics[key]
|
||||
} else if (args.action === 'add') {
|
||||
const epic = lookup(required(args.epic, 'epic'))
|
||||
if (!('tasks' in epic)) throw new Error('epic must identify an epic')
|
||||
const title = required(args.title, 'title')
|
||||
const description = required(args.description, 'description')
|
||||
if (!args.role) throw new Error('role is required')
|
||||
for (const dep of args.deps ?? []) {
|
||||
if (!('notes' in lookup(dep))) throw new Error('deps must identify tasks')
|
||||
}
|
||||
const key = `${epic.key}.${Object.keys(epic.tasks).length + 1}`
|
||||
epic.tasks[key] = {
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
role: args.role,
|
||||
deps: args.deps ?? [],
|
||||
status: 'backlog',
|
||||
notes: [],
|
||||
}
|
||||
result = epic.tasks[key]
|
||||
} else if (args.action === 'show') {
|
||||
result = args.key === undefined ? plan : lookup(args.key)
|
||||
} else {
|
||||
const item = lookup(required(args.key, 'key'))
|
||||
const task = 'notes' in item
|
||||
if (args.action === 'note') {
|
||||
if (!task) throw new Error('note requires a task key')
|
||||
item.notes.push({
|
||||
at: new Date().toISOString(),
|
||||
text: required(args.text, 'text'),
|
||||
})
|
||||
} else {
|
||||
const statuses = task
|
||||
? ['backlog', 'todo', 'in_progress', 'in_review', 'done', 'blocked', 'canceled']
|
||||
: ['planning', 'in_progress', 'done', 'blocked']
|
||||
if (args.status !== undefined && !statuses.includes(args.status))
|
||||
throw new Error(`Invalid status for ${item.key}: ${args.status}`)
|
||||
if (task && args.body !== undefined) throw new Error('body requires an epic key')
|
||||
if (!task && (args.description !== undefined || args.child !== undefined))
|
||||
throw new Error('description and child require a task key')
|
||||
if (args.status !== undefined) item.status = args.status as typeof item.status
|
||||
if (args.title !== undefined) item.title = args.title
|
||||
if (args.verdict !== undefined) item.verdict = args.verdict
|
||||
if (task) {
|
||||
if (args.description !== undefined) item.description = args.description
|
||||
if (args.child !== undefined) item.child = args.child
|
||||
} else {
|
||||
if (args.body !== undefined) item.body = args.body
|
||||
}
|
||||
}
|
||||
result = item
|
||||
}
|
||||
if (args.action !== 'show') await reindex(tx, host, coordinator, plan)
|
||||
receipt.results[receiptId] = JSON.stringify(result)
|
||||
return receipt.results[receiptId]
|
||||
}, context)
|
||||
return api.memo('result', reply(text), context)
|
||||
},
|
||||
}),
|
||||
].map((tool) => ({ ...tool, outputSchema: { type: 'object' } })),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Todos
|
||||
|
||||
The root document `todos` stores the world's to-do list.
|
||||
Each item belongs to `user` or `ultimate`.
|
||||
The `todo` tool adds items, completes items, and lists open items by owner.
|
||||
Text must contain 1–300 characters after the tool trims it.
|
||||
The list retains the newest 50 completed items. The exported `mark` helper also reopens items.
|
||||
|
||||
Only the standing `ultimate` conversation uses the tool.
|
||||
The reader uses the existing `ultimate.standing-agents` document contract.
|
||||
Until subagents supplies that document, no conversation qualifies automatically.
|
||||
This port does not designate the root as `ultimate`.
|
||||
|
||||
The `beforeInput` hook places a note only when the standing conversation's open list changes.
|
||||
The note and the remembered list commit with the input.
|
||||
The tool remains deferred. A caller must load it through `modelTools` or a tool-search extension.
|
||||
|
||||
This port contains no browser actions or side-chat delegation.
|
||||
`host/tests/plan_todos.rs` verifies the four original cases and real-loader tool calls.
|
||||
@@ -0,0 +1,176 @@
|
||||
/** 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 }] }
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user