diff --git a/src/v1/curator/curator-actor.ts b/src/v1/curator/curator-actor.ts index 7bf950b..3aefafe 100644 --- a/src/v1/curator/curator-actor.ts +++ b/src/v1/curator/curator-actor.ts @@ -58,6 +58,19 @@ export const curatorActor = { throw new Error("curator_task_has_no_handoff"); }, + async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) { + const date = input.date ?? todayIsoDate(); + const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId); + if (!task) throw new Error("curator_task_not_found"); + const event = await emitCuratorEvent({ + userId: input.userId, + type: "curator.task.completed", + mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId }, + payload: { taskId: task.id, date, reason: input.reason ?? "subtasks_completed" }, + }); + return { task: { ...task, status: "completed" as const }, eventId: event.id }; + }, + async recordServiceImpact(input: { userId: string; eventId: string }) { const streak = await buildCuratorStreak(input.userId); return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak }; diff --git a/src/v1/curator/curator-agent.ts b/src/v1/curator/curator-agent.ts index 564ed4d..0024072 100644 --- a/src/v1/curator/curator-agent.ts +++ b/src/v1/curator/curator-agent.ts @@ -1,4 +1,4 @@ -import { generateObject } from "ai"; +import { generateText } from "ai"; import { z } from "zod"; import { addMessagePg, createConversationPg, ensureCuratorTaskConversationPg, getConversationMetadataPg, listMessagesPg } from "../../grow/persistence.js"; import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js"; @@ -13,6 +13,17 @@ const chatExtractSchema = z.object({ shouldPrepareHandoff: z.boolean().default(false), }); +function parseJsonObject(text: string) { + const trimmed = text.trim(); + try { + return JSON.parse(trimmed); + } catch { + const match = trimmed.match(/\{[\s\S]*\}/); + if (!match) throw new Error("model_did_not_return_json"); + return JSON.parse(match[0]); + } +} + function buildId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } @@ -209,6 +220,17 @@ function curatorTaskKey(taskId?: string, subtaskIndex?: number) { return `${taskId}:${subtaskIndex ?? "task"}`; } +function firstTurnPrompt(input: { + subtask?: string; + task?: Awaited>[number]; +}) { + return [ + `The user opened this focused subtask: ${input.subtask ?? input.task?.title ?? "curator task"}.`, + "Generate the first live conversational question for this exact subtask.", + "Ask only one question. Do not use fallback wording. Do not prepare any service handoff yet.", + ].join("\n"); +} + async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string; subtaskIndex?: number; subtask?: string }) { if (!input.taskId) return createConversationPg(input.userId, "V1 Curator chat"); const task = (await buildCuratorTasks(input.userId, input.date)).find((item) => item.id === input.taskId); @@ -267,23 +289,40 @@ export async function runCuratorChat(input: { let reply = ""; try { - const extract = await generateObject({ - model: getConversationModel(), - schema: chatExtractSchema, - system: "Extract compact curator memory from the user's latest message. Use ASCII punctuation only.", - prompt: `Task: ${task?.title ?? "General curator chat"}\nSubtask: ${input.subtask ?? "none"}\nService: ${task?.serviceName ?? "none"}\nMessage: ${latest}`, - }); - await emitCuratorEvent({ - userId: input.userId, - type: "curator.chat.context_extracted", - mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined, - payload: { taskId: input.taskId, extract: extract.object }, - }); + try { + const extract = await generateText({ + model: getConversationModel(), + system: [ + "Extract compact curator memory from the user's latest message.", + "Return JSON only: {\"summary\": string, \"userGoal\"?: string, \"serviceIntent\"?: string, \"shouldPrepareHandoff\": boolean}.", + "Use ASCII punctuation only.", + ].join("\n"), + prompt: `Task: ${task?.title ?? "General curator chat"}\nSubtask: ${input.subtask ?? "none"}\nService: ${task?.serviceName ?? "none"}\nMessage: ${latest}`, + }); + const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text)); + await emitCuratorEvent({ + userId: input.userId, + type: "curator.chat.context_extracted", + mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined, + payload: { taskId: input.taskId, extract: parsedExtract }, + }); + } catch (error) { + console.warn("curator memory extraction failed; continuing chat", { + taskId: input.taskId, + subtaskIndex: input.subtaskIndex, + error: error instanceof Error ? error.message : String(error), + }); + } - const result = await generateConversationResponse(conversationHistory.map((message) => ({ + const modelMessages = conversationHistory.map((message) => ({ role: message.role, content: message.content, - })), { + })); + if (/^start$/i.test(latest) && modelMessages.length === 0) { + modelMessages.push({ role: "user", content: firstTurnPrompt({ subtask: input.subtask, task }) }); + } + + const result = await generateConversationResponse(modelMessages, { userId: input.userId, conversationId: conversation.id, missionInstanceId: task?.missionInstanceId, @@ -294,10 +333,16 @@ export async function runCuratorChat(input: { }); reply = sanitize(result.text); if (/what should i capture next/i.test(reply) || !reply) { - reply = fallbackReply(task, latest, conversationHistory, input.subtask, input.subtaskIndex); + throw new Error("curator_generation_failed"); } } catch (error) { - reply = fallbackReply(task, latest, conversationHistory, input.subtask, input.subtaskIndex); + console.warn("curator chat generation failed", { + taskId: input.taskId, + subtaskIndex: input.subtaskIndex, + subtask: input.subtask, + error: error instanceof Error ? error.message : String(error), + }); + throw error; } await addMessagePg(input.userId, { diff --git a/src/v1/curator/curator-routes.ts b/src/v1/curator/curator-routes.ts index 4e14ac9..7ac7859 100644 --- a/src/v1/curator/curator-routes.ts +++ b/src/v1/curator/curator-routes.ts @@ -57,6 +57,12 @@ export function v1CuratorRoutes() { return c.json(await curatorActor.prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") })); }); + app.post("/tasks/:taskId/complete", async (c) => { + const userId = c.get("userId"); + const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({}))); + return c.json(await curatorActor.completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason })); + }); + app.post("/events/service-impact", async (c) => { const userId = c.get("userId"); const body = z.object({ eventId: z.string() }).parse(await c.req.json()); diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 98f1492..a56eac4 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -202,7 +202,6 @@ async function taskCopy(input: { role?: string; serviceId?: CuratorServiceId; }) { - const fallback = fallbackTaskCopy(input); const cacheKey = JSON.stringify({ missionTitle: input.missionTitle, stageTitle: input.stageTitle, @@ -235,7 +234,6 @@ async function taskCopy(input: { `Stage role: ${input.role ?? "none"}`, `Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`, `Fallback intent: ${stageIntent(input)}`, - `Fallback copy to improve, not copy blindly: ${JSON.stringify(fallback)}`, ].join("\n"), }); const copy = generatedTaskCopySchema.parse(parseJsonObject(result.text)); @@ -248,7 +246,7 @@ async function taskCopy(input: { serviceId: input.serviceId, error: error instanceof Error ? error.message : String(error), }); - return fallback; + throw error; } }