diff --git a/src/v1/curator/curator-agent.ts b/src/v1/curator/curator-agent.ts index aeaa45f..f33cddb 100644 --- a/src/v1/curator/curator-agent.ts +++ b/src/v1/curator/curator-agent.ts @@ -39,7 +39,19 @@ function buildId(prefix: string) { } function sanitize(text: string) { - return text + const withoutControlLines = text + .split(/\r?\n/) + .filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return true; + if (/^(date|curator task id|focused subtask|curator task title|curator task context|curator task subtasks|curator service|expected completion events|captured task memory|task title|task service|task context|all task subtasks|visible history):/i.test(trimmed)) return false; + if (/^```/.test(trimmed)) return false; + return true; + }) + .join("\n") + .trim(); + const withoutJsonEnvelope = withoutControlLines.replace(/^\s*\{[\s\S]*"reply"\s*:\s*"([^"]+)"[\s\S]*\}\s*$/i, "$1"); + return withoutJsonEnvelope .replace(/[\u2013\u2014]/g, "-") .replace(/[\u2018\u2019]/g, "'") .replace(/[\u201C\u201D]/g, '"') @@ -48,6 +60,19 @@ function sanitize(text: string) { .trim(); } +function pushField(lines: string[], label: string, value?: string | number | null) { + if (value === undefined || value === null) return; + const stringValue = String(value).trim(); + if (!stringValue) return; + lines.push(`${label}: ${stringValue}`); +} + +function pushList(lines: string[], label: string, values?: string[]) { + const cleanValues = values?.map((value) => value.trim()).filter(Boolean) ?? []; + if (cleanValues.length === 0) return; + lines.push(`${label}: ${cleanValues.join(" | ")}`); +} + type CuratorMessage = Awaited>[number]; async function capturedSubtaskMemory(userId: string, taskId?: string) { @@ -131,7 +156,7 @@ function curatorSystemAddendum(input: { task?: Awaited>[number]; taskMemory?: Array<{ subtaskIndex?: number; subtask?: string; summary?: string }>; }) { - return [ + const lines = [ "You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.", "The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.", "Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.", @@ -144,17 +169,26 @@ function curatorSystemAddendum(input: { "When the user has answered the focused subtask enough, summarize what was captured and stop. Do not ask the next subtask question.", "If more detail is needed, ask exactly one follow-up question for the focused subtask only.", "Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.", - `Date: ${input.date}`, - `Curator task id: ${input.taskId ?? "none"}`, - `Focused subtask index: ${Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : "none"}`, - `Focused subtask title: ${input.subtask ?? "none"}`, - `Curator task title: ${input.task?.title ?? "General curator chat"}`, - `Curator task context: ${input.task?.contextNarrative ?? "none"}`, - `Curator task subtasks: ${input.task?.subtasks.join(" | ") ?? "none"}`, - `Curator service: ${input.task?.serviceName ?? "none"}`, - `Expected completion events: ${input.task?.completionEvents.join(", ") ?? "none"}`, - `Captured task memory: ${input.taskMemory?.map((item) => `[${item.subtaskIndex ?? "?"}] ${item.subtask ?? "Subtask"}: ${item.summary}`).join(" | ") || "none"}`, - ].join("\n"); + ]; + pushField(lines, "Date", input.date); + pushField(lines, "Curator task id", input.taskId); + pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined); + pushField(lines, "Focused subtask title", input.subtask); + pushField(lines, "Curator task title", input.task?.title); + pushField(lines, "Curator task context", input.task?.contextNarrative); + pushList(lines, "Curator task subtasks", input.task?.subtasks); + pushField(lines, "Curator service", input.task?.serviceName); + pushList(lines, "Expected completion events", input.task?.completionEvents); + const memory = input.taskMemory + ?.map((item) => { + if (!item.summary) return ""; + const subtask = item.subtask?.trim() || "Subtask"; + const index = Number.isInteger(item.subtaskIndex) ? `[${item.subtaskIndex}] ` : ""; + return `${index}${subtask}: ${item.summary}`; + }) + .filter(Boolean); + pushList(lines, "Captured task memory", memory); + return lines.join("\n"); } function curatorTaskKey(taskId?: string, subtaskIndex?: number) { @@ -174,7 +208,9 @@ function firstTurnPrompt(input: { } function isExplicitHandoffRequest(text: string) { - return /\b(start|open|launch|begin|set up|setup|create|generate)\b/i.test(text); + const trimmed = text.trim(); + if (/^start$/i.test(trimmed)) return false; + return /\b(start|open|launch|begin|set up|setup|create|generate)\b/i.test(trimmed); } async function evaluateSubtaskStatus(input: { @@ -204,17 +240,19 @@ async function evaluateSubtaskStatus(input: { "Never mark ready just because one message exists.", "Use ASCII punctuation only.", ].join("\n"), - prompt: [ - `Task title: ${input.task?.title ?? "none"}`, - `Task service: ${input.task?.serviceName ?? "none"}`, - `Focused subtask index: ${Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : "none"}`, - `Focused subtask: ${input.subtask}`, - `Task context: ${input.task?.contextNarrative ?? "none"}`, - `All task subtasks: ${input.task?.subtasks.join(" | ") ?? "none"}`, - `Latest user answer: ${input.latest}`, - `Assistant reply: ${input.reply}`, - `Visible history: ${input.history.map((message) => `${message.role}: ${message.content}`).join("\n")}`, - ].join("\n"), + prompt: (() => { + const lines: string[] = []; + pushField(lines, "Task title", input.task?.title); + pushField(lines, "Task service", input.task?.serviceName); + pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined); + pushField(lines, "Focused subtask", input.subtask); + pushField(lines, "Task context", input.task?.contextNarrative); + pushList(lines, "All task subtasks", input.task?.subtasks); + pushField(lines, "Latest user answer", input.latest); + pushField(lines, "Assistant reply", input.reply); + pushField(lines, "Visible history", input.history.map((message) => `${message.role}: ${message.content}`).join("\n")); + return lines.join("\n"); + })(), }); return subtaskStatusUpdateSchema.parse(parseJsonObject(result.text)); } catch (error) { @@ -294,7 +332,14 @@ export async function runCuratorChat(input: { "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}`, + prompt: (() => { + const lines: string[] = []; + pushField(lines, "Task", task?.title); + pushField(lines, "Subtask", input.subtask); + pushField(lines, "Service", task?.serviceName); + pushField(lines, "Message", latest); + return lines.join("\n"); + })(), }); const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text)); await emitCuratorEvent({ diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index a1a548d..8af2157 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -92,13 +92,14 @@ type GeneratedTaskCopy = z.infer; const taskCopyCache = new Map(); function parseJsonObject(text: string) { - const trimmed = text.trim(); + const trimmed = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").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]); + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) throw new Error("model_did_not_return_json"); + return JSON.parse(trimmed.slice(start, end + 1)); } } @@ -120,11 +121,9 @@ async function taskCopy(input: { if (cached) return cached; try { - const result = await generateText({ - model: getConversationModel(), - system: [ + const system = [ "You generate GrowQR V1 curator task copy.", - "Return JSON only. Do not wrap it in markdown.", + "Return valid JSON only. Do not wrap it in markdown.", "The JSON shape must be: {\"title\": string, \"subtitle\": string, \"subtasks\": [string, string, string], \"contextNarrative\": string}.", "Do not invent new missions or services. Use the provided mission, stage, role, and service only.", "Generate exactly three sequential subtasks that make sense for a real user.", @@ -133,37 +132,36 @@ async function taskCopy(input: { "Do not include rewards, coins, scores, or generic readiness-score language.", "Use ASCII punctuation only.", "For early users, prefer practical context capture: goal, current background, resume/input, constraints, or service setup.", - ].join("\n"), - prompt: [ - `Mission: ${input.missionTitle}`, - `Stage: ${input.stageTitle}`, - `Stage description: ${input.stageDescription || "none"}`, - `Stage role: ${input.role ?? "none"}`, - `Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`, - `Detected intent: ${stageIntent(input)}`, - ].join("\n"), - }); - let rawCopy = result.text; - let parsedCopy: unknown; - try { - parsedCopy = parseJsonObject(rawCopy); - } catch (parseError) { - const repaired = await generateText({ + ].join("\n"); + const basePrompt = (() => { + const lines = [ + `Mission: ${input.missionTitle}`, + `Stage: ${input.stageTitle}`, + `Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`, + `Detected intent: ${stageIntent(input)}`, + ]; + if (input.stageDescription.trim()) lines.push(`Stage description: ${input.stageDescription}`); + if (input.role?.trim()) lines.push(`Stage role: ${input.role}`); + return lines.join("\n"); + })(); + let lastError = ""; + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = await generateText({ model: getConversationModel(), - system: [ - "Repair invalid JSON into valid JSON only.", - "Do not add markdown.", - "Do not add or invent new task content.", - "Keep the exact object shape: {\"title\": string, \"subtitle\": string, \"subtasks\": [string, string, string], \"contextNarrative\": string}.", - ].join("\n"), - prompt: rawCopy, + system, + prompt: attempt === 0 + ? basePrompt + : `${basePrompt}\n\nPrevious JSON was invalid: ${lastError}\nRegenerate the same task copy as valid JSON only. Do not add comments or markdown.`, }); - rawCopy = repaired.text; - parsedCopy = parseJsonObject(rawCopy); + try { + const copy = generatedTaskCopySchema.parse(parseJsonObject(result.text)); + taskCopyCache.set(cacheKey, copy); + return copy; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } } - const copy = generatedTaskCopySchema.parse(parsedCopy); - taskCopyCache.set(cacheKey, copy); - return copy; + throw new Error(`curator_task_copy_invalid_json: ${lastError}`); } catch (error) { console.warn("curator task copy generation failed", { missionTitle: input.missionTitle,