Implement ICP-driven curator sprint flow

This commit is contained in:
Sai-karthik
2026-06-19 15:10:39 +00:00
parent c73b1a1788
commit 27c9f58b80
9 changed files with 1367 additions and 527 deletions
+2 -10
View File
@@ -9,6 +9,7 @@ import { createMissionAction, listMissionActions } from "../../missions/actions.
import { getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg } from "../../grow/persistence.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { getSubAgentModules } from "../../lib/prompt-loader.js";
import { buildMissionServiceRoute } from "../../services/service-registry.js";
const SYSTEM_PROMPT = `You are the GrowQR conversation agent.
Keep answers concise, practical, and focused on the user's active mission.
@@ -92,16 +93,7 @@ function serviceHref(input: {
stageId?: string;
goal?: string;
}) {
const params = new URLSearchParams({
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
});
if (input.stageId) params.set("stageId", input.stageId);
if (input.goal) params.set("goal", input.goal);
if (input.serviceId === "interview-service") return `/agents/interview/setup?${params.toString()}`;
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${params.toString()}`;
return `/agents/resume?${params.toString()}`;
return buildMissionServiceRoute(input);
}
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {
+10 -24
View File
@@ -1,5 +1,6 @@
import { config } from "../config.js";
import { createHash } from "node:crypto";
import { buildServiceSessionPath } from "./service-registry.js";
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
export type ServiceAgentRef = {
@@ -28,32 +29,17 @@ export function buildServiceSessionUrl(
detail: Record<string, unknown> | undefined,
goal?: string,
): string | undefined {
const base = config.workflowsDashboardUrl.replace(/\/$/, "");
const sessionId = detail?.session_id ?? detail?.sessionId;
const params = new URLSearchParams();
if (sessionId && typeof sessionId === "string") params.set("session_id", sessionId);
if (goal) params.set("goal", goal);
if (service === "interview-service") {
if (!sessionId || typeof sessionId !== "string") return undefined;
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
params.set("type", String(detail?.interview_type ?? "behavioral"));
return `${base}/v2/service-sessions/interview?${params.toString()}`;
if (
service !== "interview-service" &&
service !== "roleplay-service" &&
service !== "resume-service"
) {
return undefined;
}
if (service === "roleplay-service") {
if (!sessionId || typeof sessionId !== "string") return undefined;
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
params.set("type", String(detail?.roleplay_type ?? "custom"));
return `${base}/v2/service-sessions/roleplay?${params.toString()}`;
}
if (service === "resume-service") {
if (goal) params.set("role", goal);
return `${base}/v2/service-sessions/resume${params.size ? `?${params.toString()}` : ""}`;
}
return undefined;
const path = buildServiceSessionPath(service, detail, goal);
if (!path) return undefined;
return `${config.workflowsDashboardUrl.replace(/\/$/, "")}${path}`;
}
function stableUuid(input: string): string {
+195
View File
@@ -0,0 +1,195 @@
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
type QueryValue = string | number | undefined | null;
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
type MissionRouteInput = {
serviceId: MissionServiceId;
missionInstanceId: string;
missionId: string;
stageId?: string;
goal?: string;
};
type CuratorRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
targetRole?: string;
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
function appendQuery(
pathname: string,
params: Record<string, QueryValue>,
) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `${pathname}?${query}` : pathname;
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getSessionId(detail?: Record<string, unknown>) {
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
}
export function buildServiceSessionPath(
serviceId: MissionServiceId,
detail?: Record<string, unknown>,
goal?: string,
) {
const sessionId = getSessionId(detail);
if (serviceId === "interview-service") {
if (!sessionId) return undefined;
return appendQuery("/v2/service-sessions/interview", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
type: getString(detail?.interview_type) ?? "behavioral",
});
}
if (serviceId === "roleplay-service") {
if (!sessionId) return undefined;
return appendQuery("/v2/service-sessions/roleplay", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
type: getString(detail?.roleplay_type) ?? "custom",
});
}
return appendQuery("/v2/service-sessions/resume", {
goal,
role: goal,
});
}
export function buildMissionServiceRoute(input: MissionRouteInput) {
const baseParams = {
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
goal: input.goal,
};
if (input.serviceId === "interview-service") {
return appendQuery("/agents/interview/setup", baseParams);
}
if (input.serviceId === "roleplay-service") {
return appendQuery("/agents/roleplay/setup", baseParams);
}
return appendQuery("/agents/resume", baseParams);
}
function curatorBaseParams(input: CuratorRouteInput) {
return {
source: "curator-v1",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
curatorTaskId: input.taskId,
};
}
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
if (input.serviceId === "interview-service") {
return appendQuery("/agents/interview/preview", {
...curatorBaseParams(input),
role: input.targetRole?.trim() || "Product Manager",
type: "behavioral",
persona: input.personaId ?? "payal",
duration: input.durationMinutes ?? 5,
difficulty: input.difficulty ?? "medium",
media: input.requestedMode ?? "video",
});
}
if (input.serviceId === "roleplay-service") {
return appendQuery("/agents/roleplay/builder", {
...curatorBaseParams(input),
role: input.targetRole?.trim() || "Professional",
persona: input.personaId ?? "emma",
duration: input.durationMinutes ?? 5,
mode: input.requestedMode ?? "video",
brief: input.roleplayBrief,
});
}
if (input.serviceId === "resume-service") {
return appendQuery("/agents/resume", curatorBaseParams(input));
}
if (input.serviceId === "qscore-service") {
return appendQuery("/analytics", curatorBaseParams(input));
}
if (input.serviceId === "social-branding-service") {
return appendQuery("/social", curatorBaseParams(input));
}
if (input.serviceId === "matchmaking-service") {
return appendQuery("/pathways", curatorBaseParams(input));
}
return input.missionInstanceId
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
: "/missions/active";
}
export function getServiceDisplayName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
}
export function getServiceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_preview";
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
}
export function getServiceCompletionEvents(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") {
return ["interview.configured", "interview.review_completed", "interview.completed"];
}
if (serviceId === "roleplay-service") {
return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
}
if (serviceId === "resume-service") {
return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
}
if (serviceId === "qscore-service") {
return ["qscore.updated", "qscore.signal_projected"];
}
return ["curator.task.completed"];
}
export function getServiceActionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Open interview preview";
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
if (task.serviceId === "resume-service") return "Open resume workspace";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
}
+22 -13
View File
@@ -1,5 +1,5 @@
import { buildCuratorPlan, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.js";
@@ -19,20 +19,27 @@ export const curatorActor = {
async getToday(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate: date, endDate: date }));
const tasks = plan.days[0]?.tasks ?? await buildCuratorTasks(input.userId, date);
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date } });
return {
date,
plan,
tasks,
streak: plan.streak,
completedCount: tasks.filter((task) => task.status === "completed").length,
totalCount: tasks.length,
plan: sprint.plan,
tasks: sprint.todayTasks,
streak: sprint.streak,
completedCount: sprint.completedCount,
totalCount: sprint.totalCount,
sprint,
source: "curator-v1" as const,
};
},
async getSprint(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date, sprintId: sprint.sprintId } });
return sprint;
},
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
return runCuratorChat(input);
},
@@ -78,18 +85,20 @@ export const curatorActor = {
async recordServiceImpact(input: { userId: string; eventId: string }) {
const streak = await buildCuratorStreak(input.userId);
return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak };
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
},
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
for (const signal of input.signals) {
await emitCuratorEvent({ userId: input.userId, type: "curator.improvement_signal.applied", payload: { signal } });
}
const plan = await buildCuratorPlan(input.userId, { startDate: input.date, endDate: input.date });
return { applied: input.signals.length, plan };
const sprint = await buildCuratorSprint(input.userId, input.date);
return { applied: input.signals.length, plan: sprint.plan, sprint };
},
async getState(input: { userId: string }) {
return { tasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak: await buildCuratorStreak(input.userId) };
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
},
};
+5
View File
@@ -41,6 +41,11 @@ export function v1CuratorRoutes() {
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
});
app.get("/sprint", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getSprint({ userId, date: c.req.query("date") }));
});
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
+12 -58
View File
@@ -1,3 +1,10 @@
import {
buildCuratorServiceRoute,
getServiceActionLabel,
getServiceCompletionEvents,
getServiceDisplayName,
getServiceToolName,
} from "../../services/service-registry.js";
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
type ServiceRouteInput = {
@@ -14,75 +21,22 @@ type ServiceRouteInput = {
roleplayBrief?: string;
};
function baseParams(input: ServiceRouteInput) {
const params = new URLSearchParams({ source: "curator-v1" });
if (input.missionInstanceId) params.set("missionInstanceId", input.missionInstanceId);
if (input.missionId) params.set("missionId", input.missionId);
if (input.stageId) params.set("stageId", input.stageId);
if (input.taskId) params.set("curatorTaskId", input.taskId);
return params;
}
export function serviceRoute(input: ServiceRouteInput) {
if (input.serviceId === "interview-service") {
const params = baseParams(input);
params.set("role", input.targetRole?.trim() || "Product Manager");
params.set("type", "behavioral");
params.set("persona", input.personaId ?? "payal");
params.set("duration", String(input.durationMinutes ?? 5));
params.set("difficulty", input.difficulty ?? "medium");
params.set("media", input.requestedMode ?? "video");
return `/agents/interview/preview?${params.toString()}`;
}
if (input.serviceId === "roleplay-service") {
const params = baseParams(input);
params.set("role", input.targetRole?.trim() || "Professional");
params.set("persona", input.personaId ?? "emma");
params.set("duration", String(input.durationMinutes ?? 5));
params.set("mode", input.requestedMode ?? "video");
if (input.roleplayBrief) params.set("brief", input.roleplayBrief);
return `/agents/roleplay/builder?${params.toString()}`;
}
const params = baseParams(input);
if (input.serviceId === "resume-service") return `/agents/resume?${params.toString()}`;
if (input.serviceId === "qscore-service") return `/analytics?${params.toString()}`;
if (input.serviceId === "social-branding-service") return `/social?${params.toString()}`;
if (input.serviceId === "matchmaking-service") return `/pathways?${params.toString()}`;
return `/missions/active${input.missionInstanceId ? `?missionInstanceId=${encodeURIComponent(input.missionInstanceId)}` : ""}`;
return buildCuratorServiceRoute(input);
}
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
return getServiceDisplayName(serviceId, fallback);
}
export function serviceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_preview";
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
return getServiceToolName(serviceId);
}
export function completionEventsForService(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return ["interview.configured", "interview.review_completed", "interview.completed"];
if (serviceId === "roleplay-service") return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
if (serviceId === "resume-service") return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
if (serviceId === "qscore-service") return ["qscore.updated", "qscore.signal_projected"];
return ["curator.task.completed"];
return getServiceCompletionEvents(serviceId);
}
export function actionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Open interview preview";
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
if (task.serviceId === "resume-service") return "Open resume workspace";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
return getServiceActionLabel(task);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -62,7 +62,7 @@ export async function prepareHandoffForTask(
});
let actionId: string | undefined;
if (task.missionInstanceId) {
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
const action = await createMissionAction({
userId,
missionInstanceId: task.missionInstanceId,
+61 -7
View File
@@ -11,6 +11,9 @@ export const curatorServiceIdSchema = z.enum([
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]);
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
export const curatorTaskStatusSchema = z.enum([
"ready",
"started",
@@ -19,9 +22,16 @@ export const curatorTaskStatusSchema = z.enum([
"blocked",
]);
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg", "Excelling"]);
export const curatorTaskSchema = z.object({
id: z.string(),
date: z.string(),
dayIndex: z.number().int().min(1).max(28),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(4),
taskType: curatorTaskTypeSchema,
title: z.string(),
subtitle: z.string(),
missionId: z.string(),
@@ -39,7 +49,7 @@ export const curatorTaskSchema = z.object({
cta: z.string(),
context: z.array(z.object({ label: z.string(), value: z.string() })),
contextNarrative: z.string(),
subtasks: z.array(z.string()).min(1),
subtasks: z.array(z.string()).length(3),
signals: z.array(z.string()),
completionEvents: z.array(z.string()),
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
@@ -51,6 +61,34 @@ export const curatorStreakSchema = z.object({
lastCompletedDate: z.string().nullable(),
});
export const curatorPlanDaySchema = z.object({
date: z.string(),
dayIndex: z.number().int().min(1).max(28),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(4),
weekTheme: z.string(),
weekSummary: z.string(),
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
unlockState: z.enum(["completed", "active", "upcoming"]),
tasks: z.array(curatorTaskSchema),
});
export const curatorWeekSchema = z.object({
weekIndex: z.number().int().min(1).max(4),
title: z.string(),
theme: z.string(),
summary: z.string(),
startDayIndex: z.number().int().min(1).max(28),
endDayIndex: z.number().int().min(1).max(28),
lifecycle: curatorWeekLifecycleSchema,
performance: curatorWeekPerformanceSchema,
completedTaskCount: z.number().int().min(0),
totalTaskCount: z.number().int().min(0),
completionPercent: z.number().min(0).max(100),
days: z.array(curatorPlanDaySchema).length(7),
});
export const curatorPlanSchema = z.object({
id: z.string(),
userId: z.string(),
@@ -58,12 +96,9 @@ export const curatorPlanSchema = z.object({
endDate: z.string(),
goals: z.array(z.string()),
generatedAt: z.string(),
days: z.array(z.object({
date: z.string(),
dayIndex: z.number().int().min(1),
theme: z.string(),
tasks: z.array(curatorTaskSchema),
})),
durationDays: z.literal(28),
weeks: z.array(curatorWeekSchema).length(4),
days: z.array(curatorPlanDaySchema).length(28),
streak: curatorStreakSchema,
source: z.literal("curator-v1"),
});
@@ -81,10 +116,29 @@ export const curatorImprovementSignalSchema = z.object({
status: z.enum(["created", "applied", "skipped"]).default("created"),
});
export const curatorSprintResponseSchema = z.object({
date: z.string(),
sprintId: z.string(),
plan: curatorPlanSchema,
activeWeek: curatorWeekSchema,
activeWeekIndex: z.number().int().min(1).max(4),
activeDay: curatorPlanDaySchema,
activeDayIndex: z.number().int().min(1).max(28),
todayTasks: z.array(curatorTaskSchema).length(3),
streak: curatorStreakSchema,
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
overallProgressPercent: z.number().min(0).max(100),
source: z.literal("curator-v1"),
});
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
export type CuratorTodayResponse = {
date: string;