Port goal and question-card server extensions

This commit is contained in:
2026-11-07 15:24:06 +00:00
parent 3f5b6082e6
commit c42d4c03fb
18 changed files with 35404 additions and 1 deletions
+22
View File
@@ -0,0 +1,22 @@
# Hands-off goals
The user starts or ends a goal through the exported server actions.
Only the standing `@ultimate` conversation uses the `goal` tool.
The root document stores one open goal and its decisions.
Three failed tries block a criterion. Rewording preserves unchanged criteria.
Decisions can name screenshots from the caller's conversation.
The `latest` reference never crosses an input boundary.
Each input to `@ultimate` receives the current open goal note.
A queued start card grants nothing after the user ends the goal.
The start wakes `@ultimate`. The report uses a passive write.
The optional automations service creates one keyed reminder every 30 minutes.
A durable task synchronizes its state after each lifecycle change.
A replay adopts the existing reminder. A user-deleted reminder stays deleted.
Pause disables the reminder. Resume enables it. End deletes it.
A reminder input adds the reminder checklist to the goal note.
World automations identify a fire with the `[automation <id> fired]` prefix.
The goal hook recognizes that prefix and the structured `automations.fired` entry.
Side chats and browser components are outside this server port.
+4
View File
@@ -0,0 +1,4 @@
/** Server action references. Side chats are not part of the world port. */
export const docRef = <T>(kind: string) => ({kind})
export const actionRef = <A, R>(extension: string, action: string) => ({extension, action})
export const implementAction = (_ref: unknown, run: any) => run
+47
View File
@@ -0,0 +1,47 @@
/** Durable delivery of goal start cards and passive report cards. */
import { defineTask } from '@earendil-works/pi-durable'
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
import type { Host } from '../../host/js/extensions.ts'
/** A goal card for @ultimate: `goal.started` as an input that wakes it, `goal.report` as a write. */
type Card = { to: number; goal: number; kind: 'goal.started' | 'goal.report'; content: string }
/** Created in the commit that changes the goal, so a crash cannot lose the card; the request id makes retries harmless. */
export const deliverTask = (host: Pick<Host, 'harness'>) =>
defineTask<Card, { phase: 'submit' }, null>({
name: 'goal.deliver',
version: 1,
initial: () => ({ phase: 'submit' }),
phases: {
submit: async (task, runtime, ctx) => {
const { to, goal, kind, content } = task.input
const requestId = `${kind}:${goal}`
const data = { goal }
// The host handle, unlike the task runtime's, admits passive writes.
const target = (await host.harness().conversation(to as ConversationId, ctx))!
await target.submit(
kind === 'goal.started'
? { type: 'input', requestId, content, entry: { kind, data }, whenBusy: 'followUp' }
: {
type: 'write',
requestId,
entry: { kind, data, model: [{ role: 'user', content, timestamp: Date.now() }] },
},
ctx,
)
await runtime.commit(
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
ctx,
)
},
},
abort: (_task, runtime, ctx) =>
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
})
export const deliver = (host: Pick<Host, 'harness'>, tx: Tx, card: Card) =>
tx.createTask(deliverTask(host), card, {
conversationId: card.to as ConversationId,
ownership: { kind: 'conversation' },
background: true,
})
+57
View File
@@ -0,0 +1,57 @@
/** Hands-off goals in the browser: the goal the user put @ultimate on, and every decision it took for them. Browser-safe. */
import { actionRef, docRef } from '../goal/compat.ts'
/** One decision @ultimate took instead of asking the user. */
export type Decision = {
at: number
/** What it would have asked the user. */
question: string
/** What it chose. */
picked: string
reason: string
/** The branch the decision was for. */
conversation?: number
/** The entry of @ultimate's conversation holding the screenshots it judged, such as a UI preview it approved. */
images?: number
}
/** How a goal ended: met, blocked (every remaining criterion stuck), or ended by the user. */
export type Ending = {
at: number
by: 'user' | 'ultimate'
outcome: 'met' | 'blocked' | 'ended'
summary?: string
}
/** What "done" means, as @ultimate wrote it down: each criterion open, met, or blocked after repeated failed tries. */
export type Criterion = { text: string; status: 'open' | 'met' | 'blocked'; tries?: number }
export type Goal = {
id: number
/** The user's words: what to reach, and when to stop if they said so. */
text: string
startedAt: number
criteria: Criterion[]
decisions: Decision[]
/** The "Goal reminder" automation that wakes @ultimate every 30 minutes while the goal is open. */
reminder?: string
/** Set while @ultimate is blocked on the user: the reminder is paused until it resumes. */
waiting?: { reason: string; at: number }
ended?: Ending
}
/** `{ next, goals }` on conversation 1, oldest first; at most one goal is open (no `ended`). */
export const GoalDoc = docRef<{ next: number; goals: Goal[] }>('goal')
/** `goal.started` and `goal.report` entries in @ultimate's conversation name their goal. */
export type GoalEntry = { goal: number }
/** `{ text }` → the new goal. User-only: puts @ultimate hands-off on it. */
export const StartAction = actionRef<{ text: string }, Goal>('goal', 'start')
/** No arguments → the ended goal. User-only: ends the open goal; @ultimate asks again as usual. */
export const EndAction = actionRef<null, Goal>('goal', 'end')
/** `{ goal, entry }` → the images of a decision's screenshot entry. */
export const ImagesAction = actionRef<
{ goal: number; entry: number },
{ data: string; mimeType: string }[]
>('goal', 'images')
+94
View File
@@ -0,0 +1,94 @@
/**
* The goal reminder: an automation that wakes @ultimate every 30 minutes while a goal is open, so a stalled goal never
* waits silently. Every goal change (start, pause, resume, end) creates a sync task in its own commit; the task brings
* the automation in line with the goal as it is when the task runs. A crash leaves at most a task to rerun, and a rerun
* gets the automation an earlier try made back from its create key instead of making a second one.
*/
import { defineTask, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationDocToken, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import type { Automation } from '../automations/types.ts'
import type { Goal } from './refs.ts'
export const SCHEDULE = 'every 30m'
export const reminderName = (goal: number) => `Goal reminder · goal ${goal}`
/** Each goal's reminder has its own create key: a rerun gets it back, and no goal touches another's. */
const reminderKey = (goal: number) => `goal.reminder:${goal}`
/**
* Steps at most: each makes one change (create, link, delete, pause or resume) and looks again, so a pause and resume
* that race still end on the newest goal.
*/
const STEPS = 6
type Goals = ConversationDocToken<{ next: number; goals: Goal[] }>
export function reminderTask(host: Host, Goals: Goals) {
const find = async (tx: Tx, id: number) =>
(await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((item) => item.id === id)
/** One look: true when it changed something and must look again, false once the automation matches the goal. */
async function step(id: number): Promise<boolean> {
const automations = host.want<{ operate(args: Record<string, unknown>): Promise<unknown> }>(AutomationsApi)
// Automations is off: the goal runs without a reminder.
if (!automations) return false
// A plain copy: the document's overlay is unusable after the commit.
const goal = await host
.harness()
.commit(
async (tx) =>
await find(tx, id).then((found) => found && (JSON.parse(JSON.stringify(found)) as Goal)),
context,
)
const list = (await automations.operate({ action: 'list' })) as Automation[]
// The linked one by its id, whatever the user renamed it to; before it is linked, the one its key made.
const own = goal?.reminder
? list.find((item) => item.id === goal.reminder)
: list.find((item) => item.key === reminderKey(id))
if (!goal || goal.ended) {
if (!own) return false
await automations.operate({ action: 'delete', id: own.id })
return true
}
if (!own) {
// One the user deleted in the Automations panel stays deleted.
if (goal.reminder) return false
await automations.operate({
action: 'create',
name: reminderName(id),
schedule: SCHEDULE,
subscribers: ['@ultimate'],
key: reminderKey(id),
})
return true
}
if (goal.reminder !== own.id) {
await host.harness().commit(async (tx) => {
const current = await find(tx, id)
if (current) current.reminder = own.id
}, context)
return true
}
if (own.enabled === !goal.waiting) return false
if (goal.waiting) await automations.operate({ action: 'pause', id: own.id })
else await automations.operate({ action: 'resume', id: own.id })
return true
}
return defineTask<{ goal: number }, { phase: 'sync' }, null>({
name: 'goal.reminder',
version: 1,
initial: () => ({ phase: 'sync' }),
phases: {
sync: async (task, runtime, ctx) => {
for (let i = 0; i < STEPS; i++) if (!(await step(task.input.goal))) break
await runtime.commit(
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
ctx,
)
},
},
abort: (_task, runtime, ctx) =>
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
})
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Hands-off goals: the user puts @ultimate on a goal and goes away. While it is open, @ultimate decides what it would
* otherwise ask them, records each decision with its reason, and ends the goal with a report. Only the user starts or
* ends a goal early; @ultimate hears it on every run through a per-input note.
*/
import { arr, int, lit, obj, oneOf, opt, str, union } from '@earendil-works/pi-schema'
import {
defineDoc,
defineExtension,
defineTool,
GenerationTask,
hook,
ROOT_CONVERSATION_ID,
} from '@earendil-works/pi-durable'
import type { ConversationId, EntryId, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { implementAction } from '../goal/compat.ts'
import { imagesOf, latestImagesEntry } from '../messages/compat.ts'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import { Standing } from '../subagents/api.ts'
import { deliver, deliverTask } from './delivery.ts'
import { reminderTask } from './reminder.ts'
import { EndAction, GoalDoc, ImagesAction, StartAction } from './refs.ts'
import type { Criterion, Decision, Ending, Goal } from './refs.ts'
/** Failed tries after which a criterion is blocked; work goes on with the others. */
export const TRIES = 3
const TEXT_CHARS = 2000
export const Goals = defineDoc<{ next: number; goals: Goal[] }>({
kind: GoalDoc.kind,
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
history: 'latest',
fork: 'initial',
initial: () => ({ next: 1, goals: [] }),
})
export const docs = [Goals]
/** A plain copy: the document's overlay is a proxy that structuredClone refuses, and unusable after the commit. */
const copy = (goal: Goal) => JSON.parse(JSON.stringify(goal)) as Goal
const ultimateOf = async (tx: Tx) => (await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate
/** The open goal, if any. */
export async function openGoal(tx: Tx) {
return (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((goal) => !goal.ended)
}
const text = (value: unknown, name: string) => {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be nonempty`)
if (value.trim().length > TEXT_CHARS)
throw new Error(`${name} must be at most ${TEXT_CHARS} characters`)
return value.trim()
}
/** Start a goal for the user's words `words`; refuses while another is open. */
export async function start(tx: Tx, words: unknown, now: number) {
const goals = await tx.doc(Goals, ROOT_CONVERSATION_ID)
const open = goals.goals.find((goal) => !goal.ended)
if (open) throw new Error(`Goal ${open.id} is still open; end it first`)
const goalText = text(words, 'The goal')
const goal: Goal = {
id: goals.next++,
text: goalText,
startedAt: now,
criteria: [],
decisions: [],
}
goals.goals.push(goal)
return goal
}
/** End the open goal; refuses when none is open. */
export async function end(tx: Tx, ending: Ending) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No goal is open')
goal.ended = ending
return copy(goal)
}
const status = (criterion: Criterion) =>
criterion.status === 'open' && criterion.tries
? `open, ${criterion.tries}/${TRIES} failed tries`
: criterion.status
/**
* @ultimate's per-run note while a goal is open: the goal, its criteria, and how it works; on a fire of its reminder,
* what to check.
*/
export function noteText(goal: Goal, reminded = false) {
const criteria = goal.criteria.length
? goal.criteria.map((item, index) => `${index + 1}. [${status(item)}] ${item.text}`).join('\n')
: "None yet: write them first with goal {action: 'criteria', criteria}, each one you can check."
return [
`Hands-off goal ${goal.id}, granted by the user, who is away: ${goal.text}`,
`Done means:\n${criteria}`,
`Work toward it without the user: decide what you would ask them yourself (a go, a decision, a UI preview approval against the criteria), record each with goal {action: 'decide'} and its reason, and keep going. ${goal.decisions.length} decided so far. Mark criteria met or failed as you check them; a criterion is blocked after ${TRIES} failed tries, so learn from each failure before the next try. Finish with goal {action: 'finish'} when every criterion is met or every open one is blocked. Only when you are truly blocked on the user, goal {action: 'pause', reason} stops the reminder that wakes you.`,
...(goal.waiting
? [
`Waiting on the user since ${new Date(goal.waiting.at).toISOString()}: ${goal.waiting.reason}. The reminder is paused. Once they answer and you can go on, goal {action: 'resume'} turns it back on.`,
]
: []),
...(reminded
? [
"This is the goal reminder: check that the goal's branches are moving, the lander queue, reactor lag and watchdog cards. Nudge or fix what is stuck and record a decide only when you changed something; otherwise end the turn quietly.",
]
: []),
].join('\n\n')
}
/** The newest entry with images in this turn of conversation `id`, or entry `ref` when it has some. */
async function imagesEntry(tx: Tx, id: ConversationId, ref: 'latest' | number) {
if (ref !== 'latest') {
const entry = await tx.entry(ref as EntryId)
if (entry?.conversationId !== id || !imagesOf(entry).length)
throw new Error(`images: entry ${ref} in your conversation has no images`)
return ref
}
return (await latestImagesEntry(tx, id)).id as number
}
type ToolArgs = {
action: 'criteria' | 'decide' | 'met' | 'failed' | 'finish' | 'pause' | 'resume'
criteria?: string[]
n?: number
question?: string
picked?: string
reason?: string
conversation?: number
images?: 'latest' | number
outcome?: 'met' | 'blocked'
summary?: string
}
/** One `goal` tool call by @ultimate (`ultimate`) in `tx`; returns its answer, and the goal when it finished. */
export async function act(tx: Tx, ultimate: ConversationId, args: ToolArgs, now: number) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No hands-off goal is open; ask the user as usual.')
const criterion = () => {
const found = goal.criteria[(args.n ?? 0) - 1]
if (!found) throw new Error(`No criterion ${String(args.n)}; there are ${goal.criteria.length}`)
return found
}
if (args.action === 'criteria') {
const items = (args.criteria ?? []).map((item) => text(item, 'A criterion'))
if (!items.length) throw new Error('criteria must list at least one')
// Rewording keeps what was learned about a criterion with the same text.
goal.criteria = items.map(
(item) =>
goal.criteria.find((old) => old.text === item) ?? { text: item, status: 'open' as const },
)
return { answer: `Goal ${goal.id} has ${items.length} criteria.` }
}
if (args.action === 'decide') {
const decision: Decision = {
at: now,
question: text(args.question, 'question'),
picked: text(args.picked, 'picked'),
reason: text(args.reason, 'reason'),
...(args.conversation === undefined ? {} : { conversation: args.conversation }),
...(args.images === undefined
? {}
: { images: await imagesEntry(tx, ultimate, args.images) }),
}
goal.decisions.push(decision)
return { answer: `Recorded decision ${goal.decisions.length}: ${decision.picked}` }
}
if (args.action === 'met') {
criterion().status = 'met'
return { answer: `Criterion ${args.n} met.` }
}
if (args.action === 'failed') {
const found = criterion()
found.tries = (found.tries ?? 0) + 1
if (found.tries >= TRIES) found.status = 'blocked'
const open = goal.criteria.filter((item) => item.status === 'open').length
return {
answer:
found.status === 'blocked'
? `Criterion ${args.n} is blocked after ${TRIES} failed tries.${open ? ` Go on with the ${open} still open.` : ' None is open: finish the goal as blocked.'}`
: `Criterion ${args.n}: ${found.tries}/${TRIES} failed tries. Say what you change before the next try.`,
}
}
if (args.action === 'pause') {
goal.waiting = { reason: text(args.reason, 'reason'), at: now }
return {
answer: `Goal ${goal.id} waits on the user; the reminder is paused. Ask them now.`,
sync: goal.id,
}
}
if (args.action === 'resume') {
if (!goal.waiting) throw new Error(`Goal ${goal.id} is not paused`)
delete goal.waiting
return {
answer: `Goal ${goal.id} goes on; the reminder wakes you again.`,
sync: goal.id,
}
}
if (args.outcome !== 'met' && args.outcome !== 'blocked')
throw new Error("finish needs outcome 'met' or 'blocked'")
const ended = await end(tx, {
at: now,
by: 'ultimate',
outcome: args.outcome,
summary: text(args.summary, 'summary'),
})
return {
answer: `Goal ${goal.id} finished: ${args.outcome}. The report is in your conversation.`,
ended,
}
}
/** The report card's model text: what @ultimate (or the user, ending it) should take from it. */
const reportText = (goal: Goal) =>
goal.ended?.by === 'user'
? `The user ended hands-off goal ${goal.id}. From now on ask them as usual.`
: `Hands-off goal ${goal.id} ended (${goal.ended?.outcome}); its report card is here for the user. From now on ask them as usual.`
/**
* Goal `goal`'s card of `kind` for @ultimate, delivered by a task created in `tx`, the commit that changed the goal.
* The start card says only that the user started it: what @ultimate may do comes from the goal note, built from the
* goal as it is when the input runs, so a start still queued after the user ended the goal grants nothing.
*/
async function card(host: Host, tx: Tx, kind: 'goal.started' | 'goal.report', goal: Goal) {
const to = await ultimateOf(tx)
if (to === undefined) throw new Error('@ultimate has no conversation yet')
await deliver(host, tx, {
to,
goal: goal.id,
kind,
content:
kind === 'goal.started' ? `The user started hands-off goal ${goal.id}.` : reportText(goal),
})
}
/** `name` of an action's arguments; the helpers above validate it. */
const field = (args: unknown, name: string) =>
args && typeof args === 'object' ? (args as Record<string, unknown>)[name] : undefined
/** In the commit that changes goal `goal`: a task that brings its reminder in line once the change is durable. */
const sync = (host: Host, tx: Tx, goal: number) =>
tx.createTask(
reminderTask(host, Goals),
{ goal },
{
conversationId: ROOT_CONVERSATION_ID,
ownership: { kind: 'conversation' },
background: true,
},
)
/** Starting and ending a goal is the user's grant; @ultimate only finishes one through its tool. */
export const userActions = ['start', 'end']
export const actions = {
start: implementAction(StartAction, (args, { host }) =>
host.harness().commit(async (tx) => {
const goal = await start(tx, field(args, 'text'), Date.now())
await card(host, tx, 'goal.started', goal)
await sync(host, tx, goal.id)
return copy(goal)
}, context),
),
end: implementAction(EndAction, (_args, { host }) =>
host.harness().commit(async (tx) => {
const goal = await end(tx, { at: Date.now(), by: 'user', outcome: 'ended' })
await card(host, tx, 'goal.report', goal)
await sync(host, tx, goal.id)
return goal
}, context),
),
images: implementAction(ImagesAction, async (args, { host }) =>
host.harness().commit(async (tx) => {
const goal = (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find(
(item) => item.id === field(args, 'goal'),
)
const entry = field(args, 'entry')
// Only the screenshots a decision names, not any entry by id.
if (!goal?.decisions.some((decision) => decision.images === entry))
throw new Error('No decision of that goal has these images')
return imagesOf(await tx.entry(entry as EntryId)).map(({ data, mimeType }) => ({
data,
mimeType,
}))
}, context),
),
}
export default function goalExtension(host: Host) {
// Optional: without automations a goal runs as before, with no reminder.
host.want(AutomationsApi)
return defineExtension({
name: 'goal',
tasks: [deliverTask(host), reminderTask(host, Goals)],
hooks: [
hook(GenerationTask, {
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
// A per-run note while a goal is open, never a system-prompt section, keeps the prompt cached.
beforeInput: async (input, { tx, conversationId }) => {
if ((await ultimateOf(tx)) !== conversationId) return undefined
const goal = await openGoal(tx)
if (!goal) return undefined
// World automations use a plain input with their fire prefix.
const reminded = goal.reminder !== undefined && (
(input.kind === 'automations.fired' &&
(input.data as { id?: string } | undefined)?.id === goal.reminder) ||
input.model?.some((message) => message.role === 'user' &&
(typeof message.content === 'string' ? message.content :
message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n'))
.startsWith(`[automation ${goal.reminder} fired]`))
)
return {
entries: [
{
kind: 'goal.note',
data: { goal: goal.id },
model: [
{
role: 'user' as const,
content: noteText(goal, reminded),
timestamp: Date.now(),
},
],
},
],
}
},
}),
],
tools: [
defineTool({
name: 'goal',
deferred: true,
description:
"The hands-off goal the user put you on (only while one is open; the goal note names it): criteria {criteria: [...]} writes what done means, each checkable; decide {question, picked, reason, conversation?, images?} records a decision you took instead of asking the user (images: \"latest\" or an entry id of your conversation with screenshots you judged); met {n} / failed {n} mark criterion n; finish {outcome: 'met' | 'blocked', summary} ends the goal and leaves the report card; pause {reason} when you are truly blocked on the user stops the 30-minute goal reminder and shows them what you wait on, resume turns it back on once you can go on.",
parameters: obj({
action: oneOf(['criteria', 'decide', 'met', 'failed', 'finish', 'pause', 'resume']),
criteria: opt(arr(str())),
n: opt(int({ minimum: 1 })),
question: opt(str()),
picked: opt(str()),
reason: opt(str()),
conversation: opt(int({ minimum: 1 })),
images: opt(union([lit('latest'), int({ minimum: 1 })])),
outcome: opt(oneOf(['met', 'blocked'])),
summary: opt(str()),
}),
// Not replay-safe (the default): an interrupted call is never rerun, so a decision is recorded at most once.
async execute(args, api, ctx) {
const caller = api.conversationId
const result = await api.commit(async (tx) => {
if ((await ultimateOf(tx)) !== caller)
throw new Error('Only @ultimate keeps the hands-off goal.')
const acted = await act(tx, caller, args, Date.now())
if (acted.ended) await card(host, tx, 'goal.report', acted.ended)
const changed = acted.sync ?? acted.ended?.id
if (changed !== undefined) await sync(host, tx, changed)
return acted
}, ctx)
return { content: [{ type: 'text', text: result.answer }] }
},
}),
],
})
}
+17
View File
@@ -0,0 +1,17 @@
# Question cards
The exported user actions answer or dismiss an open message to the user.
The messages service delivers the answer to the asking conversation.
The action validates the question text and rejects duplicate answers.
Only one concurrent answer settles an open message.
Answer text preserves the message's numbers and option keys.
A note follows the answers. A note alone also settles the message.
Free text answers a message without structured answers.
Dismissal sends a passive note and starts no run.
Closed messages reject further answers and dismissals.
The messages extension owns question schemas and open-question notes.
Browser components are outside this server port.
The “settling for the user” case awaits the later world UI work.
It uses the messages UI action, not the question-card service.
+26
View File
@@ -0,0 +1,26 @@
/**
* Questions in the browser: the actions that answer an agent's questions to the user from its message's card.
* Browser-safe.
*/
import { actionRef } from '../goal/compat.ts'
/** One answered question: the question's text and the chosen or typed answer (several choices joined by ", "). */
export type Answer = { question: string; answer: string }
/**
* `{ messageId, answers?, note? }` or `{ messageId, text }` → `{ id }`: answer open message `messageId` with a reply
* to its sender, from its questions (`note` goes after the answer lines) or as free text; `id` is the reply's message
* id. Fails when the message no longer waits on the user.
*/
export const AnswerAction = actionRef<
{ messageId: number; answers?: Answer[]; note?: string; text?: string },
{ id: number }
>('questions', 'answer')
/**
* `{ messageId }` → `{ id }`: close open message `messageId` without an answer; its sender gets a one-way note,
* `id`, that starts no run. Fails when the message no longer waits on the user.
*/
export const DismissAction = actionRef<{ messageId: number }, { id: number }>(
'questions',
'dismiss',
)
+109
View File
@@ -0,0 +1,109 @@
/** User actions that answer or dismiss question cards through the messages service. */
import { defineExtension, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context, withAbortSignal } from '@earendil-works/chord/context'
import type { Host } from '../../host/js/extensions.ts'
import { Messages, MessagesApi } from '../messages/api.ts'
import type { Message, Question } from '../messages/api.ts'
import { implementAction } from '../goal/compat.ts'
import { AnswerAction, DismissAction } from './refs.ts'
const open = (message: Message) =>
message.to === 'user' && message.reply && message.state === 'delivered'
const NOT_OPEN = 'These questions are no longer open'
function validAnswers(value: unknown): value is { question: string; answer: string }[] {
return (
Array.isArray(value) &&
value.every(
(a: { question?: unknown; answer?: unknown } | null) =>
a && typeof a.question === 'string' && typeof a.answer === 'string' && a.answer.trim(),
)
)
}
function checkAnswers(answers: { question: string }[], questions: Question[]) {
if (
new Set(answers.map((a) => a.question)).size !== answers.length ||
answers.some((a) => !questions.some((q) => q.text === a.question))
)
throw new Error('Unknown question')
}
/** The keys of the chosen options, in option order: "a", "a,c"; none when an answer names no option. */
function keysOf(question: Question, answer: string) {
const labels = question.kind === 'multi' ? answer.split(', ') : [answer]
const keys = (question.options ?? []).flatMap((o) =>
o.key && labels.includes(o.label) ? [o.key] : [],
)
return keys.length === labels.length ? keys.join(',') : ''
}
/**
* The reply naming each answer by the message's own numbering: `1a. Topic: label` for an option (`2a,c.` for
* several), `3: text` for typed text or a rating, `Topic: answer` when the message numbered nothing; a note from the
* card's field follows the answers.
*/
export function answerText(
answers: { question: string; answer: string }[],
questions: Question[],
note?: string,
) {
const lines = answers.map(({ question, answer }) => {
const q = questions.find((candidate) => candidate.text === question)!
const keys = keysOf(q, answer)
if (!q.ref) return `${q.text}: ${answer}`
return keys ? `${q.ref}${keys}. ${q.text}: ${answer}` : `${q.ref}: ${answer}`
})
if (note?.trim()) lines.push(answers.length ? `Note: ${note.trim()}` : note.trim())
return lines.join('\n\n')
}
export const userActions = ['answer', 'dismiss']
export const actions = {
/** Answer an open message from its questions, with a note, or as free text: a user reply to its sender. */
answer: implementAction(AnswerAction, async (args, { host, signal, device }) => {
const data = args as {
messageId: number
answers?: { question: string; answer: string }[]
note?: unknown
text?: unknown
}
const text = typeof data?.text === 'string' ? data.text.trim() : undefined
const note = typeof data?.note === 'string' ? data.note.trim() : undefined
const answers = data?.answers ?? []
if (
!data ||
!Number.isSafeInteger(data.messageId) ||
(text === undefined
? !validAnswers(answers) || (!answers.length && !note)
: !text || data.answers !== undefined || note)
)
throw new Error('Invalid answers')
const reply = await host.harness().commit(async (tx) => {
const log = await tx.doc(Messages, ROOT_CONVERSATION_ID)
const message = log.messages.find((m) => m.id === data.messageId)
if (!message || !open(message)) throw new Error(NOT_OPEN)
if (text !== undefined) return text
checkAnswers(answers, message.questions ?? [])
return answerText(answers, message.questions ?? [], note)
}, context)
return {
id: await host
.need(MessagesApi)
.answer(data.messageId, reply, withAbortSignal(signal, context), device),
}
}),
/** Close an open message to the user unanswered; its sender gets a one-way note. */
dismiss: implementAction(DismissAction, async (args, { host, signal, device }) => {
const data = args as { messageId: number }
if (!data || !Number.isSafeInteger(data.messageId)) throw new Error('Invalid message')
return {
id: await host
.need(MessagesApi)
.dismiss(data.messageId, withAbortSignal(signal, context), device),
}
}),
}
export default function questionsExtension(host: Host) {
// Core, always on; resolved where it is called, declared here so the catalogue lists it.
host.need(MessagesApi)
return defineExtension({ name: 'questions' })
}